diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 683da86a..9806cbe8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,8 +40,8 @@ them on a private corpus without copying it into the repository, are described i - `src/`: the four entries in the root and three rings around the domain (ports and adapters). The entries wire the rings: `cli.ts` starts the `Cli` with the file adapters, `node.ts` is - `@miragon/bpmn-cli/node`, `api.ts` the in-memory functions (the only place they wire the services to the in-memory adapters; `index.ts` re-exports them), and `index.ts` the browser-safe - core. `src/adapters/in/report/CommandCatalogue.ts` lists every command with its arguments, + `@miragon/bpmn-cli/node`, `api.ts` the in-memory functions (they wire the services to the in-memory adapters; `index.ts` re-exports them), and `index.ts` the browser-safe + core, which wires the services of its own functions to the in-memory adapters too. `src/adapters/in/report/CommandCatalogue.ts` lists every command with its arguments, its `--help` text and its guide usage, and the options of every write: the commander wiring in `src/adapters/in/cli/` and `bpmn guide` both read it. `src/adapters/in/report/guide.ts` holds the error catalogue and the other hand-written tables that `bpmn guide` (`AgentGuide.ts`) and @@ -51,36 +51,37 @@ them on a private corpus without copying it into the repository, are described i | --- | --- | | `domain/document/` | the moddle tree, kinds, `Doc`, the `ChangeSet` of a mutation, the `FlowRules`, an edit's `IdAllocator`, the errors; `ids/` (id words, the file's id style, the declared id conventions), `xml/` (the text-preserving write, the encoding), `vendor/` (Camunda 7 / 8 knowledge) | | `domain/commands/` | the commands a caller can ask for (`EditCommand`: `AddElement`, `PlaceShapes`, ...), the ops JSON of `apply` and its aliases | - | `domain/diagram/` | the drawing (BPMN DI): `di/` reads and writes planes, frames, groups and the checkpoint every engine shares, `layout/` holds the engines (`clean/` redraws with `FullLayout`, `incremental/` keeps a hand-made drawing), `DrawnDiagram` is the drawing the diagram commands change, `DiagramCommandService` runs them and `format/` holds the services that change it (`ShapePlacementService`, `SpaceInsertionService`, ...); `BatchDrawing` is all a batch does to the drawing | - | `domain/editing/` | the services that change the model (`ElementCreationService`, `ElementMoveService`, `PropertyService`, ...; `flows/`, `events/`, `extensions/`, `participants/`) | - | `domain/validation/` | structural validation (`StructuralValidator`, `FlowOrder`), the validator hook, the bpmnlint validator, the `FindingBaseline` a write compares with; `profiles/` holds the engine rules that load on demand | + | `domain/diagram/` | the drawing (BPMN DI): `di/` reads and writes planes, frames, groups and the checkpoint every engine shares, `layout/` holds the engines (`clean/` redraws with `FullLayout`, `incremental/` keeps a hand-made drawing), `DrawnDiagram` is the drawing the diagram commands change, `commands/` holds one handler per diagram command (`ShapePlacement`, `ShapeAlignment`, `SpaceInsertion`, ...) and `DiagramCommands`, which dispatches them; `BatchDrawing` is all a batch does to the drawing | + | `domain/editing/` | what changes the model, one folder per concern: `elements/` (add, remove, retype, move, order, placement, artifacts, pools, lanes), `connections/` (connect, associations, sequence flows, bridges, flow ids, split), `properties/` (set, value coercion, the set keys, nested slots, vendor elements, decision links), `events/` (triggers, event definitions, root elements, the event-gateway rule), `extensions/` (ext), `ids/` (id renames); a command is a class created with the `EditSession` (`new ElementCreation(edit).add(op)`, `new Connections(edit).connect(op)`, ...) | + | `domain/validation/` | structural validation (`StructuralValidator`, `FlowOrder`), the validator hook, the `FindingBaseline` a write compares with and the `FindingMatcher` that tells the same finding before and after an edit; `profiles/` holds the engine rules that load on demand | | `domain/views/` | the read views (`show`, `find`, `--around`, `--context`) and `render/`, the SVG of a diagram | - | `domain/batch/` | one batch of commands on a document in memory: `EditBatchService` (guard, commands, validation, layout, kept text, result), `ModelCommandService` hands each model command to its domain service and `BatchDrawing` (`domain/diagram/`) lays the drawing out between them; the top of the domain, nothing imports it back | - | `application/port/in/` | one contract per use case, the way in: an interface with its `Command` and `Result` (`EditDocumentUseCase`, `RelayoutDiagramUseCase`, `ValidateDocumentUseCase`, `ShowModelUseCase`, ...) | - | `application/port/out/` | what the application needs from outside, named after what it does: `LoadDocumentPort`, `SaveDocumentPort`, `LoadProjectSettingsPort` (the `.bpmnlintrc`), `WriteImagePort` | - | `application/service/` | one service per use case that implements its contract (`EditDocumentService implements EditDocumentUseCase`): load, let the domain work, save, all through the outbound ports | - | `adapters/in/cli/` | the driving adapter: the `Cli` (commander, streams, exit codes, errors) and one class per command; it knows the use cases only by their contracts | + | `domain/batch/` | one batch of commands on a document in memory: `EditBatch` (guard, commands, validation, layout, kept text, result), `ModelCommands` hands each model command to its domain service and `BatchDrawing` (`domain/diagram/`) lays the drawing out between them; the top of the domain, nothing imports it back | + | `application/port/in/` | one contract per use case, the way in: an interface with its `Command` and `Result` (`EditDocumentUseCase`, `RelayoutDiagramUseCase`, `ValidateDocumentUseCase`, `ShowModelUseCase`, ...), also for a pure query that needs no outbound port | + | `application/port/out/` | what the application needs from outside, named after what it does: `DocumentPort` (load a document, resolve where a result goes, save it), `LoadProjectSettingsPort` (the `.bpmnlintrc`), `WriteImagePort` | + | `application/service/` | one service per use case that implements its contract (`EditDocumentService implements EditDocumentUseCase`): load, let the domain work, save, all through the outbound ports; a query asks the domain and returns its answer | + | `adapters/in/cli/` | the driving adapter: the `Cli` (commander, streams, exit codes, errors) and one class per command; it knows the use cases only by their contracts and reaches the domain only through them; from the domain it takes the command vocabulary to check its flags (errors, op rules, flag values) | | `adapters/in/report/` | what a use case's result looks like to the one who asked: text and JSON reports (`MutationText`, `MutationReportBuilder`, `ValidationText`, ...), the views as text, and the reference the guide and `bpmn kinds` print; the CLI prints them and `index.ts` / `api.ts` return them | - | `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings`, `FileImageWriter` (PNG) | + | `adapters/out/bpmnlint/` | the injected bpmnlint as a validator of the domain, browser-safe: `BpmnlintConfigResolution` (the effective `.bpmnlintrc`), `BpmnlintValidator`, `IdConventionsReader` (the id conventions of `element-id-naming`) | + | `adapters/out/files/` | the driven adapters on disk, Node only: `FileDocumentStore`, `BpmnlintProjectSettings` (loads the `.bpmnlintrc` and its plugins for `adapters/out/bpmnlint/`), `FileImageWriter` (PNG) | + | `adapters/out/document/` | `DocumentStore`, what the file store and the in-memory store share behind the `DocumentPort`: a source parsed into a `Doc`, the lossless check, the target of a write and whether to write; browser-safe | | `adapters/out/memory/` | the driven adapters of the in-memory API: `NoDocumentFiles`, `NoProjectSettings` | | `types/` | type shims for the bpmn-io packages that ship no usable declarations | - Where new code goes: a new command is a type in `domain/commands/` and a `...Service` of the - domain that `domain/batch/` hands it to; a new command line verb or API function is a contract in + Where new code goes: a new command is a type in `domain/commands/` and a class of + `domain/editing/` created with the `EditSession` that `domain/batch/` hands it; a new command line verb or API function is a contract in `application/port/in/` and a service that implements it; reading the model without changing it is a view (`domain/views/`); deciding positions is `domain/diagram/`; text or JSON for the user is `adapters/in/report/`; anything that touches the file system or `process` is an adapter behind a port of `application/port/out/`; mapping flags to options and printing a - command's result is `adapters/in/cli/`. The entries (`cli.ts`, `node.ts`, `api.ts`) create + command's result is `adapters/in/cli/`. The entries (`cli.ts`, `index.ts`, `node.ts`, `api.ts`) create the services with their adapters. Every public function that works on a document goes - through a use case, even one that only forwards to the domain: the library entries - (`index.ts`, `api.ts`, `node.ts`) import values only from `application/` and `adapters/`, and - from `domain/` only types, re-exporting just the public classes, constants and pure helpers - the test lists. `test/architecture.test.ts` checks the rings: the domain + through a use case, even one that only forwards to the domain: the entries import values only + from `application/` and `adapters/`, and from `domain/` only types, re-exporting just the + public classes, constants and pure helpers the test lists. `test/architecture.test.ts` checks the rings: the domain imports neither the application nor the adapters, the application no adapter, an adapter no - other adapter (the CLI only the reports), the driving adapter no service, only adapters and entries import `node:*`, the - domain modules import each other without a cycle, and every service of `application/service/` - implements its contract and calls no other, and the entries reach the domain only through the + other adapter (the CLI only the reports, the files only bpmnlint and the document store, the memory adapters only the document store), the driving adapter no service, only adapters and entries import `node:*`, the + domain modules import each other without a cycle, `application/service/` holds only services, + each implementing its contract and using no other class of that folder, and the entries reach the domain only through the use cases. **Classes first, helpers in `utils/`.** Each module builds on classes, one per file, the file @@ -117,8 +118,7 @@ them on a private corpus without copying it into the repository, are described i ### Dependencies -`bpmn-moddle` (the semantic model; `moddle` for its types), `bpmn-auto-layout` (pinned to -`2.0.0-alpha.2`, loaded only for `--engine auto`), `@resvg/resvg-wasm` (the PNG rasteriser of +`bpmn-moddle` (the semantic model; `moddle` for its types), `@resvg/resvg-wasm` (the PNG rasteriser of `bpmn render`), `commander` (the CLI only), and `bpmnlint` (pinned to `11.15.0`) with `@miragon/bpmnlint-plugin-rules` (pinned to `0.12.1`: bpmnlint's rules, camunda-compat and Miragon's rules behind one resolver), loaded only when a `.bpmnlintrc` applies (see diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..2bb22581 --- /dev/null +++ b/biome.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { "includes": ["src/**/*.ts"] }, + "formatter": { "enabled": false }, + "assist": { "enabled": false }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noExcessiveCognitiveComplexity": { "level": "warn", "options": { "maxAllowedComplexity": 25 } }, + "noExcessiveLinesPerFunction": { "level": "warn", "options": { "maxLines": 80 } } + }, + "style": { + "noNestedTernary": "warn" + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index a0a921e1..53df2da4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,9 +9,11 @@ goes are in [CONTRIBUTING.md](https://github.com/Miragon/bpmn-cli/blob/main/CONT bpmn-cli runs as the `bpmn` command, as a Node library (`@miragon/bpmn-cli/node`) and as a browser library (`@miragon/bpmn-cli`). All three must change a model in exactly the same way. So what a change means lives once, in the **domain**, which knows nothing about files, flags -or `process`. Around it, the **application** offers one use case per thing a caller can do. -At the edge, **adapters** translate between the outside world and those use cases. This is a -hexagonal architecture (ports and adapters). +or `process`. Around it, the **application** offers one use case per thing a caller can do, +also a pure query (`show`, `find`, `metrics`, an SVG in memory) that needs no outbound port. +At the edge, **adapters** translate between the outside world and those use cases; neither the +CLI nor the library skips the use case to call the domain. This is a hexagonal architecture +(ports and adapters). ```mermaid flowchart TB @@ -24,20 +26,28 @@ flowchart TB service --> portOut{{"Port
application/port/out"}} portOut --> files["Files
adapters/out/files"] portOut --> memory["No project, no files
adapters/out/memory"] + files --> store["Loading and saving a document
adapters/out/document"] + memory --> store + files --> bpmnlint["bpmnlint
adapters/out/bpmnlint"] + lib --> bpmnlint classDef adapter stroke:#2f80ed,stroke-width:2px classDef app stroke:#f2994a,stroke-width:2px classDef core stroke:#27ae60,stroke-width:3px - class cli,files,memory adapter + class cli,files,memory,store,bpmnlint adapter class portIn,service,portOut app class domain core ``` The frame colour shows the layer: blue for adapters, orange for the application, green for the domain. Imports point inwards only: adapters → application → domain. -The entries in the root of `src/` (`cli.ts`, `node.ts`, `api.ts`) are the only files that know +The entries in the root of `src/` (`cli.ts`, `index.ts`, `node.ts`, `api.ts`) are the only files that know every layer. They create the services with `new` and pass the adapters in. There is no container. +`adapters/out/bpmnlint/` wraps an injected bpmnlint as a `Validator` of the domain. The file +adapters hand it the `.bpmnlintrc` they load; `index.ts` hands it to hosts that bring their own +bpmnlint. `adapters/out/document/` is what the file store and the in-memory store share behind +the `DocumentPort`: a source parsed into a `Doc`, the target of a write, the save. | rule (checked by the test) | why | | --- | --- | @@ -45,7 +55,9 @@ container. | `application/` imports no adapter | a service does not know whether it writes to disk or does not write at all | | only adapters and entries import `node:*` | the browser bundle stays free of Node | | `adapters/in` knows use cases only by their interface | the entry decides which service is behind it | -| each service implements one use case and calls no other service | one way in, no hidden orchestration | +| the entries import only types from `domain/` and re-export only the public classes and constants the test lists | the entries reach the domain only through the use cases, so the CLI and the library run the same way in | +| an adapter imports another only where the test names it (the CLI the reports, the files bpmnlint, the files and the memory store the shared document store) | the entries wire the adapters | +| `application/service/` holds only services; each implements one use case and uses no other class of `application/service/` | one way in, no hidden orchestration | ## The journey of `bpmn add` @@ -55,7 +67,7 @@ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived ```mermaid flowchart TB - user(["bpmn add"]) --> s1["1 · AddElementCommand"] --> s2{{"2 · EditDocumentUseCase"}} --> s3["3 · EditDocumentService"] --> s4["4 · EditBatchService"] --> s5["5 · ModelCommandService"] --> s6["6 · ElementCreationService"] + user(["bpmn add"]) --> s1["1 · AddElementCommand"] --> s2{{"2 · EditDocumentUseCase"}} --> s3["3 · EditDocumentService"] --> s4["4 · EditBatch"] --> s5["5 · ModelCommands"] --> s6["6 · ElementCreation"] s3 <-- "read · write" --> s7[("7 · FileDocumentStore")] s3 -- result --> s8["8 · MutationText"] @@ -101,14 +113,14 @@ export class AddElementCommand extends MutationCommand { ) { const opts = this.mutationOptions(options) this.checkPlacementFlags(options) - const op: AddElement = { + const command: AddElement = { op: 'add', kind, // 'userTask' name, // 'Check invoice' after: options['after'], // 'Event_OrderReceived' // … } - await this.mutate(file, [op], opts) + await this.mutate(file, [command], opts) } } ``` @@ -126,13 +138,13 @@ behind it, `cli.ts` decided at startup. // src/adapters/in/cli/MutationCommand.ts protected async mutate( file: string, - ops: EditCommand[], + commands: EditCommand[], opts: MutationFlags, ) { const result = await this.cli.useCases.editDocument.edit({ ...opts, source: { file }, - commands: ops, + commands, }) // step 8 await this.printMutation(result, opts, this.cli.io.stdout) @@ -156,25 +168,25 @@ export declare namespace EditDocumentUseCase { ### 3. Load, run, save The service is short on purpose: get the document, let the domain work, write the result. -`settings`, `load` and `save` are outbound ports, handed in by the constructor. The call into -the domain stands on a line of its own; reading and writing the file are small steps around it. +`settings` and `documents` are outbound ports, handed in by the constructor. The call into +the domain stands on a line of its own; reading and writing the file are calls of the port +around it. ```ts // src/application/service/EditDocumentService.ts export class EditDocumentService implements EditDocumentUseCase { constructor( private readonly settings: LoadProjectSettingsPort, - private readonly load: LoadDocumentPort, - private readonly save: SaveDocumentPort, + private readonly documents: DocumentPort, ) {} async edit(command: EditDocumentUseCase.Command) { const { source, commands, ...opts } = command - const doc = await this.open(source, opts) // step 7: read the file - const target = await this.targetOf(doc, opts) + const doc = await this.documents.load(source, { ...opts, lossless: true }) // step 7: read the file + const target = await this.documents.target(doc.file, opts) const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target) - const result = await new EditBatchService(doc, commands, project).run(target) // step 4: the domain - await this.write(result, doc, target, opts) // step 7: write the file + const result = await new EditBatch(doc, commands, project).run(target) // step 4: the domain + await this.documents.save(result, doc, target, opts) // step 7: write the file return result } } @@ -186,12 +198,12 @@ or written. ### 4. The batch -The domain starts here. `EditBatchService` runs all commands of one call on the `Doc` in +The domain starts here. `EditBatch` runs all commands of one call on the `Doc` in memory, then validates, lays out the drawing and writes only the changed parts back into the original text. If anything throws, nothing is written: the caller keeps its input. ```ts -// src/domain/batch/EditBatchService.ts +// src/domain/batch/EditBatch.ts async run(file = this.doc.file): Promise { // lossless import, Camunda 7 / 8 rules loaded await this.guard() @@ -204,10 +216,10 @@ async run(file = this.doc.file): Promise { const { batch } = await this.runCommands(diagram) // new errors refuse the write - await this.validate(findings, batch) + await this.validateAndDropProfileDuplicates(findings, batch) // lay out the drawing, rewrite only what changed in the text - const drawn = await diagram.apply(this.edit, batch.ops, batch.changes) + const drawn = await diagram.apply(this.edit, batch.commands, batch.changes) const xml = await this.keptText(drawn.xml, …) return this.result(file, batch, xml, drawn.layout, …) @@ -216,24 +228,23 @@ async run(file = this.doc.file): Promise { ### 5. The right service for the op -`ModelCommandService` runs the commands in order and hands each one to the service that knows -it. Commands that only move shapes (`place`, `align`, `color`, …) are left for the layout in +`ModelCommands` runs the commands in order and hands each one to the class that knows +it, created with the `EditSession`: the `Doc` and what the running batch recorded so far. Commands that only move shapes (`place`, `align`, `color`, …) are left for the layout in step 4. ```ts -// src/domain/batch/ModelCommandService.ts -private dispatch(op: EditCommand): ChangeSet { - if (isDiagramCommand(op)) return new ChangeSet() - const { doc, edit } = this - switch (op.op) { +// src/domain/batch/ModelCommands.ts +private dispatch(command: EditCommand): ChangeSet { + if (isDiagramCommand(command)) return new ChangeSet() + switch (command.op) { case 'add': - return new ElementCreationService(doc, edit).add(op) + return new ElementCreation(this.edit).add(command) case 'connect': - return new ConnectionService(doc, edit).connect(op) + return new Connections(this.edit).connect(command) case 'set': - return new PropertyService(doc, edit).set(op) + return new Properties(this.edit).set(command) case 'remove': - return new ElementRemovalService(doc, edit).remove(op) + return new ElementRemoval(this.edit).remove(command) // … retype, move, order, ext, split } } @@ -241,61 +252,77 @@ private dispatch(op: EditCommand): ChangeSet { ### 6. The model changes -`ElementCreationService` makes the new element in the bpmn-moddle tree of the `Doc`, places it +`ElementCreation` makes the new element in the bpmn-moddle tree of the `Doc`, places it after `Event_OrderReceived` (which creates the sequence flow), and records everything it did in a `ChangeSet`. ```ts -// src/domain/editing/ElementCreationService.ts -add(op: AddElement): ChangeSet { - const cs = new ChangeSet() - // 'userTask' → bpmn:UserTask - const { def, trigger } = ElementCreationService.resolveKind(op.kind) - const el = this.addFlowNode(op, def, trigger, cs) - if (op.set) { - const values = { op: 'set', id: idOf(el), values: op.set } - cs.merge(new PropertyService(this.doc, this.edit).set(values)) +// src/domain/editing/elements/ElementCreation.ts +export class ElementCreation { + constructor(private readonly edit: EditSession) {} + + add(command: AddElement): ChangeSet { + const cs = new ChangeSet() + // 'userTask' → bpmn:UserTask + const { def, trigger } = parseKindToAdd(command.kind) + const el = this.addFlowNode(command, def, trigger, cs) + if (command.set) { + const values = { op: 'set', id: idOf(el), values: command.set } + cs.merge(new Properties(this.edit).set(values)) + } + return cs } - return cs -} -private addFlowNode( - op: AddElement, - def: KindDef, - trigger: Trigger | undefined, - cs: ChangeSet, -): El { - // 'Activity_CheckInvoice' - const id = this.edit.allocator.explicitOr(op.id, …) - const el = this.doc.create(def.type, { id, name: op.name }) - cs.create({ id, kind: def.kind, name: op.name }) - - // --after: splice in behind the anchor and add the sequence flow - new NodePlacementService(this.doc, this.edit, op).place(el, cs) - return el + private addFlowNode( + command: AddElement, + def: KindDef, + trigger: Trigger | undefined, + cs: ChangeSet, + ): El { + // 'Activity_CheckInvoice' + const id = this.edit.allocator.explicitOr(command.id, …) + const el = this.edit.doc.create(def.type, { id, name: command.name }) + cs.create({ id, kind: def.kind, name: command.name }) + + // --after: splice in behind the anchor and add the sequence flow + new NodePlacement(this.edit, command).place(el, cs) + return el + } } ``` ### 7. The file -`FileDocumentStore` implements both file ports. It is the only class on this path that imports -`node:fs`. +`FileDocumentStore` implements the `DocumentPort`. What it shares with the in-memory +`NoDocumentFiles` (parsing a source into a `Doc`, the lossless check, the target of a write, +whether to write at all) is their base class `DocumentStore` in `adapters/out/document/`; the +file store only adds the bytes on disk. It is the only class on this path that imports `node:fs`. ```ts +// src/adapters/out/document/DocumentStore.ts +export abstract class DocumentStore implements DocumentPort { + async load(source, opts = {}) { + const doc = await this.parse(source) // a { doc }, its XML or the bytes of a file + if (opts.lossless) doc.assertLossless(opts) + return doc + } + + async save(result, doc, target, opts) { + if (!opts.dryRun && target) result.written = await this.write(target, result.xml, …) + } +} + // src/adapters/out/files/FileDocumentStore.ts -export class FileDocumentStore - implements LoadDocumentPort, SaveDocumentPort -{ +export class FileDocumentStore extends DocumentStore { // decoded in the encoding the XML declares - read(file: string): Promise { + protected read(file: string): Promise { return readXml(file) } - async save(target: string, xml: string, opts: SaveOptions) { - const { source, unchanged, backup } = opts + protected async write(target: string, xml: string, opts: WriteOptions) { // a no-op leaves the file alone - if (unchanged && target === source) return false - if (backup && source) await FileDocumentStore.backup(source) + if (opts.unchanged && inPlace) return false + if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source) // a failed write leaves the old file await writeAtomic(target, xml) return true @@ -342,6 +369,6 @@ flowchart LR class def core ``` -A new command therefore needs a definition, a `case` in `ModelCommandService` and a service in -`domain/editing/`. `bpmn apply` and `applyToXml()` accept it from then on. A verb of its own on +A new command therefore needs a definition, a `case` in `ModelCommands` and a class of +`domain/editing/` created with the `EditSession`. `bpmn apply` and `applyToXml()` accept it from then on. A verb of its own on the command line (`bpmn `) is optional and lives in `adapters/in/cli/`. diff --git a/docs/layout.md b/docs/layout.md index e74362fb..34527241 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -103,8 +103,8 @@ written: order.bpmn ## Layout conventions of the built-in engine -The default engine (`--engine clean`) draws every diagram the same way, so you can predict the -picture from the model. +The built-in engine draws every diagram the same way, so you can predict the picture from the +model. | element | where it goes | | --- | --- | diff --git a/docs/library.md b/docs/library.md index d9a7f92c..1e9f0b58 100644 --- a/docs/library.md +++ b/docs/library.md @@ -14,7 +14,7 @@ The functions take the XML as a string and return the new XML with the result th ```ts import { applyToXml, - CliError, + BpmnError, newXml, renderMutation, showXml, @@ -52,13 +52,13 @@ try { { op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }, ]); } catch (err) { - if (!(err instanceof CliError)) throw err; + if (!(err instanceof BpmnError)) throw err; err.code; // 'E_NOT_FOUND' err.details.candidates; // ['Activity_CheckInvoice'] } ``` -A failure throws a `CliError` (`code`, `details`, `toJSON()` for the CLI's `--json` error). Nothing +A failure throws a `BpmnError` (`code`, `details`, `toJSON()` for the CLI's `--json` error). Nothing is half done: the caller still has its input. ## Two entries @@ -95,7 +95,6 @@ the CLI prints with `--json`. `renderMutation(result)` gives the CLI's text. | option | values | CLI | | --- | --- | --- | | `layout` | `'auto'` (default), `'incremental'`, `'full'`, `false` | `--layout`, `--no-layout` | -| `engine` | `'clean'` (default), `'auto'` | `--engine` | | `force` | write despite a guard | `--force` | | `platform` | `'auto'`, `'c7'`, `'c8'`, `'none'` | `--platform` | | `keepFlowIds` | keep flow ids; `W_FLOW_ID_STALE` names each stale one | `--keep-flow-ids` | @@ -104,11 +103,6 @@ the CLI prints with `--json`. `renderMutation(result)` gives the CLI's text. The full signatures ship as TypeScript types with the package. -::: warning engine: 'auto' and top-level await -`engine: 'auto'` loads bpmn-auto-layout on demand. A host bundled with Rollup or Vite must not -await such a call at the top level of its entry module (it hangs): call it from a function. -::: - ## File helpers (`@miragon/bpmn-cli/node`) {#file-helpers-miragon-bpmn-cli-node} | helper | runs | diff --git a/docs/testing.md b/docs/testing.md index 880dea70..6472b830 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -123,8 +123,10 @@ Every fixture must be synthetic. To keep a defect found on a private model, rebuild a minimal model that shows it (the minimiser's repro tells you which ops matter) and add that. -`test/adapters/out/files/bpmnlint.test.ts` checks the project's bpmnlint configuration (issue -#33) with the real bpmnlint and @miragon/bpmnlint-plugin-rules, offline: +The tests of the project's bpmnlint configuration (issue #33) run with the real bpmnlint and +@miragon/bpmnlint-plugin-rules, offline: `test/adapters/out/bpmnlint/` the effective rules and +the advisory validator, `test/adapters/out/files/bpmnlint.test.ts` the lookup, the loading and +the resolver chain, `test/e2e/bpmnlint-cli.test.ts` the CLI; each case writes a repository of its own into a temporary directory (a `.git` directory, a `.bpmnlintrc`, models, and a `node_modules` with project plugins where the case needs them), except the cases about a model @@ -133,7 +135,7 @@ directory, a link to `/`, a hard link to a gitfile or to `/etc/hosts` where the system allows one; the `.bpmnlintrc` applies, the plugin must not run) mock the uid (skipped as root and on Windows), and the case of a system without uids (Windows: only a repository inside the home directory runs -plugins) mocks `process.getuid` away and sets `HOME`. Its CLI cases build +plugins) mocks `process.getuid` away and sets `HOME`. The CLI cases build `src/` with tsc into a temporary directory and run it under plain node, which loads bpmnlint and the plugin as the published package does (vitest's module loader would hide a load failure); they also run as root @@ -170,7 +172,7 @@ named after it (also in the entries of an op before its retype), an id freed and then claimed explicitly, the `conventions` of a result (none in a new file or one that follows the rule), the view of `show --conventions` with its reasons). -`test/adapters/out/files/bpmnlint.test.ts` checks the lookup, the notes in validate and writes +`test/adapters/out/files/bpmnlint.test.ts` and `test/e2e/bpmnlint-cli.test.ts` check the lookup, the notes in validate and writes (also the one for a prefix declared for a DI type), `show --conventions` and `new` on the command line (its own errors before the `.bpmnlintrc`'s), and that a `.bpmnlintrc` without the rule, or @@ -195,11 +197,11 @@ unchanged file shows none of them, the `E_VALIDATION` advice (the catalogue fix, the `guide --short` line, the plugin skill's row) does not send the reader to `bpmn validate`, and the guide's JSON error shape lists the `errors` that advice points to), -`test/domain/views/decision-link.test.ts` the decision link (`calledDecision`) in models +`test/domain/editing/properties/decision-link.test.ts` the decision link (`calledDecision`) in models without an engine (the unprefixed attribute; with `platform: 'none'` the spelling of the task's link or of the file's other links kept), Camunda 7, Operaton and Camunda 8 files. -`test/domain/views/views.test.ts` checks the reading views (`show --around`, `show +`test/domain/views/reading-views.test.ts` checks the reading views (`show --around`, `show --context`, lanes and message-flow names in `show`, message flows and annotations in `show `) on the synthetic `test/fixtures/views/claims.bpmn` and holds a byte budget: on a generated 120-task model the neighbourhood @@ -221,7 +223,7 @@ profile in the warnings delta; and the strip of a removed node that must not pull an expanded sub-process over a shape of another row (found by the gate's fuzz campaign on the integrated build). The round 1 verifier's findings, each with a test that fails on round 1: -`test/domain/document/id-quality.test.ts` (ids and the report: flows at unnamed +`test/domain/document/ids/id-quality.test.ts` (ids and the report: flows at unnamed gateways named by their speaking ids (no `_2`), unnamed elements in a row (the nearest named anchor once; where the prefix says the kind, `Task_`, `task_`, the anchor's kind where an id would repeat the anchor's, @@ -240,11 +242,11 @@ delta: platform findings counted, a shrinking `W_DUPLICATE_NAME`, a bridge that takes over a removed flow's id, warnings of a batch's final state, `new` with the platform's findings), `test/domain/validation/c8-feel-and-deploy.test.ts` (Camunda 8, see "Engine checks (Camunda 8)"), -`test/domain/views/views-context.test.ts` (`show --context` on a synthetic shop +`test/domain/views/element-context.test.ts` (`show --context` on a synthetic shop model: every boundary event of the sub-processes around an element, the message of a message element with the flows of that message drawn to its pool, a message flow's ends, what uses a message / signal / error / -escalation), `test/domain/diagram/format-frames.test.ts` (align / place with selector sets +escalation), `test/domain/diagram/commands/format-frames.test.ts` (align / place with selector sets and explicit ids never take a shape out of its sub-process, also on the two scenarios the fuzzer hit), `test/domain/editing/move-lanes.test.ts` (a node moved into a flow between two lanes gets add's lane when it has none, keeps its own @@ -255,11 +257,11 @@ fixes together (the message of `refAs` and speaking ids in `--summary`, created elements not also listed as changed, the subscription redirect in the warnings delta, a plain remove of a merge and `move` into a cross-lane flow renaming flows after their new ends). -`test/domain/document/drawn-ids.test.ts` checks that a full redraw gives id-less elements +`test/domain/document/ids/drawn-ids.test.ts` checks that a full redraw gives id-less elements ids before drawing them, and that an op giving a sequence flow without id other ends gives it one from them, and `test/e2e/node-files.test.ts` the encodings (a file is read as it declares, a write is UTF-8 and says so). -`test/domain/diagram/layout-batch-parity.test.ts` checks issue #18, stage A, on the +`test/domain/diagram/layout/incremental/batch-parity.test.ts` checks issue #18, stage A, on the synthetic hand-drawn model of the plugin eval `edit-hand-drawn-model`: the agent's batches (decision + "no" to the existing node, decision + a new "no" branch, `split`, a merge gateway; each with and without the timer reminder @@ -287,7 +289,7 @@ grows at its bottom), and next to an annotation on a flow the move reroutes (no `through`); `E_NO_ROOM` names the space-tool run with the fewest layout problems, and after other ops of a batch only the batch op (no command for the file). -`test/domain/diagram/layout-room-making.test.ts` checks issue #18, stage B, on synthetic +`test/domain/diagram/layout/incremental/room-making.test.ts` checks issue #18, stage B, on synthetic hand-drawn models (`test/fixtures/layout-room/`), the eval's model and a public scenario: a boundary path above a line from an earlier boundary event opens the row below its host (the line and its end move down), a decision @@ -325,7 +327,7 @@ not in an `applyToXml`-only bundle; and the command (`-o`, the temp default, `-o -`, `--json`, `--plane`, the usage errors). The synthetic `test/fixtures/render/gallery.bpmn` has every element kind the renderer draws (built with the CLI, then a group and multiple events added by hand). -`test/domain/views/render-agent.test.ts` checks the agent features (stage 2): every layout +`test/domain/views/render/render-agent.test.ts` checks the agent features (stage 2): every layout problem of every scenario and fixture located inside the bounding box of its elements on its plane (`locateProblems`; `layoutProblems` unchanged); `--problems` listing every metrics problem in its order with the same ids, @@ -340,7 +342,7 @@ mutating commands (text, JSON, the region of a large drawing, a failed render that leaves the change standing); and the fuzz walk's render oracle. The synthetic `test/fixtures/render/long.bpmn` (28 nodes in a row, 3,940 px wide, built with the CLI) is the tiles and focus model. -`test/domain/views/render-fixes.test.ts` checks the fixes after the verification of +`test/domain/views/render/render-fixes.test.ts` checks the fixes after the verification of issue #12 on synthetic models: broken or far-flung coordinates (NaN, 1e308, Infinity, 1e9, a negative size, far label bounds) are left out with a note and never give `width="NaN"`, an all-broken plane is `E_RENDER`; @@ -388,8 +390,8 @@ npx vitest run test/e2e/isomorphic.test.ts not compile. It prints the minified and gzipped sizes, split like a host's bundler would split them: the initial chunks of the whole entry and of an `applyToXml`-only entry, and the chunks loaded on demand (the Camunda 7 - rules, the Camunda 8 rules with the FEEL, timer and schema-text checks, - bpmn-auto-layout). It fails when a rule module + rules, the Camunda 8 rules with the FEEL, timer and schema-text checks). + It fails when a rule module (`dist/platform/profiles/`: the profiles, their base, utils and checks) is in the initial chunks of either entry, naming it; when a rule module imports a module the entry loads, naming both (Rollup / Vite would put that module into the host's @@ -644,8 +646,8 @@ node tools/speaking-ids.mjs # tools/scenarios node tools/speaking-ids.mjs --baseline /dist/index.js ~/corpora/hand ``` -The regression tests: `test/domain/document/speaking-ids.test.ts` and -`test/domain/document/conventions-ids.test.ts` (ids in every file style, renamed flows and +The regression tests: `test/domain/document/ids/speaking-ids.test.ts` and +`test/domain/document/ids/id-style.test.ts` (ids in every file style, renamed flows and their DI, also after an end got another id earlier in the batch; a flow form that stays put over many edits, also in snake_case and Pascal_Snake bodies, ties, flows under an outvoted prefix or scope), diff --git a/docs/validators.md b/docs/validators.md index 72a44efa..fca423f2 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -12,7 +12,7 @@ flow and is refused; the second one passes. ```ts import { applyToXml, - CliError, + BpmnError, newXml, type NamedValidator, } from '@miragon/bpmn-cli'; @@ -50,7 +50,7 @@ try { }; await applyToXml(xml, [archive], { validators: [houseRules] }); } catch (err) { - if (!(err instanceof CliError)) throw err; + if (!(err instanceof BpmnError)) throw err; err.code; // 'E_VALIDATION' err.message; diff --git a/package-lock.json b/package-lock.json index 99c0456f..c9811b5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@miragon/bpmnlint-plugin-rules": "0.14.0", "@resvg/resvg-wasm": "2.6.2", - "bpmn-auto-layout": "2.0.0-alpha.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", "commander": "^15.0.0", @@ -21,6 +20,7 @@ "bpmn": "bin/bpmn.js" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "^26.0.0", "archunit": "2.5.4", "camunda-bpmn-moddle": "8.0.2", @@ -36,6 +36,181 @@ "node": ">=22.12.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz", + "integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.15", + "@biomejs/cli-darwin-x64": "2.5.15", + "@biomejs/cli-linux-arm64": "2.5.15", + "@biomejs/cli-linux-arm64-musl": "2.5.15", + "@biomejs/cli-linux-x64": "2.5.15", + "@biomejs/cli-linux-x64-musl": "2.5.15", + "@biomejs/cli-win32-arm64": "2.5.15", + "@biomejs/cli-win32-x64": "2.5.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz", + "integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz", + "integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz", + "integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz", + "integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz", + "integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz", + "integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz", + "integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz", + "integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bpmn-io/feel-analyzer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@bpmn-io/feel-analyzer/-/feel-analyzer-0.3.0.tgz", @@ -2180,25 +2355,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bpmn-auto-layout": { - "version": "2.0.0-alpha.2", - "resolved": "https://registry.npmjs.org/bpmn-auto-layout/-/bpmn-auto-layout-2.0.0-alpha.2.tgz", - "integrity": "sha512-Xg8BoRMijfECgWljIlE44hbGDAuF0BkjJuLfd5o3oOx1ileL06EJQhlgO040xFIegfjxq91dVFAoxCL17wo53A==", - "license": "MIT", - "workspaces": [ - "example" - ], - "dependencies": { - "bpmn-moddle": "^10.0.0", - "min-dash": "^5.0.0" - }, - "bin": { - "bpmn-auto-layout": "bin/bpmn-auto-layout.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/bpmn-moddle": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/bpmn-moddle/-/bpmn-moddle-10.3.1.tgz", diff --git a/package.json b/package.json index 977c6fca..314cc117 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json --noEmit", "knip": "knip", + "lint": "biome lint", "layout:regress": "node tools/layout-regress.mjs", "bench": "node tools/bench/run.mjs", "bench:summary": "node tools/bench/summary.mjs", @@ -61,19 +62,19 @@ "docs:dev": "npm --prefix docs run dev", "docs:build": "npm --prefix docs run build", "docs:preview": "npm --prefix docs run preview", - "gate": "npm run build && knip && knip --production --exclude dependencies && jscpd && vitest run && node tools/iso/check.mjs && node tools/bench/regress-gate.mjs && node tools/fuzz/run.mjs --walks 12 --steps 15 --seed 1 --hard warn --out tools/fuzz/out/gate", + "gate": "npm run build && biome lint && knip && knip --production --exclude dependencies && jscpd && vitest run && node tools/iso/check.mjs && node tools/bench/regress-gate.mjs && node tools/fuzz/run.mjs --walks 12 --steps 15 --seed 1 --hard warn --out tools/fuzz/out/gate", "prepublishOnly": "npm run build" }, "dependencies": { "@miragon/bpmnlint-plugin-rules": "0.14.0", "@resvg/resvg-wasm": "2.6.2", - "bpmn-auto-layout": "2.0.0-alpha.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", "commander": "^15.0.0", "moddle": "^8.2.1" }, "devDependencies": { + "@biomejs/biome": "2.5.15", "@types/node": "^26.0.0", "archunit": "2.5.4", "camunda-bpmn-moddle": "8.0.2", diff --git a/src/adapters/in/cli/AddElementCommand.ts b/src/adapters/in/cli/AddElementCommand.ts index 1397e477..c1026097 100644 --- a/src/adapters/in/cli/AddElementCommand.ts +++ b/src/adapters/in/cli/AddElementCommand.ts @@ -49,7 +49,7 @@ export class AddElementCommand extends MutationCommand { if (options['ifAbsent'] && !options['id']) { throw usageError('--if-absent needs an explicit --id to check for', { hint: 'Pass --id _ (the id add would generate), or drop --if-absent.' }); } - const op: AddElement = { + const command: AddElement = { op: 'add', kind, ...(name !== undefined ? { name } : {}), @@ -60,6 +60,6 @@ export class AddElementCommand extends MutationCommand { ...(kv.length ? { set: parseKeyValues(kv) } : {}), ...this.triggerOptions(options), }; - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/AlignCommand.ts b/src/adapters/in/cli/AlignCommand.ts index 48dbc1fc..c8e4f8cf 100644 --- a/src/adapters/in/cli/AlignCommand.ts +++ b/src/adapters/in/cli/AlignCommand.ts @@ -18,8 +18,8 @@ export class AlignCommand extends FormatCommand { ).action(async (file: string, ids: string[], options: RawOpts) => { const sel = this.selectorsOf(ids, options, true); if (!sel.path && !sel.kind && !sel.branch && ids.length < 2 && !options['to']) throw usageError('align needs two ids, or one id and --to '); - const op: AlignShapes = { op: 'align', ...sel, axis: options['axis'] as AlignShapes['axis'], ...this.pick(options, ['to'] as const) }; - await this.runFormat(file, op, options); + const command: AlignShapes = { op: 'align', ...sel, axis: options['axis'] as AlignShapes['axis'], ...this.pick(options, ['to'] as const) }; + await this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/ApplyOpsCommand.ts b/src/adapters/in/cli/ApplyOpsCommand.ts index e8f6ab59..05f9c53f 100644 --- a/src/adapters/in/cli/ApplyOpsCommand.ts +++ b/src/adapters/in/cli/ApplyOpsCommand.ts @@ -1,9 +1,7 @@ import { readFile } from 'node:fs/promises'; import type { Command as CommandType } from 'commander'; -import { CommandParser } from '../../../domain/commands/CommandParser.js'; import type { EditCommand } from '../../../domain/commands/types.js'; import { ioError, usageError } from '../../../domain/document/utils/errors.js'; -import { parseOpsJson } from '../../../domain/commands/utils/ops-json.js'; import type { RawOpts } from './CliCommand.js'; import { MutationCommand } from './MutationCommand.js'; @@ -17,8 +15,8 @@ export class ApplyOpsCommand extends MutationCommand { private async run(file: string, opsFile: string | undefined, options: RawOpts): Promise { const opts = this.mutationOptions(options); - const ops: EditCommand[] = new CommandParser().parse(parseOpsJson(await this.readOpsText(file, opsFile))); - await this.mutate(file, ops, opts); + const commands: EditCommand[] = this.cli.useCases.runCommands.parseCommandsJson({ json: await this.readOpsText(file, opsFile) }); + await this.mutate(file, commands, opts); } private async readOpsText(file: string, opsFile: string | undefined): Promise { diff --git a/src/adapters/in/cli/Cli.ts b/src/adapters/in/cli/Cli.ts index fd4b85c2..a7f406e3 100644 --- a/src/adapters/in/cli/Cli.ts +++ b/src/adapters/in/cli/Cli.ts @@ -1,7 +1,7 @@ import { Command, CommanderError, type Command as CommandType } from 'commander'; import { Doc } from '../../../domain/document/Doc.js'; -import { type Warning } from '../../../domain/document/CliError.js'; -import { ioError, isCliError, usageError } from '../../../domain/document/utils/errors.js'; +import type { Finding } from '../../../domain/document/Finding.js'; +import { ioError, isBpmnError, usageError } from '../../../domain/document/utils/errors.js'; import { validatorTag } from '../report/utils/findings.js'; import type { EditDocumentUseCase } from '../../../application/port/in/EditDocumentUseCase.js'; import type { CreateDocumentUseCase } from '../../../application/port/in/CreateDocumentUseCase.js'; @@ -11,6 +11,8 @@ import type { ReadDocumentUseCase } from '../../../application/port/in/ReadDocum import type { ShowModelUseCase } from '../../../application/port/in/ShowModelUseCase.js'; import type { ShowConventionsUseCase } from '../../../application/port/in/ShowConventionsUseCase.js'; import type { FindElementsUseCase } from '../../../application/port/in/FindElementsUseCase.js'; +import type { AnalyzeLayoutUseCase } from '../../../application/port/in/AnalyzeLayoutUseCase.js'; +import type { RunCommandsUseCase } from '../../../application/port/in/RunCommandsUseCase.js'; import type { RenderDiagramUseCase } from '../../../application/port/in/RenderDiagramUseCase.js'; import { describeOptionsByDefinitions } from './utils/definition-help.js'; import { AddElementCommand } from './AddElementCommand.js'; @@ -60,6 +62,8 @@ export interface CliUseCases { showModel: ShowModelUseCase; showConventions: ShowConventionsUseCase; findElements: FindElementsUseCase; + analyzeLayout: AnalyzeLayoutUseCase; + runCommands: RunCommandsUseCase; renderDiagram: RenderDiagramUseCase; } @@ -184,7 +188,7 @@ export class Cli { /** Writes the error to stderr and sets the exit code. */ printError(err: unknown, json: boolean | undefined): void { const stderr = this.io.stderr; - if (isCliError(err)) { + if (isBpmnError(err)) { if (json) { stderr.write(`${JSON.stringify({ ok: false, error: err.toJSON() })}\n`); } else { @@ -193,7 +197,7 @@ export class Cli { if (details.element) lines.push(` element: ${String(details.element)}`); if (Array.isArray(details.related) && details.related.length) lines.push(` related: ${details.related.join(', ')}`); if (Array.isArray(details.candidates) && details.candidates.length) lines.push(` candidates: ${details.candidates.join(', ')}`); - if (Array.isArray(details.errors)) for (const issue of details.errors as Warning[]) lines.push(` ${validatorTag(issue)}${issue.code}${issue.element ? ` ${issue.element}` : ''}: ${issue.message}${issue.hint ? ` (${issue.hint})` : ''}`); + if (Array.isArray(details.errors)) for (const issue of details.errors as Finding[]) lines.push(` ${validatorTag(issue)}${issue.code}${issue.element ? ` ${issue.element}` : ''}: ${issue.message}${issue.hint ? ` (${issue.hint})` : ''}`); if (Array.isArray(details.warnings)) for (const warning of details.warnings as string[]) lines.push(` ${warning}`); if (details.op !== undefined) lines.push(` op: #${String(details.op)}`); if (details.hint) lines.push(` hint: ${String(details.hint)}`); diff --git a/src/adapters/in/cli/ColorCommand.ts b/src/adapters/in/cli/ColorCommand.ts index 766e59a3..469c9f79 100644 --- a/src/adapters/in/cli/ColorCommand.ts +++ b/src/adapters/in/cli/ColorCommand.ts @@ -11,8 +11,8 @@ export class ColorCommand extends FormatCommand { this.withMutationOptions( this.withSelectors(this.define(program, 'color').addOption(new Option('--color ').makeOptionMandatory())), ).action(async (file: string, ids: string[], options: RawOpts) => { - const op: ColorElements = { op: 'color', ...this.selectorsOf(ids, options, true), color: options['color'] as ColorElements['color'] }; - await this.runFormat(file, op, options); + const command: ColorElements = { op: 'color', ...this.selectorsOf(ids, options, true), color: options['color'] as ColorElements['color'] }; + await this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/CompactCommand.ts b/src/adapters/in/cli/CompactCommand.ts index c7fee166..2e974898 100644 --- a/src/adapters/in/cli/CompactCommand.ts +++ b/src/adapters/in/cli/CompactCommand.ts @@ -10,8 +10,8 @@ import { FormatCommand } from './FormatCommand.js'; export class CompactCommand extends FormatCommand { register(program: CommandType): void { this.withMutationOptions(this.define(program, 'compact')).action((file: string, ids: string[], options: RawOpts) => { - const op: CompactDiagram = { op: 'compact', ...(ids.length ? { ids } : {}) }; - return this.runFormat(file, op, options); + const command: CompactDiagram = { op: 'compact', ...(ids.length ? { ids } : {}) }; + return this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/ConnectCommand.ts b/src/adapters/in/cli/ConnectCommand.ts index 5fdabd8a..01cb20b2 100644 --- a/src/adapters/in/cli/ConnectCommand.ts +++ b/src/adapters/in/cli/ConnectCommand.ts @@ -24,7 +24,7 @@ export class ConnectCommand extends MutationCommand { private async run(file: string, source: string, target: string, options: RawOpts): Promise { const opts = this.mutationOptions(options); this.checkFlowFlags(options, false); - const op: ConnectElements = { + const command: ConnectElements = { op: 'connect', source, target, @@ -32,6 +32,6 @@ export class ConnectCommand extends MutationCommand { ...givenFlags(options, ['default', 'ifAbsent']), ...(options['condition'] !== undefined ? { condition: String(options['condition']) } : {}), }; - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/ExtensionElementsCommand.ts b/src/adapters/in/cli/ExtensionElementsCommand.ts index 733753f5..054432cf 100644 --- a/src/adapters/in/cli/ExtensionElementsCommand.ts +++ b/src/adapters/in/cli/ExtensionElementsCommand.ts @@ -1,5 +1,4 @@ import type { Command as CommandType } from 'commander'; -import { ExtensionLister } from '../../../domain/document/ExtensionLister.js'; import type { EditExtensionElements } from '../../../domain/commands/types.js'; import type { RawOpts } from './CliCommand.js'; import { MutationCommand } from './MutationCommand.js'; @@ -29,7 +28,7 @@ export class ExtensionElementsCommand extends MutationCommand { private async add(file: string, id: string, type: string, attrs: string[], options: RawOpts): Promise { const opts = this.mutationOptions(options); - const op: EditExtensionElements = { + const command: EditExtensionElements = { op: 'ext', id, action: 'add', @@ -38,23 +37,23 @@ export class ExtensionElementsCommand extends MutationCommand { ...givenStrings(options, ['body', 'xml']), ...givenFlags(options, ['replace']), }; - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } /** `2` or `loop.2` is an index into the extension elements; anything else a selector. */ private async remove(file: string, id: string, typeOrIndex: string, options: RawOpts): Promise { const opts = this.mutationOptions(options); const indexed = /^(?:(definition|loop|condition)\.)?(\d+)$/.exec(typeOrIndex.trim()); - const op: EditExtensionElements = indexed + const command: EditExtensionElements = indexed ? { op: 'ext', id, action: 'remove', index: Number(indexed[2]), ...(indexed[1] ? { slot: indexed[1] as EditExtensionElements['slot'] } : {}) } : { op: 'ext', id, action: 'remove', type: typeOrIndex }; - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } private async list(file: string, id: string, options: RawOpts): Promise { const doc = await this.cli.readDocArg(file); const el = doc.require(id); - const items = new ExtensionLister().listAll(el); + const items = this.cli.useCases.showModel.allExtensions({ element: el }); if (options['json']) this.cli.printJson(items, !!options['pretty']); else this.cli.print(renderExtensionList(items)); } diff --git a/src/adapters/in/cli/FindCommand.ts b/src/adapters/in/cli/FindCommand.ts index ce90100a..665980b0 100644 --- a/src/adapters/in/cli/FindCommand.ts +++ b/src/adapters/in/cli/FindCommand.ts @@ -1,5 +1,4 @@ import type { Command as CommandType } from 'commander'; -import { assertKindToken } from '../../../domain/document/utils/kinds.js'; import { CliCommand, type RawOpts } from './CliCommand.js'; import { renderFind } from '../report/utils/view-lines.js'; @@ -18,7 +17,7 @@ export class FindCommand extends CliCommand { /** Kind tokens fail with the documented kind codes (like `add`), not commander's text. */ private kindArg(value: string): string { - assertKindToken(value); + this.cli.useCases.findElements.assertKind({ kind: value }); return value; } } diff --git a/src/adapters/in/cli/FormatCommand.ts b/src/adapters/in/cli/FormatCommand.ts index 34353ecc..4104508a 100644 --- a/src/adapters/in/cli/FormatCommand.ts +++ b/src/adapters/in/cli/FormatCommand.ts @@ -38,8 +38,8 @@ export abstract class FormatCommand extends MutationCommand { return out; } - protected async runFormat(file: string, op: EditCommand, options: RawOpts): Promise { + protected async runFormat(file: string, command: EditCommand, options: RawOpts): Promise { const opts = this.mutationOptions(options); - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/InsertSpaceCommand.ts b/src/adapters/in/cli/InsertSpaceCommand.ts index 16a2da97..ab180995 100644 --- a/src/adapters/in/cli/InsertSpaceCommand.ts +++ b/src/adapters/in/cli/InsertSpaceCommand.ts @@ -20,8 +20,8 @@ export class InsertSpaceCommand extends FormatCommand { }), ).action(async (file: string, options: RawOpts) => { this.checkFlagGroups(options, [['after', 'below']], 'space needs --after (horizontal space) or --below (vertical space)'); - const op: InsertSpace = { op: 'space', ...this.pick(options, ['after', 'below'] as const), ...(options['by'] !== undefined ? { by: options['by'] as InsertSpace['by'] } : {}) }; - await this.runFormat(file, op, options); + const command: InsertSpace = { op: 'space', ...this.pick(options, ['after', 'below'] as const), ...(options['by'] !== undefined ? { by: options['by'] as InsertSpace['by'] } : {}) }; + await this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/LabelSideCommand.ts b/src/adapters/in/cli/LabelSideCommand.ts index 32bea683..68715d04 100644 --- a/src/adapters/in/cli/LabelSideCommand.ts +++ b/src/adapters/in/cli/LabelSideCommand.ts @@ -10,8 +10,8 @@ export class LabelSideCommand extends FormatCommand { register(program: CommandType): void { this.withMutationOptions(this.define(program, 'label').addOption(new Option('--side ').makeOptionMandatory())).action( (file: string, id: string, options: RawOpts) => { - const op: PlaceLabel = { op: 'label', id, side: options['side'] as PlaceLabel['side'] }; - return this.runFormat(file, op, options); + const command: PlaceLabel = { op: 'label', id, side: options['side'] as PlaceLabel['side'] }; + return this.runFormat(file, command, options); }, ); } diff --git a/src/adapters/in/cli/LayoutMetricsCommand.ts b/src/adapters/in/cli/LayoutMetricsCommand.ts index 5efbe99a..bf89bd7e 100644 --- a/src/adapters/in/cli/LayoutMetricsCommand.ts +++ b/src/adapters/in/cli/LayoutMetricsCommand.ts @@ -1,5 +1,4 @@ import type { Command as CommandType } from 'commander'; -import { LayoutAnalysis } from '../../../domain/diagram/di/LayoutAnalysis.js'; import { metricsJson } from '../report/utils/metrics-json.js'; import { CliCommand, type RawOpts } from './CliCommand.js'; import { MetricsText } from '../report/MetricsText.js'; @@ -12,7 +11,7 @@ export class LayoutMetricsCommand extends CliCommand { register(program: CommandType): void { this.withJsonOptions(this.define(program, 'metrics')).action(async (file: string, options: RawOpts) => { const doc = await this.cli.readDocArg(file); - const metrics = LayoutAnalysis.of(doc.definitions); + const metrics = this.cli.useCases.analyzeLayout.measure({ definitions: doc.definitions }); if (options['json']) this.cli.printJson({ file, ...metricsJson(metrics) }, !!options['pretty']); else this.cli.print(new MetricsText(metrics).text()); }); diff --git a/src/adapters/in/cli/MoveCommand.ts b/src/adapters/in/cli/MoveCommand.ts index 436311ba..edad5ec6 100644 --- a/src/adapters/in/cli/MoveCommand.ts +++ b/src/adapters/in/cli/MoveCommand.ts @@ -24,15 +24,15 @@ export class MoveCommand extends MutationCommand { private async run(file: string, ids: string[], options: RawOpts): Promise { const opts = this.mutationOptions(options); this.checkPlacementFlags(options); - const op: MoveElements = { + const command: MoveElements = { op: 'move', ids, ...givenStrings(options, ['after', 'before', 'flow', 'in', 'on']), ...(options['lane'] !== undefined ? { lane: String(options['lane']) } : {}), }; - if (!op.after && !op.before && !op.flow && !op.in && !op.on && op.lane === undefined) { + if (!command.after && !command.before && !command.flow && !command.in && !command.on && command.lane === undefined) { throw usageError('move needs one of --after, --before, --flow, --in, --on or --lane'); } - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/MutationCommand.ts b/src/adapters/in/cli/MutationCommand.ts index f3101a16..36e75d89 100644 --- a/src/adapters/in/cli/MutationCommand.ts +++ b/src/adapters/in/cli/MutationCommand.ts @@ -11,7 +11,7 @@ import { RENDER_MAX_SIZE, type RenderOptions } from '../../../domain/views/rende import { changeHighlight } from '../report/utils/highlight.js'; import { type MutationReport, type MutationSummary } from '../report/command-report.js'; import { RenderReport } from './RenderReport.js'; -import { isCliError, usageError } from '../../../domain/document/utils/errors.js'; +import { isBpmnError, usageError } from '../../../domain/document/utils/errors.js'; import { CliCommand, type RawOpts } from './CliCommand.js'; import type { CliStream } from './Cli.js'; import { renderFormat, renderTarget } from './utils/flags.js'; @@ -63,11 +63,7 @@ export abstract class MutationCommand extends CliCommand { .option(...commonOption('--show')) .option(...commonOption('--strict')) .option(...commonOption('--keep-flow-ids')) - .option(...commonOption('--no-keep-flow-ids')) - .option(...commonOption('--engine'), (raw: string) => { - if (raw !== 'clean' && raw !== 'auto') throw new InvalidArgumentError('expected clean or auto'); - return raw; - }); + .option(...commonOption('--no-keep-flow-ids')); } protected withTriggerOptions(cmd: CommandType): CommandType { @@ -97,7 +93,6 @@ export abstract class MutationCommand extends CliCommand { show: !!options['show'], strict: !!options['strict'], ...(options['render'] !== undefined ? { render: String(options['render']) } : {}), - ...(options['engine'] ? { engine: options['engine'] as 'clean' | 'auto' } : {}), ...(keepFlowIds !== undefined ? { keepFlowIds } : {}), ...(keepFlowIdsEnv !== undefined ? { keepFlowIdsEnv } : {}), ...this.lintOptions(options), @@ -125,18 +120,18 @@ export abstract class MutationCommand extends CliCommand { } /** Applies `ops` to `file` (`-`: stdin). */ - protected async mutate(file: string, ops: EditCommand[], opts: MutationFlags): Promise { + protected async mutate(file: string, commands: EditCommand[], opts: MutationFlags): Promise { try { await this.runMutation( file, opts, - (fileOptions) => this.cli.useCases.editDocument.edit({ ...fileOptions, source: { file }, commands: ops }), + (fileOptions) => this.cli.useCases.editDocument.edit({ ...fileOptions, source: { file }, commands }), (doc, fileOptions) => { - return this.cli.useCases.editDocument.edit({ ...fileOptions, source: { doc }, commands: ops }); + return this.cli.useCases.editDocument.edit({ ...fileOptions, source: { doc }, commands }); }, ); } catch (err) { - throw this.withFile(this.keptIdsHint(err, ops, opts.keepFlowIdsEnv), file); + throw this.withFile(this.keptIdsHint(err, commands, opts.keepFlowIdsEnv), file); } } @@ -198,7 +193,7 @@ export abstract class MutationCommand extends CliCommand { const shot = await this.cli.useCases.renderDiagram.render({ source: { xml }, opts, format: renderFormat(undefined, target), out: target, stem: 'result', stdout: this.cli.io.stdout }); return { shot, lines: new RenderReport(shot).resultLines(highlight), json: { ...shot } }; } catch (err) { - if (!isCliError(err)) throw err; + if (!isBpmnError(err)) throw err; return { lines: [RenderReport.failureLine(err)], json: { error: err.toJSON() } }; } } @@ -234,8 +229,8 @@ export abstract class MutationCommand extends CliCommand { * With kept flow ids, a missing id may be the one the default gives a flow an earlier op * rewired; the hint says so, as whoever reads the error may not see the variable. */ - private keptIdsHint(err: unknown, ops: readonly EditCommand[], value: string | undefined): unknown { - if (value === undefined || ops.length < 2 || !isCliError(err) || err.code !== 'E_NOT_FOUND') return err; + private keptIdsHint(err: unknown, commands: readonly EditCommand[], value: string | undefined): unknown { + if (value === undefined || commands.length < 2 || !isBpmnError(err) || err.code !== 'E_NOT_FOUND') return err; const kept = `If it is the id a flow an earlier op gave other ends would be renamed to: flow ids are kept here (BPMN_KEEP_FLOW_IDS=${value}), so that flow kept its old id; use the old id, or --no-keep-flow-ids to rename it after its new ends.`; const hint = err.details.hint; err.details.hint = typeof hint === 'string' && hint ? `${hint} ${kept}` : kept; @@ -244,7 +239,7 @@ export abstract class MutationCommand extends CliCommand { /** Puts the file name into a refusal's runnable command (E_NO_ROOM) and its hint. */ private withFile(err: unknown, file: string): unknown { - if (!isCliError(err) || typeof err.details['command'] !== 'string' || file === '-') return err; + if (!isBpmnError(err) || typeof err.details['command'] !== 'string' || file === '-') return err; const quoted = /^[\w./@%+=:,-]+$/.test(file) ? file : `'${file.replace(/'/g, `'\\''`)}'`; err.details['command'] = String(err.details['command']).replaceAll('', quoted); if (typeof err.details.hint === 'string') err.details.hint = err.details.hint.replaceAll('bpmn space ', `bpmn space ${quoted}`); diff --git a/src/adapters/in/cli/PlaceCommand.ts b/src/adapters/in/cli/PlaceCommand.ts index 701e0165..cdd3b892 100644 --- a/src/adapters/in/cli/PlaceCommand.ts +++ b/src/adapters/in/cli/PlaceCommand.ts @@ -29,8 +29,8 @@ export class PlaceCommand extends FormatCommand { return; } this.checkFlagGroups(options, [['rowOf', 'below', 'above'], ['columnOf', 'after', 'before']], 'place needs a row (--row-of, --below, --above) and/or a column (--column-of, --after, --before), or --side for a boundary event'); - const op: PlaceShapes = { op: 'place', ...this.selectorsOf(ids, options, true), ...this.pick(options, ['rowOf', 'below', 'above', 'columnOf', 'after', 'before'] as const) }; - await this.runFormat(file, op, options); + const command: PlaceShapes = { op: 'place', ...this.selectorsOf(ids, options, true), ...this.pick(options, ['rowOf', 'below', 'above', 'columnOf', 'after', 'before'] as const) }; + await this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/RemoveElementsCommand.ts b/src/adapters/in/cli/RemoveElementsCommand.ts index 116fb653..a450e2eb 100644 --- a/src/adapters/in/cli/RemoveElementsCommand.ts +++ b/src/adapters/in/cli/RemoveElementsCommand.ts @@ -20,12 +20,12 @@ export class RemoveElementsCommand extends MutationCommand { private async run(file: string, ids: string[], options: RawOpts): Promise { const opts = this.mutationOptions(options); - const op: RemoveElements = { + const command: RemoveElements = { op: 'remove', ids, bridge: options['bridge'] !== false, ...givenFlags(options, ['bridgeAll', 'withBranch', 'ifExists']), }; - await this.mutate(file, [op], opts); + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/RenderReport.ts b/src/adapters/in/cli/RenderReport.ts index 423f0fb0..ea7a290d 100644 --- a/src/adapters/in/cli/RenderReport.ts +++ b/src/adapters/in/cli/RenderReport.ts @@ -1,8 +1,8 @@ import type { Shot } from '../../../application/port/out/WriteImagePort.js'; -import { AROUND_DEFAULT_DEPTH } from '../../../domain/views/context.js'; +import { AROUND_DEFAULT_DEPTH } from '../../../domain/views/around-view.js'; import type { RenderHighlight, RenderOptions } from '../../../domain/views/render/types.js'; import { scaleText } from '../../../domain/views/utils/scale.js'; -import type { CliError } from '../../../domain/document/CliError.js'; +import type { BpmnError } from '../../../domain/document/BpmnError.js'; export class RenderReport { constructor(private readonly shot: Shot) {} @@ -18,7 +18,7 @@ export class RenderReport { return [`render: ${lines[0]!}`, ...lines.slice(1).map((line) => ` ${line}`)]; } - static failureLine(err: CliError): string { + static failureLine(err: BpmnError): string { return `render: failed ${err.code}: ${err.message}${err.details.hint ? ` (${String(err.details.hint)})` : ''}`; } diff --git a/src/adapters/in/cli/ReorderCommand.ts b/src/adapters/in/cli/ReorderCommand.ts index 67e1b167..da8c695f 100644 --- a/src/adapters/in/cli/ReorderCommand.ts +++ b/src/adapters/in/cli/ReorderCommand.ts @@ -14,7 +14,7 @@ export class ReorderCommand extends MutationCommand { private async run(file: string, nodeId: string, flowIds: string[], options: RawOpts): Promise { const opts = this.mutationOptions(options); - const op: ReorderElements = { op: 'order', id: nodeId, flows: flowIds }; - await this.mutate(file, [op], opts); + const command: ReorderElements = { op: 'order', id: nodeId, flows: flowIds }; + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/RetypeCommand.ts b/src/adapters/in/cli/RetypeCommand.ts index 8d927e07..c39c8363 100644 --- a/src/adapters/in/cli/RetypeCommand.ts +++ b/src/adapters/in/cli/RetypeCommand.ts @@ -13,7 +13,7 @@ export class RetypeCommand extends MutationCommand { private async run(file: string, id: string, kind: string, options: RawOpts): Promise { const opts = this.mutationOptions(options); - const op: ChangeElementType = { op: 'retype', id, kind, ...this.triggerOptions(options) }; - await this.mutate(file, [op], opts); + const command: ChangeElementType = { op: 'retype', id, kind, ...this.triggerOptions(options) }; + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/RouteCommand.ts b/src/adapters/in/cli/RouteCommand.ts index 6d6cb39f..51f1aa1f 100644 --- a/src/adapters/in/cli/RouteCommand.ts +++ b/src/adapters/in/cli/RouteCommand.ts @@ -13,8 +13,8 @@ export class RouteCommand extends FormatCommand { .option('--exit ') .option('--entry '), ).action((file: string, flowId: string, options: RawOpts) => { - const op: RerouteFlow = { op: 'route', id: flowId, ...(this.pick(options, ['exit', 'entry'] as const) as Pick) }; - return this.runFormat(file, op, options); + const command: RerouteFlow = { op: 'route', id: flowId, ...(this.pick(options, ['exit', 'entry'] as const) as Pick) }; + return this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/SetPropertiesCommand.ts b/src/adapters/in/cli/SetPropertiesCommand.ts index 233ac336..08edfa72 100644 --- a/src/adapters/in/cli/SetPropertiesCommand.ts +++ b/src/adapters/in/cli/SetPropertiesCommand.ts @@ -1,6 +1,6 @@ import type { Command as CommandType } from 'commander'; import type { SetProperties } from '../../../domain/commands/types.js'; -import { CliError } from '../../../domain/document/CliError.js'; +import { BpmnError } from '../../../domain/document/BpmnError.js'; import type { RawOpts } from './CliCommand.js'; import { MutationCommand } from './MutationCommand.js'; import { parseKeyValues } from './utils/flags.js'; @@ -18,8 +18,8 @@ export class SetPropertiesCommand extends MutationCommand { private async run(file: string, id: string, keyValues: string[], options: RawOpts): Promise { const opts = this.mutationOptions(options); const unset = (options['unset'] as string[]) ?? []; - if (!keyValues.length && !unset.length) throw new CliError('E_USAGE', 'Nothing to set: pass key=value pairs or --unset ', 'usage'); - const op: SetProperties = { op: 'set', id, values: parseKeyValues(keyValues), ...(unset.length ? { unset } : {}) }; - await this.mutate(file, [op], opts); + if (!keyValues.length && !unset.length) throw new BpmnError('E_USAGE', 'Nothing to set: pass key=value pairs or --unset ', 'usage'); + const command: SetProperties = { op: 'set', id, values: parseKeyValues(keyValues), ...(unset.length ? { unset } : {}) }; + await this.mutate(file, [command], opts); } } diff --git a/src/adapters/in/cli/ShowCommand.ts b/src/adapters/in/cli/ShowCommand.ts index 1262f2f8..415298c9 100644 --- a/src/adapters/in/cli/ShowCommand.ts +++ b/src/adapters/in/cli/ShowCommand.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import type { Command as CommandType } from 'commander'; import { ShownText } from '../report/ShownText.js'; -import { renderConventions } from '../../../domain/document/utils/conventions-text.js'; +import { renderConventions } from '../report/utils/conventions-text.js'; import type { ViewOptions } from '../../../domain/views/ViewRequest.js'; import { usageError } from '../../../domain/document/utils/errors.js'; import { CliCommand, type RawOpts } from './CliCommand.js'; diff --git a/src/adapters/in/cli/TidyCommand.ts b/src/adapters/in/cli/TidyCommand.ts index 048e1879..7e85b9d7 100644 --- a/src/adapters/in/cli/TidyCommand.ts +++ b/src/adapters/in/cli/TidyCommand.ts @@ -9,8 +9,8 @@ import { FormatCommand } from './FormatCommand.js'; export class TidyCommand extends FormatCommand { register(program: CommandType): void { this.withMutationOptions(this.withSelectors(this.define(program, 'tidy'))).action(async (file: string, ids: string[], options: RawOpts) => { - const op: TidyShapes = { op: 'tidy', ...this.selectorsOf(ids, options, false) }; - await this.runFormat(file, op, options); + const command: TidyShapes = { op: 'tidy', ...this.selectorsOf(ids, options, false) }; + await this.runFormat(file, command, options); }); } } diff --git a/src/adapters/in/cli/utils/flags.ts b/src/adapters/in/cli/utils/flags.ts index 1b73cdbc..8529a065 100644 --- a/src/adapters/in/cli/utils/flags.ts +++ b/src/adapters/in/cli/utils/flags.ts @@ -1,14 +1,14 @@ import { extname } from 'node:path'; import { InvalidArgumentError } from 'commander'; import type { ImageFormat } from '../../../../application/port/out/WriteImagePort.js'; -import { CliError } from '../../../../domain/document/CliError.js'; +import { BpmnError } from '../../../../domain/document/BpmnError.js'; import { usageError } from '../../../../domain/document/utils/errors.js'; export function parseKeyValues(items: string[], what = 'property'): Record { const out: Record = {}; for (const item of items) { const idx = item.indexOf('='); - if (idx <= 0) throw new CliError('E_USAGE', `Expected ${what} as key=value, got "${item}"`, 'usage'); + if (idx <= 0) throw new BpmnError('E_USAGE', `Expected ${what} as key=value, got "${item}"`, 'usage'); out[item.slice(0, idx).trim()] = item.slice(idx + 1); } return out; diff --git a/src/adapters/in/report/AgentGuide.ts b/src/adapters/in/report/AgentGuide.ts index a73e3e1f..43e19193 100644 --- a/src/adapters/in/report/AgentGuide.ts +++ b/src/adapters/in/report/AgentGuide.ts @@ -1,5 +1,5 @@ import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js'; -import { FORMAT_OP_NAMES, OP_NAMES } from '../../../domain/commands/utils/op-names.js'; +import { DIAGRAM_COMMAND_NAMES, COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js'; import { usageError } from '../../../domain/document/utils/errors.js'; import { COMMANDS, COMMON_OPTIONS } from './CommandCatalogue.js'; import { COMMON_ERRORS, ERROR_CATALOGUE, TOP_ERRORS, TRIGGER_OPTIONS } from './guide.js'; @@ -191,7 +191,7 @@ export const GUIDE_TOPICS: Record`. */ private opKeyLines(): string[] { const triggerKeys = new Set([...TRIGGER_OPTIONS.flatMap((trigger) => trigger.keys), 'nonInterrupting']); - return OP_NAMES.map((opName) => { - const fields = Object.entries(EditCommandDefinitions.of(opName).fields).filter(([key]) => !FULL_GUIDE_ONLY_KEYS.has(key)); + return COMMAND_NAMES.map((commandName) => { + const fields = Object.entries(EditCommandDefinitions.of(commandName).fields).filter(([key]) => !FULL_GUIDE_ONLY_KEYS.has(key)); const folds = fields.filter(([key]) => triggerKeys.has(key)).length > 3; const keys = fields.filter(([key]) => !folds || !triggerKeys.has(key)).map(([key, spec]) => (spec.required ? `${key}*` : key)); if (folds) keys.push(''); - return ` ${opName.padEnd(8)} ${keys.join(' ')}`; + return ` ${commandName.padEnd(8)} ${keys.join(' ')}`; }); } } diff --git a/src/adapters/in/report/AroundText.ts b/src/adapters/in/report/AroundText.ts index cc0b55c0..53bbe36f 100644 --- a/src/adapters/in/report/AroundText.ts +++ b/src/adapters/in/report/AroundText.ts @@ -1,6 +1,5 @@ -import type { AroundNode, AroundScope, AroundView } from '../../../domain/views/context.js'; -import { truncate } from '../../../domain/views/utils/view.js'; -import { annotationLine, DOC_MAX, flowText, implementationFlags, INDENT, messageFlowLine, named, quote, scalar } from './utils/view-lines.js'; +import type { AroundNode, AroundScope, AroundView } from '../../../domain/views/around-view.js'; +import { annotationLine, elementFlags, elementLine, flowText, flowTraits, implementationFlags, INDENT, messageFlowLine, named } from './utils/view-lines.js'; /** * `bpmn show --around `: a header with what is shown and left out, then the window's @@ -28,12 +27,8 @@ export class AroundText { for (const node of view.nodes) { this.heading(node.scope); const hostShown = !!node.host && shownIds.has(node.host); - const parts = [node.kind, node.id]; - if (node.name) parts.push(quote(node.name)); - const flags = AroundText.flags(node, hostShown); - if (flags.length) parts.push(`[${flags.join(', ')}]`); - let line = parts.join(' '); - if (node.from?.length) line += ` <- ${node.from.map((flow) => `${flow.source} (${flow.id}${flow.name ? ` ${quote(flow.name)}` : ''})`).join(', ')}`; + let line = elementLine(node, AroundText.flags(node, hostShown)); + if (node.from?.length) line += ` <- ${node.from.map((flow) => `${flow.source} (${[flow.id, ...flowTraits(flow)].join(' ')})`).join(', ')}`; if (node.outgoing.length) line += ` -> ${node.outgoing.map(flowText).join(', ')}`; out.push(INDENT.repeat(this.level(node.scope) + 1 + (hostShown ? 1 : 0)) + line); } @@ -69,16 +64,10 @@ export class AroundText { } private static flags(node: AroundNode, hostShown: boolean): string[] { - const flags: string[] = []; - if (node.host && !hostShown) flags.push(`on ${node.host}`); - if (node.lane) flags.push(`lane=${node.lane}`); - if (node.trigger) flags.push(node.trigger); - if (node.nonInterrupting) flags.push('non-interrupting'); - if (node.expanded !== undefined) flags.push(node.expanded ? 'expanded' : 'collapsed'); - if (node.content) flags.push(`content: ${node.content} node${node.content === 1 ? '' : 's'}`); - for (const [key, value] of Object.entries(node.props ?? {})) flags.push(`${key}=${scalar(value)}`); - flags.push(...implementationFlags(node.impl)); - if (node.documentation) flags.push(`doc: ${quote(truncate(node.documentation, DOC_MAX))}`); - return flags; + return elementFlags(node, { + leading: node.host && !hostShown ? [`on ${node.host}`] : [], + afterState: node.content ? [`content: ${node.content} node${node.content === 1 ? '' : 's'}`] : [], + vendor: implementationFlags(node.impl), + }); } } diff --git a/src/adapters/in/report/CommandCatalogue.ts b/src/adapters/in/report/CommandCatalogue.ts index f4ce4dbc..f7ccb44a 100644 --- a/src/adapters/in/report/CommandCatalogue.ts +++ b/src/adapters/in/report/CommandCatalogue.ts @@ -73,7 +73,6 @@ export const COMMON_OPTIONS: CommonOptionDoc[] = [ { option: '--strict', description: 'exit 5 when the result has warnings', help: 'exit with code 5 when there are warnings' }, { option: '--keep-flow-ids', description: 'keep the id of every existing sequence flow the change gives other ends, where flow ids are API (generated code, process tests); W_FLOW_ID_STALE names each flow whose id no longer names its ends, with the `bpmn set id=` that renames it. Default: a flow whose id names its old ends is renamed after its new ones (`renamed:`). BPMN_KEEP_FLOW_IDS=1 (true, yes, on) makes keeping the default, 0 (false, no, off) or unset renames, any other value is E_USAGE', help: 'keep the id of every existing sequence flow the change gives other ends, instead of renaming a flow whose id names its old ends after its new ones; W_FLOW_ID_STALE names each flow whose id no longer names its ends. BPMN_KEEP_FLOW_IDS=1 makes keeping the default' }, { option: '--no-keep-flow-ids', description: 'rename such flows after their new ends, also when BPMN_KEEP_FLOW_IDS=1' }, - { option: '--engine ', description: 'layout engine: clean (built-in, default: straight happy path, branches as bands below, loops underneath) or auto (bpmn-auto-layout)', flags: '--engine ', help: 'layout engine: clean (default) or auto (bpmn-auto-layout)' }, ]; export function commandDoc(name: string): CommandDoc { diff --git a/src/adapters/in/report/ContextText.ts b/src/adapters/in/report/ContextText.ts index 8c9305d5..402c49d6 100644 --- a/src/adapters/in/report/ContextText.ts +++ b/src/adapters/in/report/ContextText.ts @@ -1,6 +1,6 @@ -import type { ContextCatch, ContextLink, ContextMessage, ContextRef, ElementContext } from '../../../domain/views/context.js'; +import type { ContextCatch, ContextLink, ContextMessage, ContextRef, ElementContext } from '../../../domain/views/around-view.js'; import { truncate } from '../../../domain/views/utils/view.js'; -import { detailMessageFlowText, DOC_MAX, flagValue, implementationFlags, named, quote, scalar } from './utils/view-lines.js'; +import { detailMessageFlowText, elementFlags, elementLine, flagValue, flowTraits, implementationFlags, named, quote } from './utils/view-lines.js'; /** * `bpmn show --context`: the element's line, then one line per context @@ -12,12 +12,8 @@ export class ContextText { text(): string { const context = this.context; const out: string[] = []; - const flags: string[] = []; - if (context.trigger) flags.push(context.trigger); - if (context.nonInterrupting) flags.push('non-interrupting'); - for (const [key, value] of Object.entries(context.props ?? {})) flags.push(`${key}=${scalar(value)}`); - if (context.documentation) flags.push(`doc: ${quote(truncate(context.documentation, DOC_MAX))}`); - out.push(`${context.kind} ${named(context.id, context.name)}${flags.length ? ` [${flags.join(', ')}]` : ''}`); + const { trigger, nonInterrupting, props, documentation } = context; + out.push(elementLine(context, elementFlags({ trigger, nonInterrupting, props, documentation }))); const impl = implementationFlags(context.implementation); if (impl.length) out.push(`implementation: ${impl.join(', ')}`); if (context.message) out.push(`message: ${ContextText.messageText(context.message)}`); @@ -50,10 +46,7 @@ export class ContextText { } private static linkText(link: ContextLink): string { - const flow = [link.flow]; - if (link.flowName) flow.push(quote(link.flowName)); - if (link.condition) flow.push(`if ${truncate(link.condition, 120)}`); - if (link.default) flow.push('default'); + const flow = [link.flow, ...flowTraits({ name: link.flowName, condition: link.condition, default: link.default })]; return `${ContextText.refText(link)} (${flow.join(' ')})${link.pool ? ` in ${named(link.pool, link.poolName)}` : ''}`; } diff --git a/src/adapters/in/report/DetailText.ts b/src/adapters/in/report/DetailText.ts index 6cc2ae64..8d36a3d4 100644 --- a/src/adapters/in/report/DetailText.ts +++ b/src/adapters/in/report/DetailText.ts @@ -1,6 +1,6 @@ -import type { ElementDetail, ViewFlow } from '../../../domain/views/model-view.js'; +import type { ElementDetail, FlowRef } from '../../../domain/views/model-view.js'; import { truncate } from '../../../domain/views/utils/view.js'; -import { detailMessageFlowText, extensionLines, INDENT, named, quote, scalar } from './utils/view-lines.js'; +import { detailMessageFlowText, extensionLines, flowTraits, INDENT, named, quote, scalar } from './utils/view-lines.js'; /** `bpmn show ` as `key: value` lines (property keys are `set` keys). */ export class DetailText { @@ -36,8 +36,8 @@ export class DetailText { out.push(`${key}: ${scalar(value)}`); printed.add(key); } - if (detail.incoming.length) out.push(`incoming: ${detail.incoming.map(DetailText.incomingText).join('; ')}`); - if (detail.outgoing.length) out.push(`outgoing: ${detail.outgoing.map(DetailText.outgoingText).join('; ')}`); + if (detail.incoming.length) out.push(`incoming: ${detail.incoming.map((flow) => DetailText.flowEndText(flow, 'from', flow.source)).join('; ')}`); + if (detail.outgoing.length) out.push(`outgoing: ${detail.outgoing.map((flow) => DetailText.flowEndText(flow, 'to', flow.target)).join('; ')}`); if (detail.boundary?.length) out.push(`boundary: ${detail.boundary.join(', ')}`); if (detail.children?.length) out.push(`children: ${detail.children.join(', ')}`); if (detail.messageFlows?.length) out.push(`message flows: ${detail.messageFlows.map(detailMessageFlowText).join('; ')}`); @@ -68,21 +68,7 @@ export class DetailText { return out.join('\n'); } - private static incomingText(flow: ElementDetail['incoming'][number] & { conditionResource?: string }): string { - const parts = [flow.id, 'from', flow.source]; - if (flow.name) parts.push(quote(flow.name)); - if (flow.condition) parts.push(`if ${flow.condition}`); - else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`); - if (flow.default) parts.push('default'); - return parts.join(' '); - } - - private static outgoingText(flow: ViewFlow): string { - const parts = [flow.id, 'to', flow.target]; - if (flow.name) parts.push(quote(flow.name)); - if (flow.condition) parts.push(`if ${flow.condition}`); - else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`); - if (flow.default) parts.push('default'); - return parts.join(' '); + private static flowEndText(flow: FlowRef, side: 'from' | 'to', end: string): string { + return [flow.id, side, end, ...flowTraits(flow, true)].join(' '); } } diff --git a/src/adapters/in/report/KindsReference.ts b/src/adapters/in/report/KindsReference.ts index 7553b0f7..363466b8 100644 --- a/src/adapters/in/report/KindsReference.ts +++ b/src/adapters/in/report/KindsReference.ts @@ -1,20 +1,20 @@ import { LAYOUT_MODES } from '../../../domain/diagram/types.js'; import { Doc } from '../../../domain/document/Doc.js'; -import { EditContext } from '../../../domain/document/EditContext.js'; -import { KINDS, TRIGGER_TYPES, type Family, type KindDef, type Trigger } from '../../../domain/document/utils/kinds.js'; +import { EditSession } from '../../../domain/document/EditSession.js'; +import { KINDS, TRIGGERS, type Family, type KindDef, type Trigger } from '../../../domain/document/Kinds.js'; import { SWATCHES } from '../../../domain/diagram/utils/colors.js'; -import { OPS_SCHEMA } from '../../../domain/commands/OpsSchemaBuilder.js'; +import { OPS_SCHEMA } from './OpsSchemaBuilder.js'; import { opsExample } from '../../../domain/commands/utils/op-fields.js'; import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js'; import { EXTENSION_STRUCTURE } from '../../../domain/editing/extensions/ExtensionStructure.js'; import { NESTED_SLOTS, NESTED_TYPES, type NestedSlot } from '../../../domain/document/utils/nested-slots.js'; -import { SET_KEYS, SetKeyCatalog, type SetKeyDoc } from '../../../domain/editing/SetKeyCatalog.js'; -import { OP_NAMES } from '../../../domain/commands/utils/op-names.js'; +import { SET_KEYS, SetKeyCatalog, type SetKeyDoc } from '../../../domain/editing/properties/SetKeyCatalog.js'; +import { COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js'; import { ZEEBE } from '../../../domain/document/vendor/ZeebeDescriptor.js'; import { usageError } from '../../../domain/document/utils/errors.js'; import { ERROR_CATALOGUE, EXIT_CODE_DOCS, ID_CONVENTIONS, NESTED_SELECTORS, NON_INTERRUPTING_DOC, PLACEMENT_DOCS, PLACEMENT_NOTES, TRIGGER_OPTIONS } from './guide.js'; import { heading, table } from './utils/table.js'; -import { NestedSlots } from '../../../domain/editing/NestedSlots.js'; +import { NestedSlots } from '../../../domain/editing/properties/NestedSlots.js'; const FAMILY_LABELS: Record = { task: 'Tasks', @@ -80,7 +80,7 @@ export class KindsReference { TRIGGER_OPTIONS.map((trigger) => [ trigger.trigger, { - ...(trigger.trigger !== 'none' ? { type: TRIGGER_TYPES[trigger.trigger] } : {}), + ...(trigger.trigger !== 'none' ? { type: TRIGGERS[trigger.trigger].definition } : {}), option: trigger.option, keys: trigger.keys, description: trigger.description, @@ -145,10 +145,10 @@ export class KindsReference { opsSection(): string { const out: string[] = [heading('OPS JSON (bpmn apply ops.json; keys = CLI flags in lowerCamelCase; full schema: bpmn kinds --json -> ops)')]; - for (const opName of OP_NAMES) { - const required = Object.entries(EditCommandDefinitions.of(opName).fields).filter(([, spec]) => spec.required).map(([key]) => key); - out.push(` ${opName.padEnd(8)} ${EditCommandDefinitions.of(opName).description}`); - out.push(` keys: ${Object.keys(EditCommandDefinitions.of(opName).fields).join(', ')}${required.length ? ` (required: ${required.join(', ')})` : ''}`); + for (const commandName of COMMAND_NAMES) { + const required = Object.entries(EditCommandDefinitions.of(commandName).fields).filter(([, spec]) => spec.required).map(([key]) => key); + out.push(` ${commandName.padEnd(8)} ${EditCommandDefinitions.of(commandName).description}`); + out.push(` keys: ${Object.keys(EditCommandDefinitions.of(commandName).fields).join(', ')}${required.length ? ` (required: ${required.join(', ')})` : ''}`); } out.push('\nBatch aliases: "as": "$name" on add / connect / split / split nodes ("flowAs" on add and split nodes: the flow into the'); out.push('node, on a prepend before a join or a node without incoming flow the flow out of it; "outAs" on add: the one flow'); @@ -259,11 +259,11 @@ export class KindsReference { private nestedKeyTable(): Record> { const doc = Doc.create(); - const edit = new EditContext(doc); + const edit = new EditSession(doc); const table = {} as Record>; for (const slot of NESTED_SLOTS) { table[slot] = {}; - for (const type of NESTED_TYPES[slot]) table[slot][type] = new NestedSlots(doc, edit).keysOf(slot, doc.moddle.create(type)); + for (const type of NESTED_TYPES[slot]) table[slot][type] = new NestedSlots(edit).keysOf(slot, doc.moddle.create(type)); } return table; } diff --git a/src/adapters/in/report/ModelViewText.ts b/src/adapters/in/report/ModelViewText.ts index c0b26b5c..81b6efa8 100644 --- a/src/adapters/in/report/ModelViewText.ts +++ b/src/adapters/in/report/ModelViewText.ts @@ -1,7 +1,6 @@ import type { ModelView, ViewLane, ViewNode } from '../../../domain/views/model-view.js'; -import { truncate } from '../../../domain/views/utils/view.js'; import { renderProblems } from './utils/result-lines.js'; -import { annotationLine, DOC_MAX, flagValue, flowText, INDENT, messageFlowLine, quote, scalar, vendorFlags } from './utils/view-lines.js'; +import { annotationLine, elementFlags, elementLine, flagValue, flowText, INDENT, messageFlowLine, quote, vendorFlags } from './utils/view-lines.js'; /** * The model view as text, the default output of `show`: one line per node with its flows inline @@ -85,24 +84,8 @@ export class ModelViewText { } private static nodeLine(node: ViewNode): string { - const parts = [node.kind, node.id]; - if (node.name) parts.push(quote(node.name)); - const flags = ModelViewText.nodeFlags(node); - if (flags.length) parts.push(`[${flags.join(', ')}]`); - let line = parts.join(' '); + let line = elementLine(node, elementFlags(node, { vendor: vendorFlags(node) })); if (node.outgoing.length) line += ` -> ${node.outgoing.map(flowText).join(', ')}`; return node.unreachable ? `! unreachable: ${line}` : line; } - - private static nodeFlags(node: ViewNode): string[] { - const flags: string[] = []; - if (node.lane) flags.push(`lane=${node.lane}`); - if (node.trigger) flags.push(node.trigger); - if (node.nonInterrupting) flags.push('non-interrupting'); - if (node.expanded !== undefined) flags.push(node.expanded ? 'expanded' : 'collapsed'); - for (const [key, value] of Object.entries(node.props ?? {})) flags.push(`${key}=${scalar(value)}`); - flags.push(...vendorFlags(node)); - if (node.documentation) flags.push(`doc: ${quote(truncate(node.documentation, DOC_MAX))}`); - return flags; - } } diff --git a/src/adapters/in/report/MutationReportBuilder.ts b/src/adapters/in/report/MutationReportBuilder.ts index a8c67f89..69d7672b 100644 --- a/src/adapters/in/report/MutationReportBuilder.ts +++ b/src/adapters/in/report/MutationReportBuilder.ts @@ -1,5 +1,5 @@ import type { MutationResult } from '../../../domain/batch/types.js'; -import type { Warning } from '../../../domain/document/CliError.js'; +import type { Finding } from '../../../domain/document/Finding.js'; import type { MutationReport } from './command-report.js'; import { layoutWarnings } from './utils/findings.js'; @@ -7,13 +7,13 @@ export class MutationReportBuilder { constructor(private readonly result: MutationResult) {} /** Without those the validator reports too: the validator's has the richer hint. */ - opWarnings(): Warning[] { + opWarnings(): Finding[] { const validationKeys = new Set(this.result.validation.warnings.map(MutationReportBuilder.findingKey)); return this.result.changes.warnings.filter((warning) => !validationKeys.has(MutationReportBuilder.findingKey(warning))); } /** What --strict counts. */ - warnings(): Warning[] { + warnings(): Finding[] { return [...this.opWarnings(), ...this.result.validation.warnings, ...layoutWarnings(this.result.layout)]; } @@ -70,7 +70,7 @@ export class MutationReportBuilder { } /** An event-gateway finding is about one branch: another target is another finding. */ - private static findingKey(warning: Warning): string { + private static findingKey(warning: Finding): string { return `${warning.code}|${warning.element ?? ''}${warning.code === 'W_EVENT_GATEWAY_TARGET' ? `|${warning.related?.[0] ?? ''}` : ''}`; } } diff --git a/src/domain/commands/OpsSchemaBuilder.ts b/src/adapters/in/report/OpsSchemaBuilder.ts similarity index 85% rename from src/domain/commands/OpsSchemaBuilder.ts rename to src/adapters/in/report/OpsSchemaBuilder.ts index b808f91a..bed65db8 100644 --- a/src/domain/commands/OpsSchemaBuilder.ts +++ b/src/adapters/in/report/OpsSchemaBuilder.ts @@ -1,9 +1,9 @@ -import { ALIAS } from './utils/aliases.js'; -import { OP_NAMES } from './utils/op-names.js'; -import type { FieldSpec } from './utils/field-specs.js'; -import { BRANCH_FIELDS, NODE_FIELDS } from './utils/op-fields.js'; -import { defaultRule } from './utils/schema-rules.js'; -import { EditCommandDefinitions } from './definitions/EditCommandDefinitions.js'; +import { ALIAS } from '../../../domain/commands/utils/aliases.js'; +import { COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js'; +import type { FieldSpec } from '../../../domain/commands/utils/field-specs.js'; +import { BRANCH_FIELDS, NODE_FIELDS } from '../../../domain/commands/utils/op-fields.js'; +import { defaultRule } from '../../../domain/commands/utils/schema-rules.js'; +import { EditCommandDefinitions } from '../../../domain/commands/definitions/EditCommandDefinitions.js'; /** Builds the JSON Schema of the `bpmn apply` ops format from the op field specs. */ class OpsSchemaBuilder { @@ -45,7 +45,7 @@ class OpsSchemaBuilder { } build(): Record { - const opRefs = OP_NAMES.map((opName) => ({ $ref: `#/$defs/${opName}` })); + const commandRefs = COMMAND_NAMES.map((commandName) => ({ $ref: `#/$defs/${commandName}` })); return { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: 'https://bpmn-cli/schemas/ops.json', @@ -56,7 +56,7 @@ class OpsSchemaBuilder { { type: 'object', properties: { $schema: { type: 'string' }, ops: { type: 'array', minItems: 1, items: { $ref: '#/$defs/op' } } }, required: ['ops'], additionalProperties: false }, ], $defs: { - op: { oneOf: opRefs }, + op: { oneOf: commandRefs }, ...Object.fromEntries(EditCommandDefinitions.all().map((definition) => [definition.name, this.objectSchema(definition.fields, { op: definition.name, description: definition.description, allOf: definition.schemaRules() })])), branch: this.objectSchema(BRANCH_FIELDS, { description: 'One branch of a split: flow options of the gateway -> first node flow, then the nodes.', allOf: [defaultRule()] }), node: this.objectSchema(NODE_FIELDS, { description: 'A node inside a split branch: an add op without placement (it is chained after the previous node).', allOf: [defaultRule()] }), diff --git a/src/adapters/in/report/ShownText.ts b/src/adapters/in/report/ShownText.ts index c0ae1f39..2d2fbabd 100644 --- a/src/adapters/in/report/ShownText.ts +++ b/src/adapters/in/report/ShownText.ts @@ -5,14 +5,22 @@ import { DetailText } from './DetailText.js'; import { LayoutText } from './LayoutText.js'; import { ModelViewText } from './ModelViewText.js'; +type ViewOfKind = Extract['view']; + +const RENDERERS: { [Kind in ShownView['kind']]: (view: ViewOfKind) => string } = { + model: (view) => new ModelViewText(view).text(), + detail: (view) => new DetailText(view).text(), + context: (view) => new ContextText(view).text(), + around: (view) => new AroundText(view).text(), + layout: (view) => new LayoutText(view).text(), +}; + /** `bpmn show`: whichever view was asked for, as text. */ export class ShownText { constructor(private readonly shown: ShownView) {} text(): string { - const shown = this.shown; - const text = - shown.kind === 'layout' ? new LayoutText(shown.view).text() : shown.kind === 'detail' ? new DetailText(shown.view).text() : shown.kind === 'context' ? new ContextText(shown.view).text() : shown.kind === 'around' ? new AroundText(shown.view).text() : new ModelViewText(shown.view).text(); - return text.replace(/\n$/, ''); + const render = RENDERERS[this.shown.kind] as (view: ShownView['view']) => string; + return render(this.shown.view).replace(/\n$/, ''); } } diff --git a/src/adapters/in/report/WarningLines.ts b/src/adapters/in/report/WarningLines.ts index 8b7c1ffd..f0bc09b7 100644 --- a/src/adapters/in/report/WarningLines.ts +++ b/src/adapters/in/report/WarningLines.ts @@ -1,6 +1,6 @@ -import type { Warning } from '../../../domain/document/CliError.js'; +import type { Finding } from '../../../domain/document/Finding.js'; import { renderProblems } from './utils/result-lines.js'; -import { FLOOD_MIN, floodKey, idsText, validatorOf, validatorTag, warningTag } from './utils/findings.js'; +import { FLOOD_MIN, floodKey, idsText, validatorTag, warningTag } from './utils/findings.js'; /** Never flooded: each hint is the command for that one element. */ const NEVER_FLOOD = new Set(['W_FLOW_ID_STALE']); @@ -15,12 +15,12 @@ export class WarningLines { * FLOOD_MIN or more warnings of one code become one line at the place of the first; * an advisory validator's findings group by code and message. */ - lines(warnings: readonly Warning[]): string[] { - const keyOf = (warning: Warning): string => { - const validator = validatorOf(warning); + lines(warnings: readonly Finding[]): string[] { + const keyOf = (warning: Finding): string => { + const validator = warning.validator; return validator && this.advisory.has(validator) ? `${warningTag(warning)}${warning.code}\n${warning.message}` : floodKey(warning); }; - const groups = new Map(); + const groups = new Map(); for (const warning of warnings) groups.set(keyOf(warning), [...(groups.get(keyOf(warning)) ?? []), warning]); const out: string[] = []; const done = new Set(); @@ -44,17 +44,17 @@ export class WarningLines { * line naming the most common message. * Advisory rule names carry no E_ / W_, so their errors read `[bpmnlint error]`. */ - problems(problems: readonly Warning[]): string { - const isAdvisory = (problem: Warning): boolean => { - const validator = validatorOf(problem); + problems(problems: readonly Finding[]): string { + const isAdvisory = (problem: Finding): boolean => { + const validator = problem.validator; return !!validator && this.advisory.has(validator); }; - const keyOf = (problem: Warning): string | undefined => { + const keyOf = (problem: Finding): string | undefined => { if (!isAdvisory(problem) || NO_RULE.has(problem.code)) return undefined; - return `${validatorOf(problem)}\n${problem.code}\n${problem.hint ?? ''}`; + return `${problem.validator}\n${problem.code}\n${problem.hint ?? ''}`; }; - const tag = (problem: Warning): string => (isAdvisory(problem) ? warningTag(problem) : validatorTag(problem)); - const groups = new Map(); + const tag = (problem: Finding): string => (isAdvisory(problem) ? warningTag(problem) : validatorTag(problem)); + const groups = new Map(); for (const problem of problems) { const key = keyOf(problem); if (key) groups.set(key, [...(groups.get(key) ?? []), problem]); @@ -70,7 +70,7 @@ export class WarningLines { } if (done.has(key!)) continue; done.add(key!); - const byMessage = new Map(); + const byMessage = new Map(); for (const member of group) byMessage.set(member.message, [...(byMessage.get(member.message) ?? []), member]); const [message, same] = [...byMessage].reduce((best, candidate) => (candidate[1].length > best[1].length ? candidate : best)); const others = byMessage.size - 1; @@ -81,7 +81,7 @@ export class WarningLines { return out.join('\n'); } - private static warningLine(warning: Warning): string { + private static warningLine(warning: Finding): string { return `warning ${warningTag(warning)}${warning.code}${warning.element ? ` ${warning.element}` : ''}: ${warning.message}${warning.hint ? ` (${warning.hint})` : ''}`; } } diff --git a/src/adapters/in/report/command-report.ts b/src/adapters/in/report/command-report.ts index 1e50e8f2..b5327b27 100644 --- a/src/adapters/in/report/command-report.ts +++ b/src/adapters/in/report/command-report.ts @@ -3,8 +3,8 @@ * a host that embeds the library hands its agent exactly what the CLI would print. */ import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js'; -import type { FormatResult } from '../../../domain/diagram/DiagramCommandService.js'; -import type { Warning } from '../../../domain/document/CliError.js'; +import type { DiagramCommandResult } from '../../../domain/diagram/commands/DiagramCommands.js'; +import type { Finding } from '../../../domain/document/Finding.js'; import type { CheckResult } from '../../../application/port/in/ValidateDocumentUseCase.js'; import type { ConventionsReport } from '../../../domain/batch/types.js'; import type { LayoutStatus } from '../../../domain/diagram/types.js'; @@ -19,8 +19,8 @@ import type { ModelView } from '../../../domain/views/model-view.js'; * is only counted (`bpmn validate` lists it). */ export interface WarningReport { - added: Warning[]; - resolved: Warning[]; + added: Finding[]; + resolved: Finding[]; preexistingCount: number; } @@ -52,8 +52,8 @@ export interface MutationReport { /** The JSON of `bpmn validate`. */ export interface ValidationReport { ok: boolean; - errors: Warning[]; - warnings: Warning[]; + errors: Finding[]; + warnings: Finding[]; platform?: PlatformSummary; /** every finding of each validator that ran; they are among errors / warnings too */ validators?: ValidatorReport[]; @@ -74,13 +74,13 @@ export interface MutationSummary { changed: string[]; renamed?: Record; removed: string[]; - forced?: Warning[]; + forced?: Finding[]; /** preexistingCount includes the advisory validators' findings the file already had */ - warnings: { added: Warning[]; resolvedCount: number; preexistingCount: number }; + warnings: { added: Finding[]; resolvedCount: number; preexistingCount: number }; /** the findings validators left out of the comparison, per validator and reason */ leftOut?: Array<{ validator: string; reason: string; count: number }>; /** without a drawing before, only how many problems the result has */ layout: { status: 'ok' | 'skipped'; mode?: 'full' | 'incremental'; score?: { before?: number; after: number }; added?: LayoutProblem[]; problems?: number }; - format?: FormatResult[]; + format?: DiagramCommandResult[]; conventions?: ConventionsReport; } diff --git a/src/adapters/in/report/guide.ts b/src/adapters/in/report/guide.ts index ee56b691..2162622c 100644 --- a/src/adapters/in/report/guide.ts +++ b/src/adapters/in/report/guide.ts @@ -2,10 +2,10 @@ * The hand-written tables of `bpmn kinds` and `bpmn guide`; everything else they print is * derived from the live tables, so the text cannot drift from the code. */ -import { EXIT_CODES } from '../../../domain/document/CliError.js'; +import { EXIT_CODES } from '../../../domain/document/BpmnError.js'; import type { TriggerOptions } from '../../../domain/commands/types.js'; -import type { Trigger } from '../../../domain/document/utils/kinds.js'; -import { FORMAT_OP_NAMES } from '../../../domain/commands/utils/op-names.js'; +import type { Trigger } from '../../../domain/document/Kinds.js'; +import { DIAGRAM_COMMAND_NAMES } from '../../../domain/commands/utils/command-names.js'; import { triggerFlagUsage } from './utils/option-flags.js'; export interface ErrorDoc { @@ -75,7 +75,7 @@ const SCOPE_AND_PLACEMENT_ERRORS: ErrorDoc[] = [ const FORMAT_OPERATION_ERRORS: ErrorDoc[] = [ { code: 'E_NO_MATCH', meaning: 'A selector of a format operation names nothing: no sequence-flow path leads from --path to (over the --via flows), no element of the --kind is drawn, or nothing lies on the --branch (its flow leads straight to the join). `bpmn render`: a crop (--around, --path, --scope, ids) of which nothing is drawn on the plane (no shape, or only on another plane), or a --scope on an empty plane.', fix: 'Check the direction and the --via order (`bpmn show ` lists the flows of every node), the kind name (`bpmn find "" --kind `), or name a flow that starts a branch with its own nodes. render: drop --plane or pass the plane the message names; `bpmn layout ` draws elements without a shape.' }, { code: 'E_NOT_PARTICIPANT', meaning: '`order` with pools: a listed id is a participant, but not one of the given collaboration.', fix: 'List the participants of that collaboration (the candidates are listed): `bpmn order `.' }, - { code: 'E_NO_DIAGRAM', meaning: `The file has no diagram (DI), so a format operation (${FORMAT_OP_NAMES.join(', ')}) has nothing to work on.`, fix: 'Draw it first with `bpmn layout `, or drop --no-layout (a file without diagram is then drawn in full before the format operation runs).' }, + { code: 'E_NO_DIAGRAM', meaning: `The file has no diagram (DI), so a format operation (${DIAGRAM_COMMAND_NAMES.join(', ')}) has nothing to work on.`, fix: 'Draw it first with `bpmn layout `, or drop --no-layout (a file without diagram is then drawn in full before the format operation runs).' }, { code: 'E_NO_SHAPE', meaning: 'The element exists but has no shape or connection in the diagram (added with --no-layout, a flow whose ends a --no-layout write changed, a process that is drawn as its pool, an element outside the drawn root).', fix: 'For a pool use the participant id, not the process id. A flow: `bpmn route ` draws its line. Else the next model edit without --no-layout draws it (`bpmn layout ` redraws all).' }, { code: 'E_DIFFERENT_DIAGRAM', meaning: '`place` / `align`: the element and its reference are drawn on different diagrams (the content of a collapsed sub-process has its own).', fix: 'Use a reference on the same diagram, or expand the sub-process first (`bpmn set expanded=true`).' }, { code: 'E_LEAVES_CONTAINER', meaning: '`place` / `align` would move a node out of its lane, pool or expanded sub-process: its centre would leave the frame, its box would start more than 10 px into the header of its lane / pool (before the border of a sub-process), the reference is the node\'s own frame (a node is not placed beside the sub-process it lives in), or the reference lies outside the node\'s sub-process and the node would land outside it (a sub-process grows towards a reference inside it, never out to one outside). `align` leaves out (noted) a member only a selector (--kind / --path / --branch) named.', fix: 'Change the membership first (`bpmn move --lane `; in a batch a move op before the place op; the hint names the lane at the target position), or choose a reference inside the frame.' }, @@ -116,7 +116,6 @@ const EVENT_AND_RETYPE_ERRORS: ErrorDoc[] = [ const WRITE_TIME_ERRORS: ErrorDoc[] = [ { code: 'E_VALIDATION', meaning: 'The change would introduce structural errors, or errors of a validator (a host\'s validator in library use; the project\'s bpmnlint only when a library host asked for that, lintBlocks), so the file was not written (the new errors are listed in the details, a validator\'s with its name in brackets; errors the file already had do not block, see W_PREEXISTING_ERROR). Validators check the result after the layout, i.e. exactly what would be written.', fix: 'Read the errors in the refusal: the lines under it (JSON `error.errors`, library `err.details.errors`), each with its code and hint, a validator\'s with its name in brackets. The file is unchanged, so `bpmn validate ` does not show them. Change the edit so that it does not add them, e.g. make it in one transaction (`bpmn apply ops.json`) that ends in a valid model, or pass --force (library: force: true) to write anyway.' }, { code: 'E_VALIDATOR_FAILED', meaning: 'A validator (MutationOptions.validators / checkFile validators in library use) threw or did not return an array of findings; nothing was written.', fix: 'Fix the validator or the input it cannot read, or run without it.' }, - { code: 'E_LAYOUT_*', meaning: 'The layout engine could not draw the model (e.g. E_LAYOUT_ERROR); the suffix is the layouter\'s own code, the message names the element.', fix: 'Check the element named in the message (unsupported kind, broken flow graph), or write without DI using --no-layout and run `bpmn layout` later.' }, { code: 'E_LAYOUT_INCREMENTAL', meaning: '`--layout incremental` was requested and the incremental layout (keep the drawing, place what is new) failed; nothing was written.', fix: 'Retry with --layout full (redraws the whole diagram) or --no-layout (writes without updating the diagram), and report the command and file.' }, { code: 'E_INTERNAL', meaning: 'An unexpected internal error (exit 70).', fix: 'Re-run with --json (or BPMN_DEBUG=1 for a stack trace) and report the command and file.' }, ]; diff --git a/src/domain/document/utils/conventions-text.ts b/src/adapters/in/report/utils/conventions-text.ts similarity index 87% rename from src/domain/document/utils/conventions-text.ts rename to src/adapters/in/report/utils/conventions-text.ts index 96f24c0a..3a6c130c 100644 --- a/src/domain/document/utils/conventions-text.ts +++ b/src/adapters/in/report/utils/conventions-text.ts @@ -1,11 +1,5 @@ -import type { PrefixSource } from '../ids/IdStyle.js'; -import type { ConventionsView, Undeclared } from '../ids/ConventionsTable.js'; - -/** - * Notes event triggers whose required qualified prefix new ids cannot have: - * they follow the file's style. - */ -export const UNQUALIFIED_NOTE = "qualified prefix ignored, the file's style (W_CONFIG_IGNORED)"; +import type { PrefixSource } from '../../../../domain/document/ids/IdStyle.js'; +import { UNQUALIFIED_NOTE, type ConventionsView, type Undeclared } from '../../../../domain/document/ids/ConventionsTable.js'; const SOURCE_WORDS: Record = { lint: 'element-id-naming', diff --git a/src/adapters/in/report/utils/findings.ts b/src/adapters/in/report/utils/findings.ts index 17628896..35c74657 100644 --- a/src/adapters/in/report/utils/findings.ts +++ b/src/adapters/in/report/utils/findings.ts @@ -1,5 +1,5 @@ import type { LayoutStatus } from '../../../../domain/diagram/types.js'; -import type { Warning } from '../../../../domain/document/CliError.js'; +import type { Finding } from '../../../../domain/document/Finding.js'; import type { ValidatorReport } from '../../../../domain/validation/validators.js'; /** This many warnings of one code (and validator) or more are printed as one line. */ @@ -7,32 +7,26 @@ export const FLOOD_MIN = 3; export const FLOOD_IDS = 30; -export function validatorOf(warning: Warning): string | undefined { - return (warning as Warning & { validator?: string }).validator; -} - -export function validatorTag(warning: Warning): string { - const name = validatorOf(warning); - return name ? `[${name}] ` : ''; +export function validatorTag(warning: Finding): string { + return warning.validator ? `[${warning.validator}] ` : ''; } /** Only an advisory validator's errors appear among warnings: they read `[bpmnlint error] `. */ -export function warningTag(warning: Warning): string { - const name = validatorOf(warning); - return name && (warning as Warning & { severity?: string }).severity === 'error' ? `[${name} error] ` : validatorTag(warning); +export function warningTag(warning: Finding): string { + return warning.validator && warning.severity === 'error' ? `[${warning.validator} error] ` : validatorTag(warning); } export function idsText(ids: string[]): string { return ids.length > FLOOD_IDS ? `${ids.slice(0, FLOOD_IDS).join(', ')}, +${ids.length - FLOOD_IDS} more` : ids.join(', '); } -export function floodKey(warning: Warning): string { +export function floodKey(warning: Finding): string { return `${validatorTag(warning)}${warning.code}`; } /** `W_DEAD_END Activity_A, W_UNREACHABLE x5 [A, B, C, D, E]` */ -export function findingList(findings: readonly Warning[]): string { - const groups = new Map(); +export function findingList(findings: readonly Finding[]): string { + const groups = new Map(); for (const finding of findings) groups.set(floodKey(finding), [...(groups.get(floodKey(finding)) ?? []), finding]); return [...groups] .map(([key, group]) => (group.length === 1 ? `${key}${group[0]!.element ? ` ${group[0]!.element}` : ''}` : `${key} x${group.length} [${idsText(group.map((group) => group.element ?? '?'))}]`)) @@ -43,6 +37,6 @@ export function advisoryOf(reports: readonly ValidatorReport[] | undefined): Set return new Set((reports ?? []).filter((report) => report.blocks === false).map((report) => report.name)); } -export function layoutWarnings(layout: LayoutStatus): Warning[] { +export function layoutWarnings(layout: LayoutStatus): Finding[] { return layout.warnings.map((warning) => ({ code: `W_LAYOUT_${warning.code}`, message: warning.message, element: warning.elementId })); } diff --git a/src/adapters/in/report/utils/result-lines.ts b/src/adapters/in/report/utils/result-lines.ts index e25f1b84..08bd4004 100644 --- a/src/adapters/in/report/utils/result-lines.ts +++ b/src/adapters/in/report/utils/result-lines.ts @@ -1,8 +1,8 @@ import type { LayoutProblem } from '../../../../domain/diagram/utils/problems.js'; -import type { FormatResult } from '../../../../domain/diagram/DiagramCommandService.js'; +import type { DiagramCommandResult } from '../../../../domain/diagram/commands/DiagramCommands.js'; import type { Change, ChangeSet } from '../../../../domain/document/ChangeSet.js'; import type { LayoutStatus } from '../../../../domain/diagram/types.js'; -import type { Warning } from '../../../../domain/document/CliError.js'; +import type { Finding } from '../../../../domain/document/Finding.js'; import { INDENT, quote } from './view-lines.js'; export type ChangeEntries = Pick; @@ -30,8 +30,8 @@ export function hasNoEntries(cs: Pick; + documentation?: string; +} + /** Identifiers (topics, classes) up to this length stay whole in the around / context views. */ const IMPL_VALUE_MAX = 120; @@ -39,12 +48,18 @@ export function vendorFlags(item: { attrs?: Record; extensions?: return flags; } +/** What follows a flow's id: `"name" if default`; `fullCondition` keeps the condition as written. */ +export function flowTraits(flow: Omit, fullCondition = false): string[] { + const traits: string[] = []; + if (flow.name) traits.push(quote(flow.name)); + if (flow.condition) traits.push(`if ${fullCondition ? flow.condition : truncate(flow.condition, 120)}`); + else if (flow.conditionResource) traits.push(`if resource ${flow.conditionResource}`); + if (flow.default) traits.push('default'); + return traits; +} + export function flowText(flow: ViewFlow): string { - const parts = [flow.id]; - if (flow.name) parts.push(quote(flow.name)); - if (flow.condition) parts.push(`if ${truncate(flow.condition, 120)}`); - else if (flow.conditionResource) parts.push(`if resource ${flow.conditionResource}`); - if (flow.default) parts.push('default'); + const parts = [flow.id, ...flowTraits(flow)]; const flags = [...(flow.language ? [`language=${flow.language}`] : []), ...vendorFlags(flow)]; if (flags.length) parts.push(`[${flags.join(', ')}]`); return `${flow.target} (${parts.join(' ')})`; @@ -54,6 +69,28 @@ export function named(id: string, name?: string): string { return name ? `${id} ${quote(name)}` : id; } +/** ` ["name"] []` */ +export function elementLine(element: { kind: string; id: string; name?: string }, flags: string[]): string { + return `${element.kind} ${named(element.id, element.name)}${flags.length ? ` [${flags.join(', ')}]` : ''}`; +} + +/** + * An element's flags in the order every view prints them: `leading`, lane, trigger, state, + * `afterState`, props, `vendor` (its attributes or its implementation), documentation. + */ +export function elementFlags(element: ElementFlagSource, extra: { leading?: string[]; afterState?: string[]; vendor?: string[] } = {}): string[] { + const flags = [...(extra.leading ?? [])]; + if (element.lane) flags.push(`lane=${element.lane}`); + if (element.trigger) flags.push(element.trigger); + if (element.nonInterrupting) flags.push('non-interrupting'); + if (element.expanded !== undefined) flags.push(element.expanded ? 'expanded' : 'collapsed'); + flags.push(...(extra.afterState ?? [])); + for (const [key, value] of Object.entries(element.props ?? {})) flags.push(`${key}=${scalar(value)}`); + flags.push(...(extra.vendor ?? [])); + if (element.documentation) flags.push(`doc: ${quote(truncate(element.documentation, DOC_MAX))}`); + return flags; +} + /** `messageFlow ["name"]: ["name"] -> ["name"] [message M]` */ export function messageFlowLine(mf: ViewMessageFlow): string { const head = `messageFlow ${mf.id}${mf.name ? ` ${quote(mf.name)}` : ''}`; diff --git a/src/domain/validation/BpmnlintConfigResolution.ts b/src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts similarity index 98% rename from src/domain/validation/BpmnlintConfigResolution.ts rename to src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts index 03506797..cc685164 100644 --- a/src/domain/validation/BpmnlintConfigResolution.ts +++ b/src/adapters/out/bpmnlint/BpmnlintConfigResolution.ts @@ -1,7 +1,7 @@ import { BpmnModdle } from 'bpmn-moddle'; -import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinter, BpmnlintRuleSetting, BpmnlintSeverity, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-validator.js'; +import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinter, BpmnlintRuleSetting, BpmnlintSeverity, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-contract.js'; import { MODDLE_NOT_A_MAP, moddleEntries } from './utils/bpmnlint.js'; -import { firstLine } from '../document/utils/text.js'; +import { firstLine } from '../../../domain/document/utils/text.js'; const MAX_DEPTH = 16; diff --git a/src/domain/validation/BpmnlintValidator.ts b/src/adapters/out/bpmnlint/BpmnlintValidator.ts similarity index 95% rename from src/domain/validation/BpmnlintValidator.ts rename to src/adapters/out/bpmnlint/BpmnlintValidator.ts index be089b73..b702beb7 100644 --- a/src/domain/validation/BpmnlintValidator.ts +++ b/src/adapters/out/bpmnlint/BpmnlintValidator.ts @@ -1,10 +1,10 @@ import { BpmnModdle, type ImportWarning } from 'bpmn-moddle'; -import { isDecisionLinkWarning, reportedImportWarnings } from '../editing/utils/decision-link-warnings.js'; -import type { BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-validator.js'; +import { isDecisionLinkWarning, reportedImportWarnings } from '../../../domain/editing/utils/decision-link-warnings.js'; +import type { BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from './bpmnlint-contract.js'; import { bpmnlintUnavailableFindings } from './utils/bpmnlint.js'; import { LintRun } from './LintRun.js'; -import type { NamedValidator, ValidatorContext, ValidatorFinding } from './validators.js'; -import { firstLine } from '../document/utils/text.js'; +import type { NamedValidator, ValidatorContext, ValidatorFinding } from '../../../domain/validation/validators.js'; +import { firstLine } from '../../../domain/document/utils/text.js'; export const BPMNLINT_VALIDATOR = 'bpmnlint'; diff --git a/src/domain/document/ids/IdConventionsReader.ts b/src/adapters/out/bpmnlint/IdConventionsReader.ts similarity index 85% rename from src/domain/document/ids/IdConventionsReader.ts rename to src/adapters/out/bpmnlint/IdConventionsReader.ts index 81d4ad12..b0a535f6 100644 --- a/src/domain/document/ids/IdConventionsReader.ts +++ b/src/adapters/out/bpmnlint/IdConventionsReader.ts @@ -1,34 +1,13 @@ -import type { BpmnlintRuleSetting } from './bpmnlint-rule-setting.js'; -import { closestKey } from '../utils/fuzzy.js'; -import { isUsablePrefix } from '../utils/slug.js'; -import { KINDS, TRIGGER_TYPES } from '../utils/kinds.js'; -import { createModdle } from '../utils/moddle.js'; -import type { ValidatorFinding } from '../validator-finding.js'; +import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js'; +import type { IdConventions, QualifierMode } from '../../../domain/document/ids/IdConventions.js'; +import { closestKey } from '../../../domain/document/utils/fuzzy.js'; +import { isUsablePrefix } from '../../../domain/document/utils/slug.js'; +import { KINDS, TRIGGERS } from '../../../domain/document/Kinds.js'; +import { createModdle } from '../../../domain/document/utils/moddle.js'; +import type { ValidatorFinding } from '../../../domain/document/Finding.js'; export const ID_NAMING_RULE = '@miragon/rules/element-id-naming'; -export type QualifierMode = 'off' | 'optional' | 'required'; - -/** The id conventions the core takes, as plain data (from a .bpmnlintrc or a host's table). */ -export interface IdConventions { - /** - * the prefix of new ids per BPMN type ('bpmn:ServiceTask': 'serviceTask_') and per event - * trigger ('bpmn:StartEvent:timer': 'timerStartEvent_'); other types follow the file's style - */ - prefixes: Record; - /** under `required` a new event with a trigger takes the qualified prefix (default optional) */ - qualifier?: QualifierMode; - /** the case of the body after a declared prefix; absent: the file's case */ - case?: 'camelCase' | 'PascalCase' | 'snake_case'; - /** where they come from (`.bpmnlintrc in models`) */ - source?: string; - /** - * types and event triggers (`bpmn:EndEvent:escalation`) checked with a prefix new ids cannot - * follow: they follow the file's style - */ - ignored?: string[]; -} - /** The helpers of the plugin's `naming` namespace that element-id-naming runs. */ export interface IdNamingHelpers { DEFAULT_PREFIXES: Record; @@ -156,7 +135,7 @@ export class IdConventionsReader { if (plain === undefined || !def.triggers) continue; for (const trigger of def.triggers) { if (trigger === 'none') continue; - const event = moddle.create(def.type, { id: 'x', eventDefinitions: [moddle.create(TRIGGER_TYPES[trigger])] }); + const event = moddle.create(def.type, { id: 'x', eventDefinitions: [moddle.create(TRIGGERS[trigger].definition)] }); const qualified = this.naming.acceptedPrefixes(event, plain, qualifier).find((candidate) => candidate !== plain); const key = `${def.type}:${trigger}`; if (isUsablePrefix(qualified)) table[key] = qualified; diff --git a/src/domain/validation/LintRun.ts b/src/adapters/out/bpmnlint/LintRun.ts similarity index 97% rename from src/domain/validation/LintRun.ts rename to src/adapters/out/bpmnlint/LintRun.ts index ec6adc9e..2ac55ecb 100644 --- a/src/domain/validation/LintRun.ts +++ b/src/adapters/out/bpmnlint/LintRun.ts @@ -1,6 +1,6 @@ -import type { BpmnlintEngine, BpmnlintResolver, BpmnlintRule } from './bpmnlint-validator.js'; -import type { ValidatorContext, ValidatorFinding } from './validators.js'; -import { firstLine } from '../document/utils/text.js'; +import type { BpmnlintEngine, BpmnlintResolver, BpmnlintRule } from './bpmnlint-contract.js'; +import type { ValidatorContext, ValidatorFinding } from '../../../domain/validation/validators.js'; +import { firstLine } from '../../../domain/document/utils/text.js'; /** From the sequence flows themselves, not the incoming / outgoing lists. */ interface FlowEnds { diff --git a/src/domain/validation/bpmnlint-validator.ts b/src/adapters/out/bpmnlint/bpmnlint-contract.ts similarity index 91% rename from src/domain/validation/bpmnlint-validator.ts rename to src/adapters/out/bpmnlint/bpmnlint-contract.ts index bc932723..ca5cd8a9 100644 --- a/src/domain/validation/bpmnlint-validator.ts +++ b/src/adapters/out/bpmnlint/bpmnlint-contract.ts @@ -45,8 +45,8 @@ export interface BpmnlintEngine { resolver: BpmnlintResolver; } -export type { BpmnlintRuleSetting, BpmnlintSeverity } from '../document/ids/bpmnlint-rule-setting.js'; -import type { BpmnlintRuleSetting } from '../document/ids/bpmnlint-rule-setting.js'; +export type { BpmnlintRuleSetting, BpmnlintSeverity } from '../../../domain/document/ids/bpmnlint-rule-setting.js'; +import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js'; /** A config, rule, moddle extension or file left out, or bpmnlint that could not be loaded. */ export interface BpmnlintUnavailable { diff --git a/src/domain/validation/utils/bpmnlint.ts b/src/adapters/out/bpmnlint/utils/bpmnlint.ts similarity index 94% rename from src/domain/validation/utils/bpmnlint.ts rename to src/adapters/out/bpmnlint/utils/bpmnlint.ts index 9b28908c..ab07f45b 100644 --- a/src/domain/validation/utils/bpmnlint.ts +++ b/src/adapters/out/bpmnlint/utils/bpmnlint.ts @@ -1,5 +1,5 @@ -import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint-validator.js'; -import type { ValidatorFinding } from '../validators.js'; +import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint-contract.js'; +import type { ValidatorFinding } from '../../../../domain/validation/validators.js'; export const MODDLE_NOT_A_MAP = 'it is not an object of prefix -> descriptor or module path (bpmnlint fails on the whole file with it)'; diff --git a/src/adapters/out/document/DocumentStore.ts b/src/adapters/out/document/DocumentStore.ts new file mode 100644 index 00000000..c404d632 --- /dev/null +++ b/src/adapters/out/document/DocumentStore.ts @@ -0,0 +1,51 @@ +import type { DocumentSource, DocumentText } from '../../../application/port/in/DocumentSource.js'; +import type { FileWriteOptions } from '../../../application/port/in/EditDocumentUseCase.js'; +import type { DocumentPort } from '../../../application/port/out/DocumentPort.js'; +import type { MutationResult } from '../../../domain/batch/types.js'; +import { Doc } from '../../../domain/document/Doc.js'; +import { ioError } from '../../../domain/document/utils/errors.js'; + +export interface WriteOptions { + source?: string; + unchanged: boolean; + backup?: boolean; +} + +/** Loading, targeting and saving a document; where its bytes live is up to the store. */ +export abstract class DocumentStore implements DocumentPort { + async load(source: DocumentSource | DocumentText, opts: { lossless?: boolean; force?: boolean } = {}): Promise { + const doc = await this.parse(source); + if (opts.lossless) doc.assertLossless(opts); + return doc; + } + + async text(source: DocumentText): Promise { + if ('xml' in source) return source.xml; + return 'file' in source ? this.read(source.file) : this.decode(source.bytes, source.name); + } + + async target(file: string | undefined, opts: FileWriteOptions & { force?: boolean }): Promise { + const target = opts.out ?? file; + if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given'); + if (opts.mustNotExist) await this.assertNew(target, opts); + return target; + } + + async save(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise { + if (!opts.dryRun && target) result.written = await this.write(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup }); + } + + protected abstract read(file: string): Promise; + + protected abstract decode(bytes: Uint8Array, source: string): string; + + protected abstract assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise; + + protected abstract write(target: string, xml: string, opts: WriteOptions): Promise; + + private async parse(source: DocumentSource | DocumentText): Promise { + if ('doc' in source) return source.doc; + if ('xml' in source) return Doc.fromXml(source.xml, source.file); + return 'file' in source ? Doc.fromXml(await this.read(source.file), source.file) : Doc.fromXml(this.decode(source.bytes, source.name)); + } +} diff --git a/src/adapters/out/files/BpmnlintLibs.ts b/src/adapters/out/files/BpmnlintLibs.ts index 11b3efc9..62f210fd 100644 --- a/src/adapters/out/files/BpmnlintLibs.ts +++ b/src/adapters/out/files/BpmnlintLibs.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module'; -import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinterClass, BpmnlintResolver } from '../../../domain/validation/bpmnlint-validator.js'; -import type { IdNamingHelpers } from '../../../domain/document/ids/IdConventionsReader.js'; +import type { BpmnlintConfig, BpmnlintEngine, BpmnlintLinterClass, BpmnlintResolver } from '../bpmnlint/bpmnlint-contract.js'; +import type { IdNamingHelpers } from '../bpmnlint/IdConventionsReader.js'; import { engineUnavailable, lazy, PLUGIN, SHIPPED, versionOf, type LoadFailure, type Require } from './utils/bpmnlint.js'; import { firstLine } from '../../../domain/document/utils/text.js'; diff --git a/src/adapters/out/files/BpmnlintLoader.ts b/src/adapters/out/files/BpmnlintLoader.ts index d49d9ed5..8a32270e 100644 --- a/src/adapters/out/files/BpmnlintLoader.ts +++ b/src/adapters/out/files/BpmnlintLoader.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import type { BpmnlintConfig, BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js'; -import { BpmnlintConfigResolution } from '../../../domain/validation/BpmnlintConfigResolution.js'; -import { bpmnlintUnavailableFindings } from '../../../domain/validation/utils/bpmnlint.js'; +import type { BpmnlintConfig, BpmnlintEngine, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../bpmnlint/bpmnlint-contract.js'; +import { BpmnlintConfigResolution } from '../bpmnlint/BpmnlintConfigResolution.js'; +import { bpmnlintUnavailableFindings } from '../bpmnlint/utils/bpmnlint.js'; import { BpmnlintLibs } from './BpmnlintLibs.js'; import { ChainResolver, type BpmnlintChain } from './ChainResolver.js'; import { BPMNLINT_CONFIG_FILE } from './ConfigLookup.js'; diff --git a/src/adapters/out/files/ChainResolver.ts b/src/adapters/out/files/ChainResolver.ts index 51c05b91..3e014cd2 100644 --- a/src/adapters/out/files/ChainResolver.ts +++ b/src/adapters/out/files/ChainResolver.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module'; import { join, sep } from 'node:path'; -import type { BpmnlintResolver } from '../../../domain/validation/bpmnlint-validator.js'; +import type { BpmnlintResolver } from '../bpmnlint/bpmnlint-contract.js'; import type { BpmnlintLibs } from './BpmnlintLibs.js'; import type { Repo } from './RepoTrust.js'; import { lazy, SHIPPED, SKIP_HINT, versionOf, type Require } from './utils/bpmnlint.js'; diff --git a/src/adapters/out/files/FileDocumentStore.ts b/src/adapters/out/files/FileDocumentStore.ts index 5144bdbf..24ae8fbf 100644 --- a/src/adapters/out/files/FileDocumentStore.ts +++ b/src/adapters/out/files/FileDocumentStore.ts @@ -1,20 +1,19 @@ import { copyFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import type { LoadDocumentPort } from '../../../application/port/out/LoadDocumentPort.js'; -import type { SaveDocumentPort } from '../../../application/port/out/SaveDocumentPort.js'; +import { DocumentStore, type WriteOptions } from '../document/DocumentStore.js'; import { assertNewFile, decodeXmlBytes, readXml, writeAtomic } from './utils/files.js'; /** Documents as files on disk; every write is UTF-8. */ -export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort { - read(file: string): Promise { +export class FileDocumentStore extends DocumentStore { + protected read(file: string): Promise { return readXml(file); } - decode(bytes: Uint8Array, source: string): string { + protected decode(bytes: Uint8Array, source: string): string { return decodeXmlBytes(bytes, source); } - async save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }): Promise { + protected async write(target: string, xml: string, opts: WriteOptions): Promise { const inPlace = !!opts.source && resolve(target) === resolve(opts.source); if (opts.unchanged && inPlace) return false; if (opts.backup && opts.source) await FileDocumentStore.backup(opts.source); @@ -22,7 +21,7 @@ export class FileDocumentStore implements LoadDocumentPort, SaveDocumentPort { return true; } - assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise { + protected assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise { return assertNewFile(target, opts); } diff --git a/src/adapters/out/files/ModdleExtensions.ts b/src/adapters/out/files/ModdleExtensions.ts index f703ab0d..d1e07f45 100644 --- a/src/adapters/out/files/ModdleExtensions.ts +++ b/src/adapters/out/files/ModdleExtensions.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { extname, join } from 'node:path'; -import type { BpmnlintConfig, BpmnlintUnavailable } from '../../../domain/validation/bpmnlint-validator.js'; -import { moddleEntries } from '../../../domain/validation/utils/bpmnlint.js'; +import type { BpmnlintConfig, BpmnlintUnavailable } from '../bpmnlint/bpmnlint-contract.js'; +import { moddleEntries } from '../bpmnlint/utils/bpmnlint.js'; import type { BpmnlintLibs } from './BpmnlintLibs.js'; import { depState, resolveFirst, type Dep } from './utils/dependencies.js'; import { inside, shown } from './utils/paths.js'; diff --git a/src/adapters/out/files/PngRenderer.ts b/src/adapters/out/files/PngRenderer.ts index f50d08fb..f9a3c521 100644 --- a/src/adapters/out/files/PngRenderer.ts +++ b/src/adapters/out/files/PngRenderer.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { RENDER_MAX_SIZE, type RenderOptions, type RenderProblem, type RenderTile } from '../../../domain/views/render/types.js'; -import { CliError } from '../../../domain/document/CliError.js'; +import { BpmnError } from '../../../domain/document/BpmnError.js'; import { DocRenderer } from '../../../domain/views/render/DocRenderer.js'; export interface PngImage { @@ -96,9 +96,9 @@ export class PngRenderer { } } - private renderError(message: string, cause: unknown): CliError { + private renderError(message: string, cause: unknown): BpmnError { const reason = cause instanceof Error ? cause.message : String(cause); - return new CliError('E_RENDER', `${message}: ${reason}`, 'io', { + return new BpmnError('E_RENDER', `${message}: ${reason}`, 'io', { hint: 'Write the SVG instead (`--format svg`), or render a smaller part; report the file if it keeps failing.', }); } diff --git a/src/adapters/out/files/ProjectBpmnlint.ts b/src/adapters/out/files/ProjectBpmnlint.ts index 972e34ce..ba9593ec 100644 --- a/src/adapters/out/files/ProjectBpmnlint.ts +++ b/src/adapters/out/files/ProjectBpmnlint.ts @@ -1,8 +1,8 @@ import { dirname, resolve } from 'node:path'; -import type { BpmnlintRuleSetting, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js'; -import { BpmnlintValidator, BPMNLINT_VALIDATOR } from '../../../domain/validation/BpmnlintValidator.js'; -import { bpmnlintUnavailableFindings } from '../../../domain/validation/utils/bpmnlint.js'; -import { IdConventionsReader, ID_NAMING_RULE } from '../../../domain/document/ids/IdConventionsReader.js'; +import type { BpmnlintRuleSetting, BpmnlintUnavailable, ResolvedBpmnlintConfig } from '../bpmnlint/bpmnlint-contract.js'; +import { BpmnlintValidator, BPMNLINT_VALIDATOR } from '../bpmnlint/BpmnlintValidator.js'; +import { bpmnlintUnavailableFindings } from '../bpmnlint/utils/bpmnlint.js'; +import { IdConventionsReader, ID_NAMING_RULE } from '../bpmnlint/IdConventionsReader.js'; import type { NamedValidator, Validator, ValidatorFinding } from '../../../domain/validation/validators.js'; import { BpmnlintLibs } from './BpmnlintLibs.js'; import { BpmnlintLoader, type LoadedBpmnlint } from './BpmnlintLoader.js'; diff --git a/src/adapters/out/files/utils/bpmnlint.ts b/src/adapters/out/files/utils/bpmnlint.ts index 328aca52..8ea78571 100644 --- a/src/adapters/out/files/utils/bpmnlint.ts +++ b/src/adapters/out/files/utils/bpmnlint.ts @@ -1,5 +1,5 @@ import { dirname } from 'node:path'; -import type { BpmnlintUnavailable } from '../../../../domain/validation/bpmnlint-validator.js'; +import type { BpmnlintUnavailable } from '../../bpmnlint/bpmnlint-contract.js'; import { shown } from './paths.js'; export const PLUGIN = '@miragon/bpmnlint-plugin-rules'; diff --git a/src/adapters/out/memory/NoDocumentFiles.ts b/src/adapters/out/memory/NoDocumentFiles.ts index e7c29b0c..03b9f6c5 100644 --- a/src/adapters/out/memory/NoDocumentFiles.ts +++ b/src/adapters/out/memory/NoDocumentFiles.ts @@ -1,20 +1,19 @@ -import type { LoadDocumentPort } from '../../../application/port/out/LoadDocumentPort.js'; -import type { SaveDocumentPort } from '../../../application/port/out/SaveDocumentPort.js'; import { ioError } from '../../../domain/document/utils/errors.js'; +import { DocumentStore } from '../document/DocumentStore.js'; /** A document in memory has no file: there is none to read, and none is written or taken. */ -export class NoDocumentFiles implements LoadDocumentPort, SaveDocumentPort { - async read(file: string): Promise { +export class NoDocumentFiles extends DocumentStore { + protected async read(file: string): Promise { throw ioError('E_FILE_NOT_FOUND', `Cannot read ${file}: no such document`, { file }); } - decode(_bytes: Uint8Array, source: string): string { + protected decode(_bytes: Uint8Array, source: string): string { throw ioError('E_FILE_NOT_FOUND', `Cannot read ${source}: no such document`, { file: source }); } - async save(): Promise { + protected async write(): Promise { throw ioError('E_NO_FILE', 'No output file given'); } - async assertNew(): Promise {} + protected async assertNew(): Promise {} } diff --git a/src/api.ts b/src/api.ts index c0f95b5f..0d41ff81 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,17 +1,16 @@ /** * The in-memory API: strings in, strings and data out. Each function runs the same code as the * CLI command it names, without files or environment, so it runs in Node and in the browser. - * Errors are CliError instances, exactly what the CLI prints with --json. + * Errors are BpmnError instances, exactly what the CLI prints with --json. */ import type { DebugSink } from './domain/diagram/layout/LayoutDebug.js'; import type { LayoutMetrics } from './domain/diagram/utils/problems.js'; import type { LayoutView } from './domain/views/layout-view.js'; -import type { AroundView, ElementContext } from './domain/views/context.js'; +import type { AroundView, ElementContext } from './domain/views/around-view.js'; import type { ConventionsView, ConventionsXmlOptions } from './domain/document/ids/ConventionsTable.js'; export type { ConventionsXmlOptions } from './domain/document/ids/ConventionsTable.js'; -import type { IdConventions } from './domain/document/ids/IdConventionsReader.js'; +import type { IdConventions } from './domain/document/ids/IdConventions.js'; import type { Doc, NewDocOptions } from './domain/document/Doc.js'; -import type { LayoutEngine } from './domain/diagram/layout/FullLayout.js'; import type { ExtensionInfo } from './domain/document/ExtensionLister.js'; import type { EditCommand } from './domain/commands/types.js'; import type { MutationOptions, MutationResult } from './domain/batch/types.js'; @@ -51,8 +50,6 @@ export type { ShownView, ViewOptions } from './domain/views/ViewRequest.js'; export interface EditOptions { /** 'auto' (default): keep a hand-made drawing, redraw an engine-owned one; false: no layout */ layout?: boolean | LayoutMode; - /** layout engine of full redraws: 'clean' (default) or 'auto' (bpmn-auto-layout) */ - engine?: LayoutEngine; /** write despite a lossy import, new validation errors or content a retype would delete */ force?: boolean; /** platform profile whose new findings are reported: 'auto' (default), 'c7', 'c8', 'none' */ @@ -104,7 +101,7 @@ function parseXml(xml: string, file?: string): Promise { export async function applyToXml(xml: string, ops: unknown, opts: EditOptions = {}): Promise { const list = readOps(ops); const doc = await loadXml(xml, opts); - return toEditResult(xml, await new EditDocumentService(NO_PROJECT, NO_FILES, NO_FILES).edit({ ...opts, source: { doc }, commands: list, dryRun: true })); + return toEditResult(xml, await new EditDocumentService(NO_PROJECT, NO_FILES).edit({ ...opts, source: { doc }, commands: list, dryRun: true })); } /** `bpmn new`: a laid-out document with one empty process. */ @@ -123,7 +120,7 @@ export interface LayoutXmlOptions extends Omit { export async function layoutXml(xml: string, opts: LayoutXmlOptions = {}): Promise { RelayoutDiagramService.assertOptions(opts); const doc = await loadXml(xml, opts); - return toEditResult(xml, await new RelayoutDiagramService(NO_PROJECT, NO_FILES, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true })); + return toEditResult(xml, await new RelayoutDiagramService(NO_PROJECT, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true })); } export interface ValidateXmlOptions { @@ -171,7 +168,9 @@ export async function metricsXml(xml: string): Promise { - return new FindElementsService().find({ doc: await parseXml(xml), text, ...(opts.kind !== undefined ? { kind: opts.kind, validateKind: true } : {}) }); + const findElements = new FindElementsService(); + if (opts.kind !== undefined) findElements.assertKind({ kind: opts.kind }); + return findElements.find({ doc: await parseXml(xml), text, ...(opts.kind !== undefined ? { kind: opts.kind } : {}) }); } /** `bpmn ext list --json`: the extension elements of an element and its definitions. */ @@ -182,17 +181,17 @@ export async function extensionsXml(xml: string, id: string): Promise { - return new EditDocumentService(NO_PROJECT, NO_FILES, NO_FILES).edit({ ...opts, source: { doc }, commands: ops, dryRun: true }); + return new EditDocumentService(NO_PROJECT, NO_FILES).edit({ ...opts, source: { doc }, commands: ops, dryRun: true }); } /** `bpmn layout` on an in-memory document. */ export function layoutDoc(doc: Doc, opts: LayoutDocOptions = {}): Promise { - return new RelayoutDiagramService(NO_PROJECT, NO_FILES, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true }); + return new RelayoutDiagramService(NO_PROJECT, NO_FILES).relayout({ ...opts, source: { doc }, dryRun: true }); } /** What `bpmn validate` finds in a document. */ diff --git a/src/application/port/in/DocumentSource.ts b/src/application/port/in/DocumentSource.ts index 265555d1..6e1dc1e8 100644 --- a/src/application/port/in/DocumentSource.ts +++ b/src/application/port/in/DocumentSource.ts @@ -2,7 +2,10 @@ import type { Doc } from '../../../domain/document/Doc.js'; /** * The document a use case works on: - * a file it reads through LoadDocumentPort, + * a file it reads through the DocumentPort, * or one already loaded (stdin, a host's model). */ export type DocumentSource = { file: string } | { doc: Doc }; + +/** The text of a document: a file, bytes read elsewhere (stdin) or XML with the `file` it names, if any. */ +export type DocumentText = { file: string } | { bytes: Uint8Array; name: string } | { xml: string; file?: string }; diff --git a/src/application/port/in/FindElementsUseCase.ts b/src/application/port/in/FindElementsUseCase.ts index 910a5946..2d8e0011 100644 --- a/src/application/port/in/FindElementsUseCase.ts +++ b/src/application/port/in/FindElementsUseCase.ts @@ -8,6 +8,9 @@ import type { FindHit } from '../../../domain/views/model-view.js'; export interface FindElementsUseCase { find(command: FindElementsUseCase.Command): FindElementsUseCase.Result; + + /** An unknown or rejected kind fails with the same codes as `add` instead of matching nothing. */ + assertKind(command: FindElementsUseCase.KindCommand): void; } export declare namespace FindElementsUseCase { @@ -17,11 +20,13 @@ export declare namespace FindElementsUseCase { doc: Doc; text: string; kind?: string; - - /** an unknown or rejected `kind` fails with the same codes as `add` instead of matching nothing */ - validateKind?: boolean; } /** The matching elements, in document order. */ type Result = FindHit[]; + + /** The kind a search is restricted to. */ + interface KindCommand { + kind: string; + } } diff --git a/src/application/port/in/ReadDocumentUseCase.ts b/src/application/port/in/ReadDocumentUseCase.ts index 88f9ba30..18b678ad 100644 --- a/src/application/port/in/ReadDocumentUseCase.ts +++ b/src/application/port/in/ReadDocumentUseCase.ts @@ -1,4 +1,5 @@ import type { Doc } from '../../../domain/document/Doc.js'; +import type { DocumentText } from './DocumentSource.js'; /** Opens a document from its file, from bytes read elsewhere (stdin) or from its XML text. */ export interface ReadDocumentUseCase { @@ -12,7 +13,7 @@ export declare namespace ReadDocumentUseCase { interface Command { /** `xml` with the `file` it names (`doc.file`), if any */ - source: { file: string } | { bytes: Uint8Array; name: string } | { xml: string; file?: string }; + source: DocumentText; /** * E_IMPORT_LOSSY when bpmn-moddle dropped content, unless `force` diff --git a/src/application/port/in/ShowConventionsUseCase.ts b/src/application/port/in/ShowConventionsUseCase.ts index 9bd6e564..b326c7bc 100644 --- a/src/application/port/in/ShowConventionsUseCase.ts +++ b/src/application/port/in/ShowConventionsUseCase.ts @@ -1,8 +1,5 @@ import type { Doc } from '../../../domain/document/Doc.js'; import type { ConventionsView, ConventionsXmlOptions } from '../../../domain/document/ids/ConventionsTable.js'; -import type { IdConventions, IdNamingHelpers } from '../../../domain/document/ids/IdConventionsReader.js'; -import type { BpmnlintRuleSetting } from '../../../domain/validation/bpmnlint-validator.js'; -import type { ValidatorFinding } from '../../../domain/validation/validators.js'; import type { FileBpmnlintOptions } from '../out/LoadProjectSettingsPort.js'; /** @@ -15,12 +12,6 @@ export interface ShowConventionsUseCase { /** The conventions a caller already holds, one row per pattern (no doc: a new file). */ view(command: ShowConventionsUseCase.ViewCommand): ShowConventionsUseCase.Result; - - /** - * The id conventions element-id-naming declares, with a W_CONFIG_IGNORED note for each value - * new ids cannot follow; `conventions` is undefined where nothing is declared. - */ - readFromLint(command: ShowConventionsUseCase.ReadFromLintCommand): { conventions?: IdConventions; notes: ValidatorFinding[] }; } export declare namespace ShowConventionsUseCase { @@ -41,11 +32,4 @@ export declare namespace ShowConventionsUseCase { interface ViewCommand extends ConventionsXmlOptions { doc?: Doc; } - - /** The element-id-naming setting of a .bpmnlintrc to read conventions from. */ - interface ReadFromLintCommand { - setting: BpmnlintRuleSetting | undefined; - naming: IdNamingHelpers; - source?: string; - } } diff --git a/src/application/port/in/ShowModelUseCase.ts b/src/application/port/in/ShowModelUseCase.ts index 73e32862..f0766c07 100644 --- a/src/application/port/in/ShowModelUseCase.ts +++ b/src/application/port/in/ShowModelUseCase.ts @@ -1,7 +1,7 @@ import type { Doc } from '../../../domain/document/Doc.js'; import type { ExtensionInfo } from '../../../domain/document/ExtensionLister.js'; import type { El } from '../../../domain/document/utils/moddle.js'; -import type { AroundOptions, AroundView, ElementContext, Implementation } from '../../../domain/views/context.js'; +import type { AroundOptions, AroundView, ElementContext, Implementation } from '../../../domain/views/around-view.js'; import type { LayoutView } from '../../../domain/views/layout-view.js'; import type { ElementDetail, ModelView } from '../../../domain/views/model-view.js'; import type { ShownView, ViewOptions } from '../../../domain/views/ViewRequest.js'; diff --git a/src/application/port/in/ValidateDocumentUseCase.ts b/src/application/port/in/ValidateDocumentUseCase.ts index 619a18d5..e32c314d 100644 --- a/src/application/port/in/ValidateDocumentUseCase.ts +++ b/src/application/port/in/ValidateDocumentUseCase.ts @@ -1,10 +1,7 @@ import type { Doc } from '../../../domain/document/Doc.js'; import type { Platform, PlatformChoice, PlatformInfo } from '../../../domain/document/vendor/PlatformDetection.js'; import type { ValidateOptions, ValidationResult } from '../../../domain/validation/StructuralValidator.js'; -import type { NamedValidator } from '../../../domain/validation/validators.js'; import type { ProfileReport } from '../../../domain/validation/profiles/PlatformProfiles.js'; -import type { BpmnlintValidatorOptions } from '../../../domain/validation/BpmnlintValidator.js'; -import type { BpmnlintConfig, BpmnlintEngine, ResolvedBpmnlintConfig } from '../../../domain/validation/bpmnlint-validator.js'; import type { FileBpmnlintOptions } from '../out/LoadProjectSettingsPort.js'; import type { CheckOptions, CheckResult } from '../../../domain/validation/DocumentCheck.js'; import type { DocumentSource } from './DocumentSource.js'; @@ -30,11 +27,6 @@ export interface ValidateDocumentUseCase { runProfile(command: ValidateDocumentUseCase.ProfileCommand): ProfileReport; detectPlatform(command: ValidateDocumentUseCase.DocumentCommand): PlatformInfo; - - /** The advisory validator of a resolved bpmnlint configuration. */ - bpmnlintValidator(command: ValidateDocumentUseCase.BpmnlintValidatorCommand): NamedValidator; - - resolveBpmnlintConfig(command: ValidateDocumentUseCase.ResolveBpmnlintCommand): Promise; } export declare namespace ValidateDocumentUseCase { @@ -71,19 +63,6 @@ export declare namespace ValidateDocumentUseCase { /** default: all */ platforms?: readonly Exclude[]; } - - /** A bpmnlint engine and the resolved configuration it runs. */ - interface BpmnlintValidatorCommand extends BpmnlintValidatorOptions { - engine: BpmnlintEngine; - resolved: ResolvedBpmnlintConfig; - } - - /** A bpmnlint configuration to resolve with an engine. */ - interface ResolveBpmnlintCommand { - engine: BpmnlintEngine; - config: BpmnlintConfig; - moddleExtensions?: Record; - } } export type { CheckOptions, CheckResult }; diff --git a/src/application/port/out/DocumentPort.ts b/src/application/port/out/DocumentPort.ts new file mode 100644 index 00000000..a10478bf --- /dev/null +++ b/src/application/port/out/DocumentPort.ts @@ -0,0 +1,34 @@ +import type { MutationResult } from '../../../domain/batch/types.js'; +import type { Doc } from '../../../domain/document/Doc.js'; +import type { DocumentSource, DocumentText } from '../in/DocumentSource.js'; +import type { FileWriteOptions } from '../in/EditDocumentUseCase.js'; + +/** + * Where the use cases read their documents from and write their results to: + * files for the CLI and `@miragon/bpmn-cli/node`, + * none for the in-memory API. + */ +export interface DocumentPort { + + /** + * The document of a source, its text decoded in the encoding its XML declaration names; + * with `lossless`, E_IMPORT_LOSSY when bpmn-moddle dropped content, unless `force`. + */ + load(source: DocumentSource | DocumentText, opts?: { lossless?: boolean; force?: boolean }): Promise; + + /** The XML text of a source, decoded like `load`. */ + text(source: DocumentText): Promise; + + /** + * Where a result goes: `out`, else `file`; E_NO_FILE without either unless `dryRun`. + * With `mustNotExist`, E_FILE_EXISTS where it exists, unless force or dryRun. + */ + target(file: string | undefined, opts: FileWriteOptions & { force?: boolean }): Promise; + + /** + * Writes the result to `target` at once (nothing on `dryRun`) and sets `result.written`: + * a failed write leaves the old file, an unchanged document is not written back over its own file, + * with `backup` the file is copied to .bak first. + */ + save(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise; +} diff --git a/src/application/port/out/LoadDocumentPort.ts b/src/application/port/out/LoadDocumentPort.ts deleted file mode 100644 index bc0c304a..00000000 --- a/src/application/port/out/LoadDocumentPort.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Reads documents: - * from files for the CLI and `@miragon/bpmn-cli/node`, - * from memory for the in-memory API. - */ -export interface LoadDocumentPort { - - /** The text of a file, decoded in the encoding its XML declaration names. */ - read(file: string): Promise; - - /** - * Bytes read elsewhere (stdin), decoded like a file; - * `source` names them in errors. - */ - decode(bytes: Uint8Array, source: string): string; -} diff --git a/src/application/port/out/LoadProjectSettingsPort.ts b/src/application/port/out/LoadProjectSettingsPort.ts index f1fd4c44..6e8884f5 100644 --- a/src/application/port/out/LoadProjectSettingsPort.ts +++ b/src/application/port/out/LoadProjectSettingsPort.ts @@ -1,6 +1,6 @@ import type { BpmnlintRuleSetting } from '../../../domain/document/ids/bpmnlint-rule-setting.js'; -import type { IdConventions } from '../../../domain/document/ids/IdConventionsReader.js'; -import type { ValidatorFinding } from '../../../domain/document/validator-finding.js'; +import type { IdConventions } from '../../../domain/document/ids/IdConventions.js'; +import type { ValidatorFinding } from '../../../domain/document/Finding.js'; import type { ProjectLint } from '../../../domain/validation/ProjectLint.js'; import type { Validator } from '../../../domain/validation/validators.js'; diff --git a/src/application/port/out/SaveDocumentPort.ts b/src/application/port/out/SaveDocumentPort.ts deleted file mode 100644 index b628d2ea..00000000 --- a/src/application/port/out/SaveDocumentPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** Writes documents where a result goes. */ -export interface SaveDocumentPort { - - /** - * Writes `xml` to `target` at once: a failed write leaves the old file. - * An unchanged document is not written back over its own `source` (no new mtime or sync event); - * with `backup` the source is copied to .bak first. - * Returns whether it wrote. - */ - save(target: string, xml: string, opts: { source?: string; unchanged: boolean; backup?: boolean }): Promise; - - /** Throws E_FILE_EXISTS where `target` exists, unless force or dryRun. */ - assertNew(target: string | undefined, opts: { force?: boolean; dryRun?: boolean }): Promise; -} diff --git a/src/application/service/CreateDocumentService.ts b/src/application/service/CreateDocumentService.ts index 7c49556f..aa56b6fc 100644 --- a/src/application/service/CreateDocumentService.ts +++ b/src/application/service/CreateDocumentService.ts @@ -1,41 +1,27 @@ import { Doc } from '../../domain/document/Doc.js'; -import { ioError } from '../../domain/document/utils/errors.js'; import { newProcessChange } from '../../domain/document/utils/new-process.js'; import type { CreateDocumentUseCase } from '../port/in/CreateDocumentUseCase.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js'; -import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js'; -import { EditBatchService } from '../../domain/batch/EditBatchService.js'; -import type { MutationResult } from '../../domain/batch/types.js'; -import type { FileMutationOptions, FileWriteOptions } from '../port/in/EditDocumentUseCase.js'; +import { EditBatch } from '../../domain/batch/EditBatch.js'; export class CreateDocumentService implements CreateDocumentUseCase { constructor( private readonly settings: LoadProjectSettingsPort, - private readonly save: SaveDocumentPort, + private readonly documents: DocumentPort, ) {} /** An existing file fails before the .bpmnlintrc is read. */ async create(command: CreateDocumentUseCase.Command): Promise { const { destination, ...opts } = command; if (opts.processId !== undefined) Doc.assertProcessId(opts.processId); - const target = await this.targetOf(destination, opts); + const target = await this.documents.target(destination, opts); const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target); const conventions = opts.conventions ?? (await this.settings.idConventions(target, opts))?.conventions; const doc = Doc.create({ processName: opts.processName, processId: opts.processId, executable: opts.executable, target: opts.target, ...(conventions ? { conventions } : {}) }, destination); - const result = await new EditBatchService(doc, [], project).run(target); + const result = await new EditBatch(doc, [], project).run(target); result.changes.create(newProcessChange(doc, opts.processName)); - await this.write(result, doc, target, opts); + await this.documents.save(result, doc, target, opts); return result; } - - private async targetOf(destination: string | undefined, opts: FileMutationOptions): Promise { - const target = opts.out ?? destination; - if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given'); - if (opts.mustNotExist) await this.save.assertNew(target, opts); - return target; - } - - private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise { - if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup }); - } } diff --git a/src/application/service/EditDocumentService.ts b/src/application/service/EditDocumentService.ts index a0fcc207..4e8cb4eb 100644 --- a/src/application/service/EditDocumentService.ts +++ b/src/application/service/EditDocumentService.ts @@ -1,49 +1,26 @@ -import { Doc } from '../../domain/document/Doc.js'; -import { ioError } from '../../domain/document/utils/errors.js'; -import type { DocumentSource } from '../port/in/DocumentSource.js'; -import type { EditDocumentUseCase, FileMutationOptions, FileWriteOptions } from '../port/in/EditDocumentUseCase.js'; -import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js'; +import type { EditDocumentUseCase } from '../port/in/EditDocumentUseCase.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js'; -import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js'; -import { EditBatchService } from '../../domain/batch/EditBatchService.js'; -import type { MutationResult } from '../../domain/batch/types.js'; +import { EditBatch } from '../../domain/batch/EditBatch.js'; import { TextPreservation, type PreservedText } from '../../domain/document/xml/TextPreservation.js'; export class EditDocumentService implements EditDocumentUseCase { constructor( private readonly settings: LoadProjectSettingsPort, - private readonly load: LoadDocumentPort, - private readonly save: SaveDocumentPort, + private readonly documents: DocumentPort, ) {} async edit(command: EditDocumentUseCase.Command): Promise { const { source, commands, ...opts } = command; - const doc = await this.open(source, opts); - const target = await this.targetOf(doc, opts); + const doc = await this.documents.load(source, { ...opts, lossless: true }); + const target = await this.documents.target(doc.file, opts); const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target); - const result = await new EditBatchService(doc, commands, project).run(target); - await this.write(result, doc, target, opts); + const result = await new EditBatch(doc, commands, project).run(target); + await this.documents.save(result, doc, target, opts); return result; } preserveText(command: EditDocumentUseCase.PreserveTextCommand): Promise { return new TextPreservation(command.original, command.baseline, command.next).result(); } - - private async open(source: DocumentSource, opts: { force?: boolean }): Promise { - const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file); - doc.assertLossless(opts); - return doc; - } - - private async targetOf(doc: Doc, opts: FileMutationOptions): Promise { - const target = opts.out ?? doc.file; - if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given'); - if (opts.mustNotExist) await this.save.assertNew(target, opts); - return target; - } - - private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise { - if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup }); - } } diff --git a/src/application/service/FindElementsService.ts b/src/application/service/FindElementsService.ts index ad0c5c13..88da73d0 100644 --- a/src/application/service/FindElementsService.ts +++ b/src/application/service/FindElementsService.ts @@ -1,10 +1,13 @@ import type { FindElementsUseCase } from '../port/in/FindElementsUseCase.js'; import { ElementFinder } from '../../domain/views/ElementFinder.js'; -import { assertKindToken } from '../../domain/document/utils/kinds.js'; +import { assertKindToken } from '../../domain/document/Kinds.js'; export class FindElementsService implements FindElementsUseCase { find(command: FindElementsUseCase.Command): FindElementsUseCase.Result { - if (command.validateKind && command.kind !== undefined) assertKindToken(command.kind); return new ElementFinder(command.doc).find(command.text, command.kind); } + + assertKind(command: FindElementsUseCase.KindCommand): void { + assertKindToken(command.kind); + } } diff --git a/src/application/service/ReadDocumentService.ts b/src/application/service/ReadDocumentService.ts index 1f0765b7..08da8223 100644 --- a/src/application/service/ReadDocumentService.ts +++ b/src/application/service/ReadDocumentService.ts @@ -1,19 +1,10 @@ -import { Doc } from '../../domain/document/Doc.js'; import type { ReadDocumentUseCase } from '../port/in/ReadDocumentUseCase.js'; -import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; -/** Decoded in the encoding the XML declaration names, so ISO-8859-1 and Windows-1252 characters survive. */ export class ReadDocumentService implements ReadDocumentUseCase { - constructor(private readonly load: LoadDocumentPort) {} + constructor(private readonly documents: DocumentPort) {} - async read(command: ReadDocumentUseCase.Command): Promise { - const doc = await this.parse(command.source); - if (command.lossless) doc.assertLossless(command); - return doc; - } - - private async parse(source: ReadDocumentUseCase.Command['source']): Promise { - if ('xml' in source) return Doc.fromXml(source.xml, source.file); - return 'file' in source ? Doc.fromXml(await this.load.read(source.file), source.file) : Doc.fromXml(this.load.decode(source.bytes, source.name)); + read(command: ReadDocumentUseCase.Command): Promise { + return this.documents.load(command.source, command); } } diff --git a/src/application/service/RelayoutDiagramService.ts b/src/application/service/RelayoutDiagramService.ts index 6451a8a7..c5572f40 100644 --- a/src/application/service/RelayoutDiagramService.ts +++ b/src/application/service/RelayoutDiagramService.ts @@ -1,34 +1,30 @@ import { Doc } from '../../domain/document/Doc.js'; -import { ioError, usageError } from '../../domain/document/utils/errors.js'; +import { usageError } from '../../domain/document/utils/errors.js'; import { SUB_PROCESS_TYPES } from '../../domain/diagram/utils/expansion.js'; -import { kindLabel } from '../../domain/document/utils/kinds.js'; -import { EditBatchService } from '../../domain/batch/EditBatchService.js'; +import { kindLabel } from '../../domain/document/Kinds.js'; +import { EditBatch } from '../../domain/batch/EditBatch.js'; import { FullLayout, type LayoutResult } from '../../domain/diagram/layout/FullLayout.js'; import { LayoutDebug } from '../../domain/diagram/layout/LayoutDebug.js'; -import type { FileLayoutOptions, LayoutDocOptions, RelayoutDiagramUseCase } from '../port/in/RelayoutDiagramUseCase.js'; -import type { DocumentSource } from '../port/in/DocumentSource.js'; -import type { FileWriteOptions } from '../port/in/EditDocumentUseCase.js'; +import type { LayoutDocOptions, RelayoutDiagramUseCase } from '../port/in/RelayoutDiagramUseCase.js'; import type { MutationResult } from '../../domain/batch/types.js'; -import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js'; -import type { SaveDocumentPort } from '../port/out/SaveDocumentPort.js'; /** Ids that are no sub-process are errors, not silent no-ops. */ export class RelayoutDiagramService implements RelayoutDiagramUseCase { constructor( private readonly settings: LoadProjectSettingsPort, - private readonly load: LoadDocumentPort, - private readonly save: SaveDocumentPort, + private readonly documents: DocumentPort, ) {} async relayout(command: RelayoutDiagramUseCase.Command): Promise { const { source, ...opts } = command; RelayoutDiagramService.assertOptions(opts); - const doc = await this.open(source, opts); - const target = await this.targetOf(doc, opts); + const doc = await this.documents.load(source, { ...opts, lossless: true }); + const target = await this.documents.target(doc.file, opts); const project = (await this.settings.lint(opts.file ?? target, opts)).applyTo(opts, target); const result = project.tidy ? await this.tidy(doc, project, target) : await this.redraw(doc, project, target); - await this.write(result, doc, target, opts); + await this.documents.save(result, doc, target, opts); return result; } @@ -49,7 +45,7 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase { } private tidy(doc: Doc, opts: LayoutDocOptions, target: string | undefined): Promise { - return new EditBatchService(doc, [{ op: 'tidy' }], { ...opts, layout: 'auto' }).run(target); + return new EditBatch(doc, [{ op: 'tidy' }], { ...opts, layout: 'auto' }).run(target); } private async redraw(doc: Doc, opts: LayoutDocOptions, target: string | undefined): Promise { @@ -58,7 +54,7 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase { const subs = new Map([...expand, ...collapse].map((id) => [id, doc.require(id, SUB_PROCESS_TYPES, 'sub-process')])); const both = expand.filter((id) => collapse.includes(id)); if (both.length) throw usageError(`${both.join(', ')} given to both --expand and --collapse`); - const result = await new EditBatchService(doc, [], { ...opts, layout: 'full', expand, collapse }).run(target); + const result = await new EditBatch(doc, [], { ...opts, layout: 'full', expand, collapse }).run(target); for (const [id, expanded] of [...expand.map((id) => [id, true] as const), ...collapse.map((id) => [id, false] as const)]) { const sub = subs.get(id)!; const name = sub.get('name'); @@ -66,21 +62,4 @@ export class RelayoutDiagramService implements RelayoutDiagramUseCase { } return result; } - - private async open(source: DocumentSource, opts: { force?: boolean }): Promise { - const doc = 'doc' in source ? source.doc : await Doc.fromXml(await this.load.read(source.file), source.file); - doc.assertLossless(opts); - return doc; - } - - private async targetOf(doc: Doc, opts: FileLayoutOptions): Promise { - const target = opts.out ?? doc.file; - if (!opts.dryRun && !target) throw ioError('E_NO_FILE', 'No output file given'); - if (opts.mustNotExist) await this.save.assertNew(target, opts); - return target; - } - - private async write(result: MutationResult, doc: Doc, target: string | undefined, opts: FileWriteOptions): Promise { - if (!opts.dryRun && target) result.written = await this.save.save(target, result.xml, { source: doc.file, unchanged: result.unchanged, backup: opts.backup }); - } } diff --git a/src/application/service/RenderDiagramService.ts b/src/application/service/RenderDiagramService.ts index e12bdc44..0bbf0500 100644 --- a/src/application/service/RenderDiagramService.ts +++ b/src/application/service/RenderDiagramService.ts @@ -1,21 +1,16 @@ import type { RenderDiagramUseCase } from '../port/in/RenderDiagramUseCase.js'; -import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; import type { WriteImagePort } from '../port/out/WriteImagePort.js'; export class RenderDiagramService implements RenderDiagramUseCase { constructor( - private readonly load: LoadDocumentPort, + private readonly documents: DocumentPort, private readonly images: WriteImagePort, ) {} async render(command: RenderDiagramUseCase.Command): Promise { const { source, ...request } = command; - const xml = await this.textOf(source); + const xml = await this.documents.text(source); return this.images.write({ xml, ...request }); } - - private async textOf(source: RenderDiagramUseCase.Command['source']): Promise { - if ('xml' in source) return source.xml; - return 'file' in source ? this.load.read(source.file) : this.load.decode(source.bytes, source.name); - } } diff --git a/src/application/service/RunCommandsService.ts b/src/application/service/RunCommandsService.ts index b5477686..cb02c840 100644 --- a/src/application/service/RunCommandsService.ts +++ b/src/application/service/RunCommandsService.ts @@ -3,15 +3,15 @@ import type { ChangeSet } from '../../domain/document/ChangeSet.js'; import type { EditCommand } from '../../domain/commands/types.js'; import { CommandParser } from '../../domain/commands/CommandParser.js'; import { parseOpsJson } from '../../domain/commands/utils/ops-json.js'; -import { ModelCommandService } from '../../domain/batch/ModelCommandService.js'; +import { ModelCommands } from '../../domain/batch/ModelCommands.js'; export class RunCommandsService implements RunCommandsUseCase { runModelCommand(command: RunCommandsUseCase.ModelCommand): ChangeSet { - return new ModelCommandService(command.doc).runOp(command.command); + return new ModelCommands(command.doc).runCommand(command.command); } runModelCommands(command: RunCommandsUseCase.ModelCommands): ChangeSet { - return new ModelCommandService(command.doc).runBatch(command.commands).changes; + return new ModelCommands(command.doc).runBatch(command.commands).changes; } parseCommands(command: RunCommandsUseCase.ParseCommand): EditCommand[] { diff --git a/src/application/service/ShowConventionsService.ts b/src/application/service/ShowConventionsService.ts index 5a95e8cd..d354ded3 100644 --- a/src/application/service/ShowConventionsService.ts +++ b/src/application/service/ShowConventionsService.ts @@ -1,6 +1,4 @@ import { ConventionsTable, type Undeclared } from '../../domain/document/ids/ConventionsTable.js'; -import { IdConventionsReader, type IdConventions } from '../../domain/document/ids/IdConventionsReader.js'; -import type { ValidatorFinding } from '../../domain/validation/validators.js'; import type { ShowConventionsUseCase } from '../port/in/ShowConventionsUseCase.js'; import type { LoadProjectSettingsPort, ProjectIdConventions } from '../port/out/LoadProjectSettingsPort.js'; @@ -24,10 +22,6 @@ export class ShowConventionsService implements ShowConventionsUseCase { return new ConventionsTable(doc, conventions).view(opts); } - readFromLint(command: ShowConventionsUseCase.ReadFromLintCommand): { conventions?: IdConventions; notes: ValidatorFinding[] } { - return new IdConventionsReader(command.naming, command.source).read(command.setting); - } - private static undeclared(found: ProjectIdConventions | undefined, skipped: boolean): Undeclared { return skipped ? 'skipped' : !found ? 'no config' : found.unavailable ? 'not loaded' : !found.setting || found.setting.severity === 'off' ? 'off' : 'no prefixes'; } diff --git a/src/application/service/ShowModelService.ts b/src/application/service/ShowModelService.ts index 9615c1a2..8970c1ef 100644 --- a/src/application/service/ShowModelService.ts +++ b/src/application/service/ShowModelService.ts @@ -7,7 +7,7 @@ import { ElementContextBuilder } from '../../domain/views/ElementContextBuilder. import { LayoutViewBuilder } from '../../domain/views/LayoutViewBuilder.js'; import { ElementFacts } from '../../domain/views/ElementFacts.js'; import { ExtensionLister, type ExtensionInfo } from '../../domain/document/ExtensionLister.js'; -import type { AroundView, ElementContext, Implementation } from '../../domain/views/context.js'; +import type { AroundView, ElementContext, Implementation } from '../../domain/views/around-view.js'; import type { LayoutView } from '../../domain/views/layout-view.js'; import type { ElementDetail, ModelView } from '../../domain/views/model-view.js'; diff --git a/src/application/service/ValidateDocumentService.ts b/src/application/service/ValidateDocumentService.ts index 4181da98..82cb4d29 100644 --- a/src/application/service/ValidateDocumentService.ts +++ b/src/application/service/ValidateDocumentService.ts @@ -1,26 +1,20 @@ -import { Doc } from '../../domain/document/Doc.js'; import { PlatformDetection, type PlatformInfo } from '../../domain/document/vendor/PlatformDetection.js'; import { PLATFORM_PROFILES, type ProfileReport } from '../../domain/validation/profiles/PlatformProfiles.js'; import { StructuralValidator, type ValidationResult } from '../../domain/validation/StructuralValidator.js'; -import { BpmnlintValidator } from '../../domain/validation/BpmnlintValidator.js'; -import { BpmnlintConfigResolution } from '../../domain/validation/BpmnlintConfigResolution.js'; import { DocumentCheck } from '../../domain/validation/DocumentCheck.js'; -import type { NamedValidator } from '../../domain/validation/validators.js'; -import type { ResolvedBpmnlintConfig } from '../../domain/validation/bpmnlint-validator.js'; -import type { DocumentSource } from '../port/in/DocumentSource.js'; import type { ValidateDocumentUseCase } from '../port/in/ValidateDocumentUseCase.js'; -import type { LoadDocumentPort } from '../port/out/LoadDocumentPort.js'; +import type { DocumentPort } from '../port/out/DocumentPort.js'; import type { LoadProjectSettingsPort } from '../port/out/LoadProjectSettingsPort.js'; export class ValidateDocumentService implements ValidateDocumentUseCase { constructor( private readonly settings: LoadProjectSettingsPort, - private readonly load: LoadDocumentPort, + private readonly documents: DocumentPort, ) {} async validate(command: ValidateDocumentUseCase.Command): Promise { const { source, ...opts } = command; - const doc = await this.open(source); + const doc = await this.documents.load(source); const project = (await this.settings.lint(opts.file ?? doc.file, opts)).applyTo(opts, doc.file); return new DocumentCheck(doc, project).run(); } @@ -45,18 +39,4 @@ export class ValidateDocumentService implements ValidateDocumentUseCase { detectPlatform(command: ValidateDocumentUseCase.DocumentCommand): PlatformInfo { return PlatformDetection.of(command.doc).info(); } - - bpmnlintValidator(command: ValidateDocumentUseCase.BpmnlintValidatorCommand): NamedValidator { - const { engine, resolved, ...opts } = command; - return new BpmnlintValidator(engine, resolved, opts).named(); - } - - resolveBpmnlintConfig(command: ValidateDocumentUseCase.ResolveBpmnlintCommand): Promise { - const { engine, config, ...opts } = command; - return new BpmnlintConfigResolution(engine, config, opts).resolve(); - } - - private async open(source: DocumentSource): Promise { - return 'doc' in source ? source.doc : Doc.fromXml(await this.load.read(source.file), source.file); - } } diff --git a/src/cli.ts b/src/cli.ts index c6e7a232..8c09dfc7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,17 +9,16 @@ import { CreateDocumentService } from './application/service/CreateDocumentServi import { RelayoutDiagramService } from './application/service/RelayoutDiagramService.js'; import { ValidateDocumentService } from './application/service/ValidateDocumentService.js'; import { ReadDocumentService } from './application/service/ReadDocumentService.js'; -import { ShowModelService } from './application/service/ShowModelService.js'; import { ShowConventionsService } from './application/service/ShowConventionsService.js'; -import { FindElementsService } from './application/service/FindElementsService.js'; import { RenderDiagramService } from './application/service/RenderDiagramService.js'; -import { LayoutDebug } from './domain/diagram/layout/LayoutDebug.js'; +import { ShowModelService } from './application/service/ShowModelService.js'; +import { FindElementsService } from './application/service/FindElementsService.js'; +import { AnalyzeLayoutService } from './application/service/AnalyzeLayoutService.js'; +import { RunCommandsService } from './application/service/RunCommandsService.js'; // dist/cli.js and src/cli.ts both sit one level below package.json const VERSION = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version; -if (process.env['BPMN_LAYOUT_DEBUG']) LayoutDebug.setProcessSink((line) => process.stderr.write(`${line}\n`)); - // a reader that closes early (`bpmn validate x.bpmn | head`) ends the output, not the command for (const stream of [process.stdout, process.stderr]) { stream.on('error', (err: NodeJS.ErrnoException) => { @@ -37,15 +36,19 @@ function exitWhenWritten(): void { const files = new FileDocumentStore(); const project = new BpmnlintProjectSettings(); +const relayoutDiagram = new RelayoutDiagramService(project, files); +if (process.env['BPMN_LAYOUT_DEBUG']) relayoutDiagram.setDebugSink({ sink: (line) => process.stderr.write(`${line}\n`) }); const cli = new Cli(VERSION, { - editDocument: new EditDocumentService(project, files, files), + editDocument: new EditDocumentService(project, files), createDocument: new CreateDocumentService(project, files), - relayoutDiagram: new RelayoutDiagramService(project, files, files), + relayoutDiagram, validateDocument: new ValidateDocumentService(project, files), readDocument: new ReadDocumentService(files), showModel: new ShowModelService(), showConventions: new ShowConventionsService(project), findElements: new FindElementsService(), + analyzeLayout: new AnalyzeLayoutService(), + runCommands: new RunCommandsService(), renderDiagram: new RenderDiagramService(files, new FileImageWriter()), }); void cli.run(process.argv).then(() => { diff --git a/src/domain/batch/EditBatchService.ts b/src/domain/batch/EditBatch.ts similarity index 86% rename from src/domain/batch/EditBatchService.ts rename to src/domain/batch/EditBatch.ts index 42a76112..ba0198fc 100644 --- a/src/domain/batch/EditBatchService.ts +++ b/src/domain/batch/EditBatch.ts @@ -1,17 +1,17 @@ import { LayoutDebug } from '../diagram/layout/LayoutDebug.js'; import { Doc } from '../document/Doc.js'; import { modelError } from '../document/utils/errors.js'; -import { EditContext } from '../document/EditContext.js'; +import { EditSession } from '../document/EditSession.js'; import { reportedImportWarnings } from '../editing/utils/decision-link-warnings.js'; -import { ModelCommandService, type BatchRun } from './ModelCommandService.js'; -import { withoutProfileDuplicates } from '../editing/utils/covers.js'; +import { ModelCommands, type BatchRun } from './ModelCommands.js'; +import { withoutProfileDuplicates } from '../validation/utils/finding-coverage.js'; import type { EditCommand } from '../commands/types.js'; import { PLATFORM_PROFILES } from '../validation/profiles/PlatformProfiles.js'; import type { ValidationResult } from '../validation/StructuralValidator.js'; import { FindingBaseline, type ValidationDelta } from '../validation/FindingBaseline.js'; import { ModelViewBuilder } from '../views/ModelViewBuilder.js'; import { BatchDrawing, type DrawingSegment } from '../diagram/BatchDrawing.js'; -import { toCliError } from '../document/utils/cli-error.js'; +import { toBpmnError } from '../document/utils/bpmn-error.js'; import { utf8Declaration } from '../document/utils/encoding.js'; import { serializeWithout, unkeptEntries, withoutEntries } from '../document/utils/mirror.js'; import { TextPreservation } from '../document/xml/TextPreservation.js'; @@ -24,34 +24,34 @@ import type { LayoutStatus } from '../diagram/types.js'; * keep the file's text. Never touches the file system, so it runs in the browser too. * Only errors the ops introduce block the write; --force overrides every guard. */ -export class EditBatchService { - private readonly edit: EditContext; +export class EditBatch { + private readonly edit: EditSession; /** An explicit `opts.platform` also decides the engine rules of the ops (event-gateway bridges). */ constructor( private readonly doc: Doc, - private readonly ops: EditCommand[], + private readonly commands: EditCommand[], private readonly opts: MutationOptions = {}, ) { const platformChoice = opts.platform && opts.platform !== 'auto' ? opts.platform : undefined; - this.edit = new EditContext(doc, { conventions: opts.conventions, platformChoice, keepFlowIds: opts.keepFlowIds }); + this.edit = new EditSession(doc, { conventions: opts.conventions, platformChoice, keepFlowIds: opts.keepFlowIds }); } /** * Runs the batch on the document in memory; `file` is where its result goes. Throws a - * CliError and leaves nothing half done: the caller keeps its input. + * BpmnError and leaves nothing half done: the caller keeps its input. */ async run(file = this.doc.file): Promise { return LayoutDebug.during(this.opts.debug, async () => { try { await this.guard(); const asRead = await this.textAsRead(); - const diagram = await BatchDrawing.capture(this.doc, this.ops, this.opts, asRead, EditBatchService.removeElements); - const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.ops }); + const diagram = await BatchDrawing.capture(this.doc, this.commands, this.opts, asRead, EditBatch.removeElements); + const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.commands }); const importWarnings = reportedImportWarnings(this.doc).map((warning) => warning.message.split('\n')[0]!); const { batch, last } = await this.runCommands(diagram); - await this.validate(findings, batch); - const drawn = await diagram.apply(this.edit, batch.ops, batch.changes, last); + await this.validateAndDropProfileDuplicates(findings, batch); + const drawn = await diagram.apply(this.edit, batch.commands, batch.changes, last); const xml = await this.keptText(drawn.xml, asRead, batch.changes); const layout = drawn.layout; const validation = await findings.onCandidate(xml); @@ -63,7 +63,7 @@ export class EditBatchService { } private static removeElements(doc: Doc, ids: string[]): void { - new ModelCommandService(doc).runBatch([{ op: 'remove', ids, ifExists: true }]); + new ModelCommands(doc).runBatch([{ op: 'remove', ids, ifExists: true }]); } /** Loads the platform profiles, a chunk of their own in a browser bundle, before anything runs. */ @@ -75,9 +75,9 @@ export class EditBatchService { private async runCommands(diagram: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> { let ran: { batch: BatchRun; last?: DrawingSegment }; try { - ran = await new ModelCommandService(this.doc, this.edit).runBatchDrawnBetween(this.ops, diagram); + ran = await new ModelCommands(this.doc, this.edit).runBatchDrawnBetween(this.commands, diagram); } catch (err) { - throw toCliError(err); + throw toBpmnError(err); } if (!this.opts.force) this.refuseDroppedContent(); return ran; @@ -128,8 +128,8 @@ export class EditBatchService { }); } - private async validate(findings: FindingBaseline, batch: BatchRun): Promise { - const validation = await findings.afterOps(); + private async validateAndDropProfileDuplicates(findings: FindingBaseline, batch: BatchRun): Promise { + const validation = await findings.afterCommands(); batch.changes.warnings = withoutProfileDuplicates(batch.changes.warnings, validation.warnings); } diff --git a/src/domain/batch/ModelCommandService.ts b/src/domain/batch/ModelCommands.ts similarity index 59% rename from src/domain/batch/ModelCommandService.ts rename to src/domain/batch/ModelCommands.ts index 99eebccb..20d659c3 100644 --- a/src/domain/batch/ModelCommandService.ts +++ b/src/domain/batch/ModelCommands.ts @@ -1,36 +1,36 @@ import type { Doc } from '../document/Doc.js'; -import { EditContext, type BatchContext } from '../document/EditContext.js'; +import { EditSession, type BatchContext } from '../document/EditSession.js'; import { usageError } from '../document/utils/errors.js'; -import { type Warning } from '../document/CliError.js'; -import { is, type El } from '../document/utils/moddle.js'; +import type { Finding } from '../document/Finding.js'; +import { enclosingProcess, is, type El } from '../document/utils/moddle.js'; import { ChangeSet } from '../document/ChangeSet.js'; import { AliasTable } from '../commands/AliasTable.js'; -import { OpAliases } from '../commands/OpAliases.js'; -import { isDiagramCommand } from '../commands/utils/op-names.js'; +import { CommandAliases } from '../commands/CommandAliases.js'; +import { isDiagramCommand } from '../commands/utils/command-names.js'; import type { EditCommand } from '../commands/types.js'; import type { BatchDrawing, DrawingSegment } from '../diagram/BatchDrawing.js'; -import { ElementCreationService } from '../editing/ElementCreationService.js'; -import { ConnectionService } from '../editing/ConnectionService.js'; -import { PropertyService } from '../editing/PropertyService.js'; -import { ElementRemovalService } from '../editing/ElementRemovalService.js'; -import { ElementTypeChangeService } from '../editing/ElementTypeChangeService.js'; -import { ElementMoveService } from '../editing/ElementMoveService.js'; -import { ElementOrderService } from '../editing/ElementOrderService.js'; -import { ExtensionElementService } from '../editing/extensions/ExtensionElementService.js'; -import { BranchSplitService } from '../editing/flows/BranchSplitService.js'; -import { IdRenameService } from '../editing/IdRenameService.js'; -import { StaleFlowReport } from '../editing/flows/StaleFlowReport.js'; +import { ElementCreation } from '../editing/elements/ElementCreation.js'; +import { Connections } from '../editing/connections/Connections.js'; +import { Properties } from '../editing/properties/Properties.js'; +import { ElementRemoval } from '../editing/elements/ElementRemoval.js'; +import { ElementTypeChange } from '../editing/elements/ElementTypeChange.js'; +import { ElementMove } from '../editing/elements/ElementMove.js'; +import { ElementOrder } from '../editing/elements/ElementOrder.js'; +import { ExtensionElements } from '../editing/extensions/ExtensionElements.js'; +import { BranchSplit } from '../editing/connections/BranchSplit.js'; +import { IdRename } from '../editing/ids/IdRename.js'; +import { StaleFlowReport } from '../editing/connections/StaleFlowReport.js'; /** The merged changes, the ops with aliases resolved (for layout and format) and alias -> id. */ export interface BatchRun { changes: ChangeSet; - ops: EditCommand[]; + commands: EditCommand[]; aliases: Record; } /** The state of the batch that runs. */ interface RunningBatch { - ops: EditCommand[]; + commands: EditCommand[]; all: ChangeSet; table: AliasTable; resolved: EditCommand[]; @@ -40,23 +40,23 @@ interface RunningBatch { } /** - * Dispatches ops: every CLI command and `apply` run through here, inside one edit context - * handed to every editing service; without one given, a fresh one for the document. + * Dispatches ops: every CLI command and `apply` run through here, inside one edit session + * handed to every editing class; without one given, a fresh one for the document. */ -export class ModelCommandService { +export class ModelCommands { private batch: RunningBatch | undefined; constructor( private readonly doc: Doc, - private readonly edit: EditContext = new EditContext(doc), + private readonly edit: EditSession = new EditSession(doc), ) {} /** Runs one op on its own; stale flow ids and id prefixes are reported at its end. */ - runOp(op: EditCommand): ChangeSet { + runCommand(command: EditCommand): ChangeSet { this.edit.begin(); - const cs = this.step(op); - new StaleFlowReport(this.doc, this.edit).report(cs, this.edit.flowsBefore); - new IdRenameService(this.doc, this.edit).reportStalePrefixes(cs); + const cs = this.step(command); + new StaleFlowReport(this.edit).report(cs, this.edit.flowsBefore); + new IdRename(this.edit).reportStalePrefixes(cs); return cs; } @@ -65,9 +65,9 @@ export class ModelCommandService { * commands get theirs once the model commands before them ran. Stale flow ids and id prefixes * are reported once, against the state before and after the whole batch. */ - runBatch(ops: EditCommand[]): BatchRun { - this.beginBatch(ops); - this.runCommands(0, ops.length); + runBatch(commands: EditCommand[]): BatchRun { + this.beginBatch(commands); + this.runCommands(0, commands.length); this.resolveDiagramCommands(); return this.finishBatch(); } @@ -77,10 +77,10 @@ export class ModelCommandService { * command the drawing is brought up to date first, so every diagram command sees the * commands before it; `last` is then the segment the final layout still has to draw. */ - async runBatchDrawnBetween(ops: EditCommand[], drawing: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> { - const segments = ModelCommandService.segmentsOf(ops); + async runBatchDrawnBetween(commands: EditCommand[], drawing: BatchDrawing): Promise<{ batch: BatchRun; last?: DrawingSegment }> { + const segments = ModelCommands.segmentsOf(commands); let last: DrawingSegment | undefined; - this.beginBatch(ops); + this.beginBatch(commands); for (const [position, segment] of segments.entries()) { const before = new Set(this.changesSoFar.created.map((created) => this.doc.get(created.id))); this.runCommands(segment.start, segment.end); @@ -100,16 +100,16 @@ export class ModelCommandService { * The batch cut where a model command follows a diagram command: the diagram commands * of a segment see the drawing of every command before them. */ - private static segmentsOf(ops: readonly EditCommand[]): Array> { + private static segmentsOf(commands: readonly EditCommand[]): Array> { const segments: Array> = []; let start = 0; - const segment = (end: number) => ({ start, end, model: ops.slice(start, end).some((op) => !isDiagramCommand(op)) }); - ops.forEach((op, index) => { - if (index === 0 || isDiagramCommand(op) || !isDiagramCommand(ops[index - 1]!)) return; + const segment = (end: number) => ({ start, end, model: commands.slice(start, end).some((command) => !isDiagramCommand(command)) }); + commands.forEach((command, index) => { + if (index === 0 || isDiagramCommand(command) || !isDiagramCommand(commands[index - 1]!)) return; segments.push(segment(index)); start = index; }); - segments.push(segment(ops.length)); + segments.push(segment(commands.length)); return segments; } @@ -117,10 +117,10 @@ export class ModelCommandService { * Starts a batch that runCommands works off in steps, so the diagram can be drawn in between. * Its context records what the batch did so far, so E_NOT_FOUND can explain a missing id. */ - private beginBatch(ops: EditCommand[]): void { + private beginBatch(commands: EditCommand[]): void { const table = new AliasTable(this.doc); const context: BatchContext = { created: [], renamed: new Map(), retyped: new Set(), aliases: () => table.toRecord() }; - this.batch = { ops, all: new ChangeSet(), table, resolved: [...ops], pending: [], context }; + this.batch = { commands, all: new ChangeSet(), table, resolved: [...commands], pending: [], context }; this.edit.begin(); } @@ -131,20 +131,20 @@ export class ModelCommandService { private runCommands(from: number, to: number): void { const batch = this.openBatch(); this.inBatch(() => { - for (let opIndex = from; opIndex < to; opIndex++) { - const op = batch.ops[opIndex]!; - this.atOp(opIndex, () => { - if (isDiagramCommand(op)) { - for (const { key, alias } of new OpAliases(op).references()) batch.table.idOf(alias, opIndex, op, key); - batch.pending.push(opIndex); + for (let commandIndex = from; commandIndex < to; commandIndex++) { + const command = batch.commands[commandIndex]!; + this.atCommand(commandIndex, () => { + if (isDiagramCommand(command)) { + for (const { key, alias } of new CommandAliases(command).references()) batch.table.idOf(alias, commandIndex, command, key); + batch.pending.push(commandIndex); return; } - const ready = new OpAliases(op).resolve(batch.table, opIndex); - batch.resolved[opIndex] = ready; + const ready = new CommandAliases(command).resolve(batch.table, commandIndex); + batch.resolved[commandIndex] = ready; const cs = this.step(ready); batch.all.merge(cs); for (const replacement of cs.replaced) batch.table.rebind(replacement.from, replacement.to); - for (const binding of cs.bindings) batch.table.define(binding.alias, binding.el, opIndex); + for (const binding of cs.bindings) batch.table.define(binding.alias, binding.el, commandIndex); const context = batch.context; context.created.push(...cs.created.map((change) => change.id)); for (const rename of cs.renames) { @@ -168,9 +168,9 @@ export class ModelCommandService { const pending = batch.pending.splice(0); this.inBatch(() => { for (const pendingIndex of pending) { - this.atOp(pendingIndex, () => { - const ready = (batch.resolved[pendingIndex] = new OpAliases(batch.ops[pendingIndex]!).resolve(batch.table, pendingIndex)); - for (const id of new OpAliases(ready).referencedIds()) if (!this.doc.get(id)) this.edit.require(id); + this.atCommand(pendingIndex, () => { + const ready = (batch.resolved[pendingIndex] = new CommandAliases(batch.commands[pendingIndex]!).resolve(batch.table, pendingIndex)); + for (const id of new CommandAliases(ready).referencedIds()) if (!this.doc.get(id)) this.edit.require(id); }); } }); @@ -185,9 +185,9 @@ export class ModelCommandService { all.bindings = []; all.replaced = []; all.warnings = all.warnings.filter((warning) => this.stillHolds(warning)); - new StaleFlowReport(this.doc, this.edit).report(all, this.edit.flowsBefore); - new IdRenameService(this.doc, this.edit).reportStalePrefixes(all); - return { changes: all, ops: batch.resolved, aliases: batch.table.toRecord() }; + new StaleFlowReport(this.edit).report(all, this.edit.flowsBefore); + new IdRename(this.edit).reportStalePrefixes(all); + return { changes: all, commands: batch.resolved, aliases: batch.table.toRecord() }; } /** The changes of the batch so far, e.g. the ids created since the drawing was last updated. */ @@ -210,49 +210,47 @@ export class ModelCommandService { } /** Every entry names its final id; merge applies renames to earlier ops too. */ - private step(op: EditCommand): ChangeSet { - const cs = this.dispatch(op); + private step(command: EditCommand): ChangeSet { + const cs = this.dispatch(command); this.edit.allocator.reportSuffixed(cs); cs.rename(this.edit.takeRenames()); return cs; } - /** Diagram-only commands change nothing here: DiagramCommandService runs them once the drawing is up to date. */ - private dispatch(op: EditCommand): ChangeSet { - const doc = this.doc; - if (isDiagramCommand(op)) return new ChangeSet(); - switch (op.op) { + /** Diagram-only commands change nothing here: DiagramCommands runs them once the drawing is up to date. */ + private dispatch(command: EditCommand): ChangeSet { + if (isDiagramCommand(command)) return new ChangeSet(); + switch (command.op) { case 'add': - return new ElementCreationService(doc, this.edit).add(op); + return new ElementCreation(this.edit).add(command); case 'connect': - return new ConnectionService(doc, this.edit).connect(op); + return new Connections(this.edit).connect(command); case 'set': - return new PropertyService(doc, this.edit).set(op); + return new Properties(this.edit).set(command); case 'remove': - return new ElementRemovalService(doc, this.edit).remove(op); + return new ElementRemoval(this.edit).remove(command); case 'retype': - return new ElementTypeChangeService(doc, this.edit).retype(op); + return new ElementTypeChange(this.edit).retype(command); case 'move': - return new ElementMoveService(doc, this.edit).move(op); + return new ElementMove(this.edit).move(command); case 'order': - return new ElementOrderService(doc, this.edit, op).apply(); + return new ElementOrder(this.edit).reorder(command); case 'ext': - return new ExtensionElementService(doc, this.edit).apply(op); + return new ExtensionElements(this.edit).apply(command); case 'split': - return new BranchSplitService(doc, this.edit).split(op); + return new BranchSplit(this.edit).split(command); default: - throw usageError(`Unknown operation "${String((op as { op?: unknown }).op)}"`); + throw usageError(`Unknown operation "${String((command as { op?: unknown }).op)}"`); } } /** A later op may have added the pool or put a gateway in between. */ - private stillHolds(warning: Warning): boolean { + private stillHolds(warning: Finding): boolean { const doc = this.doc; const el = warning.element ? doc.get(warning.element) : undefined; switch (warning.code) { case 'W_LANES_WITHOUT_POOL': { - let process = el?.$parent as El | undefined; - while (process && !is(process, 'bpmn:Process')) process = process.$parent as El | undefined; + const process = enclosingProcess(el?.$parent as El | undefined); return !!process && !doc.participantOf(process); } case 'W_IMPLICIT_SPLIT': @@ -265,12 +263,12 @@ export class ModelCommandService { } /** An error thrown by op `i` names the op. */ - private atOp(opIndex: number, fn: () => unknown): void { + private atCommand(commandIndex: number, fn: () => unknown): void { try { fn(); } catch (err) { if (err && typeof err === 'object' && 'details' in err) { - (err as { details: Record }).details['op'] = opIndex; + (err as { details: Record }).details['op'] = commandIndex; } throw err; } diff --git a/src/domain/batch/types.ts b/src/domain/batch/types.ts index e98d4011..b6ca8a16 100644 --- a/src/domain/batch/types.ts +++ b/src/domain/batch/types.ts @@ -1,4 +1,4 @@ -import type { IdConventions } from '../document/ids/IdConventionsReader.js'; +import type { IdConventions } from '../document/ids/IdConventions.js'; import type { DebugSink } from '../diagram/layout/LayoutDebug.js'; import type { DrawingOptions, LayoutStatus } from '../diagram/types.js'; import type { ChangeSet } from '../document/ChangeSet.js'; diff --git a/src/domain/commands/AliasTable.ts b/src/domain/commands/AliasTable.ts index c448e462..d2efa24b 100644 --- a/src/domain/commands/AliasTable.ts +++ b/src/domain/commands/AliasTable.ts @@ -1,5 +1,5 @@ import type { Doc } from '../document/Doc.js'; -import { CliError } from '../document/CliError.js'; +import { BpmnError } from '../document/BpmnError.js'; import { usageError } from '../document/utils/errors.js'; import type { El } from '../document/utils/moddle.js'; import { isAlias, unknownAlias } from './utils/aliases.js'; @@ -20,7 +20,7 @@ export class AliasTable { define(alias: string, el: El, op: number): void { const prev = this.map.get(alias); - if (prev) throw new CliError('E_DUPLICATE_ALIAS', `ops[${op}]: alias ${alias} is already defined by ops[${prev.op}]`, 'usage', { op, hint: 'Every alias names one element of the batch: pick another name.' }); + if (prev) throw new BpmnError('E_DUPLICATE_ALIAS', `ops[${op}]: alias ${alias} is already defined by ops[${prev.op}]`, 'usage', { op, hint: 'Every alias names one element of the batch: pick another name.' }); if (!isAlias(alias)) throw usageError(`ops[${op}]: alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op }); this.map.set(alias, { el, op }); } @@ -29,12 +29,12 @@ export class AliasTable { for (const entry of this.map.values()) if (entry.el === from) entry.el = to; } - idOf(alias: string, index: number, op: EditCommand, key: string): string { + idOf(alias: string, index: number, command: EditCommand, key: string): string { const entry = this.map.get(alias); - if (!entry) throw unknownAlias(index, op, key, alias, new Map([...this.map].map(([aliasName, aliasEntry]) => [aliasName, aliasEntry.op]))); + if (!entry) throw unknownAlias(index, command, key, alias, new Map([...this.map].map(([aliasName, aliasEntry]) => [aliasName, aliasEntry.op]))); const id = entry.el.get('id'); if (!id || this.doc.get(id) !== entry.el) { - throw new CliError('E_NOT_FOUND', `ops[${index}] (${op.op}): "${key}": ${alias} named ${id ?? 'an element'}, which an op before this one removed`, 'model', { op: index, element: id, hint: 'Refer to an element that still exists.' }); + throw new BpmnError('E_NOT_FOUND', `ops[${index}] (${command.op}): "${key}": ${alias} named ${id ?? 'an element'}, which an op before this one removed`, 'model', { op: index, element: id, hint: 'Refer to an element that still exists.' }); } return id; } diff --git a/src/domain/commands/OpAliases.ts b/src/domain/commands/CommandAliases.ts similarity index 87% rename from src/domain/commands/OpAliases.ts rename to src/domain/commands/CommandAliases.ts index a007b7c0..6bc38cd1 100644 --- a/src/domain/commands/OpAliases.ts +++ b/src/domain/commands/CommandAliases.ts @@ -19,13 +19,13 @@ type Slot = { key: string; get: () => unknown; put: (slotValue: unknown) => void * id goes, so generated ids need not be guessed. `$` cannot occur in a BPMN id; `${...}` * expressions are not ids. */ -export class OpAliases { - constructor(private readonly op: EditCommand) {} +export class CommandAliases { + constructor(private readonly command: EditCommand) {} /** The aliases the op defines (a split: also its branch nodes'). */ defined(): string[] { - const raw = this.op as unknown as Raw; - const definition = EditCommandDefinitions.find(this.op.op); + const raw = this.command as unknown as Raw; + const definition = EditCommandDefinitions.find(this.command.op); const definingKeys = definition ? Object.keys(definition.fields).filter((key) => definition.fields[key]!.alias) : []; const out = definingKeys.map((defKey) => raw[defKey]).filter((definedId): definedId is string => typeof definedId === 'string'); this.eachBranchNode(definition, raw, (node) => { @@ -36,7 +36,7 @@ export class OpAliases { referencedIds(): string[] { const out: string[] = []; - for (const slot of this.slots(this.op)) { + for (const slot of this.slots(this.command)) { const slotValue = slot.get(); if (typeof slotValue === 'string') out.push(slotValue); else if (Array.isArray(slotValue)) for (const entry of slotValue) if (typeof entry === 'string') out.push(entry); @@ -47,7 +47,7 @@ export class OpAliases { /** Every alias the op refers to, with the key it stands in (`ids[1]`, `values.default`). */ references(): Array<{ key: string; alias: string }> { const out: Array<{ key: string; alias: string }> = []; - for (const slot of this.slots(this.op)) { + for (const slot of this.slots(this.command)) { const slotValue = slot.get(); if (isAlias(slotValue)) out.push({ key: slot.key, alias: slotValue }); else if (Array.isArray(slotValue)) slotValue.forEach((entry, index) => isAlias(entry) && out.push({ key: `${slot.key}[${index}]`, alias: entry })); @@ -56,10 +56,10 @@ export class OpAliases { } resolve(table: AliasTable, index: number): EditCommand { - const op = this.op; - if (!this.references().length) return op; - const copy = JSON.parse(JSON.stringify(op)) as EditCommand; - const id = (alias: string, key: string): string => table.idOf(alias, index, op, key); + const command = this.command; + if (!this.references().length) return command; + const copy = JSON.parse(JSON.stringify(command)) as EditCommand; + const id = (alias: string, key: string): string => table.idOf(alias, index, command, key); for (const slot of this.slots(copy)) { const slotValue = slot.get(); if (isAlias(slotValue)) slot.put(id(slotValue, slot.key)); @@ -68,13 +68,13 @@ export class OpAliases { return copy; } - private slots(op: EditCommand): Slot[] { + private slots(command: EditCommand): Slot[] { const out: Slot[] = []; const slot = (holder: Raw, name: string, key: string): void => { out.push({ key, holder, name, get: () => holder[name], put: (slotValue) => (holder[name] = slotValue) }); }; - const raw = op as unknown as Raw; - const definition = EditCommandDefinitions.find(op.op); + const raw = command as unknown as Raw; + const definition = EditCommandDefinitions.find(command.op); for (const key of definition?.referenceKeys ?? []) slot(raw, key, key); const map = (holder: Raw, name: string, path: string): void => { const member = holder[name]; diff --git a/src/domain/commands/CommandFieldCheck.ts b/src/domain/commands/CommandFieldCheck.ts index f326371a..b2b2a97f 100644 --- a/src/domain/commands/CommandFieldCheck.ts +++ b/src/domain/commands/CommandFieldCheck.ts @@ -1,6 +1,5 @@ -import type { CliError } from '../document/CliError.js'; -import { kindByName, gatewayKindList, normalizeTrigger, parseKind, type Family } from '../document/utils/kinds.js'; -import { KindError } from '../document/KindError.js'; +import { BpmnError } from '../document/BpmnError.js'; +import { kindByName, gatewayKindList, normalizeTrigger, parseKind, type Family } from '../document/Kinds.js'; import { commandUsageError, type CommandLocation } from './utils/command-location.js'; import { defaultWithConditionViolation, groupViolation, placementViolation } from './utils/op-rules.js'; import { FLOW_KEYS, PLACEMENT_KEYS, TRIGGER_KEYS } from './utils/op-fields.js'; @@ -11,7 +10,7 @@ export class CommandFieldCheck { readonly fields: Record, ) {} - fail(message: string, hint?: string, candidates?: string[]): CliError { + fail(message: string, hint?: string, candidates?: string[]): BpmnError { return commandUsageError(this.location, message, hint, candidates); } @@ -63,7 +62,7 @@ export class CommandFieldCheck { try { family = parseKind(kind).def.family; } catch (err) { - if (err instanceof KindError) throw this.fail(err.message, 'Run `bpmn kinds` for the list of kinds and triggers.', err.candidates); + if (err instanceof BpmnError) throw this.fail(err.message, 'Run `bpmn kinds` for the list of kinds and triggers.', err.details.candidates ?? []); throw err; } if (expectFamily && family !== expectFamily) { diff --git a/src/domain/commands/CommandParser.ts b/src/domain/commands/CommandParser.ts index 4b1b6d0f..7f794989 100644 --- a/src/domain/commands/CommandParser.ts +++ b/src/domain/commands/CommandParser.ts @@ -1,12 +1,12 @@ import { usageError } from '../document/utils/errors.js'; -import { CliError } from '../document/CliError.js'; +import { BpmnError } from '../document/BpmnError.js'; import { closestKey } from '../document/utils/fuzzy.js'; import { CommandFieldCheck } from './CommandFieldCheck.js'; import { EditCommandDefinitions } from './definitions/EditCommandDefinitions.js'; -import { OpAliases } from './OpAliases.js'; +import { CommandAliases } from './CommandAliases.js'; import { ALIAS, unknownAlias } from './utils/aliases.js'; import { commandUsageError, type CommandLocation } from './utils/command-location.js'; -import { OP_NAMES } from './utils/op-names.js'; +import { COMMAND_NAMES } from './utils/command-names.js'; import { parseSpaceAmount } from './utils/space-amount.js'; import type { FieldSpec, SplitNode } from './utils/field-specs.js'; import { BRANCH_FIELDS, NODE_FIELDS } from './utils/op-fields.js'; @@ -20,7 +20,7 @@ const ID_LIKE = new Set(['flowId', 'flow', 'elementId', 'element', 'nodeId', 'no * library. Every problem is a usage error naming `ops[] ()`. */ export class CommandParser { - private fail(ctx: CommandLocation, message: string, hint?: string, candidates?: string[]): CliError { + private fail(ctx: CommandLocation, message: string, hint?: string, candidates?: string[]): BpmnError { return commandUsageError(ctx, message, hint, candidates); } @@ -193,7 +193,7 @@ export class CommandParser { }); } - private checkOp(ctx: CommandLocation, name: EditCommand['op'], raw: Record): EditCommand { + private checkCommand(ctx: CommandLocation, name: EditCommand['op'], raw: Record): EditCommand { const definition = EditCommandDefinitions.of(name); const out = this.checkFields(ctx, raw, definition.fields); definition.check(new CommandFieldCheck(ctx, out)); @@ -204,12 +204,12 @@ export class CommandParser { const base: CommandLocation = { index, path: `ops[${index}]` }; if (!this.isPlainObject(raw)) throw this.fail(base, `expected an op object, got ${this.describe(raw)}`, 'Every entry looks like {"op": "add", ...}.'); const name = raw['op']; - if (name === undefined) throw this.fail(base, `missing "op"; one of: ${OP_NAMES.join(', ')}`); - if (typeof name !== 'string' || !(OP_NAMES as string[]).includes(name)) { - const alt = typeof name === 'string' ? closestKey(name, OP_NAMES) : undefined; - throw this.fail(base, `unknown op ${JSON.stringify(name)}${alt ? ` (did you mean "${alt}"?)` : ''}; one of: ${OP_NAMES.join(', ')}`, undefined, alt ? [alt] : []); + if (name === undefined) throw this.fail(base, `missing "op"; one of: ${COMMAND_NAMES.join(', ')}`); + if (typeof name !== 'string' || !(COMMAND_NAMES as string[]).includes(name)) { + const alt = typeof name === 'string' ? closestKey(name, COMMAND_NAMES) : undefined; + throw this.fail(base, `unknown op ${JSON.stringify(name)}${alt ? ` (did you mean "${alt}"?)` : ''}; one of: ${COMMAND_NAMES.join(', ')}`, undefined, alt ? [alt] : []); } - return this.checkOp({ index, path: `ops[${index}] (${name})` }, name as EditCommand['op'], raw); + return this.checkCommand({ index, path: `ops[${index}] (${name})` }, name as EditCommand['op'], raw); } /** Validates the ops JSON (an array or `{ ops: [...] }`) and returns typed ops. */ @@ -237,28 +237,28 @@ export class CommandParser { } /** Before anything runs: every alias defined once and used only after the op defining it. */ - private checkAliases(ops: readonly EditCommand[]): void { + private checkAliases(commands: readonly EditCommand[]): void { const defined = new Map(); const all = new Map(); - ops.forEach((op, opIndex) => { - for (const alias of new OpAliases(op).defined()) if (!all.has(alias)) all.set(alias, opIndex); + commands.forEach((command, commandIndex) => { + for (const alias of new CommandAliases(command).defined()) if (!all.has(alias)) all.set(alias, commandIndex); }); - ops.forEach((op, opIndex) => { - const raw = op as unknown as Record; - for (const [key, aliasKey] of Object.entries(EditCommandDefinitions.of(op.op).aliasKeyOfNewId)) { + commands.forEach((command, commandIndex) => { + const raw = command as unknown as Record; + for (const [key, aliasKey] of Object.entries(EditCommandDefinitions.of(command.op).aliasKeyOfNewId)) { if (typeof raw[key] === 'string' && (raw[key] as string).startsWith('$')) { - throw usageError(`ops[${opIndex}] (${op.op}): "${key}" is the id of the new element, not an alias; give the alias with "${aliasKey}": "${raw[key]}"`, { op: opIndex }); + throw usageError(`ops[${commandIndex}] (${command.op}): "${key}" is the id of the new element, not an alias; give the alias with "${aliasKey}": "${raw[key]}"`, { op: commandIndex }); } } - for (const { key, alias } of new OpAliases(op).references()) { - if (!defined.has(alias)) throw unknownAlias(opIndex, op, key, alias, defined, all.get(alias)); + for (const { key, alias } of new CommandAliases(command).references()) { + if (!defined.has(alias)) throw unknownAlias(commandIndex, command, key, alias, defined, all.get(alias)); } - for (const alias of new OpAliases(op).defined()) { - if (!ALIAS.test(alias)) throw usageError(`ops[${opIndex}] (${op.op}): alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op: opIndex, hint: 'Example: "as": "$check", then "after": "$check".' }); + for (const alias of new CommandAliases(command).defined()) { + if (!ALIAS.test(alias)) throw usageError(`ops[${commandIndex}] (${command.op}): alias "${alias}" must look like $name ($ and a letter, then letters, digits, _ or -)`, { op: commandIndex, hint: 'Example: "as": "$check", then "after": "$check".' }); if (defined.has(alias)) { - throw new CliError('E_DUPLICATE_ALIAS', `ops[${opIndex}] (${op.op}): alias ${alias} is already defined by ops[${defined.get(alias)}]`, 'usage', { op: opIndex, hint: 'Every alias names one element of the batch: pick another name.' }); + throw new BpmnError('E_DUPLICATE_ALIAS', `ops[${commandIndex}] (${command.op}): alias ${alias} is already defined by ops[${defined.get(alias)}]`, 'usage', { op: commandIndex, hint: 'Every alias names one element of the batch: pick another name.' }); } - defined.set(alias, opIndex); + defined.set(alias, commandIndex); } }); } diff --git a/src/domain/commands/definitions/AddElementDefinition.ts b/src/domain/commands/definitions/AddElementDefinition.ts index c8396564..4332602b 100644 --- a/src/domain/commands/definitions/AddElementDefinition.ts +++ b/src/domain/commands/definitions/AddElementDefinition.ts @@ -1,4 +1,4 @@ -import { FLOW_NODE_FAMILIES, type Family } from '../../document/utils/kinds.js'; +import { FLOW_NODE_FAMILIES, type Family } from '../../document/Kinds.js'; import type { CommandFieldCheck } from '../CommandFieldCheck.js'; import { alias, bool, ref, type FieldsOf } from '../utils/field-specs.js'; import { FLOW_FIELDS, NODE_FIELDS, PLACEMENT_FIELDS, TRIGGER_FIELDS } from '../utils/op-fields.js'; diff --git a/src/domain/commands/definitions/AlignShapesDefinition.ts b/src/domain/commands/definitions/AlignShapesDefinition.ts index 35f7506e..68aeab38 100644 --- a/src/domain/commands/definitions/AlignShapesDefinition.ts +++ b/src/domain/commands/definitions/AlignShapesDefinition.ts @@ -8,7 +8,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class AlignShapesDefinition extends EditCommandDefinition { readonly name = 'align' as const; readonly description = 'Diagram only: put shapes on one row or one column (= `bpmn align`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { ids: list('Shapes to align (each moves on its own).', { minItems: 1 }), ...SELECTOR_FIELDS, diff --git a/src/domain/commands/definitions/ColorElementsDefinition.ts b/src/domain/commands/definitions/ColorElementsDefinition.ts index 910aa75e..86e7bcae 100644 --- a/src/domain/commands/definitions/ColorElementsDefinition.ts +++ b/src/domain/commands/definitions/ColorElementsDefinition.ts @@ -8,7 +8,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class ColorElementsDefinition extends EditCommandDefinition { readonly name = 'color' as const; readonly description = 'Diagram only: colour shapes and connections with a bpmn-js colour picker colour (= `bpmn color`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { ids: list('Shapes and connections to colour.', { minItems: 1 }), ...SELECTOR_FIELDS, diff --git a/src/domain/commands/definitions/CompactDiagramDefinition.ts b/src/domain/commands/definitions/CompactDiagramDefinition.ts index 5cbc4d3f..e198d0a8 100644 --- a/src/domain/commands/definitions/CompactDiagramDefinition.ts +++ b/src/domain/commands/definitions/CompactDiagramDefinition.ts @@ -5,7 +5,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class CompactDiagramDefinition extends EditCommandDefinition { readonly name = 'compact' as const; readonly description = 'Diagram only: close empty rows and columns and shrink pools, lanes and expanded sub-processes to their content, keeping the order and relative positions; never adds a layout problem (= `bpmn compact`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { ids: list('Only these frames (pools by participant id, lanes, expanded sub-processes) and what is inside them (default: the whole drawing).', { minItems: 1 }), }; diff --git a/src/domain/commands/definitions/EditCommandDefinition.ts b/src/domain/commands/definitions/EditCommandDefinition.ts index 6489cb17..5f8a9c02 100644 --- a/src/domain/commands/definitions/EditCommandDefinition.ts +++ b/src/domain/commands/definitions/EditCommandDefinition.ts @@ -7,7 +7,7 @@ export abstract class EditCommandDefinition & Record; /** Changes only the drawing: runs after the layout of the model commands before it. */ - readonly diagramOnly: boolean = false; + readonly diagramCommand: boolean = false; /** Keys that hold an element id (or a list of them): where an alias may stand. */ abstract readonly referenceKeys: readonly string[]; /** An alias in a new-id key is a mistake: the alias key it belongs in. */ diff --git a/src/domain/commands/definitions/InsertSpaceDefinition.ts b/src/domain/commands/definitions/InsertSpaceDefinition.ts index 08c3406b..403d0afa 100644 --- a/src/domain/commands/definitions/InsertSpaceDefinition.ts +++ b/src/domain/commands/definitions/InsertSpaceDefinition.ts @@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class InsertSpaceDefinition extends EditCommandDefinition { readonly name = 'space' as const; readonly description = 'Diagram only: insert space right of / below an element like the modeler\'s space tool (= `bpmn space`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { after: ref('Insert horizontal space right of this element (everything starting right of it moves right, frames grow).'), below: ref('Insert vertical space below this element (everything starting below it moves down, frames grow).'), diff --git a/src/domain/commands/definitions/PlaceLabelDefinition.ts b/src/domain/commands/definitions/PlaceLabelDefinition.ts index 0508091d..6cbab899 100644 --- a/src/domain/commands/definitions/PlaceLabelDefinition.ts +++ b/src/domain/commands/definitions/PlaceLabelDefinition.ts @@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class PlaceLabelDefinition extends EditCommandDefinition { readonly name = 'label' as const; readonly description = 'Diagram only: put the external label of an event, gateway, data element or flow on one side (= `bpmn label`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { id: ref('Event, gateway, data object / store or flow with a name (external label).', { required: true }), side: str('Side of the element (flows: of the longest horizontal / vertical segment) the label goes to.', { required: true, values: LABEL_SIDE_VALUES }), diff --git a/src/domain/commands/definitions/PlaceShapesDefinition.ts b/src/domain/commands/definitions/PlaceShapesDefinition.ts index cbb2d96b..df818fd9 100644 --- a/src/domain/commands/definitions/PlaceShapesDefinition.ts +++ b/src/domain/commands/definitions/PlaceShapesDefinition.ts @@ -14,7 +14,7 @@ const PLACE_GROUPS = [ export class PlaceShapesDefinition extends EditCommandDefinition { readonly name = 'place' as const; readonly description = 'Diagram only: move shapes (rigid group) to the row and/or column of another element (= `bpmn place`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { ids: list('Shapes moved as one rigid group; the first id is the reference that lands on the target row / column. Boundary events, labels and the content of an expanded sub-process follow.', { minItems: 1 }), ...SELECTOR_FIELDS, diff --git a/src/domain/commands/definitions/RerouteFlowDefinition.ts b/src/domain/commands/definitions/RerouteFlowDefinition.ts index 2f2d45d4..14564aed 100644 --- a/src/domain/commands/definitions/RerouteFlowDefinition.ts +++ b/src/domain/commands/definitions/RerouteFlowDefinition.ts @@ -6,7 +6,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class RerouteFlowDefinition extends EditCommandDefinition { readonly name = 'route' as const; readonly description = 'Diagram only: route one flow again, optionally forcing the exit / entry side (= `bpmn route`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { id: ref('Sequence flow or message flow to route again.', { required: true }), exit: str('Side the flow leaves its source by (default: the conventions).', { values: SIDE_VALUES }), diff --git a/src/domain/commands/definitions/TidyShapesDefinition.ts b/src/domain/commands/definitions/TidyShapesDefinition.ts index e40df2a1..14400a91 100644 --- a/src/domain/commands/definitions/TidyShapesDefinition.ts +++ b/src/domain/commands/definitions/TidyShapesDefinition.ts @@ -7,7 +7,7 @@ import { EditCommandDefinition } from './EditCommandDefinition.js'; export class TidyShapesDefinition extends EditCommandDefinition { readonly name = 'tidy' as const; readonly description = 'Diagram only: remove overlaps and gaps < 20 px with minimal moves, keeping the order (= `bpmn tidy`, `bpmn layout --tidy`).'; - override readonly diagramOnly = true; + override readonly diagramCommand = true; readonly fields: FieldsOf = { ids: list('Only these shapes (default: every shape of every diagram).', { minItems: 1 }), ...SELECTOR_FIELDS, diff --git a/src/domain/commands/utils/aliases.ts b/src/domain/commands/utils/aliases.ts index 0a46980f..b922e065 100644 --- a/src/domain/commands/utils/aliases.ts +++ b/src/domain/commands/utils/aliases.ts @@ -1,4 +1,4 @@ -import { CliError } from '../../document/CliError.js'; +import { BpmnError } from '../../document/BpmnError.js'; import { editDistance, typoTolerance } from '../../document/utils/fuzzy.js'; import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js'; import type { EditCommand } from '../types.js'; @@ -15,15 +15,15 @@ function opsDefining(key: string): string { } function listing(defined: ReadonlyMap): string { - return defined.size ? `defined so far: ${[...defined].map(([aliasName, opIndex]) => `${aliasName} (ops[${opIndex}])`).join(', ')}` : 'no op before it defines one'; + return defined.size ? `defined so far: ${[...defined].map(([aliasName, commandIndex]) => `${aliasName} (ops[${commandIndex}])`).join(', ')}` : 'no op before it defines one'; } -export function unknownAlias(index: number, op: EditCommand, key: string, alias: string, defined: ReadonlyMap, later?: number): CliError { +export function unknownAlias(index: number, command: EditCommand, key: string, alias: string, defined: ReadonlyMap, later?: number): BpmnError { const where = later === undefined ? '' : later === index ? ' yet (this op defines it: only the ops after it can use it)' : later > index ? ` before this op (ops[${later}] defines it)` : ''; const distance = (candidate: string): number => editDistance(candidate.toLowerCase(), alias.toLowerCase()); const candidates = [...defined.keys()].sort((left, right) => distance(left) - distance(right)); const close = candidates[0] !== undefined && later === undefined && distance(candidates[0]) <= Math.max(1, typoTolerance(alias.length)) ? ` (did you mean ${candidates[0]}?)` : ''; - return new CliError('E_UNKNOWN_ALIAS', `ops[${index}] (${op.op}): "${key}": alias ${alias} is not defined${where}${close}; ${listing(defined)}`, 'usage', { + return new BpmnError('E_UNKNOWN_ALIAS', `ops[${index}] (${command.op}): "${key}": alias ${alias} is not defined${where}${close}; ${listing(defined)}`, 'usage', { op: index, candidates, hint: `An op defines an alias with "as": "$name" (${opsDefining('as')} and the nodes of a split branch), "flowAs" (${opsDefining('flowAs')}: the flow into the new node), "outAs" (${opsDefining('outAs')}: the flow it creates out of the new node), "joinAs" (${opsDefining('joinAs')}: the join gateway) or "refAs" (${opsDefining('refAs')}: the message / error / signal / escalation the element references); only the ops after it can use it.`, diff --git a/src/domain/commands/utils/command-location.ts b/src/domain/commands/utils/command-location.ts index c1d6e5cb..a31cd5ac 100644 --- a/src/domain/commands/utils/command-location.ts +++ b/src/domain/commands/utils/command-location.ts @@ -1,5 +1,5 @@ import { usageError } from '../../document/utils/errors.js'; -import type { CliError } from '../../document/CliError.js'; +import type { BpmnError } from '../../document/BpmnError.js'; export interface CommandLocation { index: number; @@ -7,7 +7,7 @@ export interface CommandLocation { path: string; } -export function commandUsageError(location: CommandLocation, message: string, hint?: string, candidates?: string[]): CliError { +export function commandUsageError(location: CommandLocation, message: string, hint?: string, candidates?: string[]): BpmnError { return usageError(`${location.path}: ${message}`, { op: location.index, ...(hint ? { hint } : {}), diff --git a/src/domain/commands/utils/command-names.ts b/src/domain/commands/utils/command-names.ts new file mode 100644 index 00000000..5421b16f --- /dev/null +++ b/src/domain/commands/utils/command-names.ts @@ -0,0 +1,11 @@ +import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js'; +import type { DiagramCommand, EditCommand } from '../types.js'; + +export const COMMAND_NAMES = EditCommandDefinitions.all().map((definition) => definition.name) as Array; + +/** The diagram-only ops (they run in batch order, after the layout of the model ops before them). */ +export const DIAGRAM_COMMAND_NAMES = EditCommandDefinitions.all().filter((definition) => definition.diagramCommand).map((definition) => definition.name) as Array; + +export function isDiagramCommand(command: EditCommand): command is DiagramCommand { + return EditCommandDefinitions.find(command.op)?.diagramCommand ?? false; +} diff --git a/src/domain/commands/utils/field-specs.ts b/src/domain/commands/utils/field-specs.ts index 48ec5297..54d2c3e2 100644 --- a/src/domain/commands/utils/field-specs.ts +++ b/src/domain/commands/utils/field-specs.ts @@ -14,7 +14,7 @@ export interface FieldSpec { alias?: boolean; } -export type FieldsOf = { [TKey in Exclude]-?: FieldSpec }; +export type FieldsOf = { [TKey in Exclude]-?: FieldSpec }; export type SplitNode = SplitBranch['nodes'][number]; diff --git a/src/domain/commands/utils/op-names.ts b/src/domain/commands/utils/op-names.ts deleted file mode 100644 index be0e77be..00000000 --- a/src/domain/commands/utils/op-names.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { EditCommandDefinitions } from '../definitions/EditCommandDefinitions.js'; -import type { DiagramCommand, EditCommand } from '../types.js'; - -export const OP_NAMES = EditCommandDefinitions.all().map((definition) => definition.name) as Array; - -/** The diagram-only ops (they run in batch order, after the layout of the model ops before them). */ -export const FORMAT_OP_NAMES = EditCommandDefinitions.all().filter((definition) => definition.diagramOnly).map((definition) => definition.name) as Array; - -export function isDiagramCommand(op: EditCommand): op is DiagramCommand { - return EditCommandDefinitions.find(op.op)?.diagramOnly ?? false; -} diff --git a/src/domain/commands/utils/ops-json.ts b/src/domain/commands/utils/ops-json.ts index bce26957..892d28df 100644 --- a/src/domain/commands/utils/ops-json.ts +++ b/src/domain/commands/utils/ops-json.ts @@ -1,9 +1,9 @@ -import { CliError } from '../../document/CliError.js'; +import { BpmnError } from '../../document/BpmnError.js'; export function parseOpsJson(text: string): unknown { try { return JSON.parse(text); } catch (err) { - throw new CliError('E_USAGE', `Ops are not valid JSON: ${(err as Error).message}`, 'usage'); + throw new BpmnError('E_USAGE', `Ops are not valid JSON: ${(err as Error).message}`, 'usage'); } } diff --git a/src/domain/diagram/BatchDrawing.ts b/src/domain/diagram/BatchDrawing.ts index 00721c4e..97bdc0f4 100644 --- a/src/domain/diagram/BatchDrawing.ts +++ b/src/domain/diagram/BatchDrawing.ts @@ -1,26 +1,27 @@ import { Doc } from '../document/Doc.js'; -import { CliError } from '../document/CliError.js'; -import { FullLayout, type LayoutEngine } from './layout/FullLayout.js'; +import { BpmnError } from '../document/BpmnError.js'; +import { FullLayout } from './layout/FullLayout.js'; import { SUB_PROCESS_TYPES } from './utils/expansion.js'; +import { SIZES } from './utils/sizes.js'; import type { GivenId } from './di/DrawnElementIds.js'; import { DiagramGeometry } from './layout/DiagramGeometry.js'; import { IncrementalLayout, type IncrementalReport } from './layout/incremental/IncrementalLayout.js'; import { Snapshot } from './layout/incremental/Snapshot.js'; import { LayoutAnalysis } from './di/LayoutAnalysis.js'; import { metricsDelta, type LayoutMetrics } from './utils/problems.js'; -import { DiagramCommandService, type FormatEntry, type FormatResult } from './DiagramCommandService.js'; +import { DiagramCommands, type DiagramCommandEntry } from './commands/DiagramCommands.js'; import { Stickies, type ResolvedAnchor, type StickyAnchor } from './di/Stickies.js'; import { applyColors, colorsOf } from './utils/colors.js'; import { DiIds } from './di/DiIds.js'; import { Groups } from './di/Groups.js'; -import { addTo, is, layoutRoot, many, parseXml, serialize, type El } from '../document/utils/moddle.js'; +import { addTo, is, layoutRoot, many, parseXml, type El } from '../document/utils/moddle.js'; import { ModelError } from '../document/ModelError.js'; -import type { EditContext } from '../document/EditContext.js'; +import type { EditSession } from '../document/EditSession.js'; import type { ChangeSet } from '../document/ChangeSet.js'; -import { isDiagramCommand } from '../commands/utils/op-names.js'; +import { isDiagramCommand } from '../commands/utils/command-names.js'; import type { EditCommand } from '../commands/types.js'; import { firstLine } from '../document/utils/text.js'; -import { toCliError } from '../document/utils/cli-error.js'; +import { toBpmnError } from '../document/utils/bpmn-error.js'; import type { DrawingOptions, LayoutMode, LayoutStatus } from './types.js'; interface Before { @@ -45,8 +46,6 @@ export interface DrawingSegment { export type RemoveElements = (doc: Doc, ids: string[]) => void; interface Ran { - /** the drawing bpmn-auto-layout wrote; absent when the document holds it */ - engineText?: string; status: LayoutStatus; after: LayoutMetrics; givenIds?: GivenId[]; @@ -65,7 +64,7 @@ export class BatchDrawing { private readonly doc: Doc, private readonly opts: DrawingOptions, private requested: LayoutMode | 'skip', - private readonly formatOnly: boolean, + private readonly diagramCommandsOnly: boolean, private readonly before: Before, private readonly removeElements: RemoveElements, ) {} @@ -74,15 +73,15 @@ export class BatchDrawing { * `asRead`: the document as read, without the entries a caller's validateDoc added in memory; * `removeElements` takes the undrawn nodes out of a copy for the engine-owned check. */ - static async capture(doc: Doc, ops: EditCommand[], opts: DrawingOptions, asRead: string | undefined, removeElements: RemoveElements): Promise { + static async capture(doc: Doc, commands: EditCommand[], opts: DrawingOptions, asRead: string | undefined, removeElements: RemoveElements): Promise { const requested = opts.layout === false ? 'skip' : opts.layout === undefined || opts.layout === true ? 'auto' : opts.layout; - const formatOnly = ops.length > 0 && ops.every(isDiagramCommand); + const diagramCommandsOnly = commands.length > 0 && commands.every(isDiagramCommand); const snapshot = Snapshot.take(doc.definitions); const hasDiagram = many(doc.definitions, 'diagrams').length > 0 && snapshot.byDi.size > 0; const before: Before = { snapshot, stickies: new Stickies(doc.definitions).anchors() }; if (hasDiagram) before.metrics = LayoutAnalysis.of(doc.definitions); - if (requested === 'auto' && !formatOnly && snapshot.flowNodeShapes > 0) before.xml = asRead ?? (await doc.toXml()); - return new BatchDrawing(doc, opts, requested, formatOnly, before, removeElements); + if (requested === 'auto' && !diagramCommandsOnly && snapshot.flowNodeShapes > 0) before.xml = asRead ?? (await doc.toXml()); + return new BatchDrawing(doc, opts, requested, diagramCommandsOnly, before, removeElements); } /** @@ -90,22 +89,22 @@ export class BatchDrawing { * itself; the next layout keeps that drawing like a hand-made one. Between two * full redraws the clean engine draws: only its drawing is the document's own. */ - async drawBetween(edit: EditContext, ran: EditCommand[], changes: ChangeSet, segment: DrawingSegment): Promise { + async drawBetween(edit: EditSession, ran: EditCommand[], changes: ChangeSet, segment: DrawingSegment): Promise { const doc = this.doc; let layout: LayoutStatus = { status: this.requested === 'skip' ? 'skipped' : 'ok', warnings: [], expanded: [] }; const keepsDrawing = this.requested === 'skip' || (!segment.model && this.requested !== 'full' && this.before.snapshot.flowNodeShapes > 0); if (!keepsDrawing && this.requested !== 'skip') { let givenIds: GivenId[] | undefined; try { - ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(segment.start, segment.end), changes), segment.created, 'clean')); + ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(segment.start, segment.end), changes), segment.created)); } catch (err) { - throw toCliError(err); + throw toBpmnError(err); } BatchDrawing.noteGivenIds(changes, givenIds); } - const entries = BatchDrawing.formatEntries(ran).filter((entry) => entry.index >= segment.start && entry.index < segment.end); - const format = entries.length ? new DiagramCommandService(doc).run(entries) : []; - if (format.length) layout.format = format; + const entries = BatchDrawing.diagramCommandEntries(ran).filter((entry) => entry.index >= segment.start && entry.index < segment.end); + const diagramResults = entries.length ? new DiagramCommands(doc).run(entries) : []; + if (diagramResults.length) layout.format = diagramResults; this.drawn.push(layout); this.before.snapshot = Snapshot.take(doc.definitions); this.before.xml = undefined; @@ -114,16 +113,14 @@ export class BatchDrawing { /** * `ran` holds the ops with their batch aliases resolved; `segment` the commands since - * drawBetween last ran (the whole batch when it did not). The drawing is the document's - * own, except after a bpmn-auto-layout redraw: then it is the text that engine wrote. + * drawBetween last ran (the whole batch when it did not). The drawing is the document's own. * Without a layout, dangling DI and edges showing a flow's old ends are pruned, so the next write * draws such a flow like a new one. A format op on the whole batch draws the file itself, so its * refusal may name a command to run on the file. */ - async apply(edit: EditContext, ran: EditCommand[], changes: ChangeSet, segment?: DrawingSegment): Promise<{ xml: string; layout: LayoutStatus }> { + async apply(edit: EditSession, ran: EditCommand[], changes: ChangeSet, segment?: DrawingSegment): Promise<{ xml: string; layout: LayoutStatus }> { const doc = this.doc; - const current = segment ?? { start: 0, end: ran.length, model: !this.formatOnly, created: changes.created.map((created) => created.id) }; - let engineText: string | undefined; + const current = segment ?? { start: 0, end: ran.length, model: !this.diagramCommandsOnly, created: changes.created.map((created) => created.id) }; let layout: LayoutStatus; let fileDrawing = false; if (this.requested === 'skip') { @@ -138,35 +135,22 @@ export class BatchDrawing { } else { let givenIds: GivenId[] | undefined; try { - ({ engineText, layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(current.start, current.end), changes), current.created)); + ({ layout, givenIds } = await this.runLayout(edit, this.requested, BatchDrawing.relocatedIds(ran.slice(current.start, current.end), changes), current.created)); } catch (err) { - throw toCliError(err); + throw toBpmnError(err); } BatchDrawing.noteGivenIds(changes, givenIds); } - const entries = BatchDrawing.formatEntries(ran).filter((entry) => entry.index >= current.start && entry.index < current.end); - if (engineText === undefined) { - const format = entries.length ? new DiagramCommandService(doc).run(entries, fileDrawing) : []; - if (format.length) { - layout.format = format; - layout.metrics = metricsDelta(this.before.metrics, LayoutAnalysis.of(doc.definitions)); - } - const moved = new Stickies(doc.definitions).follow(this.resolveAnchors()); - if (moved.length) layout.stickies = moved; - return { xml: await doc.toXml(), layout: this.withDrawn(layout) }; - } - let xml = engineText; - const formatted = entries.length ? await BatchDrawing.runFormat(xml, doc.file, entries) : undefined; - if (formatted?.results.length) { - xml = formatted.xml; - layout.format = formatted.results; - layout.metrics = metricsDelta(this.before.metrics, formatted.after); + const entries = BatchDrawing.diagramCommandEntries(ran).filter((entry) => entry.index >= current.start && entry.index < current.end); + const diagramResults = entries.length ? new DiagramCommands(doc).run(entries, fileDrawing) : []; + if (diagramResults.length) { + layout.format = diagramResults; + layout.metrics = metricsDelta(this.before.metrics, LayoutAnalysis.of(doc.definitions)); } - const followed = await BatchDrawing.moveStickies(xml, this.resolveAnchors()); - xml = followed.xml; - if (followed.moved.length) layout.stickies = followed.moved; - return { xml, layout: this.withDrawn(layout) }; + const moved = new Stickies(doc.definitions).follow(this.resolveAnchors()); + if (moved.length) layout.stickies = moved; + return { xml: await doc.toXml(), layout: this.withDrawn(layout) }; } private static noteGivenIds(changes: ChangeSet, givenIds: GivenId[] | undefined): void { @@ -273,7 +257,7 @@ export class BatchDrawing { id: (newDi ??= new DiIds(doc.definitions)).shape(id), bpmnElement: sub, isExpanded: false, - bounds: doc.moddle.create('dc:Bounds', { x: 0, y: 0, width: 100, height: 80 }), + bounds: doc.moddle.create('dc:Bounds', { x: 0, y: 0, ...SIZES.task }), }); addTo(rootPlane, 'planeElement', shape); } @@ -281,11 +265,11 @@ export class BatchDrawing { } /** A node a move left where it was (`move B --after A` with A -> B) keeps its place. */ - private static relocatedIds(ops: EditCommand[], changes: ChangeSet): string[] { + private static relocatedIds(commands: EditCommand[], changes: ChangeSet): string[] { const moved = new Set(changes.relocated); const out: string[] = []; - for (const op of ops) { - if (op.op === 'move' && (op.after || op.before || op.flow || op.in)) out.push(...op.ids.filter((id) => moved.has(id))); + for (const command of commands) { + if (command.op === 'move' && (command.after || command.before || command.flow || command.in)) out.push(...command.ids.filter((id) => moved.has(id))); } return out; } @@ -301,34 +285,21 @@ export class BatchDrawing { return out; } - private static async moveStickies(xml: string, anchors: ResolvedAnchor[]): Promise<{ xml: string; moved: Array<{ sticky: string; node: string }> }> { - if (!anchors.length) return { xml, moved: [] }; - const model = await parseXml(xml); - const moved = new Stickies(model.definitions).follow(anchors); - return { xml: moved.length ? await serialize(model) : xml, moved }; - } - /** Diagram commands and orders run on the laid-out drawing: the diagram commands decide which orders redraw bands. */ - private static formatEntries(ops: EditCommand[]): FormatEntry[] { - const out: FormatEntry[] = []; - ops.forEach((op, index) => { - if (isDiagramCommand(op) || op.op === 'order') out.push({ op, index }); + private static diagramCommandEntries(commands: EditCommand[]): DiagramCommandEntry[] { + const out: DiagramCommandEntry[] = []; + commands.forEach((command, index) => { + if (isDiagramCommand(command) || command.op === 'order') out.push({ op: command, index }); }); return out; } - private static async runFormat(xml: string, file: string | undefined, entries: FormatEntry[]): Promise<{ xml: string; results: FormatResult[]; after: LayoutMetrics }> { - const laid = await Doc.fromXml(xml, file); - const results = new DiagramCommandService(laid).run(entries); - return { xml: await laid.toXml(), results, after: LayoutAnalysis.of(laid.definitions) }; - } - /** * Whether re-running the engine reproduces the drawing. Undrawn flow nodes are removed * first: a stale but engine-made drawing is still engine-owned. * The bridged flows keep their ids, so the drawing is compared edge by edge. */ - private async isEngineOwned(xml: string, engine: LayoutEngine | undefined, undrawn: readonly string[]): Promise { + private async isEngineOwned(xml: string, undrawn: readonly string[]): Promise { const model = await parseXml(xml); const mine = DiagramGeometry.of(model.definitions); let reference = xml; @@ -344,11 +315,7 @@ export class BatchDrawing { } const clean = await parseXml(reference); await new FullLayout(clean, {}).run(); - if (mine.drawnBy(clean.definitions)) return true; - if (engine !== 'auto') return false; - const auto = await parseXml(reference); - const result = await new FullLayout(auto, { engine: 'auto' }).run(); - return mine.drawnBy((await parseXml(result.xml)).definitions); + return mine.drawnBy(clean.definitions); } private async decideMode(requested: LayoutMode): Promise<{ mode: 'full' | 'incremental'; reason: string }> { @@ -358,28 +325,20 @@ export class BatchDrawing { if (requested === 'incremental') return { mode: 'incremental', reason: 'incremental layout requested' }; const undrawn = before.snapshot.undrawn; const missing = undrawn.length ? `${undrawn.length} flow node(s) had no shape (e.g. added with --no-layout)` : ''; - if (before.xml && (await this.isEngineOwned(before.xml, this.opts.engine, undrawn))) return { mode: 'full', reason: missing ? `engine-owned diagram (${missing}): redrawn` : 'engine-owned diagram: redrawn' }; + if (before.xml && (await this.isEngineOwned(before.xml, undrawn))) return { mode: 'full', reason: missing ? `engine-owned diagram (${missing}): redrawn` : 'engine-owned diagram: redrawn' }; if (missing) return { mode: 'incremental', reason: `diagram kept: ${missing}; they are placed locally like new elements (\`bpmn layout\` redraws everything)` }; return { mode: 'incremental', reason: 'hand-made diagram: kept, changes placed locally' }; } /** Colours survive the redraw by element id. */ - private async fullLayout(edit: EditContext, engine = this.opts.engine): Promise { + private async fullLayout(edit: EditSession): Promise { const doc = this.doc; const colors = colorsOf(doc.definitions); const expansion = doc.layoutRequests.expansion(this.opts); - const result = await new FullLayout(doc.model, { ...expansion, engine, allocateId: (req) => edit.allocator.allocate(req).id }).run(); + const result = await new FullLayout(doc.model, { ...expansion, allocateId: (req) => edit.allocator.allocate(req).id }).run(); if (result.givenIds?.length) doc.invalidate(); - let engineText: string | undefined; - let defs = doc.definitions; - if ((engine ?? 'clean') !== 'clean') { - const laidOut = await parseXml(result.xml); - defs = laidOut.definitions; - engineText = applyColors(laidOut.moddle, defs, colors) ? await serialize(laidOut) : result.xml; - } else { - applyColors(doc.moddle, defs, colors); - } - return { ...(engineText !== undefined ? { engineText } : {}), status: { status: 'ok', mode: 'full', warnings: result.warnings, expanded: result.expanded, ...(result.notes?.length ? { notes: result.notes } : {}) }, after: LayoutAnalysis.of(defs), ...(result.givenIds?.length ? { givenIds: result.givenIds } : {}) }; + applyColors(doc.moddle, doc.definitions, colors); + return { status: { status: 'ok', mode: 'full', warnings: result.warnings, expanded: result.expanded, ...(result.notes?.length ? { notes: result.notes } : {}) }, after: LayoutAnalysis.of(doc.definitions), ...(result.givenIds?.length ? { givenIds: result.givenIds } : {}) }; } private async incrementalLayout(relocated: string[], created: readonly string[]): Promise { @@ -405,26 +364,26 @@ export class BatchDrawing { } /** In auto mode a failing incremental layout falls back to a full redraw; requested, it fails. */ - private async runLayout(edit: EditContext, requested: LayoutMode, relocated: string[], created: readonly string[], engine = this.opts.engine): Promise<{ engineText?: string; layout: LayoutStatus; givenIds?: GivenId[] }> { + private async runLayout(edit: EditSession, requested: LayoutMode, relocated: string[], created: readonly string[]): Promise<{ layout: LayoutStatus; givenIds?: GivenId[] }> { const decision = await this.decideMode(requested); let ran: Ran; - if (decision.mode === 'full') ran = await this.fullLayout(edit, engine); + if (decision.mode === 'full') ran = await this.fullLayout(edit); else { try { ran = await this.incrementalLayout(relocated, created); } catch (err) { - if (err instanceof CliError) throw err; + if (err instanceof BpmnError) throw err; if (requested === 'incremental') { throw new ModelError(`Incremental layout failed: ${firstLine(err)}`, 'LAYOUT_INCREMENTAL', { hint: 'Retry with --layout full (redraws the whole diagram) or --no-layout (writes without updating the diagram), and report the command and file.', }); } - ran = await this.fullLayout(edit, engine); + ran = await this.fullLayout(edit); decision.mode = 'full'; decision.reason = `incremental layout failed (${firstLine(err)}): redrawn`; ran.status.warnings.push({ code: 'INCREMENTAL_FAILED', elementId: '', message: `The incremental layout failed and the diagram was redrawn: ${firstLine(err)}`, relatedElementIds: [] }); } } - return { ...(ran.engineText !== undefined ? { engineText: ran.engineText } : {}), layout: { ...ran.status, mode: decision.mode, reason: decision.reason, metrics: metricsDelta(this.before.metrics, ran.after) }, ...(ran.givenIds ? { givenIds: ran.givenIds } : {}) }; + return { layout: { ...ran.status, mode: decision.mode, reason: decision.reason, metrics: metricsDelta(this.before.metrics, ran.after) }, ...(ran.givenIds ? { givenIds: ran.givenIds } : {}) }; } } diff --git a/src/domain/diagram/DiagramCommandService.ts b/src/domain/diagram/DiagramCommandService.ts deleted file mode 100644 index 72ecaa63..00000000 --- a/src/domain/diagram/DiagramCommandService.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { Doc } from '../document/Doc.js'; -import { CliError } from '../document/CliError.js'; -import type { DiagramCommand, ReorderElements } from '../commands/types.js'; -import { ReorderTarget } from '../commands/ReorderTarget.js'; -import { cx } from './utils/geom.js'; -import type { DShape } from './di/types.js'; -import { DrawnDiagram } from './DrawnDiagram.js'; -import { unique } from './utils/frames.js'; -import { ShapePlacementService } from './format/ShapePlacementService.js'; -import { SpaceInsertionService } from './format/SpaceInsertionService.js'; -import { RoomHint } from './format/RoomHint.js'; -import { LaneBandService } from './format/LaneBandService.js'; -import { ElementSelector } from './format/ElementSelector.js'; - -export interface FormatResult { - op: DiagramCommand['op'] | 'order'; - index: number; - moved: string[]; - rerouted: string[]; - /** waypoints changed without a new route (stretched by moves, the space tool, compact) */ - reshaped?: string[]; - colored?: string[]; - labels?: string[]; - notes?: string[]; -} - -export interface FormatEntry { - op: DiagramCommand | ReorderElements; - index: number; -} - -/** - * Runs diagram commands (place, align, color, label, route, space, tidy, compact, lane order) - * through their domain services on the drawing, in batch order; writes the planes back once at the end. - * A failing op throws with its batch index in `details.op`, so the batch writes nothing. - */ -export class DiagramCommandService { - readonly diagram: DrawnDiagram; - - constructor(readonly doc: Doc) { - this.diagram = new DrawnDiagram(doc); - } - - /** - * `asFile`: `doc` holds the file's own drawing, so the first refusal may name a command. - * An `order` of a node's outgoing flows leaves the drawing alone: only bands are redrawn. - */ - run(entries: readonly FormatEntry[], asFile = false): FormatResult[] { - const drawn = entries.filter((entry) => entry.op.op !== 'order' || new ReorderTarget(this.doc, entry.op).ordersBands()); - if (entries.length && !drawn.length) return []; - const results: FormatResult[] = []; - for (const [position, entry] of drawn.entries()) { - try { - results.push(this.runOne(entry, asFile && position === 0 && entry.index === 0)); - } catch (err) { - if (err instanceof CliError) err.details['op'] = entry.index; - throw err; - } - } - this.diagram.write(); - return results; - } - - /** - * Explicit ids first, then what --branch, --path and --kind name. A column align picked by - * --kind alone aligns on the rightmost shape, so nothing moves left. `picked`: ids only a - * selector named; align skips those that would leave their frame. - */ - private resolveSelection(op: TOp): { op: TOp; picked: Set } { - const none = { op, picked: new Set() }; - if (op.op !== 'place' && op.op !== 'align' && op.op !== 'color' && op.op !== 'tidy') return none; - if (!op.path && !op.kind && !op.branch) return none; - if (!this.diagram.planes.length) throw this.diagram.noDiagram(); - const shapeOf = (id: string): DShape | undefined => this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found); - const ids = new ElementSelector(this.doc).select(op, { - shapesOnly: op.op !== 'color', - drawn: (id) => this.diagram.planes.some((plane) => plane.shapes.has(id) || plane.edges.has(id)), - centreX: (id) => { - const shape = shapeOf(id); - return shape ? cx(shape.bounds) : undefined; - }, - }); - const explicit = new Set(op.ids ?? []); - const picked = new Set(ids.filter((id) => !explicit.has(id))); - if (op.op === 'align' && !op.to && !op.ids?.length && !op.path && !op.branch && op.axis === 'column') { - const right = [...ids].sort((one, other) => cx(shapeOf(other)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }) - cx(shapeOf(one)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }))[0]; - return { op: { ...op, ids, ...(right ? { to: right } : {}) }, picked }; - } - return { op: { ...op, ids }, picked }; - } - - private runOne(entry: FormatEntry, asFile: boolean): FormatResult { - const index = entry.index; - const resolved = entry.op.op === 'order' ? { op: entry.op, picked: new Set() } : this.resolveSelection(entry.op); - const op = resolved.op; - const snap = this.diagram.snapshot(); - const notes: string[] = []; - let rerouted: string[] = []; - const extra: Partial = {}; - const diagram = this.diagram; - switch (op.op) { - case 'place': { - const place = () => new ShapePlacementService(diagram, op, snap, notes).run(); - rerouted = op.side ? place() : new RoomHint(diagram, op, asFile).run(place); - break; - } - case 'align': - rerouted = new RoomHint(diagram, op, asFile).run(() => diagram.alignShapes(op, snap, notes, resolved.picked)); - break; - case 'color': - extra.colored = diagram.colorElements(op); - break; - case 'label': { - const labelled = diagram.placeLabel(op, snap, notes); - extra.labels = labelled.labels; - rerouted = labelled.rerouted; - break; - } - case 'route': - rerouted = diagram.rerouteFlow(op); - break; - case 'space': - rerouted = new SpaceInsertionService(diagram, op, snap, notes).run(); - break; - case 'tidy': - rerouted = diagram.removeOverlaps(op, snap, notes); - break; - case 'compact': - rerouted = diagram.compactDiagram(op, snap, notes); - break; - case 'order': - rerouted = new LaneBandService(diagram, op, snap, notes).run(); - break; - } - const routed = unique(rerouted); - const reshaped = snap.changedEdges().filter((id) => !routed.includes(id)); - return { op: op.op, index, moved: snap.changedShapes(), rerouted: routed, ...(reshaped.length ? { reshaped } : {}), ...extra, ...(notes.length ? { notes } : {}) }; - } -} diff --git a/src/domain/diagram/DrawnDiagram.ts b/src/domain/diagram/DrawnDiagram.ts index c295984a..acb6320c 100644 --- a/src/domain/diagram/DrawnDiagram.ts +++ b/src/domain/diagram/DrawnDiagram.ts @@ -1,5 +1,5 @@ import type { Doc } from '../document/Doc.js'; -import { CliError, type ErrorDetails } from '../document/CliError.js'; +import { BpmnError, type ErrorDetails } from '../document/BpmnError.js'; import { modelError } from '../document/utils/errors.js'; import { is, type El } from '../document/utils/moddle.js'; import { isHardProblem, problemKey, type LayoutMetrics, type LayoutProblem } from './utils/problems.js'; @@ -7,31 +7,20 @@ import { LayoutAnalysis } from './di/LayoutAnalysis.js'; import { idOf, raw, rawList } from './utils/raw.js'; import { DiagramReader } from './di/DiagramReader.js'; import { Semantics } from './di/Semantics.js'; -import type { DEdge, DShape, Plane, Side } from './di/types.js'; +import type { DEdge, DShape, Plane } from './di/types.js'; import { PlaneWriter } from './di/PlaneWriter.js'; import { Drawing } from './layout/incremental/Drawing.js'; import { DrawingSnapshot } from './layout/incremental/DrawingSnapshot.js'; import { TrialGuard, type TrialPolicy } from './di/TrialGuard.js'; -import { RoomService } from './format/RoomService.js'; -import { CompactionService, type Attempt } from './format/CompactionService.js'; -import type { AlignShapes, ColorElements, CompactDiagram, PlaceLabel, RerouteFlow, TidyShapes } from '../commands/types.js'; -import { setSwatch } from './utils/colors.js'; -import { frameWord, outermost, problemWords, unique } from './utils/frames.js'; -import { copyPoints, cx, cy, inside, sameBox, samePoints, type Box } from './utils/geom.js'; -import { attachedSide } from './utils/sides.js'; -import { Frames } from './di/Frames.js'; -import { edgeLabelAt, hasExternalLabel, labelAt } from './utils/labels.js'; -import { labelSize } from './utils/text.js'; - -const OPPOSITE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; +import { RoomMaker } from './commands/RoomMaker.js'; +import type { Attempt } from './commands/Compactor.js'; /** A change of one plane must not add a hard layout problem or raise the score. */ const GUARDED: TrialPolicy = { key: (problem) => (isHardProblem(problem) ? problemKey(problem) : undefined), lower: false, rebase: false }; /** * The drawing of a document as the diagram commands change it: its planes, one drawing per - * plane, the refusals when an element is not drawn, and the commands that need no service - * of their own (color, route, tidy, align, label, compact). write() puts the planes back. + * plane and the refusals when an element is not drawn. write() puts the planes back. */ export class DrawnDiagram { readonly sem: Semantics; @@ -48,13 +37,13 @@ export class DrawnDiagram { this.doc.invalidate(); } - noDiagram(): CliError { + noDiagram(): BpmnError { return modelError('E_NO_DIAGRAM', 'The file has no diagram; format operations need one', { hint: 'Draw it first: `bpmn layout ` (or run the command without --no-layout, which draws a file without diagram).', }); } - noShape(el: El, id: string, what = 'shape'): CliError { + noShape(el: El, id: string, what = 'shape'): BpmnError { const details: ErrorDetails = { element: id, hint: 'It is not drawn (written with --no-layout, or outside the drawn root): the next model edit without --no-layout places it, `bpmn layout ` redraws all.' }; if (is(el, 'bpmn:SequenceFlow') || is(el, 'bpmn:MessageFlow')) { details.hint = `It has no line (added, or its ends changed, with --no-layout): \`bpmn route ${id}\` draws it, and so does the next model edit without --no-layout.`; @@ -130,8 +119,8 @@ export class DrawnDiagram { return drawing; } - room(plane: Plane): RoomService { - return new RoomService(plane, this.drawing(plane)); + room(plane: Plane): RoomMaker { + return new RoomMaker(plane, this.drawing(plane)); } measure(): LayoutMetrics { @@ -152,203 +141,4 @@ export class DrawnDiagram { const drawing = this.drawing(plane); return { attempt: (change, force) => drawing.attempt(trials, change, force), rejected: trials.rejected }; } - - colorElements(command: ColorElements): string[] { - const colored: string[] = []; - const swatch = command.color === 'default' ? undefined : command.color; - for (const id of unique(command.ids ?? [])) { - const el = this.doc.require(id); - if (!this.planes.length) throw this.noDiagram(); - const shape = this.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found); - const edge = shape ? undefined : this.planes.map((plane) => plane.edges.get(id)).find((found) => !!found); - const di = shape?.di ?? edge?.di; - if (!di) throw this.noShape(el, id, 'shape or connection'); - if (setSwatch(di, swatch)) colored.push(id); - } - if (swatch && colored.length) { - this.doc.declareNamespace('bioc'); - this.doc.declareNamespace('color'); - } - return colored; - } - - /** A side forced on a boundary event that points into its host would run through the host. */ - private refuseIntoHost(plane: Plane, endId: string | undefined, side: Side | undefined, role: 'exit' | 'entry', flowId: string): void { - const end = endId ? plane.shapes.get(endId) : undefined; - const host = end?.hostId ? plane.shapes.get(end.hostId) : undefined; - if (!end || !host || !side) return; - const border = attachedSide(end.bounds, host.bounds); - if (side !== OPPOSITE[border]) return; - throw modelError('E_INVALID_VALUE', `--${role} ${side} would run ${flowId} through ${host.id}: ${end.id} sits on the ${border} border of its host`, { - element: flowId, - related: [end.id, host.id], - hint: `A boundary event's flow leaves away from its host: use --${role} ${border}${border === 'bottom' || border === 'top' ? ', left or right' : ', top or bottom'}.`, - }); - } - - rerouteFlow(command: RerouteFlow): string[] { - const { plane, edge } = this.drawnEdge(command.id, true); - this.refuseIntoHost(plane, edge.sourceId, command.exit, 'exit', command.id); - this.refuseIntoHost(plane, edge.targetId, command.entry, 'entry', command.id); - const before = copyPoints(edge.points); - const ok = this.drawing(plane).reroute(edge, { ...(command.exit ? { sourceSides: [command.exit] } : {}), ...(command.entry ? { targetSides: [command.entry] } : {}) }); - if (!ok) { - if (!edge.di) plane.edges.delete(command.id); - throw modelError('E_NO_SHAPE', `${command.id} cannot be routed: an end has no shape in its diagram`, { element: command.id, hint: 'Run `bpmn layout ` to draw the missing shapes.' }); - } - this.drawing(plane).clearLabels([edge]); - return samePoints(before, edge.points) ? [] : [command.id]; - } - - removeOverlaps(command: TidyShapes, snap: DrawingSnapshot, notes: string[]): string[] { - if (!this.planes.length) throw this.noDiagram(); - const byPlane = new Map(); - if (command.ids) { - for (const id of unique(command.ids)) { - const { plane } = this.drawnShape(id); - byPlane.set(plane, [...(byPlane.get(plane) ?? []), id]); - } - } else for (const plane of this.planes) byPlane.set(plane, []); - const rerouted: string[] = []; - for (const [plane, ids] of byPlane) { - const tidied = this.drawing(plane).tidy(ids.length ? { ids } : {}); - if (tidied.unresolved.length) notes.push(`overlap left: ${tidied.unresolved.map(([first, second]) => `${first}~${second}`).join(', ')}`); - rerouted.push(...this.drawing(plane).settle(snap)); - } - return rerouted; - } - - /** A `picked` member that would leave its frame is skipped; an explicit id is refused. */ - alignShapes(command: AlignShapes, snap: DrawingSnapshot, notes: string[], picked: ReadonlySet = new Set()): string[] { - const refId = command.to ?? command.ids?.[0] ?? ""; - const { plane, shape: ref } = this.drawnShape(refId); - const items = unique(command.ids ?? []) - .filter((id) => id !== refId) - .map((id) => this.movableShape(id)); - for (const item of items) this.samePlane(plane, item.plane, item.shape.id, refId); - const shapes = outermost(plane, items.map((item) => item.shape)); - const moves = shapes.map((shape) => ({ shape, dx: command.axis === 'column' ? Math.round(cx(ref.bounds) - cx(shape.bounds)) : 0, dy: command.axis === 'row' ? Math.round(cy(ref.bounds) - cy(shape.bounds)) : 0 })); - const kept: typeof moves = []; - const left: string[] = []; - for (const move of moves) { - try { - this.room(plane).checkFrames([move.shape], move.dx, move.dy, `in the ${command.axis} of ${refId}`, [ref]); - kept.push(move); - } catch (err) { - if (!(err instanceof CliError) || err.code !== 'E_LEAVES_CONTAINER' || !picked.has(move.shape.id)) throw err; - const frame = plane.shapes.get(err.details.related?.[0] ?? ''); - left.push(`${move.shape.id}${frame ? ` (${frameWord(frame)} ${frame.id})` : ''}`); - } - } - if (left.length) notes.push(`left out ${left.join(', ')}: on the ${command.axis} of ${refId} ${left.length > 1 ? 'they' : 'it'} would leave ${left.length > 1 ? 'their frames' : 'its frame'}`); - const moved = kept.filter((move) => move.dx || move.dy); - if (!moved.length) { - if (!left.length || kept.length) notes.push(`already aligned on the ${command.axis} of ${refId}`); - return []; - } - for (const move of moved) this.drawing(plane).shift(move.shape.id, move.dx, move.dy); - const line = (box: Box): number => (command.axis === 'column' ? cx(box) : cy(box)); - const holds = (): boolean => moved.every((move) => Math.abs(line(move.shape.bounds) - line(ref.bounds)) <= 1); - this.room(plane).makeRoom(moved.map((move) => move.shape), [ref], snap, notes, `on the ${command.axis} of ${refId}`, holds, [command.axis === 'column' ? 'x' : 'y']); - return this.drawing(plane).settle(snap); - } - - /** - * Above a shape at the top of a sub-process the title takes the room, so room is made below it - * first. - */ - placeLabel(command: PlaceLabel, snap: DrawingSnapshot, notes: string[]): { labels: string[]; rerouted: string[] } { - const el = this.doc.require(command.id); - const name = raw(el, 'name')?.trim(); - if (is(el, 'bpmn:SequenceFlow') || is(el, 'bpmn:MessageFlow')) { - const { edge } = this.drawnEdge(command.id); - if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` }); - const { width, height } = edge.label ?? labelSize(name); - const size = { width, height }; - const box = edgeLabelAt(edge, size, command.side); - if (!box) { - const want = command.side === 'left' || command.side === 'right' ? 'vertical' : 'horizontal'; - throw modelError('E_INVALID_VALUE', `${command.id} has no ${want} segment to put its label ${command.side === 'left' || command.side === 'right' ? 'beside' : command.side}`, { - element: command.id, - hint: want === 'vertical' ? 'Use side above or below.' : 'Use side left or right.', - }); - } - const changed = !sameBox(edge.label, box); - edge.label = box; - return { labels: changed ? [command.id] : [], rerouted: [] }; - } - const { plane, shape } = this.drawnShape(command.id); - if (!hasExternalLabel(shape)) { - throw modelError('E_WRONG_KIND', `"${command.id}" has no external label (tasks, sub-processes, pools and lanes carry their name inside the shape)`, { - element: command.id, - hint: 'Labels can be placed for events, gateways, data objects / stores and flows.', - }); - } - if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` }); - const { width, height } = shape.label ?? labelSize(name); - const size = { width, height }; - let box = this.drawing(plane).labelOnSide(shape, command.side, size); - let rerouted: string[] = []; - const frame = shape.kind === 'boundary' ? undefined : Frames.of(plane, shape); - const room = command.side === 'above' && frame?.kind === 'subProcess' && !inside(box, Frames.labelArea(frame)) ? this.drawing(plane).roomBelowTitle(frame, labelAt(shape, size, 'above'), shape.id) : undefined; - if (room) { - rerouted = this.drawing(plane).settle(snap); - notes.push(`made ${room.delta} px of room above ${command.id} in its sub-process ${frame!.id} (for the label, below the title)`); - box = this.drawing(plane).labelOnSide(shape, command.side, size); - } - const changed = !sameBox(shape.label, box); - shape.label = box; - return { labels: changed ? [command.id] : [], rerouted }; - } - - /** A diagram's own root (a process without pool, a drill-down) targets the whole diagram. */ - private compactionTargets(ids: readonly string[]): Map | undefined> { - const out = new Map | undefined>(); - for (const id of unique(ids)) { - this.doc.require(id); - if (!this.planes.length) throw this.noDiagram(); - const root = this.planes.find((plane) => plane.rootId === id); - if (root && !root.shapes.has(id)) { - out.set(root, undefined); - continue; - } - const { plane, shape } = this.drawnShape(id); - if (!shape.container) { - throw modelError('E_WRONG_KIND', `"${id}" is not a pool, lane or expanded sub-process`, { - element: id, - hint: 'compact takes frames: participant ids, lane ids, expanded sub-process ids (or no id for the whole drawing); to close one gap use `bpmn space --after --by -column`.', - }); - } - if (out.has(plane) && out.get(plane) === undefined) continue; - out.set(plane, new Set([...(out.get(plane) ?? []), id])); - } - return out; - } - - compactDiagram(command: CompactDiagram, snap: DrawingSnapshot, notes: string[]): string[] { - if (!this.planes.length) throw this.noDiagram(); - const targets = command.ids ? this.compactionTargets(command.ids) : new Map(this.planes.map((plane) => [plane, undefined] as const)); - const rerouted: string[] = []; - const total = { strips: { x: 0, y: 0 }, px: { x: 0, y: 0 }, refused: 0 }; - const rejected: LayoutProblem[] = []; - for (const [plane, only] of targets) { - const guard = this.guard(plane); - const stats = new CompactionService(this.drawing(plane)).compact({ ...(only ? { only } : {}), attempt: guard.attempt }); - for (const axis of ['x', 'y'] as const) { - total.strips[axis] += stats.strips[axis]; - total.px[axis] += stats.px[axis]; - } - total.refused += stats.refused; - rejected.push(...guard.rejected); - rerouted.push(...this.drawing(plane).settle(snap)); - } - const closedStrips = total.strips.x + total.strips.y; - if (!closedStrips) notes.push('nothing to compact: no empty row or column to close'); - else { - const part = (count: number, px: number, what: string): string[] => (count ? [`${count} empty ${what}${count > 1 ? 's' : ''} (${px} px)`] : []); - notes.push(`closed ${[...part(total.strips.x, total.px.x, 'column'), ...part(total.strips.y, total.px.y, 'row')].join(' and ')}`); - } - if (rejected.length) notes.push(`left ${total.refused} gap${total.refused > 1 ? 's' : ''} open: closing ${total.refused > 1 ? 'them' : 'it'} would add ${problemWords(rejected)}`); - return rerouted; - } } diff --git a/src/domain/diagram/format/LaneBandService.ts b/src/domain/diagram/commands/BandReordering.ts similarity index 94% rename from src/domain/diagram/format/LaneBandService.ts rename to src/domain/diagram/commands/BandReordering.ts index 9df9b416..eefb1b00 100644 --- a/src/domain/diagram/format/LaneBandService.ts +++ b/src/domain/diagram/commands/BandReordering.ts @@ -12,10 +12,10 @@ import type { DrawnDiagram } from '../DrawnDiagram.js'; * The diagram part of an `order` op: lane or pool bands follow the new semantic order, * stacked from the top of the first band, carrying their members along. */ -export class LaneBandService { +export class BandReordering { constructor( private readonly diagram: DrawnDiagram, - private readonly op: ReorderElements, + private readonly command: ReorderElements, private readonly snap: DrawingSnapshot, private readonly notes: string[], ) {} @@ -40,7 +40,7 @@ export class LaneBandService { * moves. A band whose members hang out at its bottom grows, so nothing lands in the band below. */ private orderBands(): string[] { - const owner = this.diagram.doc.get(this.op.id); + const owner = this.diagram.doc.get(this.command.id); const set = owner ? laneSetOf(owner) : undefined; const lanes = rawList(set, 'lanes').map((lane) => idOf(lane)!).filter(Boolean); const plane = this.diagram.planes.find((candidate) => lanes.length > 0 && lanes.every((id) => candidate.shapes.has(id))); @@ -92,9 +92,9 @@ export class LaneBandService { * process) go with the pool they are drawn in. */ private orderPoolBands(): string[] { - const collab = this.diagram.doc.get(this.op.id); + const collab = this.diagram.doc.get(this.command.id); const ids = rawList(collab, 'participants').map((participant) => idOf(participant)!).filter(Boolean); - const plane = this.diagram.planes.find((candidate) => candidate.rootId === this.op.id); + const plane = this.diagram.planes.find((candidate) => candidate.rootId === this.command.id); const bands = plane ? ids.map((id) => plane.shapes.get(id)).filter((shape): shape is DShape => !!shape) : []; if (!plane || bands.length < 2) { if (this.diagram.planes.length) this.notes.push('fewer than two pools are drawn: nothing to reorder in the diagram'); @@ -131,6 +131,6 @@ export class LaneBandService { } run(): string[] { - return new ReorderTarget(this.diagram.doc, this.op).ordersPools() ? this.orderPoolBands() : this.orderBands(); + return new ReorderTarget(this.diagram.doc, this.command).ordersPools() ? this.orderPoolBands() : this.orderBands(); } } diff --git a/src/domain/diagram/format/CompactionService.ts b/src/domain/diagram/commands/Compactor.ts similarity index 99% rename from src/domain/diagram/format/CompactionService.ts rename to src/domain/diagram/commands/Compactor.ts index 5b84ef10..c4bec832 100644 --- a/src/domain/diagram/format/CompactionService.ts +++ b/src/domain/diagram/commands/Compactor.ts @@ -59,7 +59,7 @@ export interface CompactOptions { * columns, lane rows, gaps between pools), so frames shrink to their content while order * and relative positions stay. Every strip goes through `attempt`, which keeps only harmless ones. */ -export class CompactionService { +export class Compactor { private readonly plane: Plane; constructor(private readonly drawing: Drawing) { diff --git a/src/domain/diagram/commands/DiagramCommands.ts b/src/domain/diagram/commands/DiagramCommands.ts new file mode 100644 index 00000000..77a81eca --- /dev/null +++ b/src/domain/diagram/commands/DiagramCommands.ts @@ -0,0 +1,145 @@ +import type { Doc } from '../../document/Doc.js'; +import { BpmnError } from '../../document/BpmnError.js'; +import type { DiagramCommand, ReorderElements } from '../../commands/types.js'; +import { ReorderTarget } from '../../commands/ReorderTarget.js'; +import { cx } from '../utils/geom.js'; +import type { DShape } from '../di/types.js'; +import { DrawnDiagram } from '../DrawnDiagram.js'; +import { unique } from '../utils/shape-sets.js'; +import { ShapePlacement } from './ShapePlacement.js'; +import { ShapeAlignment } from './ShapeAlignment.js'; +import { ElementColoring } from './ElementColoring.js'; +import { LabelPlacement } from './LabelPlacement.js'; +import { FlowRerouting } from './FlowRerouting.js'; +import { SpaceInsertion } from './SpaceInsertion.js'; +import { OverlapTidying } from './OverlapTidying.js'; +import { DiagramCompaction } from './DiagramCompaction.js'; +import { BandReordering } from './BandReordering.js'; +import { RoomHint } from './RoomHint.js'; +import { ElementSelector } from './ElementSelector.js'; + +export interface DiagramCommandResult { + op: DiagramCommand['op'] | 'order'; + index: number; + moved: string[]; + rerouted: string[]; + /** waypoints changed without a new route (stretched by moves, the space tool, compact) */ + reshaped?: string[]; + colored?: string[]; + labels?: string[]; + notes?: string[]; +} + +export interface DiagramCommandEntry { + op: DiagramCommand | ReorderElements; + index: number; +} + +/** + * Runs diagram commands (place, align, color, label, route, space, tidy, compact, lane order) + * through their handlers on the drawing, in batch order; writes the planes back once at the end. + * A failing op throws with its batch index in `details.op`, so the batch writes nothing. + */ +export class DiagramCommands { + readonly diagram: DrawnDiagram; + + constructor(readonly doc: Doc) { + this.diagram = new DrawnDiagram(doc); + } + + /** + * `asFile`: `doc` holds the file's own drawing, so the first refusal may name a command. + * An `order` of a node's outgoing flows leaves the drawing alone: only bands are redrawn. + */ + run(entries: readonly DiagramCommandEntry[], asFile = false): DiagramCommandResult[] { + const drawn = entries.filter((entry) => entry.op.op !== 'order' || new ReorderTarget(this.doc, entry.op).ordersBands()); + if (entries.length && !drawn.length) return []; + const results: DiagramCommandResult[] = []; + for (const [position, entry] of drawn.entries()) { + try { + results.push(this.runOne(entry, asFile && position === 0 && entry.index === 0)); + } catch (err) { + if (err instanceof BpmnError) err.details['op'] = entry.index; + throw err; + } + } + this.diagram.write(); + return results; + } + + /** + * Explicit ids first, then what --branch, --path and --kind name. A column align picked by + * --kind alone aligns on the rightmost shape, so nothing moves left. `picked`: ids only a + * selector named; align skips those that would leave their frame. + */ + private resolveSelection(command: TCommand): { command: TCommand; picked: Set } { + const none = { command, picked: new Set() }; + if (command.op !== 'place' && command.op !== 'align' && command.op !== 'color' && command.op !== 'tidy') return none; + if (!command.path && !command.kind && !command.branch) return none; + if (!this.diagram.planes.length) throw this.diagram.noDiagram(); + const shapeOf = (id: string): DShape | undefined => this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found); + const ids = new ElementSelector(this.doc).select(command, { + shapesOnly: command.op !== 'color', + drawn: (id) => this.diagram.planes.some((plane) => plane.shapes.has(id) || plane.edges.has(id)), + centreX: (id) => { + const shape = shapeOf(id); + return shape ? cx(shape.bounds) : undefined; + }, + }); + const explicit = new Set(command.ids ?? []); + const picked = new Set(ids.filter((id) => !explicit.has(id))); + if (command.op === 'align' && !command.to && !command.ids?.length && !command.path && !command.branch && command.axis === 'column') { + const right = [...ids].sort((one, other) => cx(shapeOf(other)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }) - cx(shapeOf(one)?.bounds ?? { x: 0, y: 0, width: 0, height: 0 }))[0]; + return { command: { ...command, ids, ...(right ? { to: right } : {}) }, picked }; + } + return { command: { ...command, ids }, picked }; + } + + private runOne(entry: DiagramCommandEntry, asFile: boolean): DiagramCommandResult { + const index = entry.index; + const resolved = entry.op.op === 'order' ? { command: entry.op, picked: new Set() } : this.resolveSelection(entry.op); + const command = resolved.command; + const snap = this.diagram.snapshot(); + const notes: string[] = []; + let rerouted: string[] = []; + const extra: Partial = {}; + const diagram = this.diagram; + switch (command.op) { + case 'place': { + const place = () => new ShapePlacement(diagram, command, snap, notes).run(); + rerouted = command.side ? place() : new RoomHint(diagram, command, asFile).run(place); + break; + } + case 'align': + rerouted = new RoomHint(diagram, command, asFile).run(() => new ShapeAlignment(diagram, command, snap, notes, resolved.picked).run()); + break; + case 'color': + extra.colored = new ElementColoring(diagram, command).run(); + break; + case 'label': { + const labelled = new LabelPlacement(diagram, command, snap, notes).run(); + extra.labels = labelled.labels; + rerouted = labelled.rerouted; + break; + } + case 'route': + rerouted = new FlowRerouting(diagram, command).run(); + break; + case 'space': + rerouted = new SpaceInsertion(diagram, command, snap, notes).run(); + break; + case 'tidy': + rerouted = new OverlapTidying(diagram, command, snap, notes).run(); + break; + case 'compact': + rerouted = new DiagramCompaction(diagram, command, snap, notes).run(); + break; + case 'order': + rerouted = new BandReordering(diagram, command, snap, notes).run(); + break; + } + const routed = unique(rerouted); + const reshaped = snap.changedEdges().filter((id) => !routed.includes(id)); + return { op: command.op, index, moved: snap.changedShapes(), rerouted: routed, ...(reshaped.length ? { reshaped } : {}), ...extra, ...(notes.length ? { notes } : {}) }; + } +} diff --git a/src/domain/diagram/commands/DiagramCompaction.ts b/src/domain/diagram/commands/DiagramCompaction.ts new file mode 100644 index 00000000..5597432c --- /dev/null +++ b/src/domain/diagram/commands/DiagramCompaction.ts @@ -0,0 +1,69 @@ +import { modelError } from '../../document/utils/errors.js'; +import type { CompactDiagram } from '../../commands/types.js'; +import type { Plane } from '../di/types.js'; +import type { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; +import type { LayoutProblem } from '../utils/problems.js'; +import { problemWords, unique } from '../utils/shape-sets.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; +import { Compactor } from './Compactor.js'; + +export class DiagramCompaction { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: CompactDiagram, + private readonly snap: DrawingSnapshot, + private readonly notes: string[], + ) {} + + /** A diagram's own root (a process without pool, a drill-down) targets the whole diagram. */ + private compactionTargets(ids: readonly string[]): Map | undefined> { + const out = new Map | undefined>(); + for (const id of unique(ids)) { + this.diagram.doc.require(id); + if (!this.diagram.planes.length) throw this.diagram.noDiagram(); + const root = this.diagram.planes.find((plane) => plane.rootId === id); + if (root && !root.shapes.has(id)) { + out.set(root, undefined); + continue; + } + const { plane, shape } = this.diagram.drawnShape(id); + if (!shape.container) { + throw modelError('E_WRONG_KIND', `"${id}" is not a pool, lane or expanded sub-process`, { + element: id, + hint: 'compact takes frames: participant ids, lane ids, expanded sub-process ids (or no id for the whole drawing); to close one gap use `bpmn space --after --by -column`.', + }); + } + if (out.has(plane) && out.get(plane) === undefined) continue; + out.set(plane, new Set([...(out.get(plane) ?? []), id])); + } + return out; + } + + run(): string[] { + const { diagram, notes } = this; + if (!diagram.planes.length) throw diagram.noDiagram(); + const targets = this.command.ids ? this.compactionTargets(this.command.ids) : new Map(diagram.planes.map((plane) => [plane, undefined] as const)); + const rerouted: string[] = []; + const total = { strips: { x: 0, y: 0 }, px: { x: 0, y: 0 }, refused: 0 }; + const rejected: LayoutProblem[] = []; + for (const [plane, only] of targets) { + const guard = diagram.guard(plane); + const stats = new Compactor(diagram.drawing(plane)).compact({ ...(only ? { only } : {}), attempt: guard.attempt }); + for (const axis of ['x', 'y'] as const) { + total.strips[axis] += stats.strips[axis]; + total.px[axis] += stats.px[axis]; + } + total.refused += stats.refused; + rejected.push(...guard.rejected); + rerouted.push(...diagram.drawing(plane).settle(this.snap)); + } + const closedStrips = total.strips.x + total.strips.y; + if (!closedStrips) notes.push('nothing to compact: no empty row or column to close'); + else { + const part = (count: number, px: number, what: string): string[] => (count ? [`${count} empty ${what}${count > 1 ? 's' : ''} (${px} px)`] : []); + notes.push(`closed ${[...part(total.strips.x, total.px.x, 'column'), ...part(total.strips.y, total.px.y, 'row')].join(' and ')}`); + } + if (rejected.length) notes.push(`left ${total.refused} gap${total.refused > 1 ? 's' : ''} open: closing ${total.refused > 1 ? 'them' : 'it'} would add ${problemWords(rejected)}`); + return rerouted; + } +} diff --git a/src/domain/diagram/commands/ElementColoring.ts b/src/domain/diagram/commands/ElementColoring.ts new file mode 100644 index 00000000..39724624 --- /dev/null +++ b/src/domain/diagram/commands/ElementColoring.ts @@ -0,0 +1,30 @@ +import type { ColorElements } from '../../commands/types.js'; +import { setSwatch } from '../utils/colors.js'; +import { unique } from '../utils/shape-sets.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; + +export class ElementColoring { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: ColorElements, + ) {} + + run(): string[] { + const colored: string[] = []; + const swatch = this.command.color === 'default' ? undefined : this.command.color; + for (const id of unique(this.command.ids ?? [])) { + const el = this.diagram.doc.require(id); + if (!this.diagram.planes.length) throw this.diagram.noDiagram(); + const shape = this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found); + const edge = shape ? undefined : this.diagram.planes.map((plane) => plane.edges.get(id)).find((found) => !!found); + const di = shape?.di ?? edge?.di; + if (!di) throw this.diagram.noShape(el, id, 'shape or connection'); + if (setSwatch(di, swatch)) colored.push(id); + } + if (swatch && colored.length) { + this.diagram.doc.declareNamespace('bioc'); + this.diagram.doc.declareNamespace('color'); + } + return colored; + } +} diff --git a/src/domain/diagram/format/ElementSelector.ts b/src/domain/diagram/commands/ElementSelector.ts similarity index 99% rename from src/domain/diagram/format/ElementSelector.ts rename to src/domain/diagram/commands/ElementSelector.ts index 8b802a3b..9e6e1291 100644 --- a/src/domain/diagram/format/ElementSelector.ts +++ b/src/domain/diagram/commands/ElementSelector.ts @@ -1,7 +1,7 @@ import type { Doc } from '../../document/Doc.js'; import { modelError, usageError } from '../../document/utils/errors.js'; import { is, type El } from '../../document/utils/moddle.js'; -import { kindFilter } from '../../document/utils/kinds.js'; +import { kindFilter } from '../../document/Kinds.js'; export interface Selection { ids?: string[]; diff --git a/src/domain/diagram/commands/FlowRerouting.ts b/src/domain/diagram/commands/FlowRerouting.ts new file mode 100644 index 00000000..903f357b --- /dev/null +++ b/src/domain/diagram/commands/FlowRerouting.ts @@ -0,0 +1,44 @@ +import { modelError } from '../../document/utils/errors.js'; +import type { RerouteFlow } from '../../commands/types.js'; +import type { Plane, Side } from '../di/types.js'; +import { copyPoints, samePoints } from '../utils/geom.js'; +import { attachedSide } from '../utils/sides.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; + +const OPPOSITE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + +export class FlowRerouting { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: RerouteFlow, + ) {} + + /** A side forced on a boundary event that points into its host would run through the host. */ + private refuseIntoHost(plane: Plane, endId: string | undefined, side: Side | undefined, role: 'exit' | 'entry', flowId: string): void { + const end = endId ? plane.shapes.get(endId) : undefined; + const host = end?.hostId ? plane.shapes.get(end.hostId) : undefined; + if (!end || !host || !side) return; + const border = attachedSide(end.bounds, host.bounds); + if (side !== OPPOSITE[border]) return; + throw modelError('E_INVALID_VALUE', `--${role} ${side} would run ${flowId} through ${host.id}: ${end.id} sits on the ${border} border of its host`, { + element: flowId, + related: [end.id, host.id], + hint: `A boundary event's flow leaves away from its host: use --${role} ${border}${border === 'bottom' || border === 'top' ? ', left or right' : ', top or bottom'}.`, + }); + } + + run(): string[] { + const command = this.command; + const { plane, edge } = this.diagram.drawnEdge(command.id, true); + this.refuseIntoHost(plane, edge.sourceId, command.exit, 'exit', command.id); + this.refuseIntoHost(plane, edge.targetId, command.entry, 'entry', command.id); + const before = copyPoints(edge.points); + const ok = this.diagram.drawing(plane).reroute(edge, { ...(command.exit ? { sourceSides: [command.exit] } : {}), ...(command.entry ? { targetSides: [command.entry] } : {}) }); + if (!ok) { + if (!edge.di) plane.edges.delete(command.id); + throw modelError('E_NO_SHAPE', `${command.id} cannot be routed: an end has no shape in its diagram`, { element: command.id, hint: 'Run `bpmn layout ` to draw the missing shapes.' }); + } + this.diagram.drawing(plane).clearLabels([edge]); + return samePoints(before, edge.points) ? [] : [command.id]; + } +} diff --git a/src/domain/diagram/commands/LabelPlacement.ts b/src/domain/diagram/commands/LabelPlacement.ts new file mode 100644 index 00000000..20e42775 --- /dev/null +++ b/src/domain/diagram/commands/LabelPlacement.ts @@ -0,0 +1,68 @@ +import { modelError } from '../../document/utils/errors.js'; +import { is } from '../../document/utils/moddle.js'; +import type { PlaceLabel } from '../../commands/types.js'; +import { Frames } from '../di/Frames.js'; +import type { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; +import { inside, sameBox } from '../utils/geom.js'; +import { edgeLabelAt, hasExternalLabel, labelAt } from '../utils/labels.js'; +import { raw } from '../utils/raw.js'; +import { labelSize } from '../utils/text.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; + +/** + * Above a shape at the top of a sub-process the title takes the room, so room is made below it + * first. + */ +export class LabelPlacement { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: PlaceLabel, + private readonly snap: DrawingSnapshot, + private readonly notes: string[], + ) {} + + run(): { labels: string[]; rerouted: string[] } { + const { command, diagram } = this; + const el = diagram.doc.require(command.id); + const name = raw(el, 'name')?.trim(); + if (is(el, 'bpmn:SequenceFlow') || is(el, 'bpmn:MessageFlow')) { + const { edge } = diagram.drawnEdge(command.id); + if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` }); + const { width, height } = edge.label ?? labelSize(name); + const size = { width, height }; + const box = edgeLabelAt(edge, size, command.side); + if (!box) { + const want = command.side === 'left' || command.side === 'right' ? 'vertical' : 'horizontal'; + throw modelError('E_INVALID_VALUE', `${command.id} has no ${want} segment to put its label ${command.side === 'left' || command.side === 'right' ? 'beside' : command.side}`, { + element: command.id, + hint: want === 'vertical' ? 'Use side above or below.' : 'Use side left or right.', + }); + } + const changed = !sameBox(edge.label, box); + edge.label = box; + return { labels: changed ? [command.id] : [], rerouted: [] }; + } + const { plane, shape } = diagram.drawnShape(command.id); + if (!hasExternalLabel(shape)) { + throw modelError('E_WRONG_KIND', `"${command.id}" has no external label (tasks, sub-processes, pools and lanes carry their name inside the shape)`, { + element: command.id, + hint: 'Labels can be placed for events, gateways, data objects / stores and flows.', + }); + } + if (!name) throw modelError('E_NO_LABEL', `${command.id} has no name, so it has no label`, { element: command.id, hint: `Name it first: \`bpmn set ${command.id} name=...\`.` }); + const { width, height } = shape.label ?? labelSize(name); + const size = { width, height }; + let box = diagram.drawing(plane).labelOnSide(shape, command.side, size); + let rerouted: string[] = []; + const frame = shape.kind === 'boundary' ? undefined : Frames.of(plane, shape); + const room = command.side === 'above' && frame?.kind === 'subProcess' && !inside(box, Frames.labelArea(frame)) ? diagram.drawing(plane).roomBelowTitle(frame, labelAt(shape, size, 'above'), shape.id) : undefined; + if (room) { + rerouted = diagram.drawing(plane).settle(this.snap); + this.notes.push(`made ${room.delta} px of room above ${command.id} in its sub-process ${frame!.id} (for the label, below the title)`); + box = diagram.drawing(plane).labelOnSide(shape, command.side, size); + } + const changed = !sameBox(shape.label, box); + shape.label = box; + return { labels: changed ? [command.id] : [], rerouted }; + } +} diff --git a/src/domain/diagram/commands/OverlapTidying.ts b/src/domain/diagram/commands/OverlapTidying.ts new file mode 100644 index 00000000..b63ab032 --- /dev/null +++ b/src/domain/diagram/commands/OverlapTidying.ts @@ -0,0 +1,32 @@ +import type { TidyShapes } from '../../commands/types.js'; +import type { Plane } from '../di/types.js'; +import type { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; +import { unique } from '../utils/shape-sets.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; + +export class OverlapTidying { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: TidyShapes, + private readonly snap: DrawingSnapshot, + private readonly notes: string[], + ) {} + + run(): string[] { + if (!this.diagram.planes.length) throw this.diagram.noDiagram(); + const byPlane = new Map(); + if (this.command.ids) { + for (const id of unique(this.command.ids)) { + const { plane } = this.diagram.drawnShape(id); + byPlane.set(plane, [...(byPlane.get(plane) ?? []), id]); + } + } else for (const plane of this.diagram.planes) byPlane.set(plane, []); + const rerouted: string[] = []; + for (const [plane, ids] of byPlane) { + const tidied = this.diagram.drawing(plane).tidy(ids.length ? { ids } : {}); + if (tidied.unresolved.length) this.notes.push(`overlap left: ${tidied.unresolved.map(([first, second]) => `${first}~${second}`).join(', ')}`); + rerouted.push(...this.diagram.drawing(plane).settle(this.snap)); + } + return rerouted; + } +} diff --git a/src/domain/diagram/format/RoomHint.ts b/src/domain/diagram/commands/RoomHint.ts similarity index 69% rename from src/domain/diagram/format/RoomHint.ts rename to src/domain/diagram/commands/RoomHint.ts index d7bbdf7c..d94c56c9 100644 --- a/src/domain/diagram/format/RoomHint.ts +++ b/src/domain/diagram/commands/RoomHint.ts @@ -1,4 +1,4 @@ -import { CliError } from '../../document/CliError.js'; +import { BpmnError } from '../../document/BpmnError.js'; import type { AlignShapes, PlaceShapes, InsertSpace } from '../../commands/types.js'; import { cy, right } from '../utils/geom.js'; import { PlaneQuery } from '../di/PlaneQuery.js'; @@ -6,8 +6,9 @@ import { idOf, raw, rawList } from '../utils/raw.js'; import type { DShape } from '../di/types.js'; import { DrawingCheckpoint } from '../di/DrawingCheckpoint.js'; import type { DrawnDiagram } from '../DrawnDiagram.js'; -import { ShapePlacementService } from './ShapePlacementService.js'; -import { SpaceInsertionService } from './SpaceInsertionService.js'; +import { ShapePlacement } from './ShapePlacement.js'; +import { SpaceInsertion } from './SpaceInsertion.js'; +import { ShapeAlignment } from './ShapeAlignment.js'; /** * When place / align is refused with E_NO_ROOM, dry-runs space-tool candidates followed by the @@ -16,7 +17,7 @@ import { SpaceInsertionService } from './SpaceInsertionService.js'; export class RoomHint { constructor( private readonly diagram: DrawnDiagram, - private readonly op: PlaceShapes | AlignShapes, + private readonly command: PlaceShapes | AlignShapes, private readonly asFile: boolean, ) {} @@ -30,22 +31,22 @@ export class RoomHint { for (const by of ['1col', '2col', '3col'] as const) out.push({ op: 'space', after, by }); }; const shapeOf = (id: string | undefined): DShape | undefined => (id ? this.diagram.planes.map((plane) => plane.shapes.get(id)).find((found) => !!found) : undefined); - if (this.op.op === 'place') { - if (this.op.after) cols(this.op.after); - if (this.op.before) { - const beforeShape = shapeOf(this.op.before); - for (const flow of rawList(this.diagram.sem.byId.get(this.op.before), 'incoming')) { + if (this.command.op === 'place') { + if (this.command.after) cols(this.command.after); + if (this.command.before) { + const beforeShape = shapeOf(this.command.before); + for (const flow of rawList(this.diagram.sem.byId.get(this.command.before), 'incoming')) { const source = shapeOf(idOf(raw(flow, 'sourceRef'))); if (beforeShape && source && right(source.bounds) <= beforeShape.bounds.x) cols(source.id); } } - if (this.op.below) for (const by of ['1row', '2row'] as const) out.push({ op: 'space', below: this.op.below, by }); + if (this.command.below) for (const by of ['1row', '2row'] as const) out.push({ op: 'space', below: this.command.below, by }); } - if ((this.op.op === 'place' && this.op.rowOf && !this.op.after && !this.op.before && !this.op.columnOf) || (this.op.op === 'align' && this.op.axis === 'row')) { - const ref = shapeOf(this.op.op === 'place' ? this.op.rowOf : (this.op.to ?? this.op.ids?.[0])); - const first = shapeOf(this.op.ids?.[0]); + if ((this.command.op === 'place' && this.command.rowOf && !this.command.after && !this.command.before && !this.command.columnOf) || (this.command.op === 'align' && this.command.axis === 'row')) { + const ref = shapeOf(this.command.op === 'place' ? this.command.rowOf : (this.command.to ?? this.command.ids?.[0])); + const first = shapeOf(this.command.ids?.[0]); if (ref && first) { - const left = [...this.diagram.planes.find((plane) => plane.shapes.has(ref.id))!.shapes.values()].filter((shape) => PlaneQuery.isLeaf(shape) && shape.kind !== 'boundary' && !this.op.ids?.includes(shape.id) && right(shape.bounds) <= first.bounds.x && Math.abs(cy(shape.bounds) - cy(ref.bounds)) <= shape.bounds.height / 2).sort((one, other) => right(other.bounds) - right(one.bounds))[0]; + const left = [...this.diagram.planes.find((plane) => plane.shapes.has(ref.id))!.shapes.values()].filter((shape) => PlaneQuery.isLeaf(shape) && shape.kind !== 'boundary' && !this.command.ids?.includes(shape.id) && right(shape.bounds) <= first.bounds.x && Math.abs(cy(shape.bounds) - cy(ref.bounds)) <= shape.bounds.height / 2).sort((one, other) => right(other.bounds) - right(one.bounds))[0]; if (left) cols(left.id); } } @@ -65,15 +66,15 @@ export class RoomHint { try { return attempt(); } catch (err) { - if (!(err instanceof CliError) || err.code !== 'E_NO_ROOM') throw err; + if (!(err instanceof BpmnError) || err.code !== 'E_NO_ROOM') throw err; let found: InsertSpace | undefined; let best = Infinity; for (const candidate of this.candidates()) { states.forEach((state) => state.restore()); try { - new SpaceInsertionService(this.diagram, candidate, this.diagram.snapshot(), []).run(); - if (this.op.op === 'place') new ShapePlacementService(this.diagram, this.op, this.diagram.snapshot(), []).run(); - else this.diagram.alignShapes(this.op, this.diagram.snapshot(), []); + new SpaceInsertion(this.diagram, candidate, this.diagram.snapshot(), []).run(); + if (this.command.op === 'place') new ShapePlacement(this.diagram, this.command, this.diagram.snapshot(), []).run(); + else new ShapeAlignment(this.diagram, this.command, this.diagram.snapshot(), []).run(); } catch { continue; } @@ -84,7 +85,7 @@ export class RoomHint { } } states.forEach((state) => state.restore()); - const again = this.op.op === 'place' ? 'place' : 'align'; + const again = this.command.op === 'place' ? 'place' : 'align'; if (found && this.asFile) { err.details['command'] = this.spaceCommand(found); err.details['ops'] = [{ ...found }]; @@ -93,7 +94,7 @@ export class RoomHint { err.details['ops'] = [{ ...found }]; err.details.hint = `Make room first: in this batch, put ${JSON.stringify(found)} before this op (checked on the drawing the batch's earlier ops leave: the ${again} fits then).`; } else { - const inWay = (err.details.related ?? []).filter((id) => !(this.op.ids ?? []).includes(id)); + const inWay = (err.details.related ?? []).filter((id) => !(this.command.ids ?? []).includes(id)); const tried = this.candidates().map((candidate) => `--${candidate.after ? 'after' : 'below'} ${candidate.after ?? candidate.below} --by ${String(candidate.by)}`); err.details.hint = `No space-tool run makes this room (${tried.length ? `tried ${tried.join(', ')}` : 'the request names no column or row to make room at'}). ${inWay.length ? `Move ${inWay.join(', ')} out of the way first (\`bpmn place ${inWay[0]} --below \`), or use` : 'Use'} a reference inside the same lane / sub-process.`; } diff --git a/src/domain/diagram/format/RoomService.ts b/src/domain/diagram/commands/RoomMaker.ts similarity index 99% rename from src/domain/diagram/format/RoomService.ts rename to src/domain/diagram/commands/RoomMaker.ts index a4c62722..6c1d6fa3 100644 --- a/src/domain/diagram/format/RoomService.ts +++ b/src/domain/diagram/commands/RoomMaker.ts @@ -6,7 +6,7 @@ import type { DShape, Plane } from '../di/types.js'; import { DrawingCheckpoint } from '../di/DrawingCheckpoint.js'; import { Drawing } from '../layout/incremental/Drawing.js'; import { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; -import { frameWord } from '../utils/frames.js'; +import { frameWord } from '../utils/shape-sets.js'; /** A sub-process has no header: its border is the limit. */ const HEADER_TOLERANCE = 10; @@ -15,7 +15,7 @@ const HEADER_TOLERANCE = 10; * Makes room for shapes that place / align moved: refuses moves out of their frame, lets the * others give way, and refuses (E_NO_ROOM) when the request no longer holds afterwards. */ -export class RoomService { +export class RoomMaker { constructor( private readonly plane: Plane, private readonly drawing: Drawing, diff --git a/src/domain/diagram/commands/ShapeAlignment.ts b/src/domain/diagram/commands/ShapeAlignment.ts new file mode 100644 index 00000000..71bb46b4 --- /dev/null +++ b/src/domain/diagram/commands/ShapeAlignment.ts @@ -0,0 +1,52 @@ +import { BpmnError } from '../../document/BpmnError.js'; +import type { AlignShapes } from '../../commands/types.js'; +import type { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; +import { cx, cy, type Box } from '../utils/geom.js'; +import { frameWord, outermost, unique } from '../utils/shape-sets.js'; +import type { DrawnDiagram } from '../DrawnDiagram.js'; + +/** A `picked` member that would leave its frame is skipped; an explicit id is refused. */ +export class ShapeAlignment { + constructor( + private readonly diagram: DrawnDiagram, + private readonly command: AlignShapes, + private readonly snap: DrawingSnapshot, + private readonly notes: string[], + private readonly picked: ReadonlySet = new Set(), + ) {} + + run(): string[] { + const { command, diagram, notes } = this; + const refId = command.to ?? command.ids?.[0] ?? ""; + const { plane, shape: ref } = diagram.drawnShape(refId); + const items = unique(command.ids ?? []) + .filter((id) => id !== refId) + .map((id) => diagram.movableShape(id)); + for (const item of items) diagram.samePlane(plane, item.plane, item.shape.id, refId); + const shapes = outermost(plane, items.map((item) => item.shape)); + const moves = shapes.map((shape) => ({ shape, dx: command.axis === 'column' ? Math.round(cx(ref.bounds) - cx(shape.bounds)) : 0, dy: command.axis === 'row' ? Math.round(cy(ref.bounds) - cy(shape.bounds)) : 0 })); + const kept: typeof moves = []; + const left: string[] = []; + for (const move of moves) { + try { + diagram.room(plane).checkFrames([move.shape], move.dx, move.dy, `in the ${command.axis} of ${refId}`, [ref]); + kept.push(move); + } catch (err) { + if (!(err instanceof BpmnError) || err.code !== 'E_LEAVES_CONTAINER' || !this.picked.has(move.shape.id)) throw err; + const frame = plane.shapes.get(err.details.related?.[0] ?? ''); + left.push(`${move.shape.id}${frame ? ` (${frameWord(frame)} ${frame.id})` : ''}`); + } + } + if (left.length) notes.push(`left out ${left.join(', ')}: on the ${command.axis} of ${refId} ${left.length > 1 ? 'they' : 'it'} would leave ${left.length > 1 ? 'their frames' : 'its frame'}`); + const moved = kept.filter((move) => move.dx || move.dy); + if (!moved.length) { + if (!left.length || kept.length) notes.push(`already aligned on the ${command.axis} of ${refId}`); + return []; + } + for (const move of moved) diagram.drawing(plane).shift(move.shape.id, move.dx, move.dy); + const line = (box: Box): number => (command.axis === 'column' ? cx(box) : cy(box)); + const holds = (): boolean => moved.every((move) => Math.abs(line(move.shape.bounds) - line(ref.bounds)) <= 1); + diagram.room(plane).makeRoom(moved.map((move) => move.shape), [ref], this.snap, notes, `on the ${command.axis} of ${refId}`, holds, [command.axis === 'column' ? 'x' : 'y']); + return diagram.drawing(plane).settle(this.snap); + } +} diff --git a/src/domain/diagram/format/ShapePlacementService.ts b/src/domain/diagram/commands/ShapePlacement.ts similarity index 85% rename from src/domain/diagram/format/ShapePlacementService.ts rename to src/domain/diagram/commands/ShapePlacement.ts index 9c935fd0..27c8fb02 100644 --- a/src/domain/diagram/format/ShapePlacementService.ts +++ b/src/domain/diagram/commands/ShapePlacement.ts @@ -9,7 +9,7 @@ import type { DShape, Plane, Side } from '../di/types.js'; import { attachedSide } from '../utils/sides.js'; import { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; import type { DrawnDiagram } from '../DrawnDiagram.js'; -import { frameWord, outermost, unique } from '../utils/frames.js'; +import { frameWord, outermost, unique } from '../utils/shape-sets.js'; const CLEAR = 20; @@ -17,10 +17,10 @@ const CLEAR = 20; * The `place` op: moves the ids as one rigid group onto the row and / or column of a reference, * or with `side` moves a boundary event to the other border of its host. */ -export class ShapePlacementService { +export class ShapePlacement { constructor( private readonly diagram: DrawnDiagram, - private readonly op: PlaceShapes, + private readonly command: PlaceShapes, private readonly snap: DrawingSnapshot, private readonly notes: string[], ) {} @@ -30,8 +30,8 @@ export class ShapePlacementService { * frames is refused, as the shapes cannot go beside it. */ run(): string[] { - if (this.op.side) return this.boundarySide(this.op.ids?.[0] ?? '', this.op.side); - const items = unique(this.op.ids ?? []).map((id) => this.diagram.movableShape(id)); + if (this.command.side) return this.boundarySide(this.command.ids?.[0] ?? '', this.command.side); + const items = unique(this.command.ids ?? []).map((id) => this.diagram.movableShape(id)); const { plane, shape: ref } = items[0]!; for (const item of items) this.diagram.samePlane(plane, item.plane, item.shape.id, ref.id); const group = outermost(plane, items.map((item) => item.shape)); @@ -47,31 +47,31 @@ export class ShapePlacementService { let dx = 0; let dy = 0; const parts: string[] = []; - if (this.op.rowOf) { - dy = cy(target(this.op.rowOf).shape.bounds) - cy(ref.bounds); - parts.push(`in the row of ${this.op.rowOf}`); - } else if (this.op.below) { - const anchor = target(this.op.below); + if (this.command.rowOf) { + dy = cy(target(this.command.rowOf).shape.bounds) - cy(ref.bounds); + parts.push(`in the row of ${this.command.rowOf}`); + } else if (this.command.below) { + const anchor = target(this.command.below); const top = refUnit.y - ref.bounds.y; const want = Math.max(cy(anchor.shape.bounds) + sp.row, bottom(anchor.unit) + CLEAR - top + ref.bounds.height / 2); dy = want - cy(ref.bounds); - parts.push(`below ${this.op.below}`); - } else if (this.op.above) { - const anchor = target(this.op.above); + parts.push(`below ${this.command.below}`); + } else if (this.command.above) { + const anchor = target(this.command.above); const below = bottom(refUnit) - bottom(ref.bounds); const want = Math.min(cy(anchor.shape.bounds) - sp.row, anchor.unit.y - CLEAR - below - ref.bounds.height / 2); dy = want - cy(ref.bounds); - parts.push(`above ${this.op.above}`); + parts.push(`above ${this.command.above}`); } - if (this.op.columnOf) { - dx = cx(target(this.op.columnOf).shape.bounds) - cx(ref.bounds); - parts.push(`in the column of ${this.op.columnOf}`); - } else if (this.op.after) { - dx = right(target(this.op.after).unit) + sp.gap - refUnit.x; - parts.push(`after ${this.op.after}`); - } else if (this.op.before) { - dx = target(this.op.before).unit.x - sp.gap - right(refUnit); - parts.push(`before ${this.op.before}`); + if (this.command.columnOf) { + dx = cx(target(this.command.columnOf).shape.bounds) - cx(ref.bounds); + parts.push(`in the column of ${this.command.columnOf}`); + } else if (this.command.after) { + dx = right(target(this.command.after).unit) + sp.gap - refUnit.x; + parts.push(`after ${this.command.after}`); + } else if (this.command.before) { + dx = target(this.command.before).unit.x - sp.gap - right(refUnit); + parts.push(`before ${this.command.before}`); } dx = Math.round(dx); dy = Math.round(dy); @@ -98,11 +98,11 @@ export class ShapePlacementService { const shape = (id: string): DShape => plane.shapes.get(id)!; const holds = (): boolean => { const refBox = this.diagram.drawing(plane).unitBox(ref); - const rowOk = this.op.rowOf ? Math.abs(cy(ref.bounds) - cy(shape(this.op.rowOf).bounds)) <= 1 : this.op.below ? refBox.y >= bottom(unit(shape(this.op.below))) - 1 : this.op.above ? bottom(refBox) <= unit(shape(this.op.above)).y + 1 : true; - const colOk = this.op.columnOf ? Math.abs(cx(ref.bounds) - cx(shape(this.op.columnOf).bounds)) <= 1 : this.op.after ? refBox.x >= right(unit(shape(this.op.after))) - 1 : this.op.before ? right(refBox) <= unit(shape(this.op.before)).x + 1 : true; + const rowOk = this.command.rowOf ? Math.abs(cy(ref.bounds) - cy(shape(this.command.rowOf).bounds)) <= 1 : this.command.below ? refBox.y >= bottom(unit(shape(this.command.below))) - 1 : this.command.above ? bottom(refBox) <= unit(shape(this.command.above)).y + 1 : true; + const colOk = this.command.columnOf ? Math.abs(cx(ref.bounds) - cx(shape(this.command.columnOf).bounds)) <= 1 : this.command.after ? refBox.x >= right(unit(shape(this.command.after))) - 1 : this.command.before ? right(refBox) <= unit(shape(this.command.before)).x + 1 : true; return rowOk && colOk; }; - const lock: Array<'x' | 'y'> = [...(this.op.columnOf || this.op.after || this.op.before ? ['x' as const] : []), ...(this.op.rowOf || this.op.below || this.op.above ? ['y' as const] : [])]; + const lock: Array<'x' | 'y'> = [...(this.command.columnOf || this.command.after || this.command.before ? ['x' as const] : []), ...(this.command.rowOf || this.command.below || this.command.above ? ['y' as const] : [])]; this.diagram.room(plane).makeRoom(group, fixed, this.snap, this.notes, parts.join(' and '), holds, lock); return this.diagram.drawing(plane).settle(this.snap); } diff --git a/src/domain/diagram/format/SpaceInsertionService.ts b/src/domain/diagram/commands/SpaceInsertion.ts similarity index 91% rename from src/domain/diagram/format/SpaceInsertionService.ts rename to src/domain/diagram/commands/SpaceInsertion.ts index 1d853a8d..c01b72d8 100644 --- a/src/domain/diagram/format/SpaceInsertionService.ts +++ b/src/domain/diagram/commands/SpaceInsertion.ts @@ -1,19 +1,19 @@ import { modelError, usageError } from '../../document/utils/errors.js'; import type { InsertSpace } from '../../commands/types.js'; import { parseSpaceAmount } from '../../commands/utils/space-amount.js'; -import { CompactionService, PAD as COMPACT_PAD } from './CompactionService.js'; +import { Compactor, PAD as COMPACT_PAD } from './Compactor.js'; import { bottom, copyBox, median, right, type Box } from '../utils/geom.js'; import { Frames } from '../di/Frames.js'; import { PlaneQuery } from '../di/PlaneQuery.js'; import type { DShape, Plane } from '../di/types.js'; import { DrawingSnapshot } from '../layout/incremental/DrawingSnapshot.js'; import type { DrawnDiagram } from '../DrawnDiagram.js'; -import { problemWords } from '../utils/frames.js'; +import { problemWords } from '../utils/shape-sets.js'; -export class SpaceInsertionService { +export class SpaceInsertion { constructor( private readonly diagram: DrawnDiagram, - private readonly op: InsertSpace, + private readonly command: InsertSpace, private readonly snap: DrawingSnapshot, private readonly notes: string[], ) {} @@ -29,7 +29,7 @@ export class SpaceInsertionService { * otherwise it starts past what touches the far edge (its label, ...). */ private closeSpace(refId: string, plane: Plane, shape: DShape, axis: 'x' | 'y', amount: number): string[] { - const compaction = new CompactionService(this.diagram.drawing(plane)); + const compaction = new Compactor(this.diagram.drawing(plane)); const gaps = compaction.gaps(); const loose = compaction.looseEdges(); const pool = shape.kind === 'participant' ? shape : shape.poolId ? plane.shapes.get(shape.poolId) : undefined; @@ -85,11 +85,11 @@ export class SpaceInsertionService { * to ask for one. */ run(): string[] { - const refId = (this.op.after ?? this.op.below)!; + const refId = (this.command.after ?? this.command.below)!; const { plane, shape } = this.diagram.drawnShape(refId); - const axis = this.op.after ? 'x' : 'y'; - const by = parseSpaceAmount(this.op.by ?? (axis === 'x' ? 'column' : 'row')); - if (!by) throw usageError(`space: --by ${String(this.op.by)} is no amount (column, row, 2col, 3rows, 80px, 80; negative closes space)`); + const axis = this.command.after ? 'x' : 'y'; + const by = parseSpaceAmount(this.command.by ?? (axis === 'x' ? 'column' : 'row')); + if (!by) throw usageError(`space: --by ${String(this.command.by)} is no amount (column, row, 2col, 3rows, 80px, 80; negative closes space)`); const column = this.columnWidth(plane); const row = PlaneQuery.spacingOf(plane).row; const step = by.unit === 'column' ? column : by.unit === 'row' ? row : 1; diff --git a/src/domain/diagram/di/DiagramReader.ts b/src/domain/diagram/di/DiagramReader.ts index ea2c38c3..a4045a27 100644 --- a/src/domain/diagram/di/DiagramReader.ts +++ b/src/domain/diagram/di/DiagramReader.ts @@ -1,5 +1,5 @@ import { is, type El } from '../../document/utils/moddle.js'; -import type { DEdge, DShape, EdgeKind, Plane, ShapeKind } from './types.js'; +import type { Box, DEdge, DShape, EdgeKind, Plane, ShapeKind } from './types.js'; import { Semantics } from './Semantics.js'; import { boxOf, idOf, raw, rawList } from '../utils/raw.js'; @@ -51,28 +51,34 @@ export class DiagramReader { return rawList(defs, 'rootElements').find((root) => is(root, 'bpmn:Process') || is(root, 'bpmn:Collaboration')); } - /** Undefined without valid bounds: the element counts as undrawn. */ - private readShape(di: El, el: El, id: string): DShape | undefined { - const bounds = boxOf(raw(di, 'bounds')); - if (!bounds) return undefined; + /** A shape with its membership (host, lane, pool) from the semantic model; `expanded` only for sub-processes. */ + static shapeOf(sem: Semantics, id: string, el: El, bounds: Box, expanded: boolean | undefined): DShape { const kind = DiagramReader.shapeKind(el); - const expanded = kind === 'subProcess' ? raw(di, 'isExpanded') === true : undefined; - const container = kind === 'participant' || kind === 'lane' || (kind === 'subProcess' && expanded === true); - const label = boxOf(raw(raw(di, 'label'), 'bounds')); - const shape: DShape = { id, el, di, bounds, kind, container }; + const shape: DShape = { id, el, bounds, kind, container: kind === 'participant' || kind === 'lane' || (kind === 'subProcess' && expanded === true) }; if (expanded !== undefined) shape.expanded = expanded; - if (label) shape.label = label; if (kind === 'boundary') { const host = idOf(raw(el, 'attachedToRef')); if (host) shape.hostId = host; } - const lane = kind === 'lane' ? this.sem.laneParent.get(id) : this.sem.laneOf.get(id); + const lane = kind === 'lane' ? sem.laneParent.get(id) : sem.laneOf.get(id); if (lane) shape.laneId = lane; - const pool = this.sem.poolOf(id); + const pool = sem.poolOf(id); if (pool) shape.poolId = pool; return shape; } + /** Undefined without valid bounds: the element counts as undrawn. */ + private readShape(di: El, el: El, id: string): DShape | undefined { + const bounds = boxOf(raw(di, 'bounds')); + if (!bounds) return undefined; + const expanded = Semantics.isSubProcess(el) ? raw(di, 'isExpanded') === true : undefined; + const shape = DiagramReader.shapeOf(this.sem, id, el, bounds, expanded); + shape.di = di; + const label = boxOf(raw(raw(di, 'label'), 'bounds')); + if (label) shape.label = label; + return shape; + } + private readEdge(di: El, el: El, id: string): DEdge { const points = rawList(di, 'waypoint').map((waypoint) => ({ x: raw(waypoint, 'x') ?? 0, y: raw(waypoint, 'y') ?? 0 })); const label = boxOf(raw(raw(di, 'label'), 'bounds')); diff --git a/src/domain/diagram/di/DrawnElementIds.ts b/src/domain/diagram/di/DrawnElementIds.ts index e21e03bc..8b609ca5 100644 --- a/src/domain/diagram/di/DrawnElementIds.ts +++ b/src/domain/diagram/di/DrawnElementIds.ts @@ -2,7 +2,7 @@ import { IdRegistry } from '../../document/ids/IdRegistry.js'; import { IdStyle } from '../../document/ids/IdStyle.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; import { kindRequest, typeRequest, type IdRequest } from '../../document/utils/requests.js'; -import { kindLabel, kindOf, triggerOf } from '../../document/utils/kinds.js'; +import { kindLabel, kindOf, triggerOf } from '../../document/Kinds.js'; import { indexById, is, isDiElement, walk, type El } from '../../document/utils/moddle.js'; const DRAWN = [ diff --git a/src/domain/diagram/di/LayoutAnalysis.ts b/src/domain/diagram/di/LayoutAnalysis.ts index 003cae9d..39c46427 100644 --- a/src/domain/diagram/di/LayoutAnalysis.ts +++ b/src/domain/diagram/di/LayoutAnalysis.ts @@ -1,6 +1,6 @@ -import { is, parseXml, type El } from '../../document/utils/moddle.js'; +import { enclosingProcess, is, parseXml, type El } from '../../document/utils/moddle.js'; import { PlaneAnalysis, type Add } from './PlaneAnalysis.js'; -import { enclosingProcess, type MetricIndex } from '../utils/harness.js'; +import type { MetricIndex } from '../utils/harness.js'; import { idOf, raw, rawList } from '../utils/raw.js'; import { KEYS, perKey, scoreOf, type LayoutMetrics, type LayoutProblem } from '../utils/problems.js'; diff --git a/src/domain/diagram/di/PlaneAnalysis.ts b/src/domain/diagram/di/PlaneAnalysis.ts index d9e836b0..78b33969 100644 --- a/src/domain/diagram/di/PlaneAnalysis.ts +++ b/src/domain/diagram/di/PlaneAnalysis.ts @@ -1,8 +1,7 @@ -import { is, type El } from '../../document/utils/moddle.js'; +import { enclosingProcess, is, type El } from '../../document/utils/moddle.js'; import type { Box, Point } from './types.js'; -import { containsPoint, cx, cy, overlaps, segmentHits, segmentsCross } from '../utils/geom.js'; -import type { Segment } from '../utils/geometry.js'; -import { collinear, drawnEnds, enclosingProcess, glyph, hits, inside, joinTail, lookup, readDrawnPlane, subTitleOf, type Edge, type MetricIndex, type Shape } from '../utils/harness.js'; +import { containsPoint, cx, cy, overlaps, segmentHits, segmentsCross, type Segment } from '../utils/geom.js'; +import { collinear, drawnEnds, glyph, hits, inside, joinTail, lookup, readDrawnPlane, subTitleOf, type Edge, type MetricIndex, type Shape } from '../utils/harness.js'; import { idOf, raw, rawList } from '../utils/raw.js'; import type { MetricKey } from '../utils/problems.js'; @@ -194,11 +193,11 @@ export class PlaneAnalysis { private checkSequenceFlow(edge: Edge): void { const { sem, byId, add } = this; const frame = this.flowFrame(edge.id); - if (frame && edge.pts.some((point) => !containsPoint(frame.bounds, point))) add('edgeOutside', edge.id, [edge.id, frame.id]); + if (frame && edge.points.some((point) => !containsPoint(frame.bounds, point))) add('edgeOutside', edge.id, [edge.id, frame.id]); const sl = lookup(sem.laneOf, idOf(raw(edge.el, 'sourceRef'))); const tl = lookup(sem.laneOf, idOf(raw(edge.el, 'targetRef'))); const lane = sl && sl === tl ? lookup(byId, idOf(sl)) : undefined; - if (lane && edge.pts.some((point) => !containsPoint(lane.bounds, point))) add('edgeLeavesLane', edge.id, [edge.id, lane.id]); + if (lane && edge.points.some((point) => !containsPoint(lane.bounds, point))) add('edgeLeavesLane', edge.id, [edge.id, lane.id]); } private checkMessageFlow(edge: Edge): void { @@ -336,19 +335,19 @@ export class PlaneAnalysis { const { edges, add } = this; for (const edge of edges) { const length = edge.segs.reduce((sum, [start, end]) => sum + Math.abs(end.x - start.x) + Math.abs(end.y - start.y), 0); - if (edge.pts.length < 2 || !(length > 0)) add('degenerateEdge', edge.id, [edge.id]); + if (edge.points.length < 2 || !(length > 0)) add('degenerateEdge', edge.id, [edge.id]); } } private checkDetached(): void { const { edges, byId, add } = this; for (const edge of edges) { - if (edge.pts.length < 2) continue; + if (edge.points.length < 2) continue; const [sid, tid] = drawnEnds(edge.el); const missed: string[] = []; for (const [id, endPoint] of [ - [sid, edge.pts[0]!], - [tid, edge.pts[edge.pts.length - 1]!], + [sid, edge.points[0]!], + [tid, edge.points[edge.points.length - 1]!], ] as const) { const endShape = lookup(byId, id); if (endShape && (endPoint.x < endShape.bounds.x - DETACHED_GAP || endPoint.x > endShape.bounds.x + endShape.bounds.width + DETACHED_GAP || endPoint.y < endShape.bounds.y - DETACHED_GAP || endPoint.y > endShape.bounds.y + endShape.bounds.height + DETACHED_GAP)) missed.push(endShape.id); diff --git a/src/domain/diagram/di/PlaneWriter.ts b/src/domain/diagram/di/PlaneWriter.ts index e9c3745c..3081e436 100644 --- a/src/domain/diagram/di/PlaneWriter.ts +++ b/src/domain/diagram/di/PlaneWriter.ts @@ -1,7 +1,7 @@ import type { BpmnModdle } from 'bpmn-moddle'; import { addTo, is, removeFrom, type El } from '../../document/utils/moddle.js'; import { roundPoints, sameBox, samePoints, type Box, type Point } from '../utils/geom.js'; -import { idOf, raw, rawList } from '../utils/raw.js'; +import { boxOf, idOf, raw, rawList } from '../utils/raw.js'; import type { DEdge, DShape, Plane } from './types.js'; import { DiIds } from './DiIds.js'; @@ -16,14 +16,9 @@ export class PlaneWriter { return { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }; } - private readBox(bounds: unknown): Box | undefined { - if (!bounds) return undefined; - return { x: raw(bounds, 'x') ?? NaN, y: raw(bounds, 'y') ?? NaN, width: raw(bounds, 'width') ?? NaN, height: raw(bounds, 'height') ?? NaN }; - } - private setBounds(owner: El, box: Box): void { const current = raw(owner, 'bounds'); - if (current && sameBox(this.readBox(current), box)) return; + if (current && sameBox(boxOf(current), box)) return; const rounded = this.rounded(box); if (current) { current.set('x', rounded.x); diff --git a/src/domain/diagram/di/ProblemLocator.ts b/src/domain/diagram/di/ProblemLocator.ts index 6d65384a..c98b7055 100644 --- a/src/domain/diagram/di/ProblemLocator.ts +++ b/src/domain/diagram/di/ProblemLocator.ts @@ -1,7 +1,6 @@ import { is, type El } from '../../document/utils/moddle.js'; import type { Box, Point } from './types.js'; -import { containsPoint, cx, cy, segmentHits, segmentsCross } from '../utils/geom.js'; -import { toSegments, type Segment } from '../utils/geometry.js'; +import { containsPoint, cx, cy, segmentHits, segmentsCross, toSegments, type Segment } from '../utils/geom.js'; import { collinear, drawnEnds, glyph, hits, joinTail, readDrawnPlane, subTitleOf, type Edge, type Shape } from '../utils/harness.js'; import { idOf, raw, rawList } from '../utils/raw.js'; import type { LayoutProblem } from '../utils/problems.js'; @@ -147,10 +146,10 @@ export class ProblemLocator { return seg ? this.midpoint(seg[0], seg[1]) : undefined; } case 'degenerateEdge': - return ea?.pts[0]; + return ea?.points[0]; case 'detachedEdge': { - if (!ea || ea.pts.length < 2) return undefined; - return drawnEnds(ea.el)[0] === secondId ? ea.pts[0] : ea.pts[ea.pts.length - 1]; + if (!ea || ea.points.length < 2) return undefined; + return drawnEnds(ea.el)[0] === secondId ? ea.points[0] : ea.points[ea.points.length - 1]; } case 'labelClash': { const own = labelOf(firstId); @@ -172,12 +171,12 @@ export class ProblemLocator { return ea && eb ? this.sharedAt(ea, eb, joinTail(ea, eb, pl.shapes.get(problem.ids[2] ?? ''))) : undefined; case 'edgeLeavesLane': case 'edgeOutside': - return sb ? ea?.pts.find((point) => !containsPoint(sb.bounds, point)) : undefined; + return sb ? ea?.points.find((point) => !containsPoint(sb.bounds, point)) : undefined; case 'laneGap': if (!sa || !sb) return undefined; return problem.detail === 'bottom' ? { x: cx(sa.bounds), y: (sa.bounds.y + sa.bounds.height + sb.bounds.y + sb.bounds.height) / 2 } : { x: cx(sa.bounds), y: (sa.bounds.y + sb.bounds.y) / 2 }; case 'backwardFlow': - return ea ? this.halfway(ea.pts) : undefined; + return ea ? this.halfway(ea.points) : undefined; case 'labelOutsideFrame': case 'messageLabelFar': { const own = labelOf(firstId); diff --git a/src/domain/diagram/di/Semantics.ts b/src/domain/diagram/di/Semantics.ts index 8f2754d9..c41d8400 100644 --- a/src/domain/diagram/di/Semantics.ts +++ b/src/domain/diagram/di/Semantics.ts @@ -1,7 +1,7 @@ import { is, type El } from '../../document/utils/moddle.js'; import type { Plane } from './types.js'; import { SUB_PROCESS_TYPES } from '../utils/expansion.js'; -import { idOf, raw, rawList } from '../utils/raw.js'; +import { idOf, raw, rawList, rawTree } from '../utils/raw.js'; /** The semantic model a drawing is read against: elements by id, lanes, pools, scopes, flows. */ export class Semantics { @@ -44,24 +44,9 @@ export class Semantics { } private static indexAll(root: El, byId: Map): void { - const stack: El[] = [root]; - const seen = new Set(); - while (stack.length) { - const el = stack.pop()!; - if (seen.has(el)) continue; - seen.add(el); - if (/^(bpmndi|dc|di):/.test(el.$type)) continue; + for (const el of rawTree(root, (candidate) => /^(bpmndi|dc|di):/.test(candidate.$type))) { const id = idOf(el); if (id) byId.set(id, el); - const descriptor = el.$descriptor as { isGeneric?: boolean; properties?: Array<{ name: string; isReference?: boolean }> }; - if (descriptor.isGeneric) continue; - for (const property of descriptor.properties ?? []) { - if (property.isReference) continue; - const value = raw(el, property.name); - if (Array.isArray(value)) { - for (const child of value) if (child && typeof child === 'object' && '$type' in child) stack.push(child as El); - } else if (value && typeof value === 'object' && '$type' in value) stack.push(value as El); - } } } diff --git a/src/domain/diagram/di/types.ts b/src/domain/diagram/di/types.ts index 716b246a..7e3033de 100644 --- a/src/domain/diagram/di/types.ts +++ b/src/domain/diagram/di/types.ts @@ -116,7 +116,9 @@ export interface LaneBox { children: LaneBox[]; } -export type EdgeKind = 'sequenceFlow' | 'messageFlow' | 'association' | 'dataAssociation' | 'other'; +export type ConnectionKind = 'sequenceFlow' | 'messageFlow' | 'association' | 'dataAssociation'; + +export type EdgeKind = ConnectionKind | 'other'; export interface DShape { id: string; diff --git a/src/domain/diagram/layout/AssociationEdges.ts b/src/domain/diagram/layout/AssociationEdges.ts deleted file mode 100644 index ccc826fe..00000000 --- a/src/domain/diagram/layout/AssociationEdges.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { addTo, is, many, walk, type El, type Model } from '../../document/utils/moddle.js'; -import { SUB_PROCESS_TYPES } from '../utils/expansion.js'; -import { center, containsPoint, cx } from '../utils/geom.js'; - -interface Rect { - x: number; - y: number; - width: number; - height: number; -} - -interface Point { - x: number; - y: number; -} - -interface PlaneIndex { - plane: El; - shapes: Map; - edges: Map; -} - -const HANDLER_GAP = 40; - -const OVERLAP_MARGIN = 10; - -/** - * Draws the association edges bpmn-auto-layout leaves out: ends that are flows or pools, - * and compensation associations, whose handler moves below its boundary event (bpmn.io style). - */ -export class AssociationEdges { - constructor(private readonly model: Model) {} - - private boundsOf(shape: El): Rect | undefined { - const bounds = shape.get('bounds'); - if (!bounds) return undefined; - const left = bounds.get('x'); - const top = bounds.get('y'); - const width = bounds.get('width'); - const height = bounds.get('height'); - if ([left, top, width, height].some((coordinate) => typeof coordinate !== 'number')) return undefined; - return { x: left, y: top, width, height }; - } - - private setBounds(shape: El, rect: Rect): void { - const bounds = shape.get('bounds'); - bounds.set('x', rect.x); - bounds.set('y', rect.y); - } - - private overlaps(first: Rect, second: Rect, margin = 0): boolean { - return first.x - margin < second.x + second.width && first.x + first.width + margin > second.x && first.y - margin < second.y + second.height && first.y + first.height + margin > second.y; - } - - private contains(outer: Rect, inner: Rect): boolean { - return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height; - } - - private borderPoint(rect: Rect, to: Point): Point { - const middle = center(rect); - const dx = to.x - middle.x; - const dy = to.y - middle.y; - if (!dx && !dy) return middle; - const hw = rect.width / 2; - const hh = rect.height / 2; - if (Math.abs(dx) * hh >= Math.abs(dy) * hw) { - const sx = Math.sign(dx) * hw; - return { x: Math.round(middle.x + sx), y: Math.round(middle.y + (dy * sx) / dx) }; - } - const sy = Math.sign(dy) * hh; - return { x: Math.round(middle.x + (dx * sy) / dy), y: Math.round(middle.y + sy) }; - } - - private waypoints(edge: El): Point[] { - return many(edge, 'waypoint').map((waypoint) => ({ x: waypoint.get('x'), y: waypoint.get('y') })); - } - - private midpoint(points: Point[]): Point | undefined { - if (!points.length) return undefined; - if (points.length === 1) return points[0]; - const middleIndex = Math.floor((points.length - 1) / 2); - const segmentStart = points[middleIndex]!; - const segmentEnd = points[middleIndex + 1]!; - return { x: Math.round((segmentStart.x + segmentEnd.x) / 2), y: Math.round((segmentStart.y + segmentEnd.y) / 2) }; - } - - private indexPlanes(defs: El): PlaneIndex[] { - const out: PlaneIndex[] = []; - for (const diagram of many(defs, 'diagrams')) { - const plane = diagram.get('plane'); - if (!plane) continue; - const shapes = new Map(); - const edges = new Map(); - for (const di of many(plane, 'planeElement')) { - const target = di.get('bpmnElement'); - if (!target) continue; - if (is(di, 'bpmndi:BPMNShape')) shapes.set(target, di); - else if (is(di, 'bpmndi:BPMNEdge')) edges.set(target, di); - } - out.push({ plane, shapes, edges }); - } - return out; - } - - /** Where an association may dock on an element: its shape, or the middle of its edge. */ - private anchorOf(index: PlaneIndex, el: El): { rect?: Rect; point?: Point } | undefined { - const shape = index.shapes.get(el); - if (shape) { - const rect = this.boundsOf(shape); - return rect ? { rect } : undefined; - } - const edge = index.edges.get(el); - if (edge) { - const point = this.midpoint(this.waypoints(edge)); - return point ? { point } : undefined; - } - return undefined; - } - - private dock(anchor: { rect?: Rect; point?: Point }, towards: Point): Point { - return anchor.rect ? this.borderPoint(anchor.rect, towards) : anchor.point!; - } - - private referenceOf(anchor: { rect?: Rect; point?: Point }): Point { - return anchor.rect ? center(anchor.rect) : anchor.point!; - } - - private isCompensationHandlerPair(source: El, target: El): boolean { - if (!is(source, 'bpmn:BoundaryEvent') || !is(target, 'bpmn:Activity')) return false; - return many(source, 'eventDefinitions').some((definition) => is(definition, 'bpmn:CompensateEventDefinition')); - } - - private containersAround(index: PlaneIndex, point: Point): Rect[] { - const out: Rect[] = []; - for (const [el, shape] of index.shapes) { - const isContainer = is(el, 'bpmn:Participant') || is(el, 'bpmn:Lane') || (SUB_PROCESS_TYPES.some((type) => is(el, type)) && shape.get('isExpanded') === true); - if (!isContainer) continue; - const rect = this.boundsOf(shape); - if (rect && containsPoint(rect, point)) out.push(rect); - } - return out; - } - - private placeHandlerBelow(index: PlaneIndex, handler: El, handlerRect: Rect, eventRect: Rect): Rect | undefined { - const containers = this.containersAround(index, center(handlerRect)); - const others: Rect[] = []; - for (const [el, shape] of index.shapes) { - if (el === handler) continue; - if (is(el, 'bpmn:Participant') || is(el, 'bpmn:Lane')) continue; - if (SUB_PROCESS_TYPES.some((type) => is(el, type)) && shape.get('isExpanded') === true) continue; - const rect = this.boundsOf(shape); - if (rect) others.push(rect); - } - const left = Math.round(cx(eventRect) - handlerRect.width / 2); - for (let gap = HANDLER_GAP; gap <= HANDLER_GAP * 4; gap += 20) { - const candidate: Rect = { x: left, y: eventRect.y + eventRect.height + gap, width: handlerRect.width, height: handlerRect.height }; - if (others.some((other) => this.overlaps(candidate, other, OVERLAP_MARGIN))) continue; - if (!containers.every((container) => this.contains(container, candidate))) return undefined; - return candidate; - } - return undefined; - } - - synthesise(): string[] { - const { moddle, definitions } = this.model; - const planes = this.indexPlanes(definitions); - if (!planes.length) return []; - const fixed: string[] = []; - for (const assoc of [...walk(definitions)].filter((element) => is(element, 'bpmn:Association'))) { - const source = assoc.get('sourceRef'); - const target = assoc.get('targetRef'); - const id = assoc.get('id'); - if (!source || !target || !id) continue; - if (planes.some((plane) => plane.edges.has(assoc))) continue; - const index = planes.find((plane) => (plane.shapes.has(source) || plane.edges.has(source)) && (plane.shapes.has(target) || plane.edges.has(target))); - if (!index) continue; - let from = this.anchorOf(index, source); - let to = this.anchorOf(index, target); - if (!from || !to) continue; - if (this.isCompensationHandlerPair(source, target) && from.rect && to.rect) { - const moved = this.placeHandlerBelow(index, target, to.rect, from.rect); - if (moved) { - this.setBounds(index.shapes.get(target)!, moved); - to = { rect: moved }; - } - } - const sourceDock = this.dock(from, this.referenceOf(to)); - const targetDock = this.dock(to, sourceDock); - const edge = moddle.create('bpmndi:BPMNEdge', { - id: `BPMNEdge_${id}`, - bpmnElement: assoc, - waypoint: [moddle.create('dc:Point', sourceDock), moddle.create('dc:Point', targetDock)], - }); - addTo(index.plane, 'planeElement', edge); - index.edges.set(assoc, edge); - fixed.push(id); - } - return fixed; - } -} diff --git a/src/domain/diagram/layout/FullLayout.ts b/src/domain/diagram/layout/FullLayout.ts index 738a8ca7..53b18bb6 100644 --- a/src/domain/diagram/layout/FullLayout.ts +++ b/src/domain/diagram/layout/FullLayout.ts @@ -1,15 +1,10 @@ -import type { LayoutWarning } from 'bpmn-auto-layout'; import { DrawnElementIds, type GivenId } from '../di/DrawnElementIds.js'; import { Groups, type RedrawnGroups } from '../di/Groups.js'; -import { diIdsFit } from '../utils/di-ids.js'; import { DiIdMemo } from '../di/DiIdMemo.js'; import type { IdRequest } from '../../document/utils/requests.js'; import { CleanEngine } from './clean/CleanEngine.js'; -import { addTo, layoutRoot, parseXml, serialize, type El, type Model, idOf } from '../../document/utils/moddle.js'; -import { resolveExpanded, subProcesses } from '../utils/expansion.js'; -import { AssociationEdges } from './AssociationEdges.js'; -import { TemporaryLaneMembers } from './TemporaryLaneMembers.js'; -import { ModelError } from '../../document/ModelError.js'; +import type { Model } from '../../document/utils/moddle.js'; +import { resolveExpanded } from '../utils/expansion.js'; export interface LayoutWarningInfo { code: string; @@ -18,15 +13,11 @@ export interface LayoutWarningInfo { relatedElementIds: string[]; } -export type LayoutEngine = 'clean' | 'auto'; - export interface LayoutOptions { /** sub-process ids that must be expanded (in addition to what the DI says) */ expand?: Iterable; /** sub-process ids that must be collapsed */ collapse?: Iterable; - /** 'clean' (built-in engine, default) or 'auto' (bpmn-auto-layout) */ - engine?: LayoutEngine; /** claims an id for a drawn element that has none (default: the file's id style) */ allocateId?: (req: IdRequest) => string; } @@ -42,9 +33,8 @@ export interface LayoutResult { } /** - * Replaces a model's DI with a fresh drawing from the clean or the auto engine. + * Replaces a model's DI with a fresh drawing from the clean engine. * Sub-processes stay expanded unless the old DI collapsed them; DI ids and group boxes survive. - * Works around bpmn-auto-layout crashing on laneless nodes and skipping some association edges. */ export class FullLayout { constructor( @@ -52,34 +42,9 @@ export class FullLayout { private readonly opts: LayoutOptions = {}, ) {} - /** bpmn-auto-layout only reads `isExpanded` from old DI, so the hint diagram holds just that. */ - private injectLayoutHints(expanded: Set): void { - const { moddle, definitions } = this.model; - const root = layoutRoot(definitions); - definitions.set('diagrams', []); - if (!root) return; - const byId = new Map(); - for (const sub of subProcesses(definitions)) byId.set(idOf(sub), sub); - const plane = moddle.create('bpmndi:BPMNPlane', { id: `BPMNPlane_${idOf(root)}`, bpmnElement: root }); - const diagram = moddle.create('bpmndi:BPMNDiagram', { id: `BPMNDiagram_${idOf(root)}`, plane }); - plane.$parent = diagram; - for (const id of expanded) { - const sub = byId.get(id); - if (!sub) continue; - const shape = moddle.create('bpmndi:BPMNShape', { id: `BPMNShape_${id}`, bpmnElement: sub, isExpanded: true }); - addTo(plane, 'planeElement', shape); - } - addTo(definitions, 'diagrams', diagram); - } - - private toWarningInfo(warning: LayoutWarning): LayoutWarningInfo { - return { code: warning.code, elementId: warning.elementId, message: warning.message, relatedElementIds: warning.relatedElementIds ?? [] }; - } - /** * Clobbers the model's in-memory DI; treat the returned XML as the truth. - * Id-less elements get ids first, so the DI never says bpmnElement="undefined". Without old DI - * the layouter's ids stand unless a DI id would exceed 64 characters. + * Id-less elements get ids first, so the DI never says bpmnElement="undefined". */ async run(): Promise { const givenIds = new DrawnElementIds(this.model.definitions).give(this.opts.allocateId); @@ -87,39 +52,11 @@ export class FullLayout { const expanded = resolveExpanded(this.model, this.opts); const diIds = DiIdMemo.remember(this.model.definitions); const groups = [...new Groups(this.model.definitions).remembered(), ...new Groups(this.model.definitions).undrawn()]; - if ((this.opts.engine ?? 'clean') === 'clean') { - const { warnings } = new CleanEngine(this.model, expanded, Groups.membersOf(groups)).run(); - const redrawn = new Groups(this.model.definitions).redraw(this.model.moddle, groups); - diIds.restore(this.model.definitions); - const { xml } = await this.model.moddle.toXML(this.model.definitions, { format: true }); - return { xml, warnings: warnings.map((warning) => ({ code: warning.code, elementId: warning.elementId, message: warning.message, relatedElementIds: [] })), expanded: [...expanded], ...given, ...this.groupNotes(redrawn) }; - } - const cut = !diIds.diagrams.length && !diIdsFit(this.model.definitions); - this.injectLayoutHints(expanded); - const temporary = TemporaryLaneMembers.add(this.model.definitions); - let result: LayoutResult; - try { - const { xml: input } = await this.model.moddle.toXML(this.model.definitions, { format: true }); - result = await this.autoLayout(input, expanded); - } finally { - temporary.withdraw(this.model.definitions); - } - const missingDi = result.warnings.some((warning) => warning.code === 'DI_NOT_CREATED'); - if (!temporary.count && !missingDi && !diIds.diagrams.length && !cut) return { ...result, ...given }; - - const laidOut = await parseXml(result.xml); - temporary.withdraw(laidOut.definitions); - const fixed = new Set(missingDi ? new AssociationEdges(laidOut).synthesise() : []); - const redrawn = new Groups(laidOut.definitions).redraw(laidOut.moddle, groups); - const placed = new Set([...redrawn.drawn, ...redrawn.kept]); - diIds.restore(laidOut.definitions); - return { - xml: await serialize(laidOut), - warnings: result.warnings.filter((warning) => !(warning.code === 'DI_NOT_CREATED' && fixed.has(warning.elementId)) && !(warning.code === 'GROUP_MEMBERS_NOT_FOUND' && placed.has(warning.elementId))), - expanded: result.expanded, - ...given, - ...this.groupNotes(redrawn), - }; + const { warnings } = new CleanEngine(this.model, expanded, Groups.membersOf(groups)).run(); + const redrawn = new Groups(this.model.definitions).redraw(this.model.moddle, groups); + diIds.restore(this.model.definitions); + const { xml } = await this.model.moddle.toXML(this.model.definitions, { format: true }); + return { xml, warnings: warnings.map((warning) => ({ code: warning.code, elementId: warning.elementId, message: warning.message, relatedElementIds: [] })), expanded: [...expanded], ...given, ...this.groupNotes(redrawn) }; } private groupNotes({ kept, moved }: RedrawnGroups): { notes?: string[] } { @@ -129,25 +66,4 @@ export class FullLayout { if (moved.length) notes.push(moved.length === 1 ? `group ${moved[0]} moved below the drawing: no element inside it is drawn and the redraw put shapes on its box` : `groups ${moved.join(', ')} moved below the drawing: no element inside them is drawn and the redraw put shapes on their boxes`); return notes.length ? { notes } : {}; } - - /** Imported on first use so a browser bundle loads bpmn-auto-layout only for `engine: 'auto'`. */ - private async autoLayout(xml: string, expanded: Set = new Set()): Promise { - const { layoutProcess, LayoutError } = await import('bpmn-auto-layout'); - try { - const { xml: out, warnings } = await layoutProcess(xml); - return { xml: out, warnings: warnings.map(this.toWarningInfo), expanded: [...expanded] }; - } catch (err) { - if (err instanceof LayoutError) { - throw new ModelError(`Layout failed: ${err.message}`, `LAYOUT_${err.code}`, { - elementId: err.elementId, - element: err.elementId, - relatedElementIds: err.relatedElementIds ?? [], - related: err.relatedElementIds ?? [], - }); - } - throw new ModelError(`Layout failed: ${(err as Error).message}`, 'LAYOUT_ERROR', { - hint: 'bpmn-auto-layout (alpha) crashed on this model. Write without DI using --no-layout and run `bpmn layout` later, check `bpmn validate` for unusual structure (nodes outside lanes, unreachable nodes), and report the command and file.', - }); - } - } } diff --git a/src/domain/diagram/layout/TemporaryLaneMembers.ts b/src/domain/diagram/layout/TemporaryLaneMembers.ts deleted file mode 100644 index ac6c8611..00000000 --- a/src/domain/diagram/layout/TemporaryLaneMembers.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { addTo, idOf, indexById, is, many, processes, removeFrom, type El } from '../../document/utils/moddle.js'; -import { allLanes } from '../../document/utils/lanes.js'; - -interface TempMember { - laneId: string; - nodeId: string; -} - -/** - * bpmn-auto-layout crashes on a laneless node in a laned process once a data object is shared - * with a lane member, so such nodes join a lane for the layout only and leave it afterwards. - */ -export class TemporaryLaneMembers { - private constructor(private readonly members: TempMember[]) {} - - get count(): number { - return this.members.length; - } - - private static isLeafLane(lane: El): boolean { - const child = lane.get('childLaneSet'); - return !child || many(child, 'lanes').length === 0; - } - - private static laneForLayout(node: El, laneOfNode: Map, leafLanes: El[]): El | undefined { - const neighbours: El[] = []; - const host = node.get('attachedToRef'); - if (host) neighbours.push(host); - for (const flow of node.get('incoming') ?? []) { - const src = flow.get('sourceRef'); - if (src) neighbours.push(src); - } - for (const flow of node.get('outgoing') ?? []) { - const tgt = flow.get('targetRef'); - if (tgt) neighbours.push(tgt); - } - for (const neighbour of neighbours) { - const lane = laneOfNode.get(neighbour); - if (lane && TemporaryLaneMembers.isLeafLane(lane)) return lane; - } - return leafLanes[0]; - } - - static add(defs: El): TemporaryLaneMembers { - const added: TempMember[] = []; - for (const process of processes(defs)) { - const lanes = allLanes(process); - if (!lanes.length) continue; - const leafLanes = lanes.filter(TemporaryLaneMembers.isLeafLane); - if (!leafLanes.length) continue; - const laneOfNode = new Map(); - for (const lane of lanes) for (const node of lane.get('flowNodeRef') ?? []) laneOfNode.set(node, lane); - for (const node of many(process, 'flowElements')) { - if (!is(node, 'bpmn:FlowNode') || laneOfNode.has(node) || !node.get('id')) continue; - const lane = TemporaryLaneMembers.laneForLayout(node, laneOfNode, leafLanes); - if (!lane) continue; - addTo(lane, 'flowNodeRef', node); - laneOfNode.set(node, lane); - added.push({ laneId: idOf(lane), nodeId: idOf(node) }); - } - } - return new TemporaryLaneMembers(added); - } - - withdraw(defs: El): void { - if (!this.members.length) return; - const byId = indexById(defs); - for (const { laneId, nodeId } of this.members) { - const lane = byId.get(laneId); - const node = byId.get(nodeId); - if (lane && node) removeFrom(lane, 'flowNodeRef', node); - } - } -} diff --git a/src/domain/diagram/layout/clean/ChannelRouter.ts b/src/domain/diagram/layout/clean/ChannelRouter.ts index b75ac0f9..1d0c84da 100644 --- a/src/domain/diagram/layout/clean/ChannelRouter.ts +++ b/src/domain/diagram/layout/clean/ChannelRouter.ts @@ -1,10 +1,9 @@ import { labelSize } from '../../utils/text.js'; import { METRICS } from '../../utils/sizes.js'; -import { bottom, center, right, segmentHitsBox } from '../../utils/geom.js'; +import { bottom, center, right, segmentHitsBox, dedupe, hitCount, pathHitsAny, properCross, toSegments, type Segment } from '../../utils/geom.js'; import type { Box, GEdge, GNode, LaneBandInfo, Point, ScopeLayout } from '../../di/types.js'; import { ScopeLabels } from './ScopeLabels.js'; import { XIndex } from './XIndex.js'; -import { dedupe, hitCount, pathHits, properCross, toSegments, type Segment } from '../../utils/geometry.js'; import { nameOf } from '../../utils/names.js'; import { boundaryLeft } from '../../utils/nodes.js'; import { obstacles, routedArtifacts } from '../../utils/scope.js'; @@ -80,7 +79,7 @@ export class ChannelRouter { } this.labelSpots = [...this.labelsBelow, ...this.labelsAbove, ...this.gatewaySpots.values(), ...routedArtifacts(layout).map((artifact) => (artifact.kind === 'annotation' ? undefined : ScopeLabels.of(artifact, 'data')))].filter((box): box is Box => !!box); this.spotIndex = this.boxIndex(this.labelSpots); - this.openRow = new Set(layout.edges.filter((edge) => !edge.back && edge.bandY !== undefined && Math.abs(center(edge.source.box).y - center(edge.target.box).y) < 1 && !pathHits(this.straightLine(edge.source, edge.target), [...this.obstacleList, ...this.labelSpots], [edge.source.box, edge.target.box]))); + this.openRow = new Set(layout.edges.filter((edge) => !edge.back && edge.bandY !== undefined && Math.abs(center(edge.source.box).y - center(edge.target.box).y) < 1 && !pathHitsAny(this.straightLine(edge.source, edge.target), [...this.obstacleList, ...this.labelSpots], [edge.source.box, edge.target.box]))); } /** @@ -224,7 +223,7 @@ export class ChannelRouter { private bottomSlot(node: GNode, len: number): number { const cands = ChannelRouter.bottomCandidates(node); - const ok = (slotX: number): boolean => !pathHits([{ x: slotX, y: bottom(node.box) }, { x: slotX, y: bottom(node.box) + len }], this.boxes, [node.box]); + const ok = (slotX: number): boolean => !pathHitsAny([{ x: slotX, y: bottom(node.box) }, { x: slotX, y: bottom(node.box) + len }], this.boxes, [node.box]); return cands.find((slotX) => ok(slotX) && !this.usedBottom.taken(node, slotX)) ?? cands.find(ok) ?? cands[0]!; } @@ -283,11 +282,11 @@ export class ChannelRouter { private exitToBelow(source: GNode, ex: number, channelY: number): Point[] { const sb = source.box; const direct: Point[] = [{ x: ex, y: bottom(sb) }, { x: ex, y: channelY }]; - if (!pathHits(direct, this.boxes, [sb])) return direct; + if (!pathHitsAny(direct, this.boxes, [sb])) return direct; const gapX = right(sb) + METRICS.hGap / 2; const sideY = sb.y + sb.height * 0.75; const viaGap: Point[] = [{ x: right(sb), y: sideY }, { x: gapX, y: sideY }, { x: gapX, y: channelY }]; - return pathHits(viaGap, this.boxes, [sb]) ? direct : viaGap; + return pathHitsAny(viaGap, this.boxes, [sb]) ? direct : viaGap; } /** @@ -299,10 +298,10 @@ export class ChannelRouter { const tx = this.bottomSlot(target, 30); const hookY = bottom(tb) + 15; const direct: Point[] = [{ x: tx, y: fromY }, { x: tx, y: bottom(tb) }]; - if (fromY <= hookY || !pathHits(direct, this.boxes, [tb])) return direct; + if (fromY <= hookY || !pathHitsAny(direct, this.boxes, [tb])) return direct; for (const gapX of [right(tb) + METRICS.hGap / 2, tb.x - METRICS.hGap / 2]) { const path: Point[] = [{ x: gapX, y: fromY }, { x: gapX, y: hookY }, { x: tx, y: hookY }, { x: tx, y: bottom(tb) }]; - if (!pathHits(path, this.boxes, [tb])) return path; + if (!pathHitsAny(path, this.boxes, [tb])) return path; } return direct; } @@ -317,7 +316,7 @@ export class ChannelRouter { private lineFree(line: Point[]): boolean { const [start, end] = line as [Point, Point]; const on = ([segStart, segEnd]: Segment): boolean => Math.min(segStart.y, segEnd.y) <= start.y && Math.max(segStart.y, segEnd.y) >= start.y && Math.min(segStart.x, segEnd.x) <= end.x && Math.max(segStart.x, segEnd.x) >= start.x && (segStart.y === segEnd.y || segStart.x === start.x || segStart.x === end.x); - return !this.routed.near(Math.min(start.x, end.x), Math.max(start.x, end.x)).some(on) && !pathHits(line, this.flowLabels, []); + return !this.routed.near(Math.min(start.x, end.x), Math.max(start.x, end.x)).some(on) && !pathHitsAny(line, this.flowLabels, []); } /** @@ -500,7 +499,7 @@ export class ChannelRouter { pts = band; this.usedBottom.take(source, ex, true); } - } else if (pathHits(pts, this.boxes, ignore)) { + } else if (pathHitsAny(pts, this.boxes, ignore)) { const base = this.spanBottom(source.layer, target.layer) + METRICS.loopGap; const channelY = this.channelFor(this.exitX(source), tb.x - 20, base); pts = [...this.exitToBelow(source, this.exitX(source), channelY), ...this.hookFromBelow(target, channelY)]; @@ -524,19 +523,19 @@ export class ChannelRouter { const ex = source.host ? sc.x : this.exitX(source); let pts: Point[] = [{ x: ex, y: bottom(sb) }, { x: ex, y: tc.y }, { x: tb.x, y: tc.y }]; const sideAt = (dropX: number): Point[] => [{ x: right(sb), y: sc.y }, { x: dropX, y: sc.y }, { x: dropX, y: tc.y }, { x: tb.x, y: tc.y }]; - const joinSide = this.joinFromBelow(source) ? [METRICS.hGap / 4, METRICS.hGap / 2].map((offset) => sideAt(right(sb) + offset)).find((path) => !pathHits(path, this.boxes, ignore)) : undefined; + const joinSide = this.joinFromBelow(source) ? [METRICS.hGap / 4, METRICS.hGap / 2].map((offset) => sideAt(right(sb) + offset)).find((path) => !pathHitsAny(path, this.boxes, ignore)) : undefined; if (joinSide) pts = joinSide; - else if (pathHits(pts, this.boxes, ignore)) { + else if (pathHitsAny(pts, this.boxes, ignore)) { pts = sideAt(right(sb) + METRICS.hGap / 2); if (source.host) { const gx = right(source.host.box) + METRICS.hGap / 2, runY = this.runBelow(sc.x, gx, bottom(sb)); const down = [{ x: sc.x, y: bottom(sb) }, { x: sc.x, y: runY }, { x: gx, y: runY }, { x: gx, y: tc.y }, { x: tb.x, y: tc.y }]; - if (runY < tc.y && !pathHits(down, this.boxes, ignore)) { + if (runY < tc.y && !pathHitsAny(down, this.boxes, ignore)) { pts = down; runsBelow = true; } } - if (pathHits(pts, this.boxes, ignore)) { + if (pathHitsAny(pts, this.boxes, ignore)) { runsBelow = source.host && this.bandOf(source) === this.bandOf(target) ? true : undefined; const base = this.spanBottom(source.host ? source.host.layer : source.layer, target.layer) + METRICS.loopGap; const channelY = this.channelFor(ex, tb.x - 20, base); @@ -585,7 +584,7 @@ export class ChannelRouter { ]; }; const overWay = (way: Point[], runY: number, x1: number, end: OverEnd, below?: boolean): Way | undefined => { - if (pathHits(way, this.boxes, ignore) || this.onRouted(way)) return undefined; + if (pathHitsAny(way, this.boxes, ignore) || this.onRouted(way)) return undefined; const spare = end.top !== undefined ? this.gatewaySpots.get(target) : undefined; return { pts: way, ...(below ? { below } : {}), over: { y: runY, from: Math.min(x1, end.to), to: Math.max(x1, end.to) }, ...(end.top !== undefined ? { top: end.top } : {}), ...(spare ? { spare } : {}) }; }; diff --git a/src/domain/diagram/layout/clean/CollaborationArtifacts.ts b/src/domain/diagram/layout/clean/CollaborationArtifacts.ts index aae4ec98..8109836a 100644 --- a/src/domain/diagram/layout/clean/CollaborationArtifacts.ts +++ b/src/domain/diagram/layout/clean/CollaborationArtifacts.ts @@ -1,12 +1,11 @@ -import { is, many, type El, idOf } from '../../../document/utils/moddle.js'; +import { enclosingProcess, is, many, type El, idOf } from '../../../document/utils/moddle.js'; import { SIZES, annotationSize } from '../../utils/sizes.js'; -import { center, borderPoint } from '../../utils/geom.js'; +import { center, borderPoint, type Segment } from '../../utils/geom.js'; import type { Box, Point } from '../../di/types.js'; import type { EngineWarning } from './CleanEngine.js'; import type { DiWriter } from './DiWriter.js'; import { NotePlacer, type NoteJob } from './NotePlacer.js'; import type { PoolLayout } from './PoolStacker.js'; -import type { Segment } from '../../utils/geometry.js'; import { laneDepth } from '../../utils/lanes.js'; import { isContainerShape, poolOf } from '../../utils/pools.js'; @@ -134,8 +133,7 @@ export class CollaborationArtifacts { private laneBoxOf(el: El): Box | undefined { const { absBoxes } = this.writer; - let process: El | undefined = el.$parent as El | undefined; - while (process && !is(process, 'bpmn:Process')) process = process.$parent as El | undefined; + const process = enclosingProcess(el.$parent as El | undefined); if (!process) return undefined; const visit = (laneSet: El | undefined): Box | undefined => { for (const lane of laneSet ? many(laneSet, 'lanes') : []) { diff --git a/src/domain/diagram/layout/clean/LabelDeclutter.ts b/src/domain/diagram/layout/clean/LabelDeclutter.ts index 1a94d989..2722f70a 100644 --- a/src/domain/diagram/layout/clean/LabelDeclutter.ts +++ b/src/domain/diagram/layout/clean/LabelDeclutter.ts @@ -1,8 +1,7 @@ import { titleBox } from '../../utils/text.js'; -import { overlaps } from '../../utils/geom.js'; +import { overlaps, segmentHitsBox, toSegments, type Segment } from '../../utils/geom.js'; import type { Box, Point } from '../../di/types.js'; import { is, many, type El } from '../../../document/utils/moddle.js'; -import { toSegments, type Segment } from '../../utils/geometry.js'; const RUN_KEEP = 2; @@ -123,10 +122,6 @@ export class LabelDeclutter { return { x: bounds.get('x'), y: bounds.get('y'), width: bounds.get('width'), height: bounds.get('height') }; } - private hitsBox([start, end]: Segment, box: Box, shrink = 0): boolean { - return Math.min(start.x, end.x) < box.x + box.width - shrink && Math.max(start.x, end.x) > box.x + shrink && Math.min(start.y, end.y) < box.y + box.height - shrink && Math.max(start.y, end.y) > box.y + shrink; - } - private holds(frame: Box, point: Point): boolean { return point.x >= frame.x && point.x <= frame.x + frame.width && point.y >= frame.y && point.y <= frame.y + frame.height; } @@ -153,7 +148,7 @@ export class LabelDeclutter { return ( this.shapes.some((shape) => shape !== own && overlaps(shape, box, 2)) || this.placed.some((other) => overlaps(other, box, 2)) || - this.lines.some((seg) => this.hitsBox(seg, box, seg[0].y === seg[1].y ? -RUN_KEEP : 1)) || + this.lines.some(([start, end]) => segmentHitsBox(start, end, box, start.y === end.y ? -RUN_KEEP : 1)) || this.titleBoxes.some((title) => overlaps(title, box, 2)) ); } diff --git a/src/domain/diagram/layout/clean/LinkRouter.ts b/src/domain/diagram/layout/clean/LinkRouter.ts index 89d43652..a26135a9 100644 --- a/src/domain/diagram/layout/clean/LinkRouter.ts +++ b/src/domain/diagram/layout/clean/LinkRouter.ts @@ -1,7 +1,6 @@ -import { borderPoint, bottom, center, cx, right } from '../../utils/geom.js'; +import { borderPoint, bottom, center, cx, right, dedupe, hitCount, toSegments, type Segment } from '../../utils/geom.js'; import type { Box, GNode, Point, ScopeLayout } from '../../di/types.js'; import { ChannelRouter } from './ChannelRouter.js'; -import { dedupe, hitCount, toSegments, type Segment } from '../../utils/geometry.js'; import { obstacles, routedSegments } from '../../utils/scope.js'; /** Compensation links, associations and data associations. */ diff --git a/src/domain/diagram/layout/clean/MessageFlowRouter.ts b/src/domain/diagram/layout/clean/MessageFlowRouter.ts index 96927807..8d134e28 100644 --- a/src/domain/diagram/layout/clean/MessageFlowRouter.ts +++ b/src/domain/diagram/layout/clean/MessageFlowRouter.ts @@ -1,9 +1,8 @@ import { is, type El } from '../../../document/utils/moddle.js'; import { SIZES } from '../../utils/sizes.js'; -import { center } from '../../utils/geom.js'; +import { center, properCross, roundPoints, segmentHitsBox, toSegments, type Segment } from '../../utils/geom.js'; import type { Box, Point } from '../../di/types.js'; -import { properCross, toSegments, type Segment } from '../../utils/geometry.js'; -import { crossings } from '../../utils/routing.js'; +import { crossings, simplify } from '../../utils/routing.js'; export interface MessagePool { box: Box; @@ -147,7 +146,7 @@ export class MessageFlowRouter { middle = [{ x: sx, y: y1 }, { x: outX, y: y1 }, { x: outX, y: y2 }, { x: tx, y: y2 }]; channelY = y1; } - return { el: job.el, points: this.simplify([...lead, ...middle, ...tail]), ...(channelY !== undefined ? { channelY } : {}) }; + return { el: job.el, points: simplify(roundPoints([...lead, ...middle, ...tail])), ...(channelY !== undefined ? { channelY } : {}) }; }); } @@ -165,13 +164,9 @@ export class MessageFlowRouter { return { x: first.x + 6, y: labelCenterY - height / 2, width, height }; } - private cuts([start, end]: Segment, box: Box): boolean { - return Math.min(start.x, end.x) < box.x + box.width - 1 && Math.max(start.x, end.x) > box.x + 1 && Math.min(start.y, end.y) < box.y + box.height - 1 && Math.max(start.y, end.y) > box.y + 1; - } - private hits(points: Point[], boxes: Box[]): number { let hitCount = 0; - for (const box of boxes) if (toSegments(points).some((segment) => this.cuts(segment, box))) hitCount++; + for (const box of boxes) if (toSegments(points).some(([start, end]) => segmentHitsBox(start, end, box, 1))) hitCount++; return hitCount; } @@ -347,16 +342,4 @@ export class MessageFlowRouter { return { points: [{ x: dockX, y: up ? box.y : box.y + box.height }], x: dockX }; } - private simplify(points: Point[]): Point[] { - const out: Point[] = []; - for (const point of points) { - const rounded = { x: Math.round(point.x), y: Math.round(point.y) }; - const last = out[out.length - 1]; - if (last && last.x === rounded.x && last.y === rounded.y) continue; - const prev = out[out.length - 2]; - if (last && prev && ((prev.x === last.x && last.x === rounded.x) || (prev.y === last.y && last.y === rounded.y))) out.pop(); - out.push(rounded); - } - return out; - } } diff --git a/src/domain/diagram/layout/clean/NotePlacer.ts b/src/domain/diagram/layout/clean/NotePlacer.ts index a51f4b25..64847fed 100644 --- a/src/domain/diagram/layout/clean/NotePlacer.ts +++ b/src/domain/diagram/layout/clean/NotePlacer.ts @@ -1,6 +1,5 @@ import type { Box } from '../../di/types.js'; -import { overlaps } from '../../utils/geom.js'; -import type { Segment } from '../../utils/geometry.js'; +import { overlaps, segmentHitsBox, type Segment } from '../../utils/geom.js'; export interface NoteJob { width: number; @@ -42,7 +41,7 @@ export class NotePlacer { if (box.x < 0 || box.y < 0) return false; if (job.pool ? !this.inside(box, job.pool) : world.pools.some((pool) => overlaps(box, pool, 0))) return false; if ([...world.obstacles, ...taken].some((obstacle) => overlaps(box, obstacle, MARGIN))) return false; - return !world.lines.some((line) => this.lineThrough(line, box)); + return !world.lines.some(([start, end]) => segmentHitsBox(start, end, box, -2)); }; return jobs.map((job) => { const spot = job.anchor ? this.candidates(job, job.anchor).find((candidate) => free(job, candidate)) : undefined; @@ -61,10 +60,6 @@ export class NotePlacer { return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height; } - private lineThrough([start, end]: Segment, box: Box): boolean { - return Math.min(start.x, end.x) < box.x + box.width + 2 && Math.max(start.x, end.x) > box.x - 2 && Math.min(start.y, end.y) < box.y + box.height + 2 && Math.max(start.y, end.y) > box.y - 2; - } - private candidates(job: NoteJob, anchor: Box): Box[] { const { width, height } = job; const cx = anchor.x + anchor.width / 2, cy = anchor.y + anchor.height / 2; diff --git a/src/domain/diagram/layout/clean/PoolStacker.ts b/src/domain/diagram/layout/clean/PoolStacker.ts index 4d0d30ee..439efef2 100644 --- a/src/domain/diagram/layout/clean/PoolStacker.ts +++ b/src/domain/diagram/layout/clean/PoolStacker.ts @@ -7,7 +7,7 @@ import { LaneArranger } from './LaneArranger.js'; import { MessageFlowRouter, type MessageFlowJob, type MessagePool } from './MessageFlowRouter.js'; import { ScopeFrame } from './ScopeFrame.js'; import type { ScopePlacer } from './ScopePlacer.js'; -import type { Segment } from '../../utils/geometry.js'; +import type { Segment } from '../../utils/geom.js'; import { laneDepth, stretchLane } from '../../utils/lanes.js'; import { isContainerShape, poolOf } from '../../utils/pools.js'; diff --git a/src/domain/diagram/layout/clean/ScopeArtifactPlacer.ts b/src/domain/diagram/layout/clean/ScopeArtifactPlacer.ts index 1e090dd3..7a0b72d5 100644 --- a/src/domain/diagram/layout/clean/ScopeArtifactPlacer.ts +++ b/src/domain/diagram/layout/clean/ScopeArtifactPlacer.ts @@ -1,5 +1,5 @@ import { is, many, type El, idOf } from '../../../document/utils/moddle.js'; -import { METRICS, SIZES, annotationSize } from '../../utils/sizes.js'; +import { METRICS, defaultSizeOf } from '../../utils/sizes.js'; import { overlaps } from '../../utils/geom.js'; import type { Box, GArtifact, GEdge, GNode, LinkEnd, Point, ScopeLayout } from '../../di/types.js'; import { ChannelRouter } from './ChannelRouter.js'; @@ -25,11 +25,11 @@ export class ScopeArtifactPlacer { return artifact; }; for (const el of many(scope, 'flowElements')) { - if (is(el, 'bpmn:DataObjectReference')) add(el, 'dataObject', { ...SIZES.dataObject }); - else if (is(el, 'bpmn:DataStoreReference')) add(el, 'dataStore', { ...SIZES.dataStore }); + if (is(el, 'bpmn:DataObjectReference')) add(el, 'dataObject', defaultSizeOf(el)); + else if (is(el, 'bpmn:DataStoreReference')) add(el, 'dataStore', defaultSizeOf(el)); } for (const el of many(scope, 'artifacts')) { - if (is(el, 'bpmn:TextAnnotation')) add(el, 'annotation', annotationSize(el.get('text'))); + if (is(el, 'bpmn:TextAnnotation')) add(el, 'annotation', defaultSizeOf(el)); } const flowsById = new Map(layout.edges.map((edge) => [edge.id, edge])); const flowEnds = new Map(); diff --git a/src/domain/diagram/layout/clean/ScopeGraph.ts b/src/domain/diagram/layout/clean/ScopeGraph.ts index 52b1e77f..3adda2d0 100644 --- a/src/domain/diagram/layout/clean/ScopeGraph.ts +++ b/src/domain/diagram/layout/clean/ScopeGraph.ts @@ -1,5 +1,5 @@ import { is, many, type El, idOf } from '../../../document/utils/moddle.js'; -import { SIZES } from '../../utils/sizes.js'; +import { defaultSizeOf, SIZES } from '../../utils/sizes.js'; import type { GEdge, GNode, ScopeLayout } from '../../di/types.js'; import { outgoingOf } from '../../utils/nodes.js'; @@ -17,7 +17,7 @@ export class ScopeGraph { for (const el of flowElements) { if (!is(el, 'bpmn:FlowNode') || is(el, 'bpmn:BoundaryEvent')) continue; const kind = this.kindOfNode(el); - const node: GNode = { el, id: idOf(el), ...this.sizeOfNode(el), kind, boundary: [], layer: 0, row: 0, band: 0, box: { x: 0, y: 0, width: 0, height: 0 }, out: [], in: [] }; + const node: GNode = { el, id: idOf(el), ...defaultSizeOf(el), kind, boundary: [], layer: 0, row: 0, band: 0, box: { x: 0, y: 0, width: 0, height: 0 }, out: [], in: [] }; if (kind === 'subProcess' || kind === 'eventSubProcess') nest(node); byId.set(node.id, node); if (kind === 'eventSubProcess') eventSubs.push(node); @@ -67,12 +67,6 @@ export class ScopeGraph { this.markBackEdges(); } - private sizeOfNode(el: El): { width: number; height: number } { - if (is(el, 'bpmn:Event')) return { ...SIZES.event }; - if (is(el, 'bpmn:Gateway')) return { ...SIZES.gateway }; - return { ...SIZES.task }; - } - private kindOfNode(el: El): GNode['kind'] { if (is(el, 'bpmn:StartEvent')) return 'start'; if (is(el, 'bpmn:EndEvent')) return 'end'; diff --git a/src/domain/diagram/layout/clean/TopBorderTrial.ts b/src/domain/diagram/layout/clean/TopBorderTrial.ts index 381e124d..b544cce3 100644 --- a/src/domain/diagram/layout/clean/TopBorderTrial.ts +++ b/src/domain/diagram/layout/clean/TopBorderTrial.ts @@ -1,6 +1,6 @@ import { LayoutDebug } from '../LayoutDebug.js'; import { titleBox } from '../../utils/text.js'; -import { center, equalBoxes, overlaps, segmentHitsBox } from '../../utils/geom.js'; +import { center, equalBoxes, overlaps, segmentHitsBox, hitCount, properCross, toSegments, type Segment } from '../../utils/geom.js'; import type { Box, GEdge, GNode, LaneBandInfo, LinkEnd, Point } from '../../di/types.js'; import { is, many, type El, idOf } from '../../../document/utils/moddle.js'; import { ArrangementCheckpoint } from './ArrangementCheckpoint.js'; @@ -9,7 +9,6 @@ import { LinkRouter } from './LinkRouter.js'; import { ScopeGrid } from './ScopeGrid.js'; import { ScopeLabels } from './ScopeLabels.js'; import { XIndex } from './XIndex.js'; -import { hitCount, properCross, toSegments, type Segment } from '../../utils/geometry.js'; import { nameOf } from '../../utils/names.js'; import { obstacles, routedArtifacts } from '../../utils/scope.js'; diff --git a/src/domain/diagram/layout/incremental/NodePlacer.ts b/src/domain/diagram/layout/incremental/NodePlacer.ts index 967914b6..1b8aef22 100644 --- a/src/domain/diagram/layout/incremental/NodePlacer.ts +++ b/src/domain/diagram/layout/incremental/NodePlacer.ts @@ -1,6 +1,6 @@ import { LayoutDebug } from '../LayoutDebug.js'; -import { annotationSize, SIZES } from '../../utils/sizes.js'; -import { is, type El } from '../../../document/utils/moddle.js'; +import { defaultSizeOf } from '../../utils/sizes.js'; +import { is } from '../../../document/utils/moddle.js'; import { bottom, clamp, cx, cy, median, right, translateEdge, translateShape, type Box } from '../../utils/geom.js'; import { defaultLabelSide, hasExternalLabel, labelAt } from '../../utils/labels.js'; import { idOf, raw } from '../../utils/raw.js'; @@ -156,15 +156,6 @@ export class NodePlacer { return spacing; } - private defaultSize(el: El): { width: number; height: number } { - if (is(el, 'bpmn:Event')) return { ...SIZES.event }; - if (is(el, 'bpmn:Gateway')) return { ...SIZES.gateway }; - if (is(el, 'bpmn:DataObjectReference')) return { ...SIZES.dataObject }; - if (is(el, 'bpmn:DataStoreReference')) return { ...SIZES.dataStore }; - if (is(el, 'bpmn:TextAnnotation')) return annotationSize(raw(el, 'text')); - return { ...SIZES.task }; - } - /** * Size of a new shape: the median of its kind on the plane, else the engine default. * A relocated node keeps its size. Events and boundary events count as one kind, so do tasks and @@ -179,13 +170,13 @@ export class NodePlacer { const el = this.sem.byId.get(id)!; const kind = DiagramReader.shapeKind(el); if (kind === 'subProcess' && this.expandedByDefault(id)) return { width: 350, height: 200 }; - if (kind === 'annotation') return this.defaultSize(el); + if (kind === 'annotation') return defaultSizeOf(el); const family = (shapeKind: ShapeKind): string => (shapeKind === 'boundary' ? 'event' : shapeKind === 'subProcess' ? 'task' : shapeKind); const sameKind = (shape: DShape): boolean => !shape.container && family(shape.kind) === family(kind) && (kind !== 'data' || shape.el.$type === el.$type); const peers = [...plane.shapes.values()].filter(sameKind); const medianWidth = median(peers.map((shape) => shape.bounds.width)); const medianHeight = median(peers.map((shape) => shape.bounds.height)); - const fallback = this.defaultSize(el); + const fallback = defaultSizeOf(el); if (medianWidth === undefined || medianHeight === undefined) return fallback; return { width: Math.round(clamp(medianWidth, fallback.width * 0.6, fallback.width * 1.6)), height: Math.round(clamp(medianHeight, fallback.height * 0.6, fallback.height * 1.6)) }; } @@ -202,23 +193,13 @@ export class NodePlacer { newShape(plane: Plane, id: string, bounds: Box): DShape { const reuse = this.reuse.get(id); const el = this.sem.byId.get(id)!; - const kind = DiagramReader.shapeKind(el); - const expanded = kind === 'subProcess' ? (reuse ? reuse.shape.expanded === true : this.expandedByDefault(id) || this.blockSize.has(id)) : undefined; - const shape: DShape = { id, el, bounds, kind, container: kind === 'participant' || kind === 'lane' || (kind === 'subProcess' && expanded === true) }; + const expanded = Semantics.isSubProcess(el) ? (reuse ? reuse.shape.expanded === true : this.expandedByDefault(id) || this.blockSize.has(id)) : undefined; + const shape = DiagramReader.shapeOf(this.sem, id, el, bounds, expanded); if (reuse?.shape.di) shape.di = reuse.shape.di; else if (!reuse) { const di = this.planes.map((candidate) => candidate.unbounded?.get(id)).find((unbounded) => !!unbounded); if (di) shape.di = di; } - if (expanded !== undefined) shape.expanded = expanded; - if (kind === 'boundary') { - const host = idOf(raw(el, 'attachedToRef')); - if (host) shape.hostId = host; - } - const lane = kind === 'lane' ? this.sem.laneParent.get(id) : this.sem.laneOf.get(id); - if (lane) shape.laneId = lane; - const pool = this.sem.poolOf(id); - if (pool) shape.poolId = pool; const scope = this.sem.scopeOf.get(id); const sid = idOf(scope); if (scope && sid && Semantics.isSubProcess(scope) && plane.shapes.get(sid)?.expanded) shape.parentId = sid; diff --git a/src/domain/diagram/types.ts b/src/domain/diagram/types.ts index 4b6b5c77..aaed50b9 100644 --- a/src/domain/diagram/types.ts +++ b/src/domain/diagram/types.ts @@ -1,8 +1,8 @@ -import type { LayoutWarningInfo, LayoutEngine } from './layout/FullLayout.js'; +import type { LayoutWarningInfo } from './layout/FullLayout.js'; import type { MetricsDelta } from './utils/problems.js'; -import type { FormatResult } from './DiagramCommandService.js'; +import type { DiagramCommandResult } from './commands/DiagramCommands.js'; -/** How a batch draws: its layout mode, engine and the sub-processes to expand or collapse. */ +/** How a batch draws: its layout mode and the sub-processes to expand or collapse. */ export interface DrawingOptions { /** * false skips the layout; true / 'auto' (default) keeps a hand-made drawing and redraws @@ -12,8 +12,6 @@ export interface DrawingOptions { /** additional sub-process ids to expand / collapse */ expand?: string[]; collapse?: string[]; - /** 'clean' (default) or 'auto' (bpmn-auto-layout) */ - engine?: LayoutEngine; } export type LayoutMode = 'auto' | 'incremental' | 'full'; @@ -39,7 +37,7 @@ export interface LayoutStatus { /** layout problems before / after, and the difference */ metrics?: MetricsDelta; /** in batch order */ - format?: FormatResult[]; + format?: DiagramCommandResult[]; /** design-iq stickies moved along with their flow node (`#` without id) */ stickies?: Array<{ sticky: string; node: string }>; } diff --git a/src/domain/diagram/utils/di-ids.ts b/src/domain/diagram/utils/di-ids.ts index e681b45a..994cebca 100644 --- a/src/domain/diagram/utils/di-ids.ts +++ b/src/domain/diagram/utils/di-ids.ts @@ -4,7 +4,7 @@ */ import { cutAt, MAX_ID } from '../../document/utils/slug.js'; import { is, type El } from '../../document/utils/moddle.js'; -import { idOf, raw, rawList } from './raw.js'; +import { idOf, raw, rawList, rawTree } from './raw.js'; export type Style = 'suffix' | 'bpmn' | 'short'; @@ -17,23 +17,9 @@ function styleOf(diId: string, elId: string, kind: 'Shape' | 'Edge'): Style | un export function allIds(defs: El): Set { const ids = new Set(); - const stack: El[] = [defs]; - const seen = new Set(); - while (stack.length) { - const el = stack.pop()!; - if (seen.has(el)) continue; - seen.add(el); + for (const el of rawTree(defs)) { const id = idOf(el); if (id) ids.add(id); - const descriptor = el.$descriptor as { isGeneric?: boolean; properties?: Array<{ name: string; isReference?: boolean }> }; - if (descriptor.isGeneric) continue; - for (const property of descriptor.properties ?? []) { - if (property.isReference) continue; - const value = raw(el, property.name); - if (Array.isArray(value)) { - for (const child of value) if (child && typeof child === 'object' && '$type' in child) stack.push(child as El); - } else if (value && typeof value === 'object' && '$type' in value) stack.push(value as El); - } } return ids; } @@ -97,12 +83,6 @@ export function namesRoot(kind: 'Plane' | 'Diagram', diId: string | undefined, r return diId === `BPMN${kind}_${root}` || diId === rootDiId(kind, root); } -/** Then DiIdMemo keeps the engine's ids for a drawing without old DI. */ -export function diIdsFit(defs: El): boolean { - for (const id of allIds(defs)) if (id.length > MAX_DI - 'BPMNDiagram_'.length) return false; - return true; -} - export interface IdClaims { has(id: string): boolean; claim(id: string): void; diff --git a/src/domain/diagram/utils/expansion.ts b/src/domain/diagram/utils/expansion.ts index 73093f9e..539da6b5 100644 --- a/src/domain/diagram/utils/expansion.ts +++ b/src/domain/diagram/utils/expansion.ts @@ -3,7 +3,7 @@ import { is, many, walk, type El, type Model, idOf } from '../../document/utils/ export const SUB_PROCESS_TYPES = ['bpmn:SubProcess', 'bpmn:AdHocSubProcess', 'bpmn:Transaction']; -export function subProcesses(root: El): El[] { +function subProcesses(root: El): El[] { return [...walk(root)].filter((element) => SUB_PROCESS_TYPES.some((type) => is(element, type)) && !!element.get('id')); } diff --git a/src/domain/diagram/utils/geom.ts b/src/domain/diagram/utils/geom.ts index 84c2dc0b..84257c67 100644 --- a/src/domain/diagram/utils/geom.ts +++ b/src/domain/diagram/utils/geom.ts @@ -1,8 +1,11 @@ /** Pure geometry on plain boxes and points in diagram pixels, y growing downwards. */ import type { Box, Point } from '../di/types.js'; +import type { XIndex } from '../layout/clean/XIndex.js'; export type { Box, Point }; +export type Segment = [Point, Point]; + export function center(box: Box): Point { return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } @@ -92,7 +95,7 @@ function slab(origin: number, direction: number, lo: number, hi: number): [numbe * so a straight association passing beside a shape does not hit it. */ export function segmentHits(start: Point, end: Point, box: Box, shrink = 2): boolean { - const boxHit = Math.min(start.x, end.x) < right(box) - shrink && Math.max(start.x, end.x) > box.x + shrink && Math.min(start.y, end.y) < bottom(box) - shrink && Math.max(start.y, end.y) > box.y + shrink; + const boxHit = segmentHitsBox(start, end, box, shrink); if (!boxHit || start.x === end.x || start.y === end.y) return boxHit; if (right(box) - shrink <= box.x + shrink || bottom(box) - shrink <= box.y + shrink) return false; const [ax, bx] = slab(start.x, end.x - start.x, box.x + shrink, right(box) - shrink); @@ -103,8 +106,8 @@ export function segmentHits(start: Point, end: Point, box: Box, shrink = 2): boo } /** Touching the border does not count. */ -export function segmentHitsBox(start: Point, end: Point, box: Box): boolean { - return Math.min(start.x, end.x) < right(box) && Math.max(start.x, end.x) > box.x && Math.min(start.y, end.y) < bottom(box) && Math.max(start.y, end.y) > box.y; +export function segmentHitsBox(start: Point, end: Point, box: Box, shrink = 0): boolean { + return Math.min(start.x, end.x) < right(box) - shrink && Math.max(start.x, end.x) > box.x + shrink && Math.min(start.y, end.y) < bottom(box) - shrink && Math.max(start.y, end.y) > box.y + shrink; } const orient = (first: Point, second: Point, third: Point): number => Math.sign((second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x)); @@ -118,6 +121,11 @@ export function segmentsCross(firstStart: Point, firstEnd: Point, secondStart: P return o1 !== o2 && o3 !== o4 && !!o1 && !!o2 && !!o3 && !!o4; } +/** shared endpoints and collinear overlaps do not count */ +export function properCross([firstStart, firstEnd]: Segment, [secondStart, secondEnd]: Segment): boolean { + return segmentsCross(firstStart, firstEnd, secondStart, secondEnd); +} + export function polylinesCross(first: readonly Point[], second: readonly Point[]): boolean { for (let firstIndex = 0; firstIndex + 1 < first.length; firstIndex++) { for (let secondIndex = 0; secondIndex + 1 < second.length; secondIndex++) if (segmentsCross(first[firstIndex]!, first[firstIndex + 1]!, second[secondIndex]!, second[secondIndex + 1]!)) return true; @@ -130,6 +138,56 @@ export function pathHits(points: readonly Point[], box: Box, shrink = 2): boolea return false; } +export function toSegments(points: Point[]): Segment[] { + const out: Segment[] = []; + for (let index = 0; index + 1 < points.length; index++) out.push([points[index]!, points[index + 1]!]); + return out; +} + +export function dedupe(points: Point[]): Point[] { + return points.filter((point, index) => index === 0 || point.x !== points[index - 1]!.x || point.y !== points[index - 1]!.y); +} + +interface Span { + x1: number; + x2: number; + y1: number; + y2: number; +} + +function innerSpan(start: Point, end: Point): Span { + const dx = Math.sign(end.x - start.x), dy = Math.sign(end.y - start.y); + const ax = start.x + dx * 2, ay = start.y + dy * 2, bx = end.x - dx * 2, by = end.y - dy * 2; + return { x1: Math.min(ax, bx), x2: Math.max(ax, bx), y1: Math.min(ay, by), y2: Math.max(ay, by) }; +} + +function spanCuts({ x1, x2, y1, y2 }: Span, box: Box): boolean { + return x1 < box.x + box.width && x2 > box.x && y1 < box.y + box.height && y2 > box.y; +} + +/** touching a box's own border does not count */ +export function hitCount(points: Point[], boxes: Box[] | XIndex, ignore: Box[]): number { + let hit: Set | undefined; + for (let index = 0; index + 1 < points.length; index++) { + const span = innerSpan(points[index]!, points[index + 1]!); + const test = (box: Box): void => { + if (spanCuts(span, box) && !ignore.includes(box)) (hit ??= new Set()).add(box); + }; + if (Array.isArray(boxes)) boxes.forEach(test); + else boxes.each(span.x1, span.x2, test); + } + return hit?.size ?? 0; +} + +export function pathHitsAny(points: Point[], boxes: Box[] | XIndex, ignore: Box[]): boolean { + for (let index = 0; index + 1 < points.length; index++) { + const span = innerSpan(points[index]!, points[index + 1]!); + const cuts = (box: Box): boolean => spanCuts(span, box) && !ignore.includes(box); + if (Array.isArray(boxes) ? boxes.some(cuts) : boxes.each(span.x1, span.x2, cuts)) return true; + } + return false; +} + export function isOrthogonal(points: readonly Point[], tol = 0.5): boolean { for (let index = 0; index + 1 < points.length; index++) { const start = points[index]!; diff --git a/src/domain/diagram/utils/geometry.ts b/src/domain/diagram/utils/geometry.ts deleted file mode 100644 index 001f62f3..00000000 --- a/src/domain/diagram/utils/geometry.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Box, Point } from '../di/types.js'; -import { segmentsCross } from './geom.js'; -import type { XIndex } from '../layout/clean/XIndex.js'; - -export type Segment = [Point, Point]; - -export function toSegments(points: Point[]): Segment[] { - const out: Segment[] = []; - for (let index = 0; index + 1 < points.length; index++) out.push([points[index]!, points[index + 1]!]); - return out; -} - -export function dedupe(points: Point[]): Point[] { - return points.filter((point, index) => index === 0 || point.x !== points[index - 1]!.x || point.y !== points[index - 1]!.y); -} - -/** touching a box's own border does not count */ -export function hitCount(points: Point[], boxes: Box[] | XIndex, ignore: Box[]): number { - let hit: Set | undefined; - for (let index = 0; index + 1 < points.length; index++) { - const start = points[index]!, end = points[index + 1]!; - const dx = Math.sign(end.x - start.x), dy = Math.sign(end.y - start.y); - const ax = start.x + dx * 2, ay = start.y + dy * 2, bx = end.x - dx * 2, by = end.y - dy * 2; - const x1 = Math.min(ax, bx), x2 = Math.max(ax, bx), y1 = Math.min(ay, by), y2 = Math.max(ay, by); - const test = (box: Box): void => { - if (x1 < box.x + box.width && x2 > box.x && y1 < box.y + box.height && y2 > box.y && !ignore.includes(box)) (hit ??= new Set()).add(box); - }; - if (Array.isArray(boxes)) boxes.forEach(test); - else boxes.each(x1, x2, test); - } - return hit?.size ?? 0; -} - -export function pathHits(points: Point[], boxes: Box[] | XIndex, ignore: Box[]): boolean { - for (let index = 0; index + 1 < points.length; index++) { - const start = points[index]!, end = points[index + 1]!; - const dx = Math.sign(end.x - start.x), dy = Math.sign(end.y - start.y); - const ax = start.x + dx * 2, ay = start.y + dy * 2, bx = end.x - dx * 2, by = end.y - dy * 2; - const x1 = Math.min(ax, bx), x2 = Math.max(ax, bx), y1 = Math.min(ay, by), y2 = Math.max(ay, by); - const cuts = (box: Box): boolean => x1 < box.x + box.width && x2 > box.x && y1 < box.y + box.height && y2 > box.y && !ignore.includes(box); - if (Array.isArray(boxes) ? boxes.some(cuts) : boxes.each(x1, x2, cuts)) return true; - } - return false; -} - -/** shared endpoints and collinear overlaps do not count */ -export function properCross([firstStart, firstEnd]: Segment, [secondStart, secondEnd]: Segment): boolean { - return segmentsCross(firstStart, firstEnd, secondStart, secondEnd); -} diff --git a/src/domain/diagram/utils/harness.ts b/src/domain/diagram/utils/harness.ts index 3091dce8..1dc981da 100644 --- a/src/domain/diagram/utils/harness.ts +++ b/src/domain/diagram/utils/harness.ts @@ -1,13 +1,12 @@ -import { cx } from './geom.js'; +import { cx, toSegments, type Segment } from './geom.js'; /** * Geometry and lenient DI reading of the layout metrics, matching the regression harness: * every shape and edge counts and bounds and waypoints are taken without a finite check. */ import { is, type El } from '../../document/utils/moddle.js'; -import type { Box, Point } from '../di/types.js'; +import type { Box, DEdge, DShape, Point } from '../di/types.js'; import { idOf, raw, rawList } from './raw.js'; import { titleBox } from './text.js'; -import { toSegments, type Segment } from './geometry.js'; export const hits = ([start, end]: Segment, box: Box, shrink = 2): boolean => Math.min(start.x, end.x) < box.x + box.width - shrink && @@ -57,30 +56,13 @@ export interface MetricIndex { next: Map; } -export function enclosingProcess(scope: El | undefined): El | undefined { - let current: El | undefined = scope; - while (current && !is(current, 'bpmn:Process')) current = current.$parent as El | undefined; - return current; -} - -export interface Shape { - id: string; - el: El; - bounds: Box; - container: boolean; - expanded: boolean; - label?: Box; -} +export type Shape = Pick & Required>; -export interface Edge { - id: string; - el: El; - pts: Point[]; +export type Edge = Pick & { segs: Segment[]; /** `undefined` is an unresolved end and makes edges related, as in the harness */ ends: Set; - label?: Box; -} +}; function endsOf(el: El): Set { const source = raw(el, 'sourceRef'); @@ -107,8 +89,8 @@ export function readDrawnPlane(plane: unknown, seen: Set): { shapes: Sha const container = (is(el, 'bpmn:SubProcess') && expanded) || is(el, 'bpmn:Participant') || is(el, 'bpmn:Lane'); shapes.push({ id, el, bounds, container, expanded, ...(label ? { label } : {}) }); } else if (is(pe, 'bpmndi:BPMNEdge')) { - const pts = rawList(pe, 'waypoint').map((waypoint) => ({ x: raw(waypoint, 'x')!, y: raw(waypoint, 'y')! })); - edges.push({ id, el, pts, segs: toSegments(pts), ends: endsOf(el), ...(label ? { label } : {}) }); + const points = rawList(pe, 'waypoint').map((waypoint) => ({ x: raw(waypoint, 'x')!, y: raw(waypoint, 'y')! })); + edges.push({ id, el, points, segs: toSegments(points), ends: endsOf(el), ...(label ? { label } : {}) }); } } return { shapes, edges }; diff --git a/src/domain/diagram/utils/pools.ts b/src/domain/diagram/utils/pools.ts index 13b55d84..88223b6a 100644 --- a/src/domain/diagram/utils/pools.ts +++ b/src/domain/diagram/utils/pools.ts @@ -1,11 +1,10 @@ -import { is, type El } from '../../document/utils/moddle.js'; +import { enclosingProcess, is, type El } from '../../document/utils/moddle.js'; import type { PoolLayout } from '../layout/clean/PoolStacker.js'; export function poolOf(pools: PoolLayout[], el: El): PoolLayout | undefined { if (is(el, 'bpmn:Participant')) return pools.find((pool) => pool.participant === el); - let parent: El | undefined = el.$parent as El | undefined; - while (parent && !is(parent, 'bpmn:Process')) parent = parent.$parent as El | undefined; - return parent ? pools.find((pool) => pool.process === parent) : undefined; + const process = enclosingProcess(el.$parent as El | undefined); + return process ? pools.find((pool) => pool.process === process) : undefined; } export function isContainerShape(diElement: El, el: El): boolean { diff --git a/src/domain/diagram/utils/raw.ts b/src/domain/diagram/utils/raw.ts index 004013e4..90ab2bab 100644 --- a/src/domain/diagram/utils/raw.ts +++ b/src/domain/diagram/utils/raw.ts @@ -13,6 +13,27 @@ export const rawList = (el: unknown, prop: string): El[] => raw(el, prop) export const idOf = (el: unknown): string | undefined => raw(el, 'id'); +export function* rawTree(root: El, skip: (el: El) => boolean = () => false): Generator { + const stack: El[] = [root]; + const seen = new Set(); + while (stack.length) { + const el = stack.pop()!; + if (seen.has(el)) continue; + seen.add(el); + if (skip(el)) continue; + yield el; + const descriptor = el.$descriptor as { isGeneric?: boolean; properties?: Array<{ name: string; isReference?: boolean }> }; + if (descriptor.isGeneric) continue; + for (const property of descriptor.properties ?? []) { + if (property.isReference) continue; + const value = raw(el, property.name); + if (Array.isArray(value)) { + for (const child of value) if (child && typeof child === 'object' && '$type' in child) stack.push(child as El); + } else if (value && typeof value === 'object' && '$type' in value) stack.push(value as El); + } + } +} + /** The Box of a dc:Bounds; undefined when a coordinate is missing or not finite. */ export function boxOf(bounds: unknown): Box | undefined { if (!bounds) return undefined; diff --git a/src/domain/diagram/utils/scope.ts b/src/domain/diagram/utils/scope.ts index e4fbd336..58c556d2 100644 --- a/src/domain/diagram/utils/scope.ts +++ b/src/domain/diagram/utils/scope.ts @@ -1,6 +1,5 @@ import type { Box, GArtifact, GNode, LinkEnd, ScopeLayout } from '../di/types.js'; -import { toSegments, type Segment } from './geometry.js'; -import { translate, translateEdge, translatePoints } from './geom.js'; +import { toSegments, translate, translateEdge, translatePoints, type Segment } from './geom.js'; /** data objects and notes of boundary events are placed before routing */ export function routedArtifacts(layout: ScopeLayout): GArtifact[] { diff --git a/src/domain/diagram/utils/frames.ts b/src/domain/diagram/utils/shape-sets.ts similarity index 100% rename from src/domain/diagram/utils/frames.ts rename to src/domain/diagram/utils/shape-sets.ts diff --git a/src/domain/diagram/utils/sizes.ts b/src/domain/diagram/utils/sizes.ts index 75e5d498..cb9184ca 100644 --- a/src/domain/diagram/utils/sizes.ts +++ b/src/domain/diagram/utils/sizes.ts @@ -1,4 +1,7 @@ /** The engine's default shape sizes and gaps, shared by both layout engines. */ +import { is, type El } from '../../document/utils/moddle.js'; +import { raw } from './raw.js'; + export interface LayoutGaps { hGap: number; vGap: number; @@ -29,6 +32,15 @@ export const SIZES = { outerMargin: 80, } as const; +export function defaultSizeOf(el: El): { width: number; height: number } { + if (is(el, 'bpmn:Event')) return { ...SIZES.event }; + if (is(el, 'bpmn:Gateway')) return { ...SIZES.gateway }; + if (is(el, 'bpmn:DataObjectReference')) return { ...SIZES.dataObject }; + if (is(el, 'bpmn:DataStoreReference')) return { ...SIZES.dataStore }; + if (is(el, 'bpmn:TextAnnotation')) return annotationSize(raw(el, 'text')); + return { ...SIZES.task }; +} + export function annotationSize(text: string | undefined): { width: number; height: number } { const len = (text ?? '').length; const width = Math.min(300, Math.max(100, Math.ceil((len * 6.5 + 24) / 20) * 20)); diff --git a/src/domain/document/CliError.ts b/src/domain/document/BpmnError.ts similarity index 86% rename from src/domain/document/CliError.ts rename to src/domain/document/BpmnError.ts index b5bc17b8..d7144dd9 100644 --- a/src/domain/document/CliError.ts +++ b/src/domain/document/BpmnError.ts @@ -1,3 +1,5 @@ +import type { Finding } from './Finding.js'; + export type ErrorCategory = 'usage' | 'model' | 'layout' | 'io' | 'internal'; export const EXIT_CODES: Record = { @@ -20,7 +22,7 @@ export interface ErrorDetails { } /** An error with a stable code; its category decides the exit code (warnings still exit 0). */ -export class CliError extends Error { +export class BpmnError extends Error { constructor( public readonly code: string, message: string, @@ -41,10 +43,4 @@ export class CliError extends Error { } /** A non-fatal finding attached to a result. */ -export interface Warning { - code: string; - message: string; - element?: string; - related?: string[]; - hint?: string; -} +export type Warning = Finding; diff --git a/src/domain/document/ChangeSet.ts b/src/domain/document/ChangeSet.ts index 74be56f1..816e01c7 100644 --- a/src/domain/document/ChangeSet.ts +++ b/src/domain/document/ChangeSet.ts @@ -1,6 +1,6 @@ -import type { Warning } from './CliError.js'; -import { kindLabel, kindOf } from './utils/kinds.js'; -import { idOf, is, nameOf, type El } from './utils/moddle.js'; +import type { Finding } from './Finding.js'; +import { kindOrTypeLabel } from './Kinds.js'; +import { idOf, nameOf, type El } from './utils/moddle.js'; import { renamedNote } from './utils/renames.js'; export interface Change { @@ -47,7 +47,7 @@ export class ChangeSet { static entryOf(el: El, detail?: string): Change { const name = nameOf(el); - return ChangeSet.about({ id: el.get('id') ?? '', kind: ChangeSet.kindOf(el), ...(name ? { name } : {}), ...(detail ? { detail } : {}) }, el); + return ChangeSet.about({ id: el.get('id') ?? '', kind: kindOrTypeLabel(el), ...(name ? { name } : {}), ...(detail ? { detail } : {}) }, el); } static flowEntryOf(flow: El, detail?: string): { id: string; kind: string; name?: string; detail: string } { @@ -65,16 +65,6 @@ export class ChangeSet { ); } - private static kindOf(el: El): string { - if (kindOf(el)) return kindLabel(el); - if (is(el, 'bpmn:SequenceFlow')) return 'sequenceFlow'; - if (is(el, 'bpmn:MessageFlow')) return 'messageFlow'; - if (is(el, 'bpmn:DataAssociation')) return 'dataAssociation'; - if (is(el, 'bpmn:Association')) return 'association'; - const local = el.$type.split(':')[1] ?? el.$type; - return local.charAt(0).toLowerCase() + local.slice(1); - } - /** A rename recorded for `next` also renames the entries an earlier op made for `old`. */ static recordReplacement(old: El, next: El): void { if (old !== next) ChangeSet.successors.set(old, next); @@ -92,7 +82,7 @@ export class ChangeSet { created: Change[] = []; changed: Change[] = []; removed: Change[] = []; - warnings: Warning[] = []; + warnings: Finding[] = []; notes: string[] = []; renames: Rename[] = []; bindings: Array<{ alias: string; el: El }> = []; @@ -116,7 +106,7 @@ export class ChangeSet { return this; } - warn(warning: Warning): this { + warn(warning: Finding): this { this.warnings.push(warning); return this; } diff --git a/src/domain/document/Doc.ts b/src/domain/document/Doc.ts index e686ba1e..9a456d36 100644 --- a/src/domain/document/Doc.ts +++ b/src/domain/document/Doc.ts @@ -3,17 +3,17 @@ import type { BpmnModdle } from 'bpmn-moddle'; import { modelError, ioError, usageError } from './utils/errors.js'; import { hasIdWords, isValidId } from './utils/slug.js'; import { IdRegistry } from './ids/IdRegistry.js'; -import type { IdConventions } from './ids/IdConventionsReader.js'; +import type { IdConventions } from './ids/IdConventions.js'; import { IdStyle } from './ids/IdStyle.js'; import { typeRequest } from './utils/requests.js'; import { speakingStem } from './utils/id-words.js'; -import type { BatchContext } from './EditContext.js'; +import type { BatchContext } from './EditSession.js'; import { LayoutRequests } from './LayoutRequests.js'; -import { kindLabel } from './utils/kinds.js'; +import { kindLabel } from './Kinds.js'; import { IdSuggestions } from './IdSuggestions.js'; import { completeMirrorLists, takeMirrorSnapshot, type MirrorSnapshot } from './utils/mirror.js'; import { allLanes } from './utils/lanes.js'; -import { createDefinitions, createModdle, indexById, is, isBpmnElement, many, parseXml, processes as rootProcesses, collaboration as rootCollaboration, layoutRoot as pickLayoutRoot, serialize, walk, type El, type Model } from './utils/moddle.js'; +import { createDefinitions, createModdle, enclosingProcess, indexById, is, isBpmnElement, many, parseXml, processes as rootProcesses, collaboration as rootCollaboration, layoutRoot as pickLayoutRoot, serialize, walk, type El, type Model } from './utils/moddle.js'; import { ModelError } from './ModelError.js'; import { C7_DEFAULT_TTL, C7_PLATFORM_VERSION } from './vendor/CamundaDescriptor.js'; import { C8_PLATFORM_VERSION } from './vendor/ZeebeDescriptor.js'; @@ -381,9 +381,7 @@ export class Doc { } processOf(el: El): El | undefined { - let ancestor: El | undefined = is(el, 'bpmn:Process') ? el : (el.$parent as El | undefined); - while (ancestor && !is(ancestor, 'bpmn:Process')) ancestor = ancestor.$parent as El | undefined; - return ancestor; + return enclosingProcess(el); } defaultScope(): El { diff --git a/src/domain/document/EditContext.ts b/src/domain/document/EditSession.ts similarity index 97% rename from src/domain/document/EditContext.ts rename to src/domain/document/EditSession.ts index 333ff966..56c2c315 100644 --- a/src/domain/document/EditContext.ts +++ b/src/domain/document/EditSession.ts @@ -1,4 +1,4 @@ -import type { IdConventions } from './ids/IdConventionsReader.js'; +import type { IdConventions } from './ids/IdConventions.js'; import { IdStyle, type IdState } from './ids/IdStyle.js'; import { ChangeSet, type Rename } from './ChangeSet.js'; import type { Doc } from './Doc.js'; @@ -59,7 +59,7 @@ export interface EditSettings { * The state of one edit of a Doc: the call's settings and what the running batch (or a single * op) recorded so far (ids given out, renames, kept flows, former flow ends, dropped content). */ -export class EditContext { +export class EditSession { batch: BatchContext | undefined; private flows: ReadonlyMap | undefined; private renames: Rename[] = []; @@ -72,7 +72,7 @@ export class EditContext { readonly allocator: IdAllocator; constructor( - private readonly doc: Doc, + readonly doc: Doc, private readonly settings: EditSettings = {}, ) { this.allocator = new IdAllocator(doc, () => this.idStyle); @@ -211,7 +211,7 @@ export class EditContext { return out; } - /** The batch (EditBatchService) refuses dropped content with E_WOULD_DROP_CONTENT. */ + /** The batch (EditBatch) refuses dropped content with E_WOULD_DROP_CONTENT. */ recordDroppedContent(drop: DroppedContent): void { this.dropped.push(drop); } diff --git a/src/domain/document/Finding.ts b/src/domain/document/Finding.ts new file mode 100644 index 00000000..f96f261a --- /dev/null +++ b/src/domain/document/Finding.ts @@ -0,0 +1,44 @@ +export type ValidatorSeverity = 'error' | 'warning'; + +/** + * deploy: the engines refuse the deployment; runtime: it deploys, but the setting is ignored + * or fails when the process runs; practice: works, but the engine warns or the model is fragile. + */ +export type Severity = 'deploy' | 'runtime' | 'practice'; + +/** + * One finding about the model: a structural error or warning, an engine profile's finding or a + * validator's. The read-only fields are not enumerable, so they never show up in JSON output. + */ +export interface Finding { + code: string; + message: string; + element?: string; + related?: string[]; + hint?: string; + severity?: Severity | ValidatorSeverity; + validator?: string; + /** identity for the before / after comparison (default: code + element, else code + message) */ + readonly key?: string; + /** a write does not compare this finding but counts it under this reason; a check reports it */ + readonly leftOut?: string; + /** what exactly is wrong on the element, so a mutation can tell the findings it introduced from those the file already had */ + readonly subject?: string; + /** the warning is dropped when the profile reports all these subjects on its element */ + readonly covers?: string[]; +} + +export type ProfileFinding = Finding & { severity: Severity }; + +export type ValidatorIssue = Finding & { validator: string; severity: ValidatorSeverity }; + +/** What a validator returns. */ +export interface ValidatorFinding extends Pick { + /** 'error' or 'warning'; ERROR / WARN / WARNING in any case are accepted too */ + severity: string; + /** stable rule id, e.g. UNNAMED_TASK (`ruleId` is read when there is no code) */ + code?: string; + ruleId?: string; + key?: string; + leftOut?: string; +} diff --git a/src/domain/document/FlowRules.ts b/src/domain/document/FlowRules.ts index 67c0596b..9a54b8f1 100644 --- a/src/domain/document/FlowRules.ts +++ b/src/domain/document/FlowRules.ts @@ -1,6 +1,6 @@ import type { Doc } from './Doc.js'; import { modelError } from './utils/errors.js'; -import { ignoresConditions, isEventSubProcess, isLinkCatch, isLinkThrow, kindLabel } from './utils/kinds.js'; +import { ignoresConditions, isEventSubProcess, isLinkCatch, isLinkThrow, kindLabel } from './Kinds.js'; import { idOf, is, type El } from './utils/moddle.js'; type Role = 'source' | 'target'; diff --git a/src/domain/document/IdSuggestions.ts b/src/domain/document/IdSuggestions.ts index 743e6693..d6c52ea3 100644 --- a/src/domain/document/IdSuggestions.ts +++ b/src/domain/document/IdSuggestions.ts @@ -1,8 +1,8 @@ import { editDistance, typoTolerance } from './utils/fuzzy.js'; import { transliterate } from './utils/slug.js'; -import { suggestKinds } from './utils/kinds.js'; +import { suggestKinds } from './Kinds.js'; import type { Doc } from './Doc.js'; -import type { BatchContext } from './EditContext.js'; +import type { BatchContext } from './EditSession.js'; /** The ids of a document a mistyped id may have meant. */ export class IdSuggestions { diff --git a/src/domain/document/KindError.ts b/src/domain/document/KindError.ts deleted file mode 100644 index 9cd5b2fd..00000000 --- a/src/domain/document/KindError.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class KindError extends Error { - constructor( - message: string, - public readonly candidates: string[] = [], - ) { - super(message); - this.name = 'KindError'; - } -} diff --git a/src/domain/document/utils/kinds.ts b/src/domain/document/Kinds.ts similarity index 74% rename from src/domain/document/utils/kinds.ts rename to src/domain/document/Kinds.ts index db123c0b..0185bd70 100644 --- a/src/domain/document/utils/kinds.ts +++ b/src/domain/document/Kinds.ts @@ -2,11 +2,10 @@ * The kind vocabulary: lowerCamel BPMN names (`userTask`), what the AI sees in any .bpmn file. * Also accepted: aliases (`user`) and moddle types; events take a trigger (`boundary:timer`). */ -import { CliError } from '../CliError.js'; -import { KindError } from '../KindError.js'; -import { is, localType, type El } from './moddle.js'; -import { usageError } from './errors.js'; -import { levenshtein } from './fuzzy.js'; +import { BpmnError } from './BpmnError.js'; +import { is, typeLabel, type El } from './utils/moddle.js'; +import { usageError } from './utils/errors.js'; +import { levenshtein } from './utils/fuzzy.js'; export type Family = | 'task' @@ -54,17 +53,28 @@ export interface KindDef { description: string; } -export const TRIGGER_TYPES: Record, string> = { - message: 'bpmn:MessageEventDefinition', - timer: 'bpmn:TimerEventDefinition', - error: 'bpmn:ErrorEventDefinition', - signal: 'bpmn:SignalEventDefinition', - escalation: 'bpmn:EscalationEventDefinition', - conditional: 'bpmn:ConditionalEventDefinition', - link: 'bpmn:LinkEventDefinition', - compensate: 'bpmn:CompensateEventDefinition', - terminate: 'bpmn:TerminateEventDefinition', - cancel: 'bpmn:CancelEventDefinition', +export interface RootReference { + option: 'message' | 'error' | 'signal' | 'escalation'; + refProperty: string; + codeProperty?: 'errorCode' | 'escalationCode'; +} + +export interface TriggerDef { + definition: string; + root?: RootReference; +} + +export const TRIGGERS: Record, TriggerDef> = { + message: { definition: 'bpmn:MessageEventDefinition', root: { option: 'message', refProperty: 'messageRef' } }, + timer: { definition: 'bpmn:TimerEventDefinition' }, + error: { definition: 'bpmn:ErrorEventDefinition', root: { option: 'error', refProperty: 'errorRef', codeProperty: 'errorCode' } }, + signal: { definition: 'bpmn:SignalEventDefinition', root: { option: 'signal', refProperty: 'signalRef' } }, + escalation: { definition: 'bpmn:EscalationEventDefinition', root: { option: 'escalation', refProperty: 'escalationRef', codeProperty: 'escalationCode' } }, + conditional: { definition: 'bpmn:ConditionalEventDefinition' }, + link: { definition: 'bpmn:LinkEventDefinition' }, + compensate: { definition: 'bpmn:CompensateEventDefinition' }, + terminate: { definition: 'bpmn:TerminateEventDefinition' }, + cancel: { definition: 'bpmn:CancelEventDefinition' }, }; const TRIGGER_ALIASES: Record = { @@ -133,11 +143,11 @@ export function normalizeTrigger(raw: string): Trigger | undefined { const alias = TRIGGER_ALIASES[token]; if (alias) return alias; if (token === 'none') return 'none'; - return (Object.keys(TRIGGER_TYPES) as Trigger[]).find((trigger) => trigger === token); + return (Object.keys(TRIGGERS) as Trigger[]).find((trigger) => trigger === token); } /** - * Resolves `kind[:trigger]`. Throws KindError with candidates on failure. + * Resolves `kind[:trigger]`. Throws a usage BpmnError with candidates on failure. * A namespaced type (`bpmn:UserTask`) is read whole, not as a kind with a trigger. */ export function parseKind(token: string): ParsedKind { @@ -155,36 +165,74 @@ export function parseKind(token: string): ParsedKind { const kind = kindToken.toLowerCase().replace(/^bpmn:/, ''); return local === kind || local === `${kind}gateway` || local === `${kind}event`; }); - if (rejected) throw new KindError(`Kind "${kindToken}" is rejected: ${rejected[1]}`); + if (rejected) throw new BpmnError('E_UNSUPPORTED_KIND', `Kind "${kindToken}" is rejected: ${rejected[1]}`, 'usage', { hint: 'Run `bpmn kinds` for the supported kinds.' }); const candidates = suggestKinds(kindToken); - throw new KindError( - `Unknown kind "${kindToken}"${candidates.length ? ` (did you mean: ${candidates.join(', ')}?)` : ''}. Run \`bpmn kinds\` for the full list.`, + throw new BpmnError('E_UNKNOWN_KIND', `Unknown kind "${kindToken}"${candidates.length ? ` (did you mean: ${candidates.join(', ')}?)` : ''}. Run \`bpmn kinds\` for the full list.`, 'usage', { candidates, - ); + hint: 'Run `bpmn kinds` for the full list.', + }); } if (!triggerToken) return { def }; const trigger = normalizeTrigger(triggerToken); if (!trigger) { - throw new KindError(`Unknown event trigger "${triggerToken}". Allowed: ${['none', ...Object.keys(TRIGGER_TYPES)].join(', ')}`); + throw triggerError(`Unknown event trigger "${triggerToken}". Allowed: ${['none', ...Object.keys(TRIGGERS)].join(', ')}`); } if (!def.triggers) { - throw new KindError(`Kind "${def.kind}" is not an event and cannot have a trigger (":${triggerToken}")`); + throw triggerError(`Kind "${def.kind}" is not an event and cannot have a trigger (":${triggerToken}")`); } if (!def.triggers.includes(trigger)) { - throw new KindError(`Trigger "${trigger}" is not allowed on ${def.kind}. Allowed: ${def.triggers.join(', ')}`); + throw triggerError(`Trigger "${trigger}" is not allowed on ${def.kind}. Allowed: ${def.triggers.join(', ')}`); } return { def, trigger }; } +function triggerError(message: string): BpmnError { + return new BpmnError('E_INVALID_TRIGGER', message, 'usage', { hint: 'Use kind:trigger, e.g. startEvent:message or boundaryEvent:timer.' }); +} + /** A kind token given as an option (`find --kind`) fails with the same codes as `add`. */ export function assertKindToken(value: string): void { try { parseKind(value); } catch (err) { - if (!(err instanceof KindError)) throw err; - const code = /is rejected/.test(err.message) ? 'E_UNSUPPORTED_KIND' : /^Unknown kind/.test(err.message) ? 'E_UNKNOWN_KIND' : 'E_INVALID_TRIGGER'; - throw new CliError(code, err.message, 'usage', { ...(err.candidates?.length ? { candidates: err.candidates } : {}), hint: 'Run `bpmn kinds` for the full list of kinds and triggers.' }); + if (!(err instanceof BpmnError)) throw err; + const { candidates } = err.details; + throw new BpmnError(err.code, err.message, 'usage', { ...(candidates?.length ? { candidates } : {}), hint: 'Run `bpmn kinds` for the full list of kinds and triggers.' }); + } +} + +/** `parseKind`, and `eventSubProcess:`, which `add` creates with its start event inside. */ +export function parseKindToAdd(token: string): ParsedKind { + try { + return parseKind(token); + } catch (err) { + if (!(err instanceof BpmnError)) throw err; + const eventSubProcess = parseEventSubProcessKind(token); + if (eventSubProcess) return eventSubProcess; + throw err; + } +} + +function parseEventSubProcessKind(token: string): ParsedKind | undefined { + const idx = token.indexOf(':'); + if (idx < 0) return undefined; + let base: ParsedKind; + try { + base = parseKind(token.slice(0, idx)); + } catch { + return undefined; + } + if (base.def.kind !== 'eventSubProcess') return undefined; + const raw = token.slice(idx + 1); + const trigger = normalizeTrigger(raw); + const eventSubProcessTriggers: Trigger[] = (kindByName('startEvent')?.triggers ?? []).filter((startTrigger) => startTrigger !== 'none'); + if (!trigger || !eventSubProcessTriggers.includes(trigger)) { + throw new BpmnError('E_INVALID_TRIGGER', `Trigger "${raw}" is not allowed on eventSubProcess. Allowed: ${eventSubProcessTriggers.join(', ')}`, 'usage', { + candidates: eventSubProcessTriggers, + hint: 'eventSubProcess: creates the event sub-process with a startEvent: inside, e.g. eventSubProcess:error --error PaymentFailed.', + }); } + return { def: base.def, trigger }; } export function suggestKinds(token: string, max = 4): string[] { @@ -244,8 +292,11 @@ export function triggerOf(el: El): Trigger | undefined { const defs = el.get('eventDefinitions') ?? []; const first = defs[0]; if (!first) return 'none'; - const entry = (Object.entries(TRIGGER_TYPES) as Array<[Trigger, string]>).find(([, type]) => is(first, type)); - return entry?.[0]; + return triggerOfDefinition(first); +} + +export function triggerOfDefinition(definition: El): Exclude | undefined { + return (Object.keys(TRIGGERS) as Array>).find((trigger) => is(definition, TRIGGERS[trigger].definition)); } /** A link throw ends its path: it continues at the link catch of the same name. */ @@ -280,8 +331,7 @@ export function isRejectedType(type: string): string | undefined { export function kindOrTypeLabel(el: El): string { if (kindOf(el)) return kindLabel(el); if (is(el, 'bpmn:DataAssociation')) return 'dataAssociation'; - const local = localType(el); - return local.charAt(0).toLowerCase() + local.slice(1); + return typeLabel(el); } /** Non-kind labels `find --kind` also accepts. */ @@ -292,10 +342,10 @@ export function kindFilter(kind: string): (el: El) => boolean { const { def, trigger } = parseKind(kind); return (el) => kindOf(el)?.kind === def.kind && (!trigger || triggerOf(el) === trigger); } catch (err) { - if (!(err instanceof KindError)) throw err; + if (!(err instanceof BpmnError)) throw err; const token = kind.trim().toLowerCase(); const extra = EXTRA_KINDS.find((kind) => kind.toLowerCase() === token); if (extra) return (el) => kindOrTypeLabel(el) === extra; - throw usageError(err.message, { candidates: [...err.candidates, ...EXTRA_KINDS.filter((kind) => kind.toLowerCase().includes(token))] }); + throw usageError(err.message, { candidates: [...(err.details.candidates ?? []), ...EXTRA_KINDS.filter((kind) => kind.toLowerCase().includes(token))] }); } } diff --git a/src/domain/document/ids/ConventionsTable.ts b/src/domain/document/ids/ConventionsTable.ts index 68a4b768..ea09dbfc 100644 --- a/src/domain/document/ids/ConventionsTable.ts +++ b/src/domain/document/ids/ConventionsTable.ts @@ -1,11 +1,16 @@ import type { BpmnlintSeverity } from './bpmnlint-rule-setting.js'; import type { Doc } from '../Doc.js'; -import { KINDS } from '../utils/kinds.js'; -import type { ValidatorFinding } from '../validator-finding.js'; +import { KINDS } from '../Kinds.js'; +import type { ValidatorFinding } from '../Finding.js'; import { IdStyle, type FlowForm, type PrefixSource } from './IdStyle.js'; -import type { IdConventions, QualifierMode } from './IdConventionsReader.js'; +import type { IdConventions, QualifierMode } from './IdConventions.js'; import { kindRequest, typeRequest, type IdRequest } from '../utils/requests.js'; -import { UNQUALIFIED_NOTE } from '../utils/conventions-text.js'; + +/** + * Notes event triggers whose required qualified prefix new ids cannot have: + * they follow the file's style. + */ +export const UNQUALIFIED_NOTE = "qualified prefix ignored, the file's style (W_CONFIG_IGNORED)"; export interface ConventionsRow { /** `serviceTask_{name:camel}`; `_{name:camel}` for kinds whose prefix is their own name */ diff --git a/src/domain/document/ids/DeclaredConventions.ts b/src/domain/document/ids/DeclaredConventions.ts index 455b2902..bcbc4c45 100644 --- a/src/domain/document/ids/DeclaredConventions.ts +++ b/src/domain/document/ids/DeclaredConventions.ts @@ -1,8 +1,8 @@ -import { KINDS } from '../utils/kinds.js'; +import { KINDS } from '../Kinds.js'; import { isUsablePrefix } from '../utils/slug.js'; import { PREFIXED, prefixClass } from '../utils/id-words.js'; import { kindTypeNames } from '../utils/requests.js'; -import type { IdConventions } from './IdConventionsReader.js'; +import type { IdConventions } from './IdConventions.js'; import type { Body, IdState } from './IdStyle.js'; /** The declared prefixes as votes on the shape of a prefix nothing declares. */ diff --git a/src/domain/document/ids/ElementLabels.ts b/src/domain/document/ids/ElementLabels.ts index 48e7d0da..f1245be0 100644 --- a/src/domain/document/ids/ElementLabels.ts +++ b/src/domain/document/ids/ElementLabels.ts @@ -1,4 +1,4 @@ -import { kindOf, triggerOf } from '../utils/kinds.js'; +import { kindOf, triggerOf } from '../Kinds.js'; import { is, type El } from '../utils/moddle.js'; import { hasIdWords, nameWords } from '../utils/slug.js'; import { CONTEXT_WORDS, kindWord, localName, speakingStem, stemWords, ucfirst, type ContextWord } from '../utils/id-words.js'; diff --git a/src/domain/document/ids/IdConventions.ts b/src/domain/document/ids/IdConventions.ts new file mode 100644 index 00000000..808d0c77 --- /dev/null +++ b/src/domain/document/ids/IdConventions.ts @@ -0,0 +1,21 @@ +export type QualifierMode = 'off' | 'optional' | 'required'; + +/** The id conventions the core takes, as plain data (from a .bpmnlintrc or a host's table). */ +export interface IdConventions { + /** + * the prefix of new ids per BPMN type ('bpmn:ServiceTask': 'serviceTask_') and per event + * trigger ('bpmn:StartEvent:timer': 'timerStartEvent_'); other types follow the file's style + */ + prefixes: Record; + /** under `required` a new event with a trigger takes the qualified prefix (default optional) */ + qualifier?: QualifierMode; + /** the case of the body after a declared prefix; absent: the file's case */ + case?: 'camelCase' | 'PascalCase' | 'snake_case'; + /** where they come from (`.bpmnlintrc in models`) */ + source?: string; + /** + * types and event triggers (`bpmn:EndEvent:escalation`) checked with a prefix new ids cannot + * follow: they follow the file's style + */ + ignored?: string[]; +} diff --git a/src/domain/document/ids/IdStyle.ts b/src/domain/document/ids/IdStyle.ts index 1eac5e63..d83069ca 100644 --- a/src/domain/document/ids/IdStyle.ts +++ b/src/domain/document/ids/IdStyle.ts @@ -3,10 +3,10 @@ import type { IdRegistry } from './IdRegistry.js'; import { FORM_JOIN, IdFitter } from './IdFitter.js'; import { caseLike, CONTEXT_WORDS, kindWord, PREFIXED, speakingStem, stemWords } from '../utils/id-words.js'; import { CONNECTIONS, connectionRequest, kindRequest, typeRequest, type IdRequest } from '../utils/requests.js'; -import type { IdConventions } from './IdConventionsReader.js'; +import type { IdConventions } from './IdConventions.js'; import { DeclaredConventions } from './DeclaredConventions.js'; import { DEFAULT_FLOW, LearnedStyle } from './LearnedStyle.js'; -import { kindByName } from '../utils/kinds.js'; +import { FLOW_NODE_FAMILIES, KINDS, kindByName } from '../Kinds.js'; import type { El } from '../utils/moddle.js'; /** The case of the bodies of new ids. */ @@ -42,25 +42,7 @@ export interface IdState { const FILE_LEVEL = new Set(['bpmn:Process', 'bpmn:Definitions']); /** Their prefix says the kind, so an unnamed one's id leaves it out (Gateway_AfterCheck). */ -const SPELLABLE = new Set([ - 'task', - 'userTask', - 'serviceTask', - 'scriptTask', - 'sendTask', - 'receiveTask', - 'manualTask', - 'businessRuleTask', - 'subProcess', - 'adHocSubProcess', - 'transaction', - 'callActivity', - 'exclusiveGateway', - 'parallelGateway', - 'inclusiveGateway', - 'complexGateway', - 'eventBasedGateway', -]); +const SPELLABLE = new Set(KINDS.filter((def) => FLOW_NODE_FAMILIES.has(def.family) && def.family !== 'event' && def.kind !== 'eventSubProcess').map((def) => def.kind)); /** * The id conventions of a document, so that new ids look like the ones it already has and every diff --git a/src/domain/document/ids/LearnedStyle.ts b/src/domain/document/ids/LearnedStyle.ts index 66e9dacf..dfeb6f38 100644 --- a/src/domain/document/ids/LearnedStyle.ts +++ b/src/domain/document/ids/LearnedStyle.ts @@ -1,4 +1,4 @@ -import { kindOf, triggerOf } from '../utils/kinds.js'; +import { kindOf, triggerOf } from '../Kinds.js'; import { is, isBpmnElement, walk, type El } from '../utils/moddle.js'; import { isSuffix, MAX_ID, nameWords, pascalSnakeSlug, snakeSlug, SUFFIX_WORD } from '../utils/slug.js'; import { beginAlike, caseLike, GENERIC, GLUED, HASH, lcfirst, localName, PREFIXED, prefixClass, stem, TOOL_DEFAULTS, ucfirst, type PrefixClass } from '../utils/id-words.js'; diff --git a/src/domain/document/utils/bpmn-error.ts b/src/domain/document/utils/bpmn-error.ts new file mode 100644 index 00000000..faf5ac50 --- /dev/null +++ b/src/domain/document/utils/bpmn-error.ts @@ -0,0 +1,11 @@ +import { BpmnError } from '../BpmnError.js'; +import { ModelError } from '../ModelError.js'; + +/** A ModelError as the BpmnError the pipeline throws (`E_`); anything else as it is. */ +export function toBpmnError(err: unknown): unknown { + if (err instanceof ModelError) { + const category = err.code.startsWith('LAYOUT') ? 'layout' : err.code === 'PARSE_ERROR' ? 'io' : 'model'; + return new BpmnError(`E_${err.code}`, err.message, category, err.details); + } + return err; +} diff --git a/src/domain/document/utils/cli-error.ts b/src/domain/document/utils/cli-error.ts deleted file mode 100644 index bb80f093..00000000 --- a/src/domain/document/utils/cli-error.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CliError } from '../CliError.js'; -import { ModelError } from '../ModelError.js'; - -/** A ModelError as the CliError the pipeline throws (`E_`); anything else as it is. */ -export function toCliError(err: unknown): unknown { - if (err instanceof ModelError) { - const category = err.code.startsWith('LAYOUT') ? 'layout' : err.code === 'PARSE_ERROR' ? 'io' : 'model'; - return new CliError(err.code.startsWith('LAYOUT') ? `E_${err.code}` : `E_${err.code}`, err.message, category, err.details); - } - return err; -} diff --git a/src/domain/document/utils/elements.ts b/src/domain/document/utils/elements.ts index c37f2e7a..fa789412 100644 --- a/src/domain/document/utils/elements.ts +++ b/src/domain/document/utils/elements.ts @@ -1,5 +1,5 @@ -import { kindLabel } from './kinds.js'; -import { localType, type El } from './moddle.js'; +import { kindLabel } from '../Kinds.js'; +import type { El } from './moddle.js'; /** The subset of a moddle property descriptor the mutations rely on. */ export interface PropDescriptor { @@ -41,12 +41,6 @@ export function isEl(value: unknown): value is El { return !!value && typeof value === 'object' && typeof (value as El).$type === 'string'; } -/** `multiInstanceLoopCharacteristics` */ -export function typeLabel(el: El): string { - const local = localType(el); - return local.charAt(0).toLowerCase() + local.slice(1); -} - export function vendorAttributes(el: El): Record { const out: Record = {}; for (const [attrName, attrValue] of Object.entries(el.$attrs ?? {})) { diff --git a/src/domain/document/utils/errors.ts b/src/domain/document/utils/errors.ts index afb1d3bc..15d17d05 100644 --- a/src/domain/document/utils/errors.ts +++ b/src/domain/document/utils/errors.ts @@ -1,17 +1,17 @@ -import { CliError, type ErrorDetails } from '../CliError.js'; +import { BpmnError, type ErrorDetails } from '../BpmnError.js'; -export function usageError(message: string, details: ErrorDetails = {}): CliError { - return new CliError('E_USAGE', message, 'usage', details); +export function usageError(message: string, details: ErrorDetails = {}): BpmnError { + return new BpmnError('E_USAGE', message, 'usage', details); } -export function modelError(code: string, message: string, details: ErrorDetails = {}): CliError { - return new CliError(code, message, 'model', details); +export function modelError(code: string, message: string, details: ErrorDetails = {}): BpmnError { + return new BpmnError(code, message, 'model', details); } -export function ioError(code: string, message: string, details: ErrorDetails = {}): CliError { - return new CliError(code, message, 'io', details); +export function ioError(code: string, message: string, details: ErrorDetails = {}): BpmnError { + return new BpmnError(code, message, 'io', details); } -export function isCliError(err: unknown): err is CliError { - return err instanceof CliError; +export function isBpmnError(err: unknown): err is BpmnError { + return err instanceof BpmnError; } diff --git a/src/domain/document/utils/finding.ts b/src/domain/document/utils/finding.ts index f030aa92..233326c5 100644 --- a/src/domain/document/utils/finding.ts +++ b/src/domain/document/utils/finding.ts @@ -1,24 +1,13 @@ -import type { Warning } from '../CliError.js'; - -/** - * deploy: the engines refuse the deployment; runtime: it deploys, but the setting is ignored - * or fails when the process runs; practice: works, but the engine warns or the model is fragile. - */ -export type Severity = 'deploy' | 'runtime' | 'practice'; +import type { Finding, ProfileFinding, Severity } from '../Finding.js'; export const SEVERITIES: readonly Severity[] = ['deploy', 'runtime', 'practice']; -export interface ProfileFinding extends Warning { - severity: Severity; -} - -/** - * What exactly is wrong on the element, so a mutation can tell the findings it introduced - * from those the file already had, without it showing up in JSON output. - */ -const SUBJECT: unique symbol = Symbol('profile-finding-subject'); +type UnserialisedFields = Pick; -type WithSubject = ProfileFinding & { [SUBJECT]?: string }; +export function withUnserialised(finding: TFinding, fields: UnserialisedFields): TFinding { + for (const [name, value] of Object.entries(fields)) if (value !== undefined) Object.defineProperty(finding, name, { value, enumerable: false }); + return finding; +} export function makeFinding( severity: Severity, @@ -27,14 +16,9 @@ export function makeFinding( element: string | undefined, extra: { related?: string[]; hint?: string; subject?: string } = {}, ): ProfileFinding { - const finding: WithSubject = { code, message, severity }; + const finding: ProfileFinding = { code, message, severity }; if (element) finding.element = element; if (extra.related?.length) finding.related = extra.related; if (extra.hint) finding.hint = extra.hint; - Object.defineProperty(finding, SUBJECT, { value: extra.subject ?? '', enumerable: false }); - return finding; -} - -export function subjectOf(finding: ProfileFinding): string { - return (finding as WithSubject)[SUBJECT] ?? ''; + return withUnserialised(finding, { subject: extra.subject ?? '' }); } diff --git a/src/domain/document/utils/moddle.ts b/src/domain/document/utils/moddle.ts index 75415278..1e2082bd 100644 --- a/src/domain/document/utils/moddle.ts +++ b/src/domain/document/utils/moddle.ts @@ -44,6 +44,12 @@ export function is(el: unknown, type: string): boolean { return !!el && typeof el === 'object' && typeof (el as El).$instanceOf === 'function' && (el as El).$instanceOf(type); } +export function enclosingProcess(scope: El | undefined): El | undefined { + let current: El | undefined = scope; + while (current && !is(current, 'bpmn:Process')) current = current.$parent as El | undefined; + return current; +} + /** A group has no `name`: it is the value of its category value. */ export function groupName(group: El): string | undefined { return group.get('categoryValueRef')?.get('value') || undefined; @@ -53,10 +59,16 @@ export function nameOf(el: El): string | undefined { return is(el, 'bpmn:Group') ? groupName(el) : el.get('name'); } -export function localType(el: El): string { +function localType(el: El): string { return el.$type.split(':')[1] ?? el.$type; } +/** `multiInstanceLoopCharacteristics` */ +export function typeLabel(el: El): string { + const local = localType(el); + return local.charAt(0).toLowerCase() + local.slice(1); +} + export function many(el: El, prop: string): El[] { return el.get(prop); } @@ -153,24 +165,6 @@ export function indexById(root: El, opts: { semanticOnly?: boolean } = {}): Map< return map; } -export function findReferences(root: El, target: El): Array<{ holder: El; prop: string; isMany: boolean }> { - const refs: Array<{ holder: El; prop: string; isMany: boolean }> = []; - for (const el of walk(root)) { - const descriptor = el.$descriptor as { isGeneric?: boolean; properties?: Array<{ name: string; isReference?: boolean; isMany?: boolean }> }; - if (descriptor.isGeneric) continue; - for (const property of descriptor.properties ?? []) { - if (!property.isReference) continue; - const value = el.get(property.name); - if (property.isMany) { - if (Array.isArray(value) && value.includes(target)) refs.push({ holder: el, prop: property.name, isMany: true }); - } else if (value === target) { - refs.push({ holder: el, prop: property.name, isMany: false }); - } - } - } - return refs; -} - function rootElements(defs: El): El[] { return many(defs, 'rootElements'); } @@ -183,7 +177,7 @@ export function collaboration(defs: El): El | undefined { return rootElements(defs).find((element) => is(element, 'bpmn:Collaboration')); } -/** What bpmn-auto-layout lays out. */ +/** What a full layout lays out. */ export function layoutRoot(defs: El): El | undefined { return collaboration(defs) ?? processes(defs)[0]; } diff --git a/src/domain/document/utils/nested-slots.ts b/src/domain/document/utils/nested-slots.ts index 1b834b90..f315f53e 100644 --- a/src/domain/document/utils/nested-slots.ts +++ b/src/domain/document/utils/nested-slots.ts @@ -3,9 +3,9 @@ * prefix: `definition.` (or `definition[]` / `definition[]`), `loop.`, `condition.`. */ import { modelError } from './errors.js'; -import { kindLabel, normalizeTrigger, TRIGGER_TYPES, type Trigger } from './kinds.js'; -import { is, type El } from './moddle.js'; -import { idOf, ownValue, typeLabel } from './elements.js'; +import { kindLabel, normalizeTrigger, TRIGGERS, triggerOfDefinition, type Trigger } from '../Kinds.js'; +import { is, typeLabel, type El } from './moddle.js'; +import { idOf, ownValue } from './elements.js'; export const NESTED_SLOTS = ['definition', 'loop', 'condition'] as const; export type NestedSlot = (typeof NESTED_SLOTS)[number]; @@ -140,7 +140,7 @@ export function resolveNested(el: El, ref: SlotRef, what: string, example?: (pre const defs = definitionsOf(el); const listing = defs.map((definition, index) => `definition[${index}] = ${definitionText(definition)}`).join(', '); if (ref.index !== undefined || ref.trigger) { - const hits = ref.index !== undefined ? (defs[ref.index] ? [defs[ref.index]!] : []) : defs.filter((definition) => is(definition, TRIGGER_TYPES[ref.trigger!])); + const hits = ref.index !== undefined ? (defs[ref.index] ? [defs[ref.index]!] : []) : defs.filter((definition) => is(definition, TRIGGERS[ref.trigger!].definition)); if (hits.length === 1) return hits[0]; if (!hits.length) { throw modelError('E_NO_NESTED_ELEMENT', `${kindLabel(el)} ${id} has no event definition ${ref.text} (${what})`, { @@ -157,7 +157,7 @@ export function resolveNested(el: El, ref: SlotRef, what: string, example?: (pre function ambiguousNested(el: El, defs: El[], what: string, count: string, example?: (prefix: string) => string) { const id = idOf(el); const listing = defs.map((definition, index) => `definition[${index}] = ${definitionText(definition)}`).join(', '); - const triggers = defs.map((definition) => (Object.entries(TRIGGER_TYPES) as Array<[string, string]>).find(([, type]) => is(definition, type))?.[0]).filter((trigger): trigger is string => !!trigger); + const triggers = defs.map(triggerOfDefinition).filter((trigger): trigger is Exclude => !!trigger); const unique = triggers.filter((trigger, index) => triggers.indexOf(trigger) === index && triggers.lastIndexOf(trigger) === index); const rest = what.replace(/^definition(\[[^\]]*\])?\./, ''); const show = example ?? ((prefix: string) => `bpmn set ${id} '${prefix}.${rest}='`); diff --git a/src/domain/document/utils/requests.ts b/src/domain/document/utils/requests.ts index fb3736d9..dd50ec70 100644 --- a/src/domain/document/utils/requests.ts +++ b/src/domain/document/utils/requests.ts @@ -1,4 +1,4 @@ -import type { KindDef } from './kinds.js'; +import type { KindDef } from '../Kinds.js'; import { lcfirst, localName } from './id-words.js'; /** diff --git a/src/domain/document/validator-finding.ts b/src/domain/document/validator-finding.ts deleted file mode 100644 index d9adba93..00000000 --- a/src/domain/document/validator-finding.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type ValidatorSeverity = 'error' | 'warning'; - -/** What a validator returns. */ -export interface ValidatorFinding { - /** 'error' or 'warning'; ERROR / WARN / WARNING in any case are accepted too */ - severity: string; - /** stable rule id, e.g. UNNAMED_TASK (`ruleId` is read when there is no code) */ - code?: string; - ruleId?: string; - message: string; - element?: string; - related?: string[]; - hint?: string; - /** identity for the before / after comparison (default: code + element, else code + message) */ - key?: string; - /** a write does not compare this finding but counts it under this reason; a check reports it */ - leftOut?: string; -} diff --git a/src/domain/document/vendor/Camunda8Defaults.ts b/src/domain/document/vendor/Camunda8Defaults.ts index 2d44305c..cdf01b39 100644 --- a/src/domain/document/vendor/Camunda8Defaults.ts +++ b/src/domain/document/vendor/Camunda8Defaults.ts @@ -1,5 +1,4 @@ -import type { Doc } from '../Doc.js'; -import type { EditContext } from '../EditContext.js'; +import type { EditSession } from '../EditSession.js'; import { ElementLabels } from '../ids/ElementLabels.js'; import { typeRequest } from '../utils/requests.js'; import { speakingStem } from '../utils/id-words.js'; @@ -11,10 +10,7 @@ import { ZEEBE_URI } from './ZeebeDescriptor.js'; * `` on new user tasks and speaking ids on new event definitions. */ export class Camunda8Defaults { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} + constructor(private readonly edit: EditSession) {} /** * Without `` Camunda 8 runs a job worker user task, @@ -24,16 +20,16 @@ export class Camunda8Defaults { if (this.edit.platform() !== 'c8' || !is(el, 'bpmn:UserTask')) return false; const container = el.get('extensionElements'); const values = container ? many(container, 'values') : []; - if (values.some((extension) => extension.$type.endsWith(':userTask') && this.doc.namespaceUri(extension.$type.slice(0, extension.$type.indexOf(':'))) === ZEEBE_URI)) return false; - const prefix = this.doc.prefixFor(ZEEBE_URI) ?? 'zeebe'; - this.doc.declareNamespace(prefix, ZEEBE_URI); + if (values.some((extension) => extension.$type.endsWith(':userTask') && this.edit.doc.namespaceUri(extension.$type.slice(0, extension.$type.indexOf(':'))) === ZEEBE_URI)) return false; + const prefix = this.edit.doc.prefixFor(ZEEBE_URI) ?? 'zeebe'; + this.edit.doc.declareNamespace(prefix, ZEEBE_URI); let ext = container; if (!ext) { - ext = this.doc.moddle.create('bpmn:ExtensionElements'); + ext = this.edit.doc.moddle.create('bpmn:ExtensionElements'); ext.$parent = el; el.set('extensionElements', ext); } - addTo(ext, 'values', this.doc.moddle.createAny(`${prefix}:userTask`, ZEEBE_URI, {}) as unknown as El); + addTo(ext, 'values', this.edit.doc.moddle.createAny(`${prefix}:userTask`, ZEEBE_URI, {}) as unknown as El); return true; } diff --git a/src/domain/editing/ElementCreationService.ts b/src/domain/editing/ElementCreationService.ts deleted file mode 100644 index 0cd4077f..00000000 --- a/src/domain/editing/ElementCreationService.ts +++ /dev/null @@ -1,453 +0,0 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { CliError } from '../document/CliError.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { ElementLabels, type AnchorContext } from '../document/ids/ElementLabels.js'; -import { kindRequest, type IdRequest } from '../document/utils/requests.js'; -import type { ContextWord } from '../document/utils/id-words.js'; -import { FLOW_NODE_FAMILIES, isEventSubProcess, kindByName, kindLabel, normalizeTrigger, parseKind, type KindDef, type ParsedKind, type Trigger } from '../document/utils/kinds.js'; -import { KindError } from '../document/KindError.js'; -import { addTo, is, many, type El, idOf } from '../document/utils/moddle.js'; -import { typeLabel } from '../document/utils/elements.js'; -import { ChangeSet, type Change } from '../document/ChangeSet.js'; -import { renamedNote } from '../document/utils/renames.js'; -import { AssociationService } from './AssociationService.js'; -import { ArtifactService } from './ArtifactService.js'; -import { LaneService } from './participants/LaneService.js'; -import { PoolService } from './participants/PoolService.js'; -import { EventTrigger } from './events/EventTrigger.js'; -import { LaneInheritance } from './participants/LaneInheritance.js'; -import { Camunda8Defaults } from '../document/vendor/Camunda8Defaults.js'; -import { FlowOptions } from './flows/FlowOptions.js'; -import type { AddElement, TriggerOptions } from '../commands/types.js'; -import { StaleFlowReport } from './flows/StaleFlowReport.js'; -import { NodePlacementService } from './NodePlacementService.js'; -import { EventGatewayRule } from './events/EventGatewayRule.js'; -import { FlowRules } from '../document/FlowRules.js'; -import { EventDefinitionService } from './events/EventDefinitionService.js'; -import { RootElementService } from './events/RootElementService.js'; -import { PropertyService } from './PropertyService.js'; - -const EVENT_SUBPROCESS_TRIGGERS: Trigger[] = (kindByName('startEvent')?.triggers ?? []).filter((trigger) => trigger !== 'none'); - -const PLACEMENT_KEYS = ['after', 'before', 'flow', 'on', 'in'] as const; - -/** - * Runs the `add` op: creates one element and wires it into the model. - * Collapse requests go to Doc.layoutRequests because `expanded` lives in DI. - */ -export class ElementCreationService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} - - private static parseEventSubProcessKind(token: string): ParsedKind | undefined { - const idx = token.indexOf(':'); - if (idx < 0) return undefined; - let base: ParsedKind; - try { - base = parseKind(token.slice(0, idx)); - } catch { - return undefined; - } - if (base.def.kind !== 'eventSubProcess') return undefined; - const raw = token.slice(idx + 1); - const trigger = normalizeTrigger(raw); - if (!trigger || !EVENT_SUBPROCESS_TRIGGERS.includes(trigger)) { - throw new CliError('E_INVALID_TRIGGER', `Trigger "${raw}" is not allowed on eventSubProcess. Allowed: ${EVENT_SUBPROCESS_TRIGGERS.join(', ')}`, 'usage', { - candidates: EVENT_SUBPROCESS_TRIGGERS, - hint: 'eventSubProcess: creates the event sub-process with a startEvent: inside, e.g. eventSubProcess:error --error PaymentFailed.', - }); - } - return { def: base.def, trigger }; - } - - private static resolveKind(token: string): ParsedKind { - try { - return parseKind(token); - } catch (err) { - if (!(err instanceof KindError)) throw err; - const sub = ElementCreationService.parseEventSubProcessKind(token); - if (sub) return sub; - const msg = err.message; - if (/is rejected/.test(msg)) throw new CliError('E_UNSUPPORTED_KIND', msg, 'usage', { hint: 'Run `bpmn kinds` for the supported kinds.' }); - if (/^Unknown kind/.test(msg)) throw new CliError('E_UNKNOWN_KIND', msg, 'usage', { candidates: err.candidates, hint: 'Run `bpmn kinds` for the full list.' }); - throw new CliError('E_INVALID_TRIGGER', msg, 'usage', { hint: 'Use kind:trigger, e.g. startEvent:message or boundaryEvent:timer.' }); - } - } - - /** What tells an unnamed element's id apart: its placement (`After `, `On `). */ - private placementContext(op: AddElement): AnchorContext | undefined { - const at = (word: ContextWord, id: string | undefined): AnchorContext => { - const el = id ? this.doc.get(id) : undefined; - return el ? new ElementLabels(el).anchorContext(word) : { context: word }; - }; - switch (NodePlacementService.modeOf(op)) { - case 'after': - case 'between': - return at('After', op.after); - case 'before': - return at('Before', op.before); - case 'flow': { - const source = this.doc.get(op.flow!)?.get('sourceRef'); - return source ? new ElementLabels(source).anchorContext('After') : undefined; - } - case 'on': - return at('On', op.on); - default: { - const scope = op.in ? this.doc.get(op.in) : undefined; - if (!scope) return undefined; - return is(scope, 'bpmn:SubProcess') || this.doc.processes().length > 1 ? new ElementLabels(scope).anchorContext('In') : undefined; - } - } - } - - private elementRequest(def: KindDef, trigger: Trigger | undefined, op: AddElement, context?: AnchorContext): IdRequest { - const where = context ?? this.placementContext(op); - return kindRequest(def, { - ...(def.family === 'event' && trigger && trigger !== 'none' ? { trigger } : {}), - ...(op.name ? { name: op.name } : {}), - ...(where ? { context: where.context, ...(where.anchorKind ? { anchorKind: where.anchorKind } : {}) } : {}), - }); - } - - private bindOutAs(op: AddElement, alias: string, node: El, cs: ChangeSet): void { - const id = idOf(node); - const out = this.doc.outgoing(node); - if (out.length === 1) { - cs.bind(alias, out[0]); - return; - } - const mode = NodePlacementService.modeOf(op); - if (!out.length) { - const why = mode === 'after' ? `--after ${op.after} appends it` : mode === 'none' ? 'no placement' : `--${mode}`; - throw usageError(`outAs ${alias}: ${id} gets no flow out of it from this placement (${why})`, { - element: id, - hint: 'outAs names the flow an add creates out of the new node: a splice, a prepend or "to" creates one. Otherwise connect the node with a connect op that has "as".', - }); - } - const ends = out.map((flow) => `${idOf(flow)} to ${idOf(flow.get('targetRef'))}`).join(', '); - throw usageError(`outAs ${alias}: ${id} gets ${out.length} flows out of it from this placement (${ends})`, { - element: id, - related: out.map(idOf), - hint: `outAs names one flow: drop "to" and connect ${id} to ${op.to} with a connect op that has "as" (outAs then names the other flow).`, - }); - } - - /** - * Binds only when exactly one flow leaves the existing node, so a re-run never guesses: - * a spliced gateway gets more branches later, in any order. - */ - private bindExistingOutAs(alias: string, existing: El, cs: ChangeSet): void { - const id = idOf(existing); - const flowNode = is(existing, 'bpmn:FlowNode'); - const out = flowNode ? this.doc.outgoing(existing) : []; - if (out.length === 1) { - cs.bind(alias, out[0]); - return; - } - const ids = out.map(idOf); - const what = !flowNode ? `is a ${kindLabel(existing)}, no flow node` : out.length ? `has ${out.length} flows out of it (${ids.join(', ')})` : 'has no flow out of it'; - throw usageError(`outAs ${alias}: ${id} already exists and ${what}`, { - element: id, - ...(ids.length ? { related: ids, candidates: ids } : {}), - hint: 'With ifAbsent, outAs binds the flow out of an existing node only when exactly one leaves it: drop "outAs" there and use the flow\'s id.', - }); - } - - private static rejectPlacement(op: AddElement, allowed: ReadonlyArray<(typeof PLACEMENT_KEYS)[number] | 'to'>, kind: string): void { - for (const key of [...PLACEMENT_KEYS, 'to'] as const) { - if (op[key] !== undefined && !allowed.includes(key)) { - throw modelError('E_INVALID_PLACEMENT', `--${key} does not apply to ${kind}`, { - hint: allowed.length ? `${kind} accepts ${allowed.map((option) => `--${option}`).join(', ')} only.` : `${kind} takes no placement options.`, - }); - } - } - } - - private addDocumentation(el: El, text: string | undefined): void { - if (!text) return; - addTo(el, 'documentation', this.doc.moddle.create('bpmn:Documentation', { text })); - } - - private static inferTrigger(opts: TriggerOptions): Trigger | undefined { - if (opts.timer !== undefined) return 'timer'; - if (opts.message !== undefined) return 'message'; - if (opts.error !== undefined || opts.errorCode !== undefined) return 'error'; - if (opts.signal !== undefined) return 'signal'; - if (opts.escalation !== undefined || opts.escalationCode !== undefined) return 'escalation'; - if (opts.when !== undefined) return 'conditional'; - if (opts.link !== undefined) return 'link'; - return undefined; - } - - private static hasTriggerOptions(opts: TriggerOptions): boolean { - return ElementCreationService.inferTrigger(opts) !== undefined || opts.nonInterrupting !== undefined; - } - - private static takesMessage(def: KindDef): boolean { - return def.kind === 'sendTask' || def.kind === 'receiveTask'; - } - - private checkFlowOptions(op: AddElement): void { - new FlowOptions(op).assertConsistent(); - new FlowRules(this.doc).assertCondition(op.condition); - } - - /** Warns about flow options the add ignored; op.flowId counts as used when a spliced flow without id took it. */ - private warnIgnoredFlowOptions(op: AddElement, node: El, entryFlow: El | undefined, cs: ChangeSet): void { - const id = idOf(node); - const mode = NodePlacementService.modeOf(op); - if (mode === 'in' || mode === 'none' || mode === 'on') { - const given = new FlowOptions(op).flags; - if (given.length) { - const why = mode === 'on' ? '--on attaches the event, it' : mode === 'in' ? '--in' : 'a placement without --after/--before/--flow'; - cs.warn({ - code: 'W_OPTION_IGNORED', - message: `${given.join(', ')} ignored for ${id}: ${why} creates no sequence flow into the node`, - element: id, - hint: 'Place the node with --after / --before / --flow, or connect it afterwards with `bpmn connect`.', - }); - } - return; - } - const named = (change: Change): boolean => change.kind === 'sequenceFlow' && change.id === op.flowId; - if (op.flowId && !cs.created.some(named) && !cs.changed.some((change) => named(change) && !!change.detail?.endsWith(renamedNote('')))) { - const entry = entryFlow ?? this.doc.incoming(node)[0]; - const kept = !!entry && this.edit.preferFlowId(entry, op.flowId, node); - cs.warn({ - code: 'W_OPTION_IGNORED', - message: `--flow-id ${op.flowId} ignored: the flow into ${id} is the existing ${entry ? idOf(entry) : 'flow'}, which keeps its id`, - element: id, - ...(entry ? { related: [idOf(entry)] } : {}), - hint: StaleFlowReport.flowIdIgnoredHint(op.flowId, entry && idOf(entry), kept), - }); - } - } - - private reportNewRoots(cs: ChangeSet, fn: () => void): void { - const before = new Set(many(this.doc.definitions, 'rootElements')); - fn(); - for (const root of many(this.doc.definitions, 'rootElements')) { - if (before.has(root)) continue; - const name = root.get('name'); - cs.create({ id: idOf(root), kind: typeLabel(root), ...(name ? { name } : {}), detail: 'root element' }); - } - } - - private placementDetail(op: AddElement): string { - switch (NodePlacementService.modeOf(op)) { - case 'between': - return `between ${op.after} and ${op.before}`; - case 'after': - return `after ${op.after}`; - case 'before': - return `before ${op.before}`; - case 'flow': - return `in flow ${op.flow}`; - case 'on': - return `on ${op.on}`; - default: - return `in ${idOf(new NodePlacementService(this.doc, this.edit, op).scope())}`; - } - } - - private resolveLane(op: AddElement): El | undefined { - if (!op.lane) return undefined; - const lane = this.edit.require(op.lane, 'bpmn:Lane', 'lane'); - const scope = new NodePlacementService(this.doc, this.edit, op).scope(); - if (!is(scope, 'bpmn:Process')) { - throw modelError('E_INVALID_LANE_MEMBERSHIP', `Nodes inside sub-process ${idOf(scope)} cannot be lane members`, { - element: op.lane, - hint: 'Only nodes directly in the process can be assigned to lanes; drop --lane.', - }); - } - return lane; - } - - /** - * Adds a flow node. An event sub-process gets its start trigger here, so it is valid in one op; - * the inherited lane is read before the placement, as a splice may rename the flow op.flow - * names; the event-based gateway rule checks the node's flows once the trigger is set. - */ - private addFlowNode(op: AddElement, def: KindDef, trigger: Trigger | undefined, cs: ChangeSet, context: AnchorContext | undefined): El { - this.checkFlowOptions(op); - const explicitLane = this.resolveLane(op); - const id = this.edit.allocator.explicitOr(op.id, this.elementRequest(def, trigger ?? ElementCreationService.inferTrigger(op), op, context)); - const el = this.doc.create(def.type, { id, ...(op.name ? { name: op.name } : {}), ...(def.props ?? {}) }); - const isEvent = def.family === 'event'; - const isEventSub = def.kind === 'eventSubProcess'; - const startTrigger = isEventSub ? (trigger ?? ElementCreationService.inferTrigger(op)) : undefined; - const messageTask = ElementCreationService.takesMessage(def) && op.message !== undefined; - if (!isEvent && !startTrigger && ElementCreationService.hasTriggerOptions(messageTask ? { ...op, message: undefined } : op)) { - cs.warn({ - code: 'W_OPTION_IGNORED', - message: isEventSub ? `Trigger options are ignored for eventSubProcess ${id}: no trigger given` : `Trigger options are ignored for ${def.kind} ${id}`, - element: id, - hint: isEventSub - ? 'Use eventSubProcess: or --error/--message/--timer/... to create the start event with the sub-process.' - : `Trigger options apply to events only${ElementCreationService.takesMessage(def) ? ' (a send / receive task takes --message)' : ''}.`, - }); - } - if (op.collapsed && def.family !== 'subProcess') { - cs.warn({ code: 'W_OPTION_IGNORED', message: `--collapsed is ignored for ${def.kind} ${id}`, element: id, hint: '--collapsed applies to sub-processes only.' }); - } - - const entry = { id, kind: def.kind, ...(op.name ? { name: op.name } : {}), detail: this.placementDetail(op) }; - cs.create(entry); - const inherited = explicitLane ? LaneInheritance.NONE : LaneInheritance.of(this.doc, op); - const entryFlow = new NodePlacementService(this.doc, this.edit, op).place(el, cs); - this.warnIgnoredFlowOptions(op, el, entryFlow, cs); - if (op.flowAs) { - const flow = entryFlow ?? (NodePlacementService.modeOf(op) === 'before' ? this.doc.outgoing(el)[0] : undefined); - if (!flow) { - throw usageError(`flowAs ${op.flowAs}: ${id} gets no flow from this placement (${NodePlacementService.modeOf(op) === 'none' ? 'no placement' : `--${NodePlacementService.modeOf(op)}`})`, { - element: id, - hint: 'Place the node with after / before / flow, or connect it with a connect op that has "as".', - }); - } - cs.bind(op.flowAs, flow); - } - if (op.outAs) this.bindOutAs(op, op.outAs, el, cs); - - if (isEvent) { - let resolved = trigger ?? ElementCreationService.inferTrigger(op); - if (!resolved) { - const scope = this.doc.scopeOf(el); - if (is(el, 'bpmn:BoundaryEvent')) { - throw modelError('E_TRIGGER_REQUIRED', `Boundary event ${id} needs a trigger`, { - element: id, - candidates: def.triggers ?? [], - hint: `Use boundaryEvent:, e.g. boundaryEvent:timer --timer P2D or boundaryEvent:error --error PaymentFailed.`, - }); - } - if (is(el, 'bpmn:StartEvent') && isEventSubProcess(scope)) { - throw modelError('E_TRIGGER_REQUIRED', `Start event ${id} inside event sub-process ${idOf(scope!)} needs a trigger`, { - element: id, - candidates: (def.triggers ?? []).filter((kindTrigger) => kindTrigger !== 'none'), - hint: 'Use startEvent:, e.g. startEvent:error --error PaymentFailed or startEvent:timer --timer R/PT1H.', - }); - } - resolved = 'none'; - } - this.reportNewRoots(cs, () => new EventDefinitionService(this.doc, this.edit, el).apply(resolved, op)); - if (resolved !== 'none') entry.kind = kindLabel(el); - } - if (messageTask) new PropertyService(this.doc, this.edit).setTaskMessage(el, op.message ?? '', cs); - if (new Camunda8Defaults(this.doc, this.edit).userTask(el)) cs.note(`${id} is a Camunda user task (zeebe:userTask), like Camunda Modeler creates them`); - for (const flow of new Set([...this.doc.incoming(el), ...this.doc.outgoing(el)])) new EventGatewayRule(this.doc, this.edit).warnFlow(flow, cs); - if (op.collapsed && def.family === 'subProcess') { - this.doc.layoutRequests.collapse(id); - cs.note(`${id} will be laid out collapsed`); - } - this.addDocumentation(el, op.doc); - - if (explicitLane) { - new LaneService(this.doc, this.edit).assign(el, explicitLane, cs); - } else { - const scope = this.doc.scopeOf(el); - if (inherited.lane && scope && is(scope, 'bpmn:Process')) { - new LaneService(this.doc, this.edit).assign(el, inherited.lane, cs); - const warning = inherited.warning(id); - if (warning) cs.warn(warning); - } - } - if (startTrigger) { - const startCs = this.add({ op: 'add', kind: `startEvent:${startTrigger}`, in: id, ...EventTrigger.optionsOf(op) }); - const start = startCs.created[0]; - if (start) start.detail = `start of event sub-process ${id}`; - cs.merge(startCs); - } - return el; - } - - /** `idContext` names an unnamed element's id when the placement cannot (split). */ - add(op: AddElement, idContext?: AnchorContext): ChangeSet { - const cs = new ChangeSet(); - const { def, trigger } = ElementCreationService.resolveKind(op.kind); - - if (op.ifAbsent && !op.id) { - const base = this.edit.allocator.baseOf(this.elementRequest(def, trigger, op)); - throw usageError('--if-absent needs an explicit --id to check for', { - hint: `Pass --id ${base} (the id add generates${op.name ? ` for "${op.name}"` : ''}), or drop --if-absent.`, - }); - } - if (op.ifAbsent && op.id && this.doc.has(op.id)) { - const existing = this.doc.get(op.id)!; - cs.note(`${op.id} already exists (${kindLabel(existing)}); nothing to do`); - cs.bind(op.as, existing); - if (op.flowAs) cs.bind(op.flowAs, this.doc.incoming(existing)[0]); - if (op.outAs) this.bindExistingOutAs(op.outAs, existing, cs); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, existing); - if (kindLabel(existing) !== def.kind && !kindLabel(existing).startsWith(`${def.kind}:`)) { - cs.warn({ - code: 'W_KIND_MISMATCH', - message: `${op.id} exists but is a ${kindLabel(existing)}, not a ${def.kind}`, - element: op.id, - hint: 'Use `retype` to change its kind, or pick another id.', - }); - } - return cs; - } - - if (op.outAs && !FLOW_NODE_FAMILIES.has(def.family)) { - throw usageError(`outAs ${op.outAs}: add ${def.kind} creates no sequence flow out of it`, { - hint: 'outAs names the sequence flow an add creates out of a new flow node; drop it, and name a connection with a connect op that has "as".', - }); - } - let el: El; - switch (def.family) { - case 'participant': { - ElementCreationService.rejectPlacement(op, [], 'participant'); - const before = new Set(this.doc.processes()); - el = new PoolService(this.doc, this.edit).createParticipant(op, cs); - for (const process of this.doc.processes()) if (!before.has(process)) this.doc.initProcess(process, this.edit.platform()); - break; - } - case 'lane': - ElementCreationService.rejectPlacement(op, ['in'], 'lane'); - el = new LaneService(this.doc, this.edit).createLane(op, cs); - break; - case 'data': { - ElementCreationService.rejectPlacement(op, ['in', 'to'], def.kind); - const scope = this.edit.requireScope(op.in); - const opts = { ...(op.id ? { id: op.id } : {}), ...(op.name ? { name: op.name } : {}) }; - el = def.kind === 'dataStore' ? new ArtifactService(this.doc, this.edit).createDataStore(scope, opts, cs) : new ArtifactService(this.doc, this.edit).createDataObject(scope, opts, cs); - if (op.to) new AssociationService(this.doc, this.edit).createDataAssociation(el, this.edit.require(op.to), {}, cs); - break; - } - case 'artifact': { - if (def.kind === 'group') { - ElementCreationService.rejectPlacement(op, [], def.kind); - el = new ArtifactService(this.doc, this.edit).createGroup({ ...(op.id ? { id: op.id } : {}), ...(op.name ? { name: op.name } : {}), ...(op.members ? { members: op.members } : {}) }, cs); - break; - } - ElementCreationService.rejectPlacement(op, ['in', 'to'], def.kind); - const scope = this.edit.requireScope(op.in); - const text = op.text ?? op.name; - el = new ArtifactService(this.doc, this.edit).createTextAnnotation(scope, { ...(op.id ? { id: op.id } : {}), ...(op.name ? { name: op.name } : {}), ...(text ? { text } : {}) }, cs); - if (op.to) new AssociationService(this.doc, this.edit).createAssociation(this.edit.require(op.to), el, {}, cs); - break; - } - default: - el = this.addFlowNode(op, def, trigger, cs, idContext); - break; - } - - if (!FLOW_NODE_FAMILIES.has(def.family)) { - this.addDocumentation(el, op.doc); - if (op.lane) { - cs.warn({ code: 'W_OPTION_IGNORED', message: `--lane is ignored for ${def.kind} ${idOf(el)}`, element: idOf(el), hint: 'Only flow nodes can be lane members.' }); - } - } - if (op.set && Object.keys(op.set).length) { - cs.merge(new PropertyService(this.doc, this.edit).set({ op: 'set', id: idOf(el), values: op.set })); - } - cs.bind(op.as, el); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, el); - this.edit.allocator.reportSuffixed(cs); - this.doc.invalidate(); - return cs; - } -} diff --git a/src/domain/editing/AssociationService.ts b/src/domain/editing/connections/Associations.ts similarity index 64% rename from src/domain/editing/AssociationService.ts rename to src/domain/editing/connections/Associations.ts index 53915419..da585b88 100644 --- a/src/domain/editing/AssociationService.ts +++ b/src/domain/editing/connections/Associations.ts @@ -1,13 +1,11 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError } from '../document/utils/errors.js'; -import { kindLabel, triggerOf } from '../document/utils/kinds.js'; -import { ElementLabels } from '../document/ids/ElementLabels.js'; -import { typeRequest } from '../document/utils/requests.js'; -import { addTo, is, many, removeFrom, type El, idOf } from '../document/utils/moddle.js'; -import type { ChangeSet } from '../document/ChangeSet.js'; -import { allocateId } from './utils/allocate-id.js'; -import { artifactContainerOf, isDataReference } from './utils/artifacts.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError } from '../../document/utils/errors.js'; +import { ElementLabels } from '../../document/ids/ElementLabels.js'; +import { kindLabel, triggerOf } from '../../document/Kinds.js'; +import { addTo, is, many, type El, idOf, removeFrom } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { artifactContainerOf, isDataReference } from '../utils/artifacts.js'; +import { typeRequest } from '../../document/utils/requests.js'; /** @internal */ export const PLACEHOLDER_PROPERTY = '__targetRef_placeholder'; @@ -16,21 +14,18 @@ export const PLACEHOLDER_PROPERTY = '__targetRef_placeholder'; * Creates and removes associations and data associations. * A data input association targets a placeholder bpmn:Property on the node (bpmn-js convention). */ -export class AssociationService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class Associations { + constructor(private readonly edit: EditSession) {} /** * One end must be a text annotation, or the pair is a compensate boundary event -> * compensation handler (associationDirection=One, the handler gets isForCompensation). */ - createAssociation(source: El, target: El, opts: { id?: string }, cs: ChangeSet): El { - const doc = this.doc; + createAssociation(source: El, target: El, opts: { id?: string }, changes: ChangeSet): El { + const doc = this.edit.doc; const sourceIsNote = is(source, 'bpmn:TextAnnotation'); const targetIsNote = is(target, 'bpmn:TextAnnotation'); - const compensation = !sourceIsNote && !targetIsNote && this.isCompensationPair(source, target); + const compensation = !sourceIsNote && !targetIsNote && Associations.isCompensationPair(source, target); if (sourceIsNote && targetIsNote) { throw modelError('E_INVALID_ENDPOINT', `Cannot associate two text annotations (${idOf(source)}, ${idOf(target)})`, { element: idOf(source), @@ -50,7 +45,7 @@ export class AssociationService { if (!container) { throw modelError('E_INVALID_SCOPE', `Cannot find a process or collaboration to hold the association of ${idOf(note)}`, { element: idOf(note) }); } - const id = allocateId(this.edit, typeRequest('bpmn:Association', { context: this.between(source, target) }), opts.id); + const id = this.edit.allocator.explicitOr(opts.id, typeRequest('bpmn:Association', { context: Associations.between(source, target) })); const association = doc.create('bpmn:Association', { id, sourceRef: source, @@ -58,17 +53,17 @@ export class AssociationService { ...(compensation ? { associationDirection: 'One' } : {}), }); addTo(container, 'artifacts', association); - cs.create({ id, kind: 'association', detail: `${idOf(source)} -> ${idOf(target)}` }); + changes.create({ id, kind: 'association', detail: `${idOf(source)} -> ${idOf(target)}` }); if (compensation && !target.get('isForCompensation')) { target.set('isForCompensation', true); - cs.change({ id: idOf(target), kind: kindLabel(target), detail: 'isForCompensation=true (compensation handler)' }); + changes.change({ id: idOf(target), kind: kindLabel(target), detail: 'isForCompensation=true (compensation handler)' }); } doc.invalidate(); return association; } - createDataAssociation(source: El, target: El, opts: { id?: string }, cs: ChangeSet): El { - const doc = this.doc; + createDataAssociation(source: El, target: El, opts: { id?: string }, changes: ChangeSet): El { + const doc = this.edit.doc; const sourceIsData = isDataReference(source); const targetIsData = isDataReference(target); if (sourceIsData === targetIsData) { @@ -79,38 +74,24 @@ export class AssociationService { } let association: El; if (sourceIsData) { - this.assertDataNode(target, 'input', source); - const id = allocateId(this.edit, typeRequest('bpmn:DataInputAssociation', { context: this.between(source, target) }), opts.id); + Associations.assertDataNode(target, 'input', source); + const id = this.edit.allocator.explicitOr(opts.id, typeRequest('bpmn:DataInputAssociation', { context: Associations.between(source, target) })); association = doc.create('bpmn:DataInputAssociation', { id, sourceRef: [source], targetRef: this.placeholderProperty(target) }); addTo(target, 'dataInputAssociations', association); } else { - this.assertDataNode(source, 'output', target); - const id = allocateId(this.edit, typeRequest('bpmn:DataOutputAssociation', { context: this.between(source, target) }), opts.id); + Associations.assertDataNode(source, 'output', target); + const id = this.edit.allocator.explicitOr(opts.id, typeRequest('bpmn:DataOutputAssociation', { context: Associations.between(source, target) })); association = doc.create('bpmn:DataOutputAssociation', { id, targetRef: target }); addTo(source, 'dataOutputAssociations', association); } - cs.create({ id: idOf(association), kind: 'dataAssociation', detail: `${idOf(source)} -> ${idOf(target)}` }); + changes.create({ id: idOf(association), kind: 'dataAssociation', detail: `${idOf(source)} -> ${idOf(target)}` }); doc.invalidate(); return association; } - dataAssociationEnds(association: El): { source?: El; target?: El } { - const node = association.$parent as El | undefined; - if (is(association, 'bpmn:DataInputAssociation')) { - return { source: many(association, 'sourceRef')[0], target: node }; - } - return { source: node, target: association.get('targetRef') }; - } - - removeAssociation(association: El): void { - const container = association.$parent as El | undefined; - if (container) removeFrom(container, 'artifacts', association); - this.doc.ids.release(idOf(association)); - this.doc.invalidate(); - } - + /** @internal */ removeDataAssociation(association: El): void { - const doc = this.doc; + const doc = this.edit.doc; const node = association.$parent as El | undefined; if (node) { if (is(association, 'bpmn:DataInputAssociation')) { @@ -129,23 +110,23 @@ export class AssociationService { doc.invalidate(); } - private between(source: El, target: El): string { + private static between(source: El, target: El): string { return `${new ElementLabels(source).label()} To ${new ElementLabels(target).label()}`; } - private isCompensationPair(source: El, target: El): boolean { + private static isCompensationPair(source: El, target: El): boolean { return is(source, 'bpmn:BoundaryEvent') && triggerOf(source) === 'compensate' && is(target, 'bpmn:Activity'); } private placeholderProperty(node: El): El { const existing = many(node, 'properties').find((candidate) => candidate.get('name') === PLACEHOLDER_PROPERTY); if (existing) return existing; - const property = this.doc.create('bpmn:Property', { id: this.edit.allocator.allocate(typeRequest('bpmn:Property', { context: new ElementLabels(node).label() })).id, name: PLACEHOLDER_PROPERTY }); + const property = this.edit.doc.create('bpmn:Property', { id: this.edit.allocator.allocate(typeRequest('bpmn:Property', { context: new ElementLabels(node).label() })).id, name: PLACEHOLDER_PROPERTY }); addTo(node, 'properties', property); return property; } - private assertDataNode(node: El, direction: 'input' | 'output', data: El): void { + private static assertDataNode(node: El, direction: 'input' | 'output', data: El): void { const ok = direction === 'input' ? is(node, 'bpmn:Activity') || is(node, 'bpmn:ThrowEvent') : is(node, 'bpmn:Activity') || is(node, 'bpmn:CatchEvent'); if (ok) return; throw modelError( diff --git a/src/domain/editing/flows/BranchSplitService.ts b/src/domain/editing/connections/BranchSplit.ts similarity index 51% rename from src/domain/editing/flows/BranchSplitService.ts rename to src/domain/editing/connections/BranchSplit.ts index 22481ea3..ffc06db5 100644 --- a/src/domain/editing/flows/BranchSplitService.ts +++ b/src/domain/editing/connections/BranchSplit.ts @@ -1,8 +1,7 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import { BpmnError } from '../../document/BpmnError.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError, usageError } from '../../document/utils/errors.js'; -import { gatewayKindList, parseKind } from '../../document/utils/kinds.js'; -import { KindError } from '../../document/KindError.js'; +import { gatewayKindList, parseKind } from '../../document/Kinds.js'; import { is, type El, idOf } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; import { IdStyle } from '../../document/ids/IdStyle.js'; @@ -13,20 +12,17 @@ import { renamedNote } from '../../document/utils/renames.js'; import { insertAfterInScope } from '../utils/declaration-order.js'; import { FlowOptions } from './FlowOptions.js'; import type { AddElement, FlowOptions as FlowOptionValues, SplitIntoBranches } from '../../commands/types.js'; -import { SequenceFlowService } from './SequenceFlowService.js'; -import { ElementCreationService } from '../ElementCreationService.js'; -import { ConnectionService } from '../ConnectionService.js'; +import { SequenceFlows } from './SequenceFlows.js'; +import { ElementCreation } from '../elements/ElementCreation.js'; +import { Connections } from './Connections.js'; /** * The `split` macro: a split gateway, its branches and an optional join, built from - * ElementCreationService / ConnectionService calls so the usual warnings apply. A branch ending in an + * ElementCreation / Connections calls so the usual warnings apply. A branch ending in an * end event terminates there; a join needs two branches that reach it. */ -export class BranchSplitService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class BranchSplit { + constructor(private readonly edit: EditSession) {} private static gatewayKind(token: string | undefined): string { const kind = token ?? 'exclusiveGateway'; @@ -34,7 +30,7 @@ export class BranchSplitService { try { def = parseKind(kind).def; } catch (err) { - if (err instanceof KindError) throw modelError('E_UNKNOWN_KIND', err.message, { candidates: err.candidates }); + if (err instanceof BpmnError) throw modelError('E_UNKNOWN_KIND', err.message, { candidates: err.details.candidates ?? [] }); throw err; } if (def.family !== 'gateway') { @@ -46,8 +42,8 @@ export class BranchSplitService { } /** An add's created entry is always first. */ - private static createdId(cs: ChangeSet): string { - const first = cs.created[0]; + private static createdId(changes: ChangeSet): string { + const first = changes.created[0]; if (!first) throw modelError('E_INTERNAL', 'add reported no created element'); return first.id; } @@ -58,14 +54,14 @@ export class BranchSplitService { * A join named by declared conventions rather than the file's style is recorded for the * result's `conventions:` line. */ - private joinIdFor(op: SplitIntoBranches, gateway: El, cs: ChangeSet, kind: string): { id: string; base?: string } { - if (op.joinId) return { id: op.joinId }; + private joinIdFor(command: SplitIntoBranches, gateway: El, changes: ChangeSet, kind: string): { id: string; base?: string } { + if (command.joinId) return { id: command.joinId }; const gatewayId = idOf(gateway); const base = this.edit.idStyle.joinId(gatewayId, kind, new ElementLabels(gateway).label()); const sep = this.edit.idStyle.suffixSep(kind); const id = this.edit.allocator.suffixed(base, sep); if (id !== base) { - cs.warn({ code: 'W_ID_SUFFIXED', message: `Id ${base} is already taken; using ${id}`, element: id, hint: 'Pass joinId to choose the join id yourself.' }); + changes.warn({ code: 'W_ID_SUFFIXED', message: `Id ${base} is already taken; using ${id}`, element: id, hint: 'Pass joinId to choose the join id yourself.' }); } const style = this.edit.idStyle; if (style.declared && style.explain(IdStyle.keyRequest(kind)).source === 'lint' && base !== style.learnedOnly().joinId(gatewayId, kind)) this.edit.allocator.recordDeclared(id); @@ -75,54 +71,54 @@ export class BranchSplitService { /** * Splits after op.after into a gateway, its branches and a join. Branches ending in an end * event terminate there instead of reaching the join; a generated join id is marked generated, - * not the caller's (ElementCreationService.add claimed it as given). + * not the caller's (ElementCreation claimed it as given). */ - split(op: SplitIntoBranches): ChangeSet { - const cs = new ChangeSet(); - const kind = BranchSplitService.gatewayKind(op.kind); - if (op.branches !== undefined && op.branches !== null) new CommandParser().parseBranches(op.branches); - if (!op.branches?.length) { + split(command: SplitIntoBranches): ChangeSet { + const changes = new ChangeSet(); + const kind = BranchSplit.gatewayKind(command.kind); + if (command.branches !== undefined && command.branches !== null) new CommandParser().parseBranches(command.branches); + if (!command.branches?.length) { throw modelError('E_INVALID_VALUE', 'split needs at least one branch', { hint: 'Pass branches: [{ nodes: [...] }, ...].' }); } - const anchor = this.edit.require(op.after, 'bpmn:FlowNode'); - const scope = this.doc.scopeOf(anchor); - if (!scope) throw modelError('E_INVALID_PLACEMENT', `${op.after} is not inside a process`, { element: op.after }); - const outgoing = this.doc.outgoing(anchor); + const anchor = this.edit.require(command.after, 'bpmn:FlowNode'); + const scope = this.edit.doc.scopeOf(anchor); + if (!scope) throw modelError('E_INVALID_PLACEMENT', `${command.after} is not inside a process`, { element: command.after }); + const outgoing = this.edit.doc.outgoing(anchor); const isGateway = is(anchor, 'bpmn:Gateway'); if (!isGateway && outgoing.length > 1) { - throw modelError('E_HAS_SUCCESSOR', `${op.after} already has ${outgoing.length} outgoing flows; split after a node with at most one`, { - element: op.after, + throw modelError('E_HAS_SUCCESSOR', `${command.after} already has ${outgoing.length} outgoing flows; split after a node with at most one`, { + element: command.after, candidates: outgoing.map(idOf), hint: 'Insert a node into the wanted flow first (`add ... --flow `) and split after it.', }); } const spliced = !isGateway && outgoing.length === 1 ? outgoing[0]! : undefined; const successor = spliced?.get('targetRef'); - const withJoin = op.join !== false; - const lane = laneOf(this.doc, anchor); + const withJoin = command.join !== false; + const lane = laneOf(this.edit.doc, anchor); const laneOpt = lane ? { lane: idOf(lane) } : {}; - const gwCs = new ElementCreationService(this.doc, this.edit).add({ op: 'add', kind, ...(op.name ? { name: op.name } : {}), ...(op.id ? { id: op.id } : {}), in: idOf(scope), ...laneOpt }, new ElementLabels(anchor).anchorContext('After')); - cs.merge(gwCs); - const gatewayId = BranchSplitService.createdId(gwCs); + const gwCs = new ElementCreation(this.edit).add({ op: 'add', kind, ...(command.name ? { name: command.name } : {}), ...(command.id ? { id: command.id } : {}), in: idOf(scope), ...laneOpt }, new ElementLabels(anchor).anchorContext('After')); + changes.merge(gwCs); + const gatewayId = BranchSplit.createdId(gwCs); const gateway = this.edit.require(gatewayId); - cs.bind(op.as, gateway); + changes.bind(command.as, gateway); if (spliced) { - const renamed = new SequenceFlowService(this.doc, this.edit).redirect(spliced, { target: gateway }); + const renamed = new SequenceFlows(this.edit).redirect(spliced, { target: gateway }); insertAfterInScope(scope, spliced, gateway); - cs.change(ChangeSet.flowEntryOf(spliced, `${op.after} -> ${gatewayId} (was -> ${idOf(successor!)})${renamedNote(renamed)}`)); - cs.note(`inserted ${gatewayId} between ${op.after} and ${idOf(successor!)}`); + changes.change(ChangeSet.flowEntryOf(spliced, `${command.after} -> ${gatewayId} (was -> ${idOf(successor!)})${renamedNote(renamed)}`)); + changes.note(`inserted ${gatewayId} between ${command.after} and ${idOf(successor!)}`); } else { - const flow = new SequenceFlowService(this.doc, this.edit).create(anchor, gateway); + const flow = new SequenceFlows(this.edit).create(anchor, gateway); insertAfterInScope(scope, flow, gateway); - cs.create(ChangeSet.flowEntryOf(flow)); - cs.note(`appended ${gatewayId} after ${op.after}`); + changes.create(ChangeSet.flowEntryOf(flow)); + changes.note(`appended ${gatewayId} after ${command.after}`); } - const gwEntry = cs.created.find((change) => change.id === gatewayId); - if (gwEntry) gwEntry.detail = spliced ? `between ${op.after} and ${idOf(successor!)}` : `after ${op.after}`; + const gwEntry = changes.created.find((change) => change.id === gatewayId); + if (gwEntry) gwEntry.detail = spliced ? `between ${command.after} and ${idOf(successor!)}` : `after ${command.after}`; const ends: Array<{ end: string; flowOptions: FlowOptionValues; direct: boolean }> = []; - for (const branch of op.branches) { + for (const branch of command.branches) { const flowOptions = FlowOptions.of(branch); if (!branch.nodes?.length) { if (!withJoin && !spliced) { @@ -137,10 +133,10 @@ export class BranchSplitService { let prev = gatewayId; branch.nodes.forEach((node, index) => { const own = FlowOptions.of(node); - const addOp: AddElement = { ...node, ...(index === 0 ? { ...flowOptions, ...own } : own), op: 'add', after: prev }; - const nodeCs = new ElementCreationService(this.doc, this.edit).add(addOp); - cs.merge(nodeCs); - prev = BranchSplitService.createdId(nodeCs); + const addCommand: AddElement = { ...node, ...(index === 0 ? { ...flowOptions, ...own } : own), op: 'add', after: prev }; + const nodeCs = new ElementCreation(this.edit).add(addCommand); + changes.merge(nodeCs); + prev = BranchSplit.createdId(nodeCs); }); ends.push({ end: prev, flowOptions, direct: false }); } @@ -149,8 +145,8 @@ export class BranchSplitService { const continuing = ends.filter((branchEnd) => !terminating.includes(branchEnd)); let joinId: string | undefined; const why = !withJoin ? 'join is false' : !continuing.length ? 'every branch ends in an end event' : continuing.length === 1 ? `only one branch continues (the others end in an end event), and a join needs two` : undefined; - if (op.joinAs && why) { - throw usageError(`joinAs ${op.joinAs}: split ${gatewayId} creates no join gateway (${why})`, { element: gatewayId, hint: 'Drop joinAs, or give another branch that continues.' }); + if (command.joinAs && why) { + throw usageError(`joinAs ${command.joinAs}: split ${gatewayId} creates no join gateway (${why})`, { element: gatewayId, hint: 'Drop joinAs, or give another branch that continues.' }); } const lone = continuing.length === 1 ? continuing[0]! : undefined; if (withJoin && lone?.direct && !successor) { @@ -160,39 +156,39 @@ export class BranchSplitService { }); } if (withJoin && !continuing.length) { - cs.note('every branch ends in an end event; no join gateway created'); + changes.note('every branch ends in an end event; no join gateway created'); } else if (withJoin && lone) { - cs.note(`only the branch ending at ${lone.direct ? gatewayId : lone.end} continues; no join gateway created${successor ? ` (it runs on to ${idOf(successor)})` : ''}`); + changes.note(`only the branch ending at ${lone.direct ? gatewayId : lone.end} continues; no join gateway created${successor ? ` (it runs on to ${idOf(successor)})` : ''}`); } else if (withJoin) { - const wanted = this.joinIdFor(op, gateway, cs, kind); - const joinCs = new ElementCreationService(this.doc, this.edit).add({ op: 'add', kind, ...(op.joinName ? { name: op.joinName } : {}), id: wanted.id, in: idOf(scope), ...laneOpt }); - cs.merge(joinCs); - joinId = BranchSplitService.createdId(joinCs); + const wanted = this.joinIdFor(command, gateway, changes, kind); + const joinCs = new ElementCreation(this.edit).add({ op: 'add', kind, ...(command.joinName ? { name: command.joinName } : {}), id: wanted.id, in: idOf(scope), ...laneOpt }); + changes.merge(joinCs); + joinId = BranchSplit.createdId(joinCs); if (wanted.base !== undefined) this.edit.allocator.markGenerated(joinId, wanted.base); - cs.bind(op.joinAs, this.doc.get(joinId)); - const joinEntry = cs.created.find((change) => change.id === joinId); + changes.bind(command.joinAs, this.edit.doc.get(joinId)); + const joinEntry = changes.created.find((change) => change.id === joinId); if (joinEntry) joinEntry.detail = `join of ${gatewayId}`; } const target = joinId ?? (successor ? idOf(successor) : undefined); - for (const { end } of terminating) cs.note(`branch ending at ${end} terminates (end event)${target ? `; not connected to ${target}` : ''}`); + for (const { end } of terminating) changes.note(`branch ending at ${end} terminates (end event)${target ? `; not connected to ${target}` : ''}`); if (successor && !continuing.length) { - cs.note(`${idOf(successor)} is no longer reached: every branch terminates (connect it with \`bpmn connect ${idOf(successor)}\` or remove it)`); + changes.note(`${idOf(successor)} is no longer reached: every branch terminates (connect it with \`bpmn connect ${idOf(successor)}\` or remove it)`); } if (target) { for (const { end, flowOptions, direct } of continuing) { const opts = direct ? flowOptions : {}; - cs.merge( - new ConnectionService(this.doc, this.edit).connect({ op: 'connect', source: end, target, ...new FlowOptions(opts).toConnect() }), + changes.merge( + new Connections(this.edit).connect({ op: 'connect', source: end, target, ...new FlowOptions(opts).toConnect() }), ); } } if (joinId && successor) { - cs.merge(new ConnectionService(this.doc, this.edit).connect({ op: 'connect', source: joinId, target: idOf(successor) })); + changes.merge(new Connections(this.edit).connect({ op: 'connect', source: joinId, target: idOf(successor) })); } const branchIds = ends.map((branchEnd) => (branchEnd.direct ? '(direct)' : terminating.includes(branchEnd) ? `${branchEnd.end} (end event)` : branchEnd.end)); - cs.note(`split ${gatewayId}: ${ends.length} branch(es) ending at ${branchIds.join(', ')}${joinId ? `, joined at ${joinId}` : ''}${successor && continuing.length ? `, continues to ${idOf(successor)}` : ''}`); - this.doc.invalidate(); - return cs; + changes.note(`split ${gatewayId}: ${ends.length} branch(es) ending at ${branchIds.join(', ')}${joinId ? `, joined at ${joinId}` : ''}${successor && continuing.length ? `, continues to ${idOf(successor)}` : ''}`); + this.edit.doc.invalidate(); + return changes; } } diff --git a/src/domain/editing/ConnectionService.ts b/src/domain/editing/connections/Connections.ts similarity index 56% rename from src/domain/editing/ConnectionService.ts rename to src/domain/editing/connections/Connections.ts index 983b84fb..dade0d6b 100644 --- a/src/domain/editing/ConnectionService.ts +++ b/src/domain/editing/connections/Connections.ts @@ -1,29 +1,24 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError } from '../document/utils/errors.js'; -import { ElementLabels } from '../document/ids/ElementLabels.js'; -import { isEventSubProcess, kindLabel, triggerOf } from '../document/utils/kinds.js'; -import { addTo, is, many, type El, idOf } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { artifactContainerOf, isDataReference } from './utils/artifacts.js'; -import { AssociationService } from './AssociationService.js'; -import { FlowOptions } from './flows/FlowOptions.js'; -import type { ConnectElements } from '../commands/types.js'; -import { EventGatewayRule } from './events/EventGatewayRule.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; -import { RootElementService } from './events/RootElementService.js'; - -export type ConnectionKind = 'sequenceFlow' | 'messageFlow' | 'association' | 'dataAssociation'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError } from '../../document/utils/errors.js'; +import { ElementLabels } from '../../document/ids/ElementLabels.js'; +import { isEventSubProcess, kindLabel, triggerOf } from '../../document/Kinds.js'; +import { addTo, is, many, type El, idOf } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { artifactContainerOf, isDataReference } from '../utils/artifacts.js'; +import { FlowOptions } from './FlowOptions.js'; +import type { ConnectElements } from '../../commands/types.js'; +import type { ConnectionKind } from '../../diagram/di/types.js'; +import { EventGatewayRule } from '../events/EventGatewayRule.js'; +import { SequenceFlows } from './SequenceFlows.js'; +import { RootElements } from '../events/RootElements.js'; +import { Associations } from './Associations.js'; /** * Runs the `connect` op: infers the connection kind (sequence flow, message flow, * association or data association) from the two endpoints. */ -export class ConnectionService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class Connections { + constructor(private readonly edit: EditSession) {} private static describe(el: El): string { return `${idOf(el)} (${kindLabel(el)})`; @@ -35,12 +30,12 @@ export class ConnectionService { if (is(source, 'bpmn:Participant') || is(target, 'bpmn:Participant')) return 'messageFlow'; if (is(source, 'bpmn:FlowNode') && is(target, 'bpmn:FlowNode')) { if (is(source, 'bpmn:BoundaryEvent') && triggerOf(source) === 'compensate' && is(target, 'bpmn:Activity')) return 'association'; - const ps = this.doc.processOf(source); - const pt = this.doc.processOf(target); + const ps = this.edit.doc.processOf(source); + const pt = this.edit.doc.processOf(target); if (ps && pt && ps !== pt) return 'messageFlow'; return 'sequenceFlow'; } - throw modelError('E_INVALID_ENDPOINT', `Cannot connect ${ConnectionService.describe(source)} with ${ConnectionService.describe(target)}`, { + throw modelError('E_INVALID_ENDPOINT', `Cannot connect ${Connections.describe(source)} with ${Connections.describe(target)}`, { element: idOf(source), related: [idOf(target)], hint: 'connect joins flow nodes (sequence flow), nodes in different pools (message flow), a text annotation (association) or a data object/store (data association).', @@ -49,13 +44,13 @@ export class ConnectionService { private participantFor(el: El): El | undefined { if (is(el, 'bpmn:Participant')) return el; - const process = this.doc.processOf(el); - return process ? this.doc.participantOf(process) : undefined; + const process = this.edit.doc.processOf(el); + return process ? this.edit.doc.participantOf(process) : undefined; } private static assertMessageEndpoint(el: El, role: 'source' | 'target'): void { const fail = (why: string, hint: string): never => { - throw modelError('E_INVALID_ENDPOINT', `${ConnectionService.describe(el)} cannot be the ${role} of a message flow: ${why}`, { element: idOf(el), hint }); + throw modelError('E_INVALID_ENDPOINT', `${Connections.describe(el)} cannot be the ${role} of a message flow: ${why}`, { element: idOf(el), hint }); }; if (!is(el, 'bpmn:InteractionNode')) fail('not an interaction node', 'Message flows connect participants, tasks, events and sub-processes; not gateways or data.'); if (isEventSubProcess(el)) fail('event sub-processes cannot send or receive messages', 'Target a message start event inside it instead.'); @@ -67,8 +62,8 @@ export class ConnectionService { } } - private createMessageFlow(source: El, target: El, op: ConnectElements, cs: ChangeSet): El { - const collab = this.doc.collaboration(); + private createMessageFlow(source: El, target: El, command: ConnectElements, changes: ChangeSet): El { + const collab = this.edit.doc.collaboration(); if (!collab) { throw modelError('E_NO_COLLABORATION', 'Message flows need pools; the file has no collaboration', { element: idOf(source), @@ -76,8 +71,8 @@ export class ConnectionService { hint: 'Add participants first: `add participant ""` wraps the existing process, a second one gets its own process.', }); } - ConnectionService.assertMessageEndpoint(source, 'source'); - ConnectionService.assertMessageEndpoint(target, 'target'); + Connections.assertMessageEndpoint(source, 'source'); + Connections.assertMessageEndpoint(target, 'target'); const ps = this.participantFor(source); const pt = this.participantFor(target); for (const [el, property] of [ @@ -85,7 +80,7 @@ export class ConnectionService { [target, pt], ] as const) { if (!property) { - throw modelError('E_NO_PARTICIPANT', `${ConnectionService.describe(el)} is not inside a participant; message flows only connect pools`, { + throw modelError('E_NO_PARTICIPANT', `${Connections.describe(el)} is not inside a participant; message flows only connect pools`, { element: idOf(el), hint: 'Bind its process to a participant with `add participant "" --process `.', }); @@ -98,31 +93,31 @@ export class ConnectionService { hint: 'Use a sequence flow inside a pool.', }); } - const id = this.edit.allocator.explicitOr(op.id, ElementLabels.flowRequest('bpmn:MessageFlow', source, target)); - const message = op.message ? new RootElementService(this.doc, this.edit).ensure('bpmn:Message', op.message) : undefined; + const id = this.edit.allocator.explicitOr(command.id, ElementLabels.flowRequest('bpmn:MessageFlow', source, target)); + const message = command.message ? new RootElements(this.edit).ensure('bpmn:Message', command.message) : undefined; if (message?.created) { const name = message.el.get('name'); - cs.create({ id: idOf(message.el), kind: 'message', ...(name ? { name } : {}), detail: 'root element' }); + changes.create({ id: idOf(message.el), kind: 'message', ...(name ? { name } : {}), detail: 'root element' }); } - const flow = this.doc.create('bpmn:MessageFlow', { + const flow = this.edit.doc.create('bpmn:MessageFlow', { id, - ...(op.name ? { name: op.name } : {}), + ...(command.name ? { name: command.name } : {}), sourceRef: source, targetRef: target, ...(message ? { messageRef: message.el } : {}), }); addTo(collab, 'messageFlows', flow); - cs.create({ id, kind: 'messageFlow', ...(op.name ? { name: op.name } : {}), detail: `${idOf(source)} -> ${idOf(target)}` }); - this.doc.invalidate(); + changes.create({ id, kind: 'messageFlow', ...(command.name ? { name: command.name } : {}), detail: `${idOf(source)} -> ${idOf(target)}` }); + this.edit.doc.invalidate(); return flow; } private existingConnection(kind: ConnectionKind, source: El, target: El): El | undefined { switch (kind) { case 'sequenceFlow': - return this.doc.outgoing(source).find((flow) => flow.get('targetRef') === target); + return this.edit.doc.outgoing(source).find((flow) => flow.get('targetRef') === target); case 'messageFlow': - return this.doc.messageFlows().find((flow) => flow.get('sourceRef') === source && flow.get('targetRef') === target); + return this.edit.doc.messageFlows().find((flow) => flow.get('sourceRef') === source && flow.get('targetRef') === target); case 'association': { const containers = [artifactContainerOf(source), artifactContainerOf(target)].filter((container): container is El => !!container); for (const container of containers) { @@ -146,56 +141,56 @@ export class ConnectionService { } } - private static assertOptionsFor(kind: ConnectionKind, op: ConnectElements): void { + private static assertOptionsFor(kind: ConnectionKind, command: ConnectElements): void { const reject = (option: string, allowed: string): never => { throw modelError('E_INVALID_VALUE', `--${option} only applies to ${allowed}, not to a ${kind}`, { - element: op.source, - related: [op.target], + element: command.source, + related: [command.target], }); }; if (kind !== 'sequenceFlow') { - if (op.condition !== undefined) reject('condition', 'sequence flows'); - if (op.default) reject('default', 'sequence flows'); - if (op.language !== undefined) reject('language', 'sequence flows'); + if (command.condition !== undefined) reject('condition', 'sequence flows'); + if (command.default) reject('default', 'sequence flows'); + if (command.language !== undefined) reject('language', 'sequence flows'); } - if (kind !== 'sequenceFlow' && kind !== 'messageFlow' && op.name !== undefined) reject('name', 'sequence and message flows'); - if (kind !== 'messageFlow' && op.message !== undefined) reject('message', 'message flows (between pools)'); - new FlowOptions({ condition: op.condition, default: op.default }).assertConsistent({ element: op.source, related: [op.target] }); + if (kind !== 'sequenceFlow' && kind !== 'messageFlow' && command.name !== undefined) reject('name', 'sequence and message flows'); + if (kind !== 'messageFlow' && command.message !== undefined) reject('message', 'message flows (between pools)'); + new FlowOptions({ condition: command.condition, default: command.default }).assertConsistent({ element: command.source, related: [command.target] }); } /** Connects source and target; the op's alias names the connection, not a root message created with it. */ - connect(op: ConnectElements): ChangeSet { - const cs = new ChangeSet(); - const source = this.edit.require(op.source); - const target = this.edit.require(op.target); + connect(command: ConnectElements): ChangeSet { + const changes = new ChangeSet(); + const source = this.edit.require(command.source); + const target = this.edit.require(command.target); const kind = this.kindOf(source, target); const selfLoop = source === target; if (selfLoop && !(kind === 'sequenceFlow' && is(source, 'bpmn:Activity'))) { - throw modelError('E_INVALID_ENDPOINT', `Cannot connect ${op.source} to itself`, { - element: op.source, + throw modelError('E_INVALID_ENDPOINT', `Cannot connect ${command.source} to itself`, { + element: command.source, hint: 'Only activities (tasks, sub-processes, call activities) can loop back to themselves with a sequence flow; route other loops through a gateway.', }); } - ConnectionService.assertOptionsFor(kind, op); + Connections.assertOptionsFor(kind, command); const existing = kind === 'sequenceFlow' || kind === 'messageFlow' ? this.existingConnection(kind, source, target) : undefined; - if (op.ifAbsent) { + if (command.ifAbsent) { const same = existing ?? this.existingConnection(kind, source, target); if (same) { - cs.note(`${kind} ${idOf(same)} ${op.source} -> ${op.target} already exists; nothing to do`); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, same); - return cs.bind(op.as, same); + changes.note(`${kind} ${idOf(same)} ${command.source} -> ${command.target} already exists; nothing to do`); + RootElements.bindRefAs(changes, command.refAs, same); + return changes.bind(command.as, same); } } if (existing) { - const all = (kind === 'sequenceFlow' ? this.doc.outgoing(source) : this.doc.messageFlows()).filter( + const all = (kind === 'sequenceFlow' ? this.edit.doc.outgoing(source) : this.edit.doc.messageFlows()).filter( (flow) => flow.get('sourceRef') === source && flow.get('targetRef') === target, ); - cs.warn({ + changes.warn({ code: 'W_DUPLICATE_FLOW', - message: `${op.source} -> ${op.target} is already connected by the ${kind} ${all.map(idOf).join(', ')}; this adds another one`, - element: op.source, - related: [op.target, ...all.map(idOf)], + message: `${command.source} -> ${command.target} is already connected by the ${kind} ${all.map(idOf).join(', ')}; this adds another one`, + element: command.source, + related: [command.target, ...all.map(idOf)], hint: kind === 'sequenceFlow' ? `Two flows between the same nodes run the target twice (or join with itself). Use --if-absent to skip an existing connection; remove the extra flow with \`bpmn remove \`.` @@ -206,14 +201,14 @@ export class ConnectionService { let made: El | undefined; switch (kind) { case 'sequenceFlow': { - const flow = (made = new SequenceFlowService(this.doc, this.edit).create(source, target, new FlowOptions({ flowId: op.id, flowName: op.name, condition: op.condition, language: op.language, default: op.default }).toAttrs(true))); - cs.create(ChangeSet.flowEntryOf(flow)); - new EventGatewayRule(this.doc, this.edit).warnFlow(flow, cs); - if (selfLoop) cs.note(`${idOf(source)} loops back to itself; a standard loop marker (\`bpmn set ${idOf(source)} loop=standard\`) is the conventional alternative`); - const out = this.doc.outgoing(source).length; - const inc = this.doc.incoming(target).length; + const flow = (made = new SequenceFlows(this.edit).create(source, target, new FlowOptions({ flowId: command.id, flowName: command.name, condition: command.condition, language: command.language, default: command.default }).toAttrs(true))); + changes.create(ChangeSet.flowEntryOf(flow)); + new EventGatewayRule(this.edit).warnFlow(flow, changes); + if (selfLoop) changes.note(`${idOf(source)} loops back to itself; a standard loop marker (\`bpmn set ${idOf(source)} loop=standard\`) is the conventional alternative`); + const out = this.edit.doc.outgoing(source).length; + const inc = this.edit.doc.incoming(target).length; if (!is(source, 'bpmn:Gateway') && out > 1) { - cs.warn({ + changes.warn({ code: 'W_IMPLICIT_SPLIT', message: `${idOf(source)} now has ${out} outgoing flows (implicit parallel split)`, element: idOf(source), @@ -221,7 +216,7 @@ export class ConnectionService { }); } if (!is(target, 'bpmn:Gateway') && inc > 1) { - cs.warn({ + changes.warn({ code: 'W_IMPLICIT_JOIN', message: `${idOf(target)} now has ${inc} incoming flows (implicit join)`, element: idOf(target), @@ -231,21 +226,21 @@ export class ConnectionService { break; } case 'messageFlow': - made = this.createMessageFlow(source, target, op, cs); + made = this.createMessageFlow(source, target, command, changes); break; case 'association': - made = new AssociationService(this.doc, this.edit).createAssociation(source, target, op.id ? { id: op.id } : {}, cs); + made = new Associations(this.edit).createAssociation(source, target, command.id ? { id: command.id } : {}, changes); break; case 'dataAssociation': - made = new AssociationService(this.doc, this.edit).createDataAssociation(source, target, op.id ? { id: op.id } : {}, cs); + made = new Associations(this.edit).createDataAssociation(source, target, command.id ? { id: command.id } : {}, changes); break; default: break; } - cs.bind(op.as, made); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, made); - this.edit.allocator.reportSuffixed(cs); - this.doc.invalidate(); - return cs; + changes.bind(command.as, made); + RootElements.bindRefAs(changes, command.refAs, made); + this.edit.allocator.reportSuffixed(changes); + this.edit.doc.invalidate(); + return changes; } } diff --git a/src/domain/editing/flows/FlowBridgeService.ts b/src/domain/editing/connections/FlowBridge.ts similarity index 69% rename from src/domain/editing/flows/FlowBridgeService.ts rename to src/domain/editing/connections/FlowBridge.ts index 822ebd22..f6fbe62f 100644 --- a/src/domain/editing/flows/FlowBridgeService.ts +++ b/src/domain/editing/connections/FlowBridge.ts @@ -1,12 +1,11 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError } from '../../document/utils/errors.js'; -import { kindOf, synchronises } from '../../document/utils/kinds.js'; +import { kindOf, synchronises } from '../../document/Kinds.js'; import { is, type El, idOf } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; import { renamedNote } from '../../document/utils/renames.js'; import { EventGatewayRule } from '../events/EventGatewayRule.js'; -import { SequenceFlowService } from './SequenceFlowService.js'; +import { SequenceFlows } from './SequenceFlows.js'; /** The in-flow now running to the successor, the removed out-flow and what it took over. */ export interface BridgedFlow { @@ -24,17 +23,12 @@ type CarryPolicy = 'node' | 'group'; * Reconnects the predecessors of a node that `remove` or `move` takes away to its successor, * so the flow keeps running: a single node, a join (all predecessors) or a moved group. */ -export class FlowBridgeService { - private readonly flows: SequenceFlowService; - +export class FlowBridge { constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - private readonly cs: ChangeSet, + private readonly edit: EditSession, + private readonly changes: ChangeSet, private readonly command: BridgeCommand = 'remove', - ) { - this.flows = new SequenceFlowService(doc, edit); - } + ) {} /** A plain remove joins a non-synchronising merge and refuses a synchronising one. */ forRemoval(node: El, bridge: boolean, all: boolean, removing: ReadonlySet): void { @@ -46,7 +40,7 @@ export class FlowBridgeService { } single(node: El, bridge: boolean, removing?: ReadonlySet): BridgedFlow | undefined { - const doc = this.doc; + const doc = this.edit.doc; const incoming = doc.incoming(node); const outgoing = doc.outgoing(node); if (!bridge || incoming.length !== 1 || outgoing.length !== 1) return this.detach(node, bridge); @@ -54,9 +48,9 @@ export class FlowBridgeService { const outFlow = outgoing[0]!; const predecessor = inFlow.get('sourceRef'); const successor = outFlow.get('targetRef'); - if (predecessor === successor && !FlowBridgeService.canLoopToItself(predecessor)) { + if (predecessor === successor && !FlowBridge.canLoopToItself(predecessor)) { this.detach(node, false); - if (predecessor !== node) this.cs.note(`not bridged: ${idOf(predecessor)} is both predecessor and successor of ${idOf(node)} and cannot loop back to itself`); + if (predecessor !== node) this.changes.note(`not bridged: ${idOf(predecessor)} is both predecessor and successor of ${idOf(node)} and cannot loop back to itself`); return undefined; } this.assertAllowed(node, inFlow, outFlow, removing); @@ -65,7 +59,7 @@ export class FlowBridgeService { /** Exception paths of the group's boundary events are not exits: the move drops them. */ group(group: El[]): void { - const doc = this.doc; + const doc = this.edit.doc; const inside = new Set(); for (const member of group) { inside.add(member); @@ -83,7 +77,7 @@ export class FlowBridgeService { const outFlow = exits[0]!; const predecessor = inFlow.get('sourceRef'); const successor = outFlow.get('targetRef'); - if ((predecessor === successor && !FlowBridgeService.canLoopToItself(predecessor)) || doc.scopeOf(predecessor) !== doc.scopeOf(successor)) return; + if ((predecessor === successor && !FlowBridge.canLoopToItself(predecessor)) || doc.scopeOf(predecessor) !== doc.scopeOf(successor)) return; const via = group.map(idOf).join(', '); this.assertAllowed(outFlow.get('sourceRef'), inFlow, outFlow, undefined, via); this.carry(inFlow, outFlow, via, 'group'); @@ -96,7 +90,8 @@ export class FlowBridgeService { /** Detaches the flows of the node; a self-loop, both incoming and outgoing, is removed and reported once. */ private detach(node: El, bridge: boolean): BridgedFlow | undefined { - const { doc, cs } = this; + const { changes } = this; + const doc = this.edit.doc; const incoming = doc.incoming(node); const outgoing = doc.outgoing(node); if (bridge && incoming.length === 1 && outgoing.length === 1) { @@ -105,15 +100,16 @@ export class FlowBridgeService { if (outFlow.get('targetRef') !== node && inFlow.get('sourceRef') !== node) return this.carry(inFlow, outFlow, idOf(node), 'node'); } for (const flow of new Set([...incoming, ...outgoing])) { - cs.remove(ChangeSet.flowEntryOf(flow)); - this.flows.remove(flow, cs); + changes.remove(ChangeSet.flowEntryOf(flow)); + new SequenceFlows(this.edit).remove(flow, changes); } return undefined; } /** Carries the in-flow on to the out-flow's target; under the group policy the out-flow's id stays claimed, so a flow the move creates afterwards does not reuse it. */ private carry(inFlow: El, outFlow: El, via: string, policy: CarryPolicy): BridgedFlow { - const { doc, cs } = this; + const { changes } = this; + const doc = this.edit.doc; const predecessor = inFlow.get('sourceRef'); const successor = outFlow.get('targetRef'); const outName = outFlow.get('name'); @@ -122,10 +118,10 @@ export class FlowBridgeService { if (outName && !inFlow.get('name')) { inFlow.set('name', outName); took.name = outName; - } else if (outName) cs.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${outName}" of ${idOf(outFlow)} was dropped while bridging ${via}`, element: idOf(outFlow) }); + } else if (outName) changes.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${outName}" of ${idOf(outFlow)} was dropped while bridging ${via}`, element: idOf(outFlow) }); const ontoDefault = predecessor.get('default') === inFlow; if (outCond && !inFlow.get('conditionExpression') && ontoDefault && policy === 'node') { - cs.warn({ + changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(outFlow)} was dropped while bridging ${via}: ${idOf(inFlow)} is the default flow of ${idOf(predecessor)}`, element: idOf(outFlow), @@ -136,19 +132,20 @@ export class FlowBridgeService { inFlow.set('conditionExpression', outCond); if (policy === 'group') outCond.$parent = inFlow; else took.condition = outCond; - } else if (outCond) cs.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(outFlow)} was dropped while bridging ${via}`, element: idOf(outFlow) }); - took.associations = this.flows.carryAssociations(outFlow, inFlow, cs); - this.flows.remove(outFlow, cs); - cs.remove(ChangeSet.flowEntryOf(outFlow)); + } else if (outCond) changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(outFlow)} was dropped while bridging ${via}`, element: idOf(outFlow) }); + took.associations = new SequenceFlows(this.edit).carryAssociations(outFlow, inFlow, changes); + new SequenceFlows(this.edit).remove(outFlow, changes); + changes.remove(ChangeSet.flowEntryOf(outFlow)); if (policy === 'group' && idOf(outFlow) && !doc.has(idOf(outFlow))) doc.ids.claim(idOf(outFlow)); - const renamed = this.flows.redirect(inFlow, { target: successor }); - cs.change(ChangeSet.flowEntryOf(inFlow, `${idOf(predecessor)} -> ${idOf(successor)} (bridged ${via})${renamedNote(renamed)}`)); - cs.note(`bridged: ${idOf(predecessor)} -> ${idOf(successor)}`); + const renamed = new SequenceFlows(this.edit).redirect(inFlow, { target: successor }); + changes.change(ChangeSet.flowEntryOf(inFlow, `${idOf(predecessor)} -> ${idOf(successor)} (bridged ${via})${renamedNote(renamed)}`)); + changes.note(`bridged: ${idOf(predecessor)} -> ${idOf(successor)}`); return { flow: inFlow, from: outFlow, took }; } private join(node: El, removing: ReadonlySet): void { - const { doc, cs } = this; + const { changes } = this; + const doc = this.edit.doc; const incoming = doc.incoming(node); const outgoing = doc.outgoing(node); if (outgoing.length > 1) { @@ -166,53 +163,53 @@ export class FlowBridgeService { const successor = outFlow.get('targetRef'); for (const inFlow of incoming) this.assertAllowed(node, inFlow, outFlow, removing); const outName = outFlow.get('name'); - if (outName) cs.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${outName}" of ${idOf(outFlow)} was dropped while bridging ${idOf(node)} from ${incoming.length} predecessors`, element: idOf(outFlow) }); - if (outFlow.get('conditionExpression')) cs.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(outFlow)} was dropped while bridging ${idOf(node)} from ${incoming.length} predecessors`, element: idOf(outFlow) }); - this.flows.remove(outFlow, cs); - cs.remove(ChangeSet.flowEntryOf(outFlow)); + if (outName) changes.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${outName}" of ${idOf(outFlow)} was dropped while bridging ${idOf(node)} from ${incoming.length} predecessors`, element: idOf(outFlow) }); + if (outFlow.get('conditionExpression')) changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(outFlow)} was dropped while bridging ${idOf(node)} from ${incoming.length} predecessors`, element: idOf(outFlow) }); + new SequenceFlows(this.edit).remove(outFlow, changes); + changes.remove(ChangeSet.flowEntryOf(outFlow)); const bridged: string[] = []; for (const inFlow of incoming) { const predecessor = inFlow.get('sourceRef'); const already = doc.outgoing(predecessor).some((flow) => flow !== inFlow && flow.get('targetRef') === successor); if (predecessor === successor || predecessor === node || already) { - this.flows.remove(inFlow, cs); - cs.remove(ChangeSet.flowEntryOf(inFlow)); - cs.note(`not bridged: ${idOf(predecessor)} ${predecessor === successor ? 'is the successor itself' : predecessor === node ? 'is the removed node' : `already flows to ${idOf(successor)}`}`); + new SequenceFlows(this.edit).remove(inFlow, changes); + changes.remove(ChangeSet.flowEntryOf(inFlow)); + changes.note(`not bridged: ${idOf(predecessor)} ${predecessor === successor ? 'is the successor itself' : predecessor === node ? 'is the removed node' : `already flows to ${idOf(successor)}`}`); continue; } - const renamed = this.flows.redirect(inFlow, { target: successor }); - cs.change(ChangeSet.flowEntryOf(inFlow, `${idOf(predecessor)} -> ${idOf(successor)} (bridged ${idOf(node)})${renamedNote(renamed)}`)); + const renamed = new SequenceFlows(this.edit).redirect(inFlow, { target: successor }); + changes.change(ChangeSet.flowEntryOf(inFlow, `${idOf(predecessor)} -> ${idOf(successor)} (bridged ${idOf(node)})${renamedNote(renamed)}`)); bridged.push(idOf(predecessor)); } - cs.note(`bridged all: ${bridged.join(', ')} -> ${idOf(successor)}`); + changes.note(`bridged all: ${bridged.join(', ')} -> ${idOf(successor)}`); const sync = synchronises(node); - if (sync) cs.note(`${idOf(node)} synchronised ${bridged.join(', ')}; now each of them runs on to ${idOf(successor)} on its own`); + if (sync) changes.note(`${idOf(node)} synchronised ${bridged.join(', ')}; now each of them runs on to ${idOf(successor)} on its own`); const count = doc.incoming(successor).length; if (count > 1 && (sync || !is(successor, 'bpmn:Gateway'))) { - cs.warn({ + changes.warn({ code: 'W_IMPLICIT_JOIN', message: sync - ? `${idOf(successor)} now has ${count} incoming flows: the ${FlowBridgeService.joinKind(node)} join ${idOf(node)} synchronised them, now each path runs on to ${idOf(successor)} on its own` + ? `${idOf(successor)} now has ${count} incoming flows: the ${FlowBridge.joinKind(node)} join ${idOf(node)} synchronised them, now each path runs on to ${idOf(successor)} on its own` : `${idOf(successor)} now has ${count} incoming flows (implicit join)`, element: idOf(successor), - hint: sync ? `Keep a ${FlowBridgeService.joinKind(node)} join in front of ${idOf(successor)} if the paths must wait for each other.` : 'Consider joining through a gateway.', + hint: sync ? `Keep a ${FlowBridge.joinKind(node)} join in front of ${idOf(successor)} if the paths must wait for each other.` : 'Consider joining through a gateway.', }); } } private isMerge(node: El): boolean { - return this.doc.incoming(node).length > 1 && this.doc.outgoing(node).length === 1; + return this.edit.doc.incoming(node).length > 1 && this.edit.doc.outgoing(node).length === 1; } /** Bridging ends the synchronisation, not bridging disconnects the successor: ask which. */ private refuseSynchronisingJoin(node: El): void { if (!synchronises(node)) return; - const incoming = this.doc.incoming(node); - const successor = this.doc.outgoing(node)[0]!.get('targetRef'); + const incoming = this.edit.doc.incoming(node); + const successor = this.edit.doc.outgoing(node)[0]!.get('targetRef'); const preds = [...new Set(incoming.map((flow) => idOf(flow.get('sourceRef'))))]; throw modelError( 'E_AMBIGUOUS_BRIDGE', - `${idOf(node)} is a ${FlowBridgeService.joinKind(node)} join of ${incoming.length} paths (${preds.join(', ')}): bridging them to ${idOf(successor)} ends the synchronisation (${idOf(successor)} would run once per path), removing it without a bridge leaves ${idOf(successor)} unconnected`, + `${idOf(node)} is a ${FlowBridge.joinKind(node)} join of ${incoming.length} paths (${preds.join(', ')}): bridging them to ${idOf(successor)} ends the synchronisation (${idOf(successor)} would run once per path), removing it without a bridge leaves ${idOf(successor)} unconnected`, { element: idOf(node), candidates: incoming.map(idOf), @@ -234,11 +231,10 @@ export class FlowBridgeService { const successor = outFlow.get('targetRef'); if (!is(predecessor, 'bpmn:EventBasedGateway') || successor === node) return; if (removing?.has(idOf(successor))) return; - const rule = new EventGatewayRule(this.doc, this.edit); - const problem = rule.targetProblem(predecessor, successor, [outFlow, inFlow]); + const problem = new EventGatewayRule(this.edit).targetProblem(predecessor, successor, [outFlow, inFlow]); if (!problem) return; const catchFirst = `put an intermediate catch event (message / timer / signal / conditional) in front of ${idOf(successor)} first (\`bpmn add intermediateCatchEvent:timer "" --flow ${idOf(outFlow)} --timer PT1H\`)`; - const rejectedBy = rule.rule === 'engines' ? 'which the engines reject' : 'which BPMN 2.0 does not allow'; + const rejectedBy = new EventGatewayRule(this.edit).rule === 'engines' ? 'which the engines reject' : 'which BPMN 2.0 does not allow'; throw modelError('E_INVALID_BRIDGE', `${this.command === 'move' ? 'Moving' : 'Bridging'} ${via} would connect the event-based gateway ${idOf(predecessor)} to ${idOf(successor)}, ${rejectedBy}: ${problem}`, { element: idOf(node), related: [idOf(predecessor), idOf(successor)], diff --git a/src/domain/editing/flows/FlowIdFollower.ts b/src/domain/editing/connections/FlowIdFollower.ts similarity index 89% rename from src/domain/editing/flows/FlowIdFollower.ts rename to src/domain/editing/connections/FlowIdFollower.ts index b26c6172..ac6c906f 100644 --- a/src/domain/editing/flows/FlowIdFollower.ts +++ b/src/domain/editing/connections/FlowIdFollower.ts @@ -1,5 +1,4 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext, FormerEnd } from '../../document/EditContext.js'; +import type { EditSession, FormerEnd } from '../../document/EditSession.js'; import { followDiIds } from '../../diagram/utils/di-ids.js'; import { connectionRequest, type IdRequest } from '../../document/utils/requests.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; @@ -13,10 +12,7 @@ import { is, many, type El, idOf } from '../../document/utils/moddle.js'; * the document's flows keep theirs and get reported as stale. */ export class FlowIdFollower { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} + constructor(private readonly edit: EditSession) {} /** * Call before `set` renames `node`: a flow still named after its old id or name @@ -24,16 +20,16 @@ export class FlowIdFollower { */ recordFormerEnd(node: El): void { if (!is(node, 'bpmn:FlowNode') || !(many(node, 'incoming').length || many(node, 'outgoing').length)) return; - this.edit.recordFormerEnd(node, { id: idOf(node), labels: this.endLabels(node) }); + this.edit.recordFormerEnd(node, { id: idOf(node), labels: FlowIdFollower.endLabels(node) }); } /** * Whether `id` is one any id style (also of earlier versions, with a collision suffix) - * gives a flow between `source` and `target`, by their current or former names and ids. + * gives a flow between `source` and `target`, by their current or former names and ids. @internal */ namesEnds(id: string | undefined, source: El, target: El): boolean { if (!id) return false; - const names = (el: El): FormerEnd[] => [{ id: idOf(el), labels: this.endLabels(el) }, ...this.edit.formerEndsOf(el)]; + const names = (el: El): FormerEnd[] => [{ id: idOf(el), labels: FlowIdFollower.endLabels(el) }, ...this.edit.formerEndsOf(el)]; const targets = names(target); return names(source).some((sourceName) => targets.some((targetName) => sourceName.labels.some((sourceLabel) => targetName.labels.some((targetLabel) => this.edit.allocator.isDerived(id, connectionRequest('bpmn:SequenceFlow', sourceName.id, targetName.id, { sourceLabel, targetLabel }))))), @@ -45,7 +41,7 @@ export class FlowIdFollower { * With ids kept, a flow the batch created is nobody's API yet and still follows. */ follow(flow: El, oldSource: El | undefined, oldTarget: El | undefined, wanted?: string): string | undefined { - const doc = this.doc; + const doc = this.edit.doc; if (!is(flow, 'bpmn:SequenceFlow') || !oldSource || !oldTarget) return undefined; const source = flow.get('sourceRef'); const target = flow.get('targetRef'); @@ -76,7 +72,7 @@ export class FlowIdFollower { * they follow when `node` gets another id. Call it before the rename. */ namedAfter(node: El): El[] { - const doc = this.doc; + const doc = this.edit.doc; const flows = new Set([...many(node, 'incoming'), ...many(node, 'outgoing')]); for (const root of many(doc.definitions, 'rootElements')) { if (!is(root, 'bpmn:Collaboration')) continue; @@ -87,7 +83,7 @@ export class FlowIdFollower { /** `was`: the ends before the rename (`A -> B`). */ followRenamedEnd(flow: El, was: string): void { - const doc = this.doc; + const doc = this.edit.doc; const old = idOf(flow); const req = FlowIdFollower.connectionRequestOf(flow); if (this.edit.allocator.generatedFrom(old, req)) return; @@ -103,7 +99,7 @@ export class FlowIdFollower { * Its label now, the speaking part of its id (renamed since), its name where the id * speaks too, and how earlier versions named an unnamed end. */ - private endLabels(el: El): string[] { + private static endLabels(el: El): string[] { const labels = [new ElementLabels(el).label(), speakingStem(idOf(el)), ...new ElementLabels(el).formerLabels()].filter((label): label is string => !!label); const name = el.get('name'); if (hasIdWords(name) && !labels.some((label) => label === name || (!label.includes('_') && slugify(label) === slugify(name)))) labels.push(name!); @@ -111,8 +107,8 @@ export class FlowIdFollower { } private followEdgeId(flow: El, old: string, id: string): void { - followDiIds(this.doc.definitions, flow, old, id, this.doc.ids); - this.doc.invalidate(); + followDiIds(this.edit.doc.definitions, flow, old, id, this.edit.doc.ids); + this.edit.doc.invalidate(); } private static connectionRequestOf(flow: El): IdRequest { diff --git a/src/domain/editing/flows/FlowOptions.ts b/src/domain/editing/connections/FlowOptions.ts similarity index 84% rename from src/domain/editing/flows/FlowOptions.ts rename to src/domain/editing/connections/FlowOptions.ts index 998de1f5..d4b405c7 100644 --- a/src/domain/editing/flows/FlowOptions.ts +++ b/src/domain/editing/connections/FlowOptions.ts @@ -1,9 +1,8 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { ChangeSet } from '../../document/ChangeSet.js'; import type { El } from '../../document/utils/moddle.js'; import { usageError } from '../../document/utils/errors.js'; -import { SequenceFlowService, type FlowAttrs } from './SequenceFlowService.js'; +import { type FlowAttrs, SequenceFlows } from './SequenceFlows.js'; import { defaultWithConditionViolation } from '../../commands/utils/op-rules.js'; import type { ConnectElements, FlowOptions as FlowOptionValues } from '../../commands/types.js'; @@ -34,7 +33,7 @@ export class FlowOptions { if (violation) throw usageError(violation.message, { ...details, hint: violation.hint }); } - /** `keepEmptyCondition`: an empty condition stays, for SequenceFlowService.create to refuse. */ + /** `keepEmptyCondition`: an empty condition stays, for SequenceFlows.create to refuse. */ toAttrs(keepEmptyCondition = false): FlowAttrs { const { flowId, flowName, condition, language } = this.values; return { @@ -58,20 +57,20 @@ export class FlowOptions { } /** For an existing flow (the one into a spliced node); reported as changed when one was given. */ - apply(doc: Doc, edit: EditContext, flow: El, cs: ChangeSet): void { + apply(edit: EditSession, flow: El, changes: ChangeSet): void { let touched = false; if (this.values.flowName !== undefined) { flow.set('name', this.values.flowName || undefined); touched = true; } if (this.values.condition !== undefined) { - new SequenceFlowService(doc, edit).setCondition(flow, this.values.condition, this.values.language); + new SequenceFlows(edit).setCondition(flow, this.values.condition, this.values.language); touched = true; } if (this.values.default) { - new SequenceFlowService(doc, edit).setDefault(flow, true); + new SequenceFlows(edit).setDefault(flow, true); touched = true; } - if (touched) cs.change(ChangeSet.flowEntryOf(flow)); + if (touched) changes.change(ChangeSet.flowEntryOf(flow)); } } diff --git a/src/domain/editing/flows/MovedFlows.ts b/src/domain/editing/connections/MovedFlows.ts similarity index 79% rename from src/domain/editing/flows/MovedFlows.ts rename to src/domain/editing/connections/MovedFlows.ts index 684b0f2a..a1641de7 100644 --- a/src/domain/editing/flows/MovedFlows.ts +++ b/src/domain/editing/connections/MovedFlows.ts @@ -1,10 +1,9 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; -import { type Warning } from '../../document/CliError.js'; +import type { EditSession } from '../../document/EditSession.js'; +import type { Finding } from '../../document/Finding.js'; import { is, type El } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; import { idOf } from '../../document/utils/elements.js'; -import type { BridgedFlow } from './FlowBridgeService.js'; +import type { BridgedFlow } from './FlowBridge.js'; /** * A flow the detach took out (flow ids kept). `label`: what no bridge took; given back when the @@ -28,7 +27,7 @@ interface DroppedLabel { flow: El; target: El; label: FlowLabel; - warnings: Warning[]; + warnings: Finding[]; } interface BridgeMade { @@ -55,12 +54,11 @@ export class MovedFlows { private readonly before: Map | undefined; constructor( - private readonly doc: Doc, - edit: EditContext, - private readonly cs: ChangeSet, + private readonly edit: EditSession, + private readonly changes: ChangeSet, nodes: El[], ) { - this.before = edit.followFlowEnds ? undefined : MovedFlows.labelsBefore(doc, edit, nodes); + this.before = edit.followFlowEnds ? undefined : MovedFlows.labelsBefore(edit, nodes); } /** The flow taken out at `source`, to put back where the placement connects it. */ @@ -94,34 +92,34 @@ export class MovedFlows { /** A put-back flow is reported as one changed entry, not as removed plus created. */ putBack(): void { - const cs = this.cs; + const changes = this.changes; const spare = this.spare; for (const [node, { flow, target, label }] of spare) { - if (this.doc.get(idOf(flow)) !== flow) continue; + if (this.edit.doc.get(idOf(flow)) !== flow) continue; spare.delete(node); const id = idOf(flow); const back = flow.get('targetRef') === target; - const lost = label && back ? MovedFlows.giveBack(node, flow, label, cs) : ''; - cs.removed = cs.removed.filter((change) => change.id !== id); - cs.created = cs.created.filter((change) => change.id !== id); - cs.change(ChangeSet.flowEntryOf(flow, `${ChangeSet.flowEntryOf(flow).detail}${back ? lost : ` (was -> ${idOf(target)})`}`)); + const lost = label && back ? MovedFlows.giveBack(node, flow, label, changes) : ''; + changes.removed = changes.removed.filter((change) => change.id !== id); + changes.created = changes.created.filter((change) => change.id !== id); + changes.change(ChangeSet.flowEntryOf(flow, `${ChangeSet.flowEntryOf(flow).detail}${back ? lost : ` (was -> ${idOf(target)})`}`)); if (!label || back) continue; const why = `while moving ${idOf(node)}: ${idOf(node)} was not bridged, so nothing at its old place took it, and ${id} was put back at its new place without it`; - const warnings: Warning[] = []; + const warnings: Finding[] = []; if (label.name) warnings.push({ code: 'W_LABEL_DROPPED', message: `Flow label "${label.name}" of ${id} was dropped ${why}`, element: id }); if (label.condition) warnings.push({ code: 'W_CONDITION_DROPPED', message: `Condition of ${id} was dropped ${why}`, element: id }); - for (const warning of warnings) cs.warn(warning); + for (const warning of warnings) changes.warn(warning); this.dropped.push({ node, flow, target, label, warnings }); } } /** A default flow has no condition, so a flow the placement made the default keeps none. */ - private static giveBack(node: El, flow: El, label: FlowLabel, cs: ChangeSet): string { + private static giveBack(node: El, flow: El, label: FlowLabel, changes: ChangeSet): string { const id = idOf(flow); if (label.name && !flow.get('name')) flow.set('name', label.name); if (label.condition && !flow.get('conditionExpression')) { if (node.get('default') === flow) { - cs.warn({ + changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${id} was dropped while moving ${idOf(node)}: ${id} is now the default flow of ${idOf(node)}`, element: id, @@ -142,18 +140,19 @@ export class MovedFlows { /** A flow a later node of the chain took back between its old ends gets its label back. */ private giveBackLate(): void { - const cs = this.cs; + const changes = this.changes; for (const { node, flow, target, label, warnings } of this.dropped) { - if (this.doc.get(idOf(flow)) !== flow || flow.get('sourceRef') !== node || flow.get('targetRef') !== target) continue; - cs.warnings = cs.warnings.filter((warning) => !warnings.includes(warning)); - const lost = MovedFlows.giveBack(node, flow, label, cs); + if (this.edit.doc.get(idOf(flow)) !== flow || flow.get('sourceRef') !== node || flow.get('targetRef') !== target) continue; + changes.warnings = changes.warnings.filter((warning) => !warnings.includes(warning)); + const lost = MovedFlows.giveBack(node, flow, label, changes); const change = ChangeSet.flowEntryOf(flow); - cs.changed = cs.changed.filter((entry) => entry.id !== change.id); - cs.change(ChangeSet.flowEntryOf(flow, `${change.detail}${lost}`)); + changes.changed = changes.changed.filter((entry) => entry.id !== change.id); + changes.change(ChangeSet.flowEntryOf(flow, `${change.detail}${lost}`)); } } - private static labelsBefore(doc: Doc, edit: EditContext, nodes: El[]): Map { + private static labelsBefore(edit: EditSession, nodes: El[]): Map { + const doc = edit.doc; const out = new Map(); for (const movedNode of nodes) { for (const flow of doc.outgoing(movedNode)) if (edit.keepsFlowId(flow)) out.set(flow, { name: flow.get('name'), condition: flow.get('conditionExpression'), associations: [] }); @@ -171,9 +170,9 @@ export class MovedFlows { * `(was -> C; its name stays with the bridge Flow_AToB)`. */ private reportBridgesKept(before: Map): void { - const cs = this.cs; + const changes = this.changes; const made = this.bridges; - const alive = (el: El): boolean => this.doc.get(idOf(el)) === el; + const alive = (el: El): boolean => this.edit.doc.get(idOf(el)) === el; made.forEach(({ node, target, bridge }, index) => { const flow = bridge.from; const had = before.get(flow); @@ -212,14 +211,14 @@ export class MovedFlows { const back = flow.get('sourceRef') === node && flow.get('targetRef') === target; const returned = lost.filter(({ what }) => back && !flow.get(what === 'name' ? 'name' : 'conditionExpression')); if (returned.length) { - MovedFlows.giveBack(node, flow, { name: returned.some((loss) => loss.what === 'name') ? name : undefined, condition: returned.some((loss) => loss.what === 'condition') ? condition : undefined, isDefault: false }, cs); - cs.changed = cs.changed.filter((change) => change.id !== id); - cs.change(ChangeSet.flowEntryOf(flow)); + MovedFlows.giveBack(node, flow, { name: returned.some((loss) => loss.what === 'name') ? name : undefined, condition: returned.some((loss) => loss.what === 'condition') ? condition : undefined, isDefault: false }, changes); + changes.changed = changes.changed.filter((change) => change.id !== id); + changes.change(ChangeSet.flowEntryOf(flow)); } for (const { what, at } of lost.filter((loss) => !returned.includes(loss))) { const why = `while moving ${idOf(node)}: the bridge ${idOf(at)} took it, and a later node of the move removed ${idOf(at)}`; - if (what === 'name') cs.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${name}" of ${id} was dropped ${why}`, element: id, related: [idOf(at)] }); - else cs.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${id} was dropped ${why}`, element: id, related: [idOf(at)] }); + if (what === 'name') changes.warn({ code: 'W_LABEL_DROPPED', message: `Flow label "${name}" of ${id} was dropped ${why}`, element: id, related: [idOf(at)] }); + else changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${id} was dropped ${why}`, element: id, related: [idOf(at)] }); } if (!kept.size) return; const clause = [...kept].map(([at, what]) => { @@ -228,12 +227,12 @@ export class MovedFlows { const list = items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}` : items[0]!; return `its ${list} ${items.length > 1 || notes > 1 ? 'stay' : 'stays'} with the bridge ${idOf(at)}`; }).join('; '); - const last = cs.changed.map((change) => change.id).lastIndexOf(id); - const entry = last === -1 ? ChangeSet.flowEntryOf(flow) : cs.changed[last]!; + const last = changes.changed.map((change) => change.id).lastIndexOf(id); + const entry = last === -1 ? ChangeSet.flowEntryOf(flow) : changes.changed[last]!; const detail = entry.detail ?? ''; const said = /\(was -> [^()]*\)$/.test(detail) ? `${detail.slice(0, -1)}; ${clause})` : `${detail}${detail ? ' ' : ''}(${clause})`; - if (last === -1) cs.change(ChangeSet.about({ ...entry, detail: said }, flow)); - else cs.changed[last] = ChangeSet.about({ ...entry, detail: said }, flow); + if (last === -1) changes.change(ChangeSet.about({ ...entry, detail: said }, flow)); + else changes.changed[last] = ChangeSet.about({ ...entry, detail: said }, flow); }); } } diff --git a/src/domain/editing/flows/SequenceFlowService.ts b/src/domain/editing/connections/SequenceFlows.ts similarity index 74% rename from src/domain/editing/flows/SequenceFlowService.ts rename to src/domain/editing/connections/SequenceFlows.ts index 41a12620..9d2867db 100644 --- a/src/domain/editing/flows/SequenceFlowService.ts +++ b/src/domain/editing/connections/SequenceFlows.ts @@ -1,9 +1,8 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { FlowRules } from '../../document/FlowRules.js'; import { modelError } from '../../document/utils/errors.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; -import { branchesByCondition, ignoresConditions, kindLabel } from '../../document/utils/kinds.js'; +import { branchesByCondition, ignoresConditions, kindLabel } from '../../document/Kinds.js'; import { addTo, is, many, removeFrom, type El, idOf } from '../../document/utils/moddle.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; import { FlowIdFollower } from './FlowIdFollower.js'; @@ -24,21 +23,15 @@ export interface FlowAttrs { * layouter depends on (a flow right after its source). No association is left pointing * at a removed flow. */ -export class SequenceFlowService { - private readonly rules: FlowRules; - - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) { - this.rules = new FlowRules(doc); - } +export class SequenceFlows { + constructor(private readonly edit: EditSession) {} create(source: El, target: El, attrs: FlowAttrs = {}, declareAfter?: El): El { - const doc = this.doc; - this.rules.assertEndpoints(source, target); - this.rules.assertCondition(attrs.condition); - this.rules.assertConditionTaken(source, attrs.condition, attrs.id); + const doc = this.edit.doc; + const rules = new FlowRules(doc); + rules.assertEndpoints(source, target); + rules.assertCondition(attrs.condition); + rules.assertConditionTaken(source, attrs.condition, attrs.id); const scope = doc.scopeOf(source)!; const reuse = attrs.id ? undefined : attrs.reuse; const oldSource = reuse?.get('sourceRef'); @@ -66,7 +59,7 @@ export class SequenceFlowService { insertAfterInScope(scope, declareAfter ?? lastFlowOf(scope, source) ?? source, flow); if (attrs.isDefault) this.setDefault(flow, true); doc.invalidate(); - if (reuse) new FlowIdFollower(doc, this.edit).follow(flow, oldSource, oldTarget); + if (reuse) new FlowIdFollower(this.edit).follow(flow, oldSource, oldTarget); return flow; } @@ -74,9 +67,9 @@ export class SequenceFlowService { * Also clears stale incoming/outgoing entries a modeler file may carry on other nodes. * Without a ChangeSet the caller resolves the flow's associations. */ - remove(flow: El, cs?: ChangeSet): void { - const doc = this.doc; - if (cs) this.dropAssociations(flow, cs); + remove(flow: El, changes?: ChangeSet): void { + const doc = this.edit.doc; + if (changes) this.dropAssociations(flow, changes); const source = flow.get('sourceRef'); if (source && source.get('default') === flow) source.set('default', undefined); const scope = flow.$parent as El | undefined; @@ -93,10 +86,10 @@ export class SequenceFlowService { /** Returns the old id when the flow's id followed its new ends. */ redirect(flow: El, ends: { source?: El; target?: El }): string | undefined { - const doc = this.doc; + const doc = this.edit.doc; const source = ends.source ?? flow.get('sourceRef'); const target = ends.target ?? flow.get('targetRef'); - this.rules.assertEndpoints(source, target); + new FlowRules(this.edit.doc).assertEndpoints(source, target); const oldSource = flow.get('sourceRef'); const oldTarget = flow.get('targetRef'); if (oldSource && oldSource !== source) { @@ -109,7 +102,7 @@ export class SequenceFlowService { addTo(source, 'outgoing', flow); addTo(target, 'incoming', flow); doc.invalidate(); - return new FlowIdFollower(doc, this.edit).follow(flow, oldSource, oldTarget); + return new FlowIdFollower(this.edit).follow(flow, oldSource, oldTarget); } /** @@ -117,17 +110,18 @@ export class SequenceFlowService { * `node`; a new plain flow (or `reuse`) runs node -> B. */ splice(flow: El, node: El, reuse?: El, flowId?: string): { first: El; second: El; renamedFrom?: string } { - const doc = this.doc; + const doc = this.edit.doc; const target = flow.get('targetRef'); const scope = doc.scopeOf(flow)!; const source = flow.get('sourceRef'); - this.rules.assertRoles(source, node); - this.rules.assertRoles(node, target); + const rules = new FlowRules(this.edit.doc); + rules.assertRoles(source, node); + rules.assertRoles(node, target); insertAfterInScope(scope, lastFlowOf(scope, source) ?? source, node); removeFrom(target, 'incoming', flow); flow.set('targetRef', node); addTo(node, 'incoming', flow); - const renamedFrom = new FlowIdFollower(doc, this.edit).follow(flow, source, target, flowId); + const renamedFrom = new FlowIdFollower(this.edit).follow(flow, source, target, flowId); const second = this.create(node, target, reuse ? { reuse } : {}, node); doc.invalidate(); return { first: flow, second, ...(renamedFrom !== undefined ? { renamedFrom } : {}) }; @@ -147,13 +141,14 @@ export class SequenceFlowService { } else if (source.get('default') === flow) { source.set('default', undefined); } - this.doc.invalidate(); + this.edit.doc.invalidate(); } /** A condition takes the default marker away. */ setCondition(flow: El, expr: string | undefined, language?: string): void { - this.rules.assertCondition(expr); - this.rules.assertConditionTaken(flow.get('sourceRef'), expr, idOf(flow)); + const rules = new FlowRules(this.edit.doc); + rules.assertCondition(expr); + rules.assertConditionTaken(flow.get('sourceRef'), expr, idOf(flow)); if (expr) { flow.set('conditionExpression', this.conditionExpression(expr, language)); const source = flow.get('sourceRef'); @@ -161,45 +156,45 @@ export class SequenceFlowService { } else { flow.set('conditionExpression', undefined); } - this.doc.invalidate(); + this.edit.doc.invalidate(); } - dropIgnoredConditions(source: El, cs: ChangeSet, flows: El[] = this.doc.outgoing(source)): void { + dropIgnoredConditions(source: El, changes: ChangeSet, flows: El[] = this.edit.doc.outgoing(source)): void { if (!ignoresConditions(source)) return; for (const flow of flows) { if (!flow.get('conditionExpression')) continue; flow.set('conditionExpression', undefined); - cs.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(flow)} was dropped: a ${kindLabel(source)} takes no conditions`, element: idOf(flow), related: [idOf(source)] }); + changes.warn({ code: 'W_CONDITION_DROPPED', message: `Condition of ${idOf(flow)} was dropped: a ${kindLabel(source)} takes no conditions`, element: idOf(flow), related: [idOf(source)] }); } - this.doc.invalidate(); + this.edit.doc.invalidate(); } /** Bridging: an annotation stays on the flow that replaces `from`. */ - carryAssociations(from: El, to: El, cs: ChangeSet): El[] { + carryAssociations(from: El, to: El, changes: ChangeSet): El[] { const carried = this.associationsOf(from); for (const association of carried) { for (const end of ['sourceRef', 'targetRef']) if (association.get(end) === from) association.set(end, to); - cs.change({ id: idOf(association), kind: 'association', detail: `re-attached from ${idOf(from)} to ${idOf(to)}` }); + changes.change({ id: idOf(association), kind: 'association', detail: `re-attached from ${idOf(from)} to ${idOf(to)}` }); } - this.doc.invalidate(); + this.edit.doc.invalidate(); return carried; } private associationsOf(flow: El): El[] { - return [...this.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && (association.get('sourceRef') === flow || association.get('targetRef') === flow)); + return [...this.edit.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && (association.get('sourceRef') === flow || association.get('targetRef') === flow)); } - private dropAssociations(flow: El, cs: ChangeSet): void { + private dropAssociations(flow: El, changes: ChangeSet): void { for (const association of this.associationsOf(flow)) { const parent = association.$parent as El | undefined; if (parent) removeFrom(parent, 'artifacts', association); - this.doc.ids.release(idOf(association)); - cs.remove({ id: idOf(association), kind: 'association' }); + this.edit.doc.ids.release(idOf(association)); + changes.remove({ id: idOf(association), kind: 'association' }); } - this.doc.invalidate(); + this.edit.doc.invalidate(); } private conditionExpression(expr: string, language?: string): El { - return this.doc.moddle.create('bpmn:FormalExpression', { body: expr, ...(language ? { language } : {}) }); + return this.edit.doc.moddle.create('bpmn:FormalExpression', { body: expr, ...(language ? { language } : {}) }); } } diff --git a/src/domain/editing/flows/StaleFlowReport.ts b/src/domain/editing/connections/StaleFlowReport.ts similarity index 77% rename from src/domain/editing/flows/StaleFlowReport.ts rename to src/domain/editing/connections/StaleFlowReport.ts index cac6ef8b..cb6fe037 100644 --- a/src/domain/editing/flows/StaleFlowReport.ts +++ b/src/domain/editing/connections/StaleFlowReport.ts @@ -1,11 +1,10 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext, FlowEnds, KeptFlow } from '../../document/EditContext.js'; -import type { ChangeSet } from '../../document/ChangeSet.js'; -import { type Warning } from '../../document/CliError.js'; +import type { EditSession, FlowEnds, KeptFlow } from '../../document/EditSession.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; import { isValidId } from '../../document/utils/slug.js'; -import { IdRegistry } from '../../document/ids/IdRegistry.js'; import { type El, idOf } from '../../document/utils/moddle.js'; +import type { ChangeSet } from '../../document/ChangeSet.js'; +import type { Finding } from '../../document/Finding.js'; +import { IdRegistry } from '../../document/ids/IdRegistry.js'; import { FlowIdFollower } from './FlowIdFollower.js'; /** @@ -13,10 +12,7 @@ import { FlowIdFollower } from './FlowIdFollower.js'; * id that no longer names its ends. */ export class StaleFlowReport { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} + constructor(private readonly edit: EditSession) {} /** * `deferred`: the flow kept its id, so its W_FLOW_ID_STALE names the rename command; @@ -31,14 +27,14 @@ export class StaleFlowReport { * Each suggested id is reserved for the later hints, so following every hint never * collides. `before`: the flows' ends when the batch began. */ - report(cs: ChangeSet, before?: ReadonlyMap): void { + report(changes: ChangeSet, before?: ReadonlyMap): void { const kept = this.edit.takeKeptFlows(); if (!kept.size) return; - const hinted = new IdRegistry(this.doc.ids.values()); + const hinted = new IdRegistry(this.edit.doc.ids.values()); for (const [flow, was] of kept) { const warning = this.warningFor(flow, was, hinted, before?.get(flow)); - if (warning) cs.warn(warning); - else if (was.preferred) this.restoreFlowIdHints(cs, flow, was.id); + if (warning) changes.warn(warning); + else if (was.preferred) this.restoreFlowIdHints(changes, flow, was.id); } } @@ -46,12 +42,12 @@ export class StaleFlowReport { * No warning when the flow is gone, renamed, names its ends again or was stale before * the batch. Renaming stays the human's call, together with the code that uses the id. */ - private warningFor(flow: El, { was, id: keptId, preferred }: KeptFlow, hinted: IdRegistry, began?: FlowEnds): Warning | undefined { - const doc = this.doc; + private warningFor(flow: El, { was, id: keptId, preferred }: KeptFlow, hinted: IdRegistry, began?: FlowEnds): Finding | undefined { + const doc = this.edit.doc; const id = idOf(flow); const source = flow.get('sourceRef'); const target = flow.get('targetRef'); - if (id !== keptId || doc.get(id) !== flow || !source || !target || new FlowIdFollower(doc, this.edit).namesEnds(id, source, target)) return undefined; + if (id !== keptId || doc.get(id) !== flow || !source || !target || new FlowIdFollower(this.edit).namesEnds(id, source, target)) return undefined; if (began?.[0] === source && began[1] === target) return undefined; const wanted = preferred?.target === target && isValidId(preferred.id) && !hinted.has(preferred.id) && !doc.has(preferred.id) ? preferred.id : undefined; const suggested = wanted ?? this.edit.idStyle.next(ElementLabels.flowRequest('bpmn:SequenceFlow', source, target), hinted).id; @@ -66,11 +62,11 @@ export class StaleFlowReport { } /** The W_OPTION_IGNORED hints that deferred to a W_FLOW_ID_STALE that never came. */ - private restoreFlowIdHints(cs: ChangeSet, flow: El, id: string): void { - cs.warnings = cs.warnings.map((warning) => { + private restoreFlowIdHints(changes: ChangeSet, flow: El, id: string): void { + changes.warnings = changes.warnings.map((warning) => { const flowId = warning.code === 'W_OPTION_IGNORED' ? /^--flow-id (\S+) ignored/.exec(warning.message)?.[1] : undefined; if (!flowId || warning.hint !== StaleFlowReport.flowIdIgnoredHint(flowId, id, true)) return warning; - const still = this.doc.get(id) === flow && !this.doc.has(flowId); + const still = this.edit.doc.get(id) === flow && !this.edit.doc.has(flowId); return { ...warning, hint: StaleFlowReport.flowIdIgnoredHint(flowId, still ? id : undefined) }; }); } diff --git a/src/domain/editing/ArtifactService.ts b/src/domain/editing/elements/Artifacts.ts similarity index 67% rename from src/domain/editing/ArtifactService.ts rename to src/domain/editing/elements/Artifacts.ts index b20bc4c7..107bc386 100644 --- a/src/domain/editing/ArtifactService.ts +++ b/src/domain/editing/elements/Artifacts.ts @@ -1,70 +1,65 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { kindByName, kindLabel } from '../document/utils/kinds.js'; -import { ElementLabels } from '../document/ids/ElementLabels.js'; -import { kindRequest, typeRequest, type IdRequest } from '../document/utils/requests.js'; -import { speakingStem } from '../document/utils/id-words.js'; -import { addTo, collaboration, is, type El, idOf } from '../document/utils/moddle.js'; -import type { ChangeSet } from '../document/ChangeSet.js'; -import { Groups } from '../diagram/di/Groups.js'; -import { allocateId } from './utils/allocate-id.js'; -import { artifactContainerOf, isDataReference } from './utils/artifacts.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { kindByName, kindLabel } from '../../document/Kinds.js'; +import { ElementLabels } from '../../document/ids/ElementLabels.js'; +import { kindRequest, typeRequest, type IdRequest } from '../../document/utils/requests.js'; +import { speakingStem } from '../../document/utils/id-words.js'; +import { addTo, collaboration, is, type El, idOf } from '../../document/utils/moddle.js'; +import type { ChangeSet } from '../../document/ChangeSet.js'; +import { Groups } from '../../diagram/di/Groups.js'; +import { artifactContainerOf, isDataReference } from '../utils/artifacts.js'; /** * Creates data objects, data stores, text annotations and groups. * Group members share the group's bpmn:CategoryValue, so membership needs no coordinates. */ -export class ArtifactService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class Artifacts { + constructor(private readonly edit: EditSession) {} - createDataObject(scope: El, opts: { id?: string; name?: string }, cs: ChangeSet): El { - const doc = this.doc; - this.assertFlowScope(scope, 'data objects'); - const id = allocateId(this.edit, this.artifactRequest('dataObject', scope, opts), opts.id); + createDataObject(scope: El, opts: { id?: string; name?: string }, changes: ChangeSet): El { + const doc = this.edit.doc; + Artifacts.assertFlowScope(scope, 'data objects'); + const id = this.edit.allocator.explicitOr(opts.id, Artifacts.artifactRequest('dataObject', scope, opts)); const dataObject = doc.create('bpmn:DataObject', { id: this.edit.allocator.allocate(this.backingRequest('bpmn:DataObject', id, opts.name)).id }); const ref = doc.create('bpmn:DataObjectReference', { id, ...(opts.name ? { name: opts.name } : {}), dataObjectRef: dataObject }); addTo(scope, 'flowElements', dataObject, ref); - cs.create({ id, kind: 'dataObject', ...(opts.name ? { name: opts.name } : {}), detail: `in ${idOf(scope)}` }); - cs.note(`backing dataObject ${idOf(dataObject)} created for ${id}`); + changes.create({ id, kind: 'dataObject', ...(opts.name ? { name: opts.name } : {}), detail: `in ${idOf(scope)}` }); + changes.note(`backing dataObject ${idOf(dataObject)} created for ${id}`); doc.invalidate(); return ref; } - createDataStore(scope: El, opts: { id?: string; name?: string }, cs: ChangeSet): El { - const doc = this.doc; - this.assertFlowScope(scope, 'data stores'); - const id = allocateId(this.edit, this.artifactRequest('dataStore', scope, opts), opts.id); + createDataStore(scope: El, opts: { id?: string; name?: string }, changes: ChangeSet): El { + const doc = this.edit.doc; + Artifacts.assertFlowScope(scope, 'data stores'); + const id = this.edit.allocator.explicitOr(opts.id, Artifacts.artifactRequest('dataStore', scope, opts)); const store = doc.create('bpmn:DataStore', { id: this.edit.allocator.allocate(this.backingRequest('bpmn:DataStore', id, opts.name)).id, ...(opts.name ? { name: opts.name } : {}) }); addTo(doc.definitions, 'rootElements', store); const ref = doc.create('bpmn:DataStoreReference', { id, ...(opts.name ? { name: opts.name } : {}), dataStoreRef: store }); addTo(scope, 'flowElements', ref); - cs.create({ id, kind: 'dataStore', ...(opts.name ? { name: opts.name } : {}), detail: `in ${idOf(scope)}` }); - cs.note(`root dataStore ${idOf(store)} created for ${id}`); + changes.create({ id, kind: 'dataStore', ...(opts.name ? { name: opts.name } : {}), detail: `in ${idOf(scope)}` }); + changes.note(`root dataStore ${idOf(store)} created for ${id}`); doc.invalidate(); return ref; } - createTextAnnotation(scope: El, opts: { id?: string; name?: string; text?: string }, cs: ChangeSet): El { - const doc = this.doc; + createTextAnnotation(scope: El, opts: { id?: string; name?: string; text?: string }, changes: ChangeSet): El { + const doc = this.edit.doc; if (artifactContainerOf(scope) !== scope) { throw modelError('E_INVALID_SCOPE', `${idOf(scope)} is a ${kindLabel(scope)}; text annotations can only be created in a process, sub-process or collaboration`, { element: idOf(scope), }); } - const id = allocateId(this.edit, this.artifactRequest('textAnnotation', scope, opts), opts.id); + const id = this.edit.allocator.explicitOr(opts.id, Artifacts.artifactRequest('textAnnotation', scope, opts)); const annotation = doc.create('bpmn:TextAnnotation', { id, ...(opts.text ? { text: opts.text } : {}) }); addTo(scope, 'artifacts', annotation); - cs.create({ id, kind: 'textAnnotation', ...(opts.text ? { name: opts.text } : {}), detail: `in ${idOf(scope)}` }); + changes.create({ id, kind: 'textAnnotation', ...(opts.text ? { name: opts.text } : {}), detail: `in ${idOf(scope)}` }); doc.invalidate(); return annotation; } - createGroup(opts: { id?: string; name?: string; members?: string[] }, cs: ChangeSet): El { - const doc = this.doc; + createGroup(opts: { id?: string; name?: string; members?: string[] }, changes: ChangeSet): El { + const doc = this.edit.doc; if (!opts.members?.length) throw usageError('A group needs --members : the elements it is drawn around'); const members = opts.members.map((id) => this.edit.require(id)); const wrong = members.find((member) => !is(member, 'bpmn:FlowNode') && !isDataReference(member)); @@ -72,12 +67,12 @@ export class ArtifactService { throw modelError('E_INVALID_VALUE', `${idOf(wrong)} is a ${kindLabel(wrong)}; group members are flow nodes, data objects and data stores`, { element: idOf(wrong) }); } const around = `Around ${new ElementLabels(members[0]!).label()}`; - const id = allocateId(this.edit, kindRequest(kindByName('group')!, opts.name ? { name: opts.name } : { context: around }), opts.id); + const id = this.edit.allocator.explicitOr(opts.id, kindRequest(kindByName('group')!, opts.name ? { name: opts.name } : { context: around })); const value = this.createCategoryValue(speakingStem(id) ?? around, opts.name); const group = doc.create('bpmn:Group', { id, categoryValueRef: value }); addTo(this.groupContainer(members), 'artifacts', group); for (const member of members) addTo(member, 'categoryValueRef', value); - cs.create({ id, kind: 'group', ...(opts.name ? { name: opts.name } : {}), detail: `around ${members.map(idOf).join(', ')}` }); + changes.create({ id, kind: 'group', ...(opts.name ? { name: opts.name } : {}), detail: `around ${members.map(idOf).join(', ')}` }); doc.invalidate(); return group; } @@ -90,14 +85,14 @@ export class ArtifactService { const value = group.get('categoryValueRef'); if (value) value.set('value', name || undefined); else if (name) group.set('categoryValueRef', this.createCategoryValue(speakingStem(idOf(group)) ?? '', name)); - new Groups(this.doc.definitions).relabel(this.doc.moddle, group, name); + new Groups(this.edit.doc.definitions).relabel(this.edit.doc.moddle, group, name); } - private artifactRequest(kind: 'dataObject' | 'dataStore' | 'textAnnotation', scope: El, opts: { name?: string; text?: string }): IdRequest { + private static artifactRequest(kind: 'dataObject' | 'dataStore' | 'textAnnotation', scope: El, opts: { name?: string; text?: string }): IdRequest { return kindRequest(kindByName(kind)!, { ...(opts.name ? { name: opts.name } : {}), context: opts.text?.trim() ? opts.text : new ElementLabels(scope).context('In') }); } - private assertFlowScope(scope: El, what: string): void { + private static assertFlowScope(scope: El, what: string): void { if (!is(scope, 'bpmn:Process') && !is(scope, 'bpmn:SubProcess')) { throw modelError('E_INVALID_SCOPE', `${idOf(scope)} is a ${kindLabel(scope)}; ${what} can only be created in a process or sub-process`, { element: idOf(scope), @@ -120,7 +115,7 @@ export class ArtifactService { } private createCategoryValue(context: string, name: string | undefined): El { - const doc = this.doc; + const doc = this.edit.doc; const request = { ...(name ? { name } : {}), context }; const value = doc.create('bpmn:CategoryValue', { id: this.edit.allocator.allocate(typeRequest('bpmn:CategoryValue', request)).id, ...(name ? { value: name } : {}) }); const category = doc.create('bpmn:Category', { id: this.edit.allocator.allocate(typeRequest('bpmn:Category', request)).id }); @@ -134,7 +129,7 @@ export class ArtifactService { * holding every member, else the collaboration, else the members' process. */ private groupContainer(members: El[]): El { - const doc = this.doc; + const doc = this.edit.doc; const within = (el: El, scope: El): boolean => { for (let ancestor = el.$parent as El | undefined; ancestor; ancestor = ancestor.$parent as El | undefined) if (ancestor === scope) return true; return false; diff --git a/src/domain/editing/elements/ElementCreation.ts b/src/domain/editing/elements/ElementCreation.ts new file mode 100644 index 00000000..2d037ce0 --- /dev/null +++ b/src/domain/editing/elements/ElementCreation.ts @@ -0,0 +1,409 @@ +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { ElementLabels, type AnchorContext } from '../../document/ids/ElementLabels.js'; +import { kindRequest, type IdRequest } from '../../document/utils/requests.js'; +import type { ContextWord } from '../../document/utils/id-words.js'; +import { FLOW_NODE_FAMILIES, isEventSubProcess, kindLabel, parseKindToAdd, type KindDef, type Trigger } from '../../document/Kinds.js'; +import { addTo, is, many, typeLabel, type El, idOf } from '../../document/utils/moddle.js'; +import { ChangeSet, type Change } from '../../document/ChangeSet.js'; +import { renamedNote } from '../../document/utils/renames.js'; +import { Associations } from '../connections/Associations.js'; +import { Artifacts } from './Artifacts.js'; +import { Lanes } from './Lanes.js'; +import { Pools } from './Pools.js'; +import { EventTrigger } from '../events/EventTrigger.js'; +import { LaneInheritance } from './LaneInheritance.js'; +import { Camunda8Defaults } from '../../document/vendor/Camunda8Defaults.js'; +import { FlowOptions } from '../connections/FlowOptions.js'; +import type { AddElement, TriggerOptions } from '../../commands/types.js'; +import { StaleFlowReport } from '../connections/StaleFlowReport.js'; +import { NodePlacement } from './NodePlacement.js'; +import { EventGatewayRule } from '../events/EventGatewayRule.js'; +import { FlowRules } from '../../document/FlowRules.js'; +import { EventDefinitions } from '../events/EventDefinitions.js'; +import { RootElements } from '../events/RootElements.js'; +import { Properties } from '../properties/Properties.js'; + +const PLACEMENT_KEYS = ['after', 'before', 'flow', 'on', 'in'] as const; + +/** + * Runs the `add` op: creates one element and wires it into the model. + * Collapse requests go to Doc.layoutRequests because `expanded` lives in DI. + */ +export class ElementCreation { + constructor(private readonly edit: EditSession) {} + + /** What tells an unnamed element's id apart: its placement (`After `, `On `). */ + private placementContext(command: AddElement): AnchorContext | undefined { + const at = (word: ContextWord, id: string | undefined): AnchorContext => { + const el = id ? this.edit.doc.get(id) : undefined; + return el ? new ElementLabels(el).anchorContext(word) : { context: word }; + }; + switch (NodePlacement.modeOf(command)) { + case 'after': + case 'between': + return at('After', command.after); + case 'before': + return at('Before', command.before); + case 'flow': { + const source = this.edit.doc.get(command.flow!)?.get('sourceRef'); + return source ? new ElementLabels(source).anchorContext('After') : undefined; + } + case 'on': + return at('On', command.on); + default: { + const scope = command.in ? this.edit.doc.get(command.in) : undefined; + if (!scope) return undefined; + return is(scope, 'bpmn:SubProcess') || this.edit.doc.processes().length > 1 ? new ElementLabels(scope).anchorContext('In') : undefined; + } + } + } + + private elementRequest(def: KindDef, trigger: Trigger | undefined, command: AddElement, context?: AnchorContext): IdRequest { + const where = context ?? this.placementContext(command); + return kindRequest(def, { + ...(def.family === 'event' && trigger && trigger !== 'none' ? { trigger } : {}), + ...(command.name ? { name: command.name } : {}), + ...(where ? { context: where.context, ...(where.anchorKind ? { anchorKind: where.anchorKind } : {}) } : {}), + }); + } + + private bindOutAs(command: AddElement, alias: string, node: El, changes: ChangeSet): void { + const id = idOf(node); + const out = this.edit.doc.outgoing(node); + if (out.length === 1) { + changes.bind(alias, out[0]); + return; + } + const mode = NodePlacement.modeOf(command); + if (!out.length) { + const why = mode === 'after' ? `--after ${command.after} appends it` : mode === 'none' ? 'no placement' : `--${mode}`; + throw usageError(`outAs ${alias}: ${id} gets no flow out of it from this placement (${why})`, { + element: id, + hint: 'outAs names the flow an add creates out of the new node: a splice, a prepend or "to" creates one. Otherwise connect the node with a connect op that has "as".', + }); + } + const ends = out.map((flow) => `${idOf(flow)} to ${idOf(flow.get('targetRef'))}`).join(', '); + throw usageError(`outAs ${alias}: ${id} gets ${out.length} flows out of it from this placement (${ends})`, { + element: id, + related: out.map(idOf), + hint: `outAs names one flow: drop "to" and connect ${id} to ${command.to} with a connect op that has "as" (outAs then names the other flow).`, + }); + } + + /** + * Binds only when exactly one flow leaves the existing node, so a re-run never guesses: + * a spliced gateway gets more branches later, in any order. + */ + private bindExistingOutAs(alias: string, existing: El, changes: ChangeSet): void { + const id = idOf(existing); + const flowNode = is(existing, 'bpmn:FlowNode'); + const out = flowNode ? this.edit.doc.outgoing(existing) : []; + if (out.length === 1) { + changes.bind(alias, out[0]); + return; + } + const ids = out.map(idOf); + const what = !flowNode ? `is a ${kindLabel(existing)}, no flow node` : out.length ? `has ${out.length} flows out of it (${ids.join(', ')})` : 'has no flow out of it'; + throw usageError(`outAs ${alias}: ${id} already exists and ${what}`, { + element: id, + ...(ids.length ? { related: ids, candidates: ids } : {}), + hint: 'With ifAbsent, outAs binds the flow out of an existing node only when exactly one leaves it: drop "outAs" there and use the flow\'s id.', + }); + } + + private static rejectPlacement(command: AddElement, allowed: ReadonlyArray<(typeof PLACEMENT_KEYS)[number] | 'to'>, kind: string): void { + for (const key of [...PLACEMENT_KEYS, 'to'] as const) { + if (command[key] !== undefined && !allowed.includes(key)) { + throw modelError('E_INVALID_PLACEMENT', `--${key} does not apply to ${kind}`, { + hint: allowed.length ? `${kind} accepts ${allowed.map((option) => `--${option}`).join(', ')} only.` : `${kind} takes no placement options.`, + }); + } + } + } + + private addDocumentation(el: El, text: string | undefined): void { + if (!text) return; + addTo(el, 'documentation', this.edit.doc.moddle.create('bpmn:Documentation', { text })); + } + + private static inferTrigger(opts: TriggerOptions): Trigger | undefined { + if (opts.timer !== undefined) return 'timer'; + if (opts.message !== undefined) return 'message'; + if (opts.error !== undefined || opts.errorCode !== undefined) return 'error'; + if (opts.signal !== undefined) return 'signal'; + if (opts.escalation !== undefined || opts.escalationCode !== undefined) return 'escalation'; + if (opts.when !== undefined) return 'conditional'; + if (opts.link !== undefined) return 'link'; + return undefined; + } + + private static hasTriggerOptions(opts: TriggerOptions): boolean { + return ElementCreation.inferTrigger(opts) !== undefined || opts.nonInterrupting !== undefined; + } + + private static takesMessage(def: KindDef): boolean { + return def.kind === 'sendTask' || def.kind === 'receiveTask'; + } + + private checkFlowOptions(command: AddElement): void { + new FlowOptions(command).assertConsistent(); + new FlowRules(this.edit.doc).assertCondition(command.condition); + } + + /** Warns about flow options the add ignored; op.flowId counts as used when a spliced flow without id took it. */ + private warnIgnoredFlowOptions(command: AddElement, node: El, entryFlow: El | undefined, changes: ChangeSet): void { + const id = idOf(node); + const mode = NodePlacement.modeOf(command); + if (mode === 'in' || mode === 'none' || mode === 'on') { + const given = new FlowOptions(command).flags; + if (given.length) { + const why = mode === 'on' ? '--on attaches the event, it' : mode === 'in' ? '--in' : 'a placement without --after/--before/--flow'; + changes.warn({ + code: 'W_OPTION_IGNORED', + message: `${given.join(', ')} ignored for ${id}: ${why} creates no sequence flow into the node`, + element: id, + hint: 'Place the node with --after / --before / --flow, or connect it afterwards with `bpmn connect`.', + }); + } + return; + } + const named = (change: Change): boolean => change.kind === 'sequenceFlow' && change.id === command.flowId; + if (command.flowId && !changes.created.some(named) && !changes.changed.some((change) => named(change) && !!change.detail?.endsWith(renamedNote('')))) { + const entry = entryFlow ?? this.edit.doc.incoming(node)[0]; + const kept = !!entry && this.edit.preferFlowId(entry, command.flowId, node); + changes.warn({ + code: 'W_OPTION_IGNORED', + message: `--flow-id ${command.flowId} ignored: the flow into ${id} is the existing ${entry ? idOf(entry) : 'flow'}, which keeps its id`, + element: id, + ...(entry ? { related: [idOf(entry)] } : {}), + hint: StaleFlowReport.flowIdIgnoredHint(command.flowId, entry && idOf(entry), kept), + }); + } + } + + private reportNewRoots(changes: ChangeSet, fn: () => void): void { + const before = new Set(many(this.edit.doc.definitions, 'rootElements')); + fn(); + for (const root of many(this.edit.doc.definitions, 'rootElements')) { + if (before.has(root)) continue; + const name = root.get('name'); + changes.create({ id: idOf(root), kind: typeLabel(root), ...(name ? { name } : {}), detail: 'root element' }); + } + } + + private placementDetail(command: AddElement): string { + switch (NodePlacement.modeOf(command)) { + case 'between': + return `between ${command.after} and ${command.before}`; + case 'after': + return `after ${command.after}`; + case 'before': + return `before ${command.before}`; + case 'flow': + return `in flow ${command.flow}`; + case 'on': + return `on ${command.on}`; + default: + return `in ${idOf(new NodePlacement(this.edit, command).scope())}`; + } + } + + private resolveLane(command: AddElement): El | undefined { + if (!command.lane) return undefined; + const lane = this.edit.require(command.lane, 'bpmn:Lane', 'lane'); + const scope = new NodePlacement(this.edit, command).scope(); + if (!is(scope, 'bpmn:Process')) { + throw modelError('E_INVALID_LANE_MEMBERSHIP', `Nodes inside sub-process ${idOf(scope)} cannot be lane members`, { + element: command.lane, + hint: 'Only nodes directly in the process can be assigned to lanes; drop --lane.', + }); + } + return lane; + } + + /** + * Adds a flow node. An event sub-process gets its start trigger here, so it is valid in one op; + * the inherited lane is read before the placement, as a splice may rename the flow op.flow + * names; the event-based gateway rule checks the node's flows once the trigger is set. + */ + private addFlowNode(command: AddElement, def: KindDef, trigger: Trigger | undefined, changes: ChangeSet, context: AnchorContext | undefined): El { + this.checkFlowOptions(command); + const explicitLane = this.resolveLane(command); + const id = this.edit.allocator.explicitOr(command.id, this.elementRequest(def, trigger ?? ElementCreation.inferTrigger(command), command, context)); + const el = this.edit.doc.create(def.type, { id, ...(command.name ? { name: command.name } : {}), ...(def.props ?? {}) }); + const isEvent = def.family === 'event'; + const isEventSub = def.kind === 'eventSubProcess'; + const startTrigger = isEventSub ? (trigger ?? ElementCreation.inferTrigger(command)) : undefined; + const messageTask = ElementCreation.takesMessage(def) && command.message !== undefined; + if (!isEvent && !startTrigger && ElementCreation.hasTriggerOptions(messageTask ? { ...command, message: undefined } : command)) { + changes.warn({ + code: 'W_OPTION_IGNORED', + message: isEventSub ? `Trigger options are ignored for eventSubProcess ${id}: no trigger given` : `Trigger options are ignored for ${def.kind} ${id}`, + element: id, + hint: isEventSub + ? 'Use eventSubProcess: or --error/--message/--timer/... to create the start event with the sub-process.' + : `Trigger options apply to events only${ElementCreation.takesMessage(def) ? ' (a send / receive task takes --message)' : ''}.`, + }); + } + if (command.collapsed && def.family !== 'subProcess') { + changes.warn({ code: 'W_OPTION_IGNORED', message: `--collapsed is ignored for ${def.kind} ${id}`, element: id, hint: '--collapsed applies to sub-processes only.' }); + } + + const entry = { id, kind: def.kind, ...(command.name ? { name: command.name } : {}), detail: this.placementDetail(command) }; + changes.create(entry); + const inherited = explicitLane ? LaneInheritance.NONE : LaneInheritance.of(this.edit.doc, command); + const entryFlow = new NodePlacement(this.edit, command).place(el, changes); + this.warnIgnoredFlowOptions(command, el, entryFlow, changes); + if (command.flowAs) { + const flow = entryFlow ?? (NodePlacement.modeOf(command) === 'before' ? this.edit.doc.outgoing(el)[0] : undefined); + if (!flow) { + throw usageError(`flowAs ${command.flowAs}: ${id} gets no flow from this placement (${NodePlacement.modeOf(command) === 'none' ? 'no placement' : `--${NodePlacement.modeOf(command)}`})`, { + element: id, + hint: 'Place the node with after / before / flow, or connect it with a connect op that has "as".', + }); + } + changes.bind(command.flowAs, flow); + } + if (command.outAs) this.bindOutAs(command, command.outAs, el, changes); + + if (isEvent) { + let resolved = trigger ?? ElementCreation.inferTrigger(command); + if (!resolved) { + const scope = this.edit.doc.scopeOf(el); + if (is(el, 'bpmn:BoundaryEvent')) { + throw modelError('E_TRIGGER_REQUIRED', `Boundary event ${id} needs a trigger`, { + element: id, + candidates: def.triggers ?? [], + hint: `Use boundaryEvent:, e.g. boundaryEvent:timer --timer P2D or boundaryEvent:error --error PaymentFailed.`, + }); + } + if (is(el, 'bpmn:StartEvent') && isEventSubProcess(scope)) { + throw modelError('E_TRIGGER_REQUIRED', `Start event ${id} inside event sub-process ${idOf(scope!)} needs a trigger`, { + element: id, + candidates: (def.triggers ?? []).filter((kindTrigger) => kindTrigger !== 'none'), + hint: 'Use startEvent:, e.g. startEvent:error --error PaymentFailed or startEvent:timer --timer R/PT1H.', + }); + } + resolved = 'none'; + } + this.reportNewRoots(changes, () => new EventDefinitions(this.edit).setTrigger(el, resolved, command)); + if (resolved !== 'none') entry.kind = kindLabel(el); + } + if (messageTask) new Properties(this.edit).setTaskMessage(el, command.message ?? '', changes); + if (new Camunda8Defaults(this.edit).userTask(el)) changes.note(`${id} is a Camunda user task (zeebe:userTask), like Camunda Modeler creates them`); + for (const flow of new Set([...this.edit.doc.incoming(el), ...this.edit.doc.outgoing(el)])) new EventGatewayRule(this.edit).warnFlow(flow, changes); + if (command.collapsed && def.family === 'subProcess') { + this.edit.doc.layoutRequests.collapse(id); + changes.note(`${id} will be laid out collapsed`); + } + this.addDocumentation(el, command.doc); + + if (explicitLane) { + new Lanes(this.edit).assign(el, explicitLane, changes); + } else { + const scope = this.edit.doc.scopeOf(el); + if (inherited.lane && scope && is(scope, 'bpmn:Process')) { + new Lanes(this.edit).assign(el, inherited.lane, changes); + const warning = inherited.warning(id); + if (warning) changes.warn(warning); + } + } + if (startTrigger) { + const startCs = this.add({ op: 'add', kind: `startEvent:${startTrigger}`, in: id, ...EventTrigger.optionsOf(command) }); + const start = startCs.created[0]; + if (start) start.detail = `start of event sub-process ${id}`; + changes.merge(startCs); + } + return el; + } + + /** `idContext` names an unnamed element's id when the placement cannot (split). */ + add(command: AddElement, idContext?: AnchorContext): ChangeSet { + const changes = new ChangeSet(); + const { def, trigger } = parseKindToAdd(command.kind); + + if (command.ifAbsent && !command.id) { + const base = this.edit.allocator.baseOf(this.elementRequest(def, trigger, command)); + throw usageError('--if-absent needs an explicit --id to check for', { + hint: `Pass --id ${base} (the id add generates${command.name ? ` for "${command.name}"` : ''}), or drop --if-absent.`, + }); + } + if (command.ifAbsent && command.id && this.edit.doc.has(command.id)) { + const existing = this.edit.doc.get(command.id)!; + changes.note(`${command.id} already exists (${kindLabel(existing)}); nothing to do`); + changes.bind(command.as, existing); + if (command.flowAs) changes.bind(command.flowAs, this.edit.doc.incoming(existing)[0]); + if (command.outAs) this.bindExistingOutAs(command.outAs, existing, changes); + RootElements.bindRefAs(changes, command.refAs, existing); + if (kindLabel(existing) !== def.kind && !kindLabel(existing).startsWith(`${def.kind}:`)) { + changes.warn({ + code: 'W_KIND_MISMATCH', + message: `${command.id} exists but is a ${kindLabel(existing)}, not a ${def.kind}`, + element: command.id, + hint: 'Use `retype` to change its kind, or pick another id.', + }); + } + return changes; + } + + if (command.outAs && !FLOW_NODE_FAMILIES.has(def.family)) { + throw usageError(`outAs ${command.outAs}: add ${def.kind} creates no sequence flow out of it`, { + hint: 'outAs names the sequence flow an add creates out of a new flow node; drop it, and name a connection with a connect op that has "as".', + }); + } + let el: El; + switch (def.family) { + case 'participant': { + ElementCreation.rejectPlacement(command, [], 'participant'); + const before = new Set(this.edit.doc.processes()); + el = new Pools(this.edit).createParticipant(command, changes); + for (const process of this.edit.doc.processes()) if (!before.has(process)) this.edit.doc.initProcess(process, this.edit.platform()); + break; + } + case 'lane': + ElementCreation.rejectPlacement(command, ['in'], 'lane'); + el = new Lanes(this.edit).createLane(command, changes); + break; + case 'data': { + ElementCreation.rejectPlacement(command, ['in', 'to'], def.kind); + const scope = this.edit.requireScope(command.in); + const opts = { ...(command.id ? { id: command.id } : {}), ...(command.name ? { name: command.name } : {}) }; + el = def.kind === 'dataStore' ? new Artifacts(this.edit).createDataStore(scope, opts, changes) : new Artifacts(this.edit).createDataObject(scope, opts, changes); + if (command.to) new Associations(this.edit).createDataAssociation(el, this.edit.require(command.to), {}, changes); + break; + } + case 'artifact': { + if (def.kind === 'group') { + ElementCreation.rejectPlacement(command, [], def.kind); + el = new Artifacts(this.edit).createGroup({ ...(command.id ? { id: command.id } : {}), ...(command.name ? { name: command.name } : {}), ...(command.members ? { members: command.members } : {}) }, changes); + break; + } + ElementCreation.rejectPlacement(command, ['in', 'to'], def.kind); + const scope = this.edit.requireScope(command.in); + const text = command.text ?? command.name; + el = new Artifacts(this.edit).createTextAnnotation(scope, { ...(command.id ? { id: command.id } : {}), ...(command.name ? { name: command.name } : {}), ...(text ? { text } : {}) }, changes); + if (command.to) new Associations(this.edit).createAssociation(this.edit.require(command.to), el, {}, changes); + break; + } + default: + el = this.addFlowNode(command, def, trigger, changes, idContext); + break; + } + + if (!FLOW_NODE_FAMILIES.has(def.family)) { + this.addDocumentation(el, command.doc); + if (command.lane) { + changes.warn({ code: 'W_OPTION_IGNORED', message: `--lane is ignored for ${def.kind} ${idOf(el)}`, element: idOf(el), hint: 'Only flow nodes can be lane members.' }); + } + } + if (command.set && Object.keys(command.set).length) { + changes.merge(new Properties(this.edit).set({ op: 'set', id: idOf(el), values: command.set })); + } + changes.bind(command.as, el); + RootElements.bindRefAs(changes, command.refAs, el); + this.edit.allocator.reportSuffixed(changes); + this.edit.doc.invalidate(); + return changes; + } +} diff --git a/src/domain/editing/ElementMoveService.ts b/src/domain/editing/elements/ElementMove.ts similarity index 56% rename from src/domain/editing/ElementMoveService.ts rename to src/domain/editing/elements/ElementMove.ts index c996bc52..bd039f5f 100644 --- a/src/domain/editing/ElementMoveService.ts +++ b/src/domain/editing/elements/ElementMove.ts @@ -1,83 +1,79 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { kindLabel } from '../document/utils/kinds.js'; -import { addTo, is, removeFrom, walk, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { LaneService } from './participants/LaneService.js'; -import { laneOf } from '../document/utils/lanes.js'; -import { FlowBridgeService } from './flows/FlowBridgeService.js'; -import { MovedFlows } from './flows/MovedFlows.js'; -import { insertAfterInScope } from './utils/declaration-order.js'; -import { LaneInheritance } from './participants/LaneInheritance.js'; -import { IdRenameService } from './IdRenameService.js'; -import { idOf } from '../document/utils/elements.js'; -import { FlowOptions } from './flows/FlowOptions.js'; -import type { MoveElements } from '../commands/types.js'; -import { NodePlacementService, type PlacementOptions } from './NodePlacementService.js'; -import { EventGatewayRule } from './events/EventGatewayRule.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { kindLabel } from '../../document/Kinds.js'; +import { addTo, is, removeFrom, walk, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { Lanes } from './Lanes.js'; +import { laneOf } from '../../document/utils/lanes.js'; +import { FlowBridge } from '../connections/FlowBridge.js'; +import { MovedFlows } from '../connections/MovedFlows.js'; +import { insertAfterInScope } from '../utils/declaration-order.js'; +import { LaneInheritance } from './LaneInheritance.js'; +import { IdRename } from '../ids/IdRename.js'; +import { idOf } from '../../document/utils/elements.js'; +import { FlowOptions } from '../connections/FlowOptions.js'; +import type { MoveElements } from '../../commands/types.js'; +import { NodePlacement, type PlacementOptions } from './NodePlacement.js'; +import { EventGatewayRule } from '../events/EventGatewayRule.js'; +import { SequenceFlows } from '../connections/SequenceFlows.js'; /** * `move`: relocates nodes via --after/--before/--flow (chained in order), --in, --on or --lane. * The old place is bridged; with flow ids kept, a moved node's out-flow is put back at the * new place, keeping its id. A move that leaves every node where it is changes nothing. */ -export class ElementMoveService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class ElementMove { + constructor(private readonly edit: EditSession) {} - move(op: MoveElements): ChangeSet { - const cs = new ChangeSet(); - if (!op.ids?.length) throw usageError('move needs at least one node id'); - const mode = NodePlacementService.modeOf(op); - const hasLane = op.lane !== undefined; + move(command: MoveElements): ChangeSet { + const changes = new ChangeSet(); + if (!command.ids?.length) throw usageError('move needs at least one node id'); + const mode = NodePlacement.modeOf(command); + const hasLane = command.lane !== undefined; if (mode === 'none' && !hasLane) { throw usageError('move needs a placement (--after, --before, --flow, --in, --on) and/or --lane '); } - const nodes = op.ids.map((id) => this.edit.require(id, 'bpmn:FlowNode')); - const placement = ElementMoveService.placementOf(op); + const nodes = command.ids.map((id) => this.edit.require(id, 'bpmn:FlowNode')); + const placement = ElementMove.placementOf(command); if (mode === 'in') { - this.moveInto(nodes, placement, cs); + this.moveInto(nodes, placement, changes); } else if (mode === 'on') { - for (const node of nodes) this.moveOnto(node, placement, cs); + for (const node of nodes) this.moveOnto(node, placement, changes); } else if (mode !== 'none') { this.assertFirstPlacement(nodes[0]!, placement); this.assertChainAnchor(nodes, placement); if (this.inPlace(nodes, placement)) { - cs.note(`${nodes.map(idOf).join(', ')} ${nodes.length > 1 ? 'are' : 'is'} already ${ElementMoveService.describePlacement(placement)}${nodes.length > 1 ? ', in this order' : ''}`); - this.optionsOnInFlow(this.doc.incoming(nodes[0]!)[0], placement, cs); + changes.note(`${nodes.map(idOf).join(', ')} ${nodes.length > 1 ? 'are' : 'is'} already ${ElementMove.describePlacement(placement)}${nodes.length > 1 ? ', in this order' : ''}`); + this.optionsOnInFlow(this.edit.doc.incoming(nodes[0]!)[0], placement, changes); } else { - const moved = new MovedFlows(this.doc, this.edit, cs, nodes); + const moved = new MovedFlows(this.edit, changes, nodes); nodes.forEach((node, index) => { const prev = nodes[index - 1]; - this.moveNode(node, prev ? { after: idOf(prev) } : placement, cs, hasLane, moved, { prev, next: nodes[index + 1] }); + this.moveNode(node, prev ? { after: idOf(prev) } : placement, changes, hasLane, moved, { prev, next: nodes[index + 1] }); }); moved.finish(); } } if (hasLane) { - const lane = op.lane ? this.edit.require(op.lane, 'bpmn:Lane', 'lane') : undefined; - for (const node of nodes) new LaneService(this.doc, this.edit).assign(node, lane, cs); + const lane = command.lane ? this.edit.require(command.lane, 'bpmn:Lane', 'lane') : undefined; + for (const node of nodes) new Lanes(this.edit).assign(node, lane, changes); } - this.doc.invalidate(); - return cs; + this.edit.doc.invalidate(); + return changes; } - private static placementOf(op: MoveElements): PlacementOptions { + private static placementOf(command: MoveElements): PlacementOptions { const placement: PlacementOptions = {}; - if (op.after) placement.after = op.after; - if (op.before) placement.before = op.before; - if (op.flow) placement.flow = op.flow; - if (op.in) placement.in = op.in; - if (op.on) placement.on = op.on; - if (op.flowName !== undefined) placement.flowName = op.flowName; - if (op.flowId) placement.flowId = op.flowId; - if (op.condition !== undefined) placement.condition = op.condition; - if (op.language) placement.language = op.language; - if (op.default) placement.default = true; + if (command.after) placement.after = command.after; + if (command.before) placement.before = command.before; + if (command.flow) placement.flow = command.flow; + if (command.in) placement.in = command.in; + if (command.on) placement.on = command.on; + if (command.flowName !== undefined) placement.flowName = command.flowName; + if (command.flowId) placement.flowId = command.flowId; + if (command.condition !== undefined) placement.condition = command.condition; + if (command.language) placement.language = command.language; + if (command.default) placement.default = true; return placement; } @@ -108,12 +104,12 @@ export class ElementMoveService { } private static isInside(node: El, anchor: El): boolean { - return anchor === node || ElementMoveService.isBoundaryOf(node, anchor) || [...walk(node)].includes(anchor); + return anchor === node || ElementMove.isBoundaryOf(node, anchor) || [...walk(node)].includes(anchor); } private static assertNotInside(node: El, anchor: El): void { - if (ElementMoveService.isInside(node, anchor)) { - const what = ElementMoveService.isBoundaryOf(node, anchor) ? 'that is one of its boundary events, which move with it' : 'that is the node itself or one of its children'; + if (ElementMove.isInside(node, anchor)) { + const what = ElementMove.isBoundaryOf(node, anchor) ? 'that is one of its boundary events, which move with it' : 'that is the node itself or one of its children'; throw modelError('E_INVALID_PLACEMENT', `${idOf(node)} cannot be moved relative to ${idOf(anchor)}: ${what}`, { element: idOf(node), related: [idOf(anchor)], @@ -123,8 +119,8 @@ export class ElementMoveService { } private assertFirstPlacement(node: El, placement: PlacementOptions): void { - ElementMoveService.assertNotBoundary(node); - for (const anchor of this.anchorsOf(placement)) ElementMoveService.assertNotInside(node, anchor); + ElementMove.assertNotBoundary(node); + for (const anchor of this.anchorsOf(placement)) ElementMove.assertNotInside(node, anchor); } private static assertNotBoundary(node: El): void { @@ -138,27 +134,27 @@ export class ElementMoveService { * node is fine (`move A Join --before Join` ends A -> Join). */ private assertChainAnchor(nodes: El[], placementOptions: PlacementOptions): void { - const mode = NodePlacementService.modeOf(placementOptions); + const mode = NodePlacement.modeOf(placementOptions); const later = nodes.slice(1); const flow = mode === 'flow' ? this.edit.require(placementOptions.flow!, 'bpmn:SequenceFlow', 'sequence flow') : undefined; const after = mode === 'after' || mode === 'between' ? this.edit.require(placementOptions.after!, 'bpmn:FlowNode') : flow?.get('sourceRef'); const before = mode === 'before' || mode === 'between' ? this.edit.require(placementOptions.before!, 'bpmn:FlowNode') : undefined; let anchor = after; - let holder = after && later.find((laterNode) => ElementMoveService.isInside(laterNode, after)); + let holder = after && later.find((laterNode) => ElementMove.isInside(laterNode, after)); if (!holder && before) { anchor = before; - holder = later.find((laterNode) => laterNode !== before && ElementMoveService.isInside(laterNode, before)); + holder = later.find((laterNode) => laterNode !== before && ElementMove.isInside(laterNode, before)); } if (!anchor || !holder) return; const ids = nodes.map(idOf); const first = ids[0]!; - const what = anchor === holder ? 'is one of the moved nodes' : `is ${ElementMoveService.isBoundaryOf(holder, anchor) ? 'a boundary event of' : 'inside'} ${idOf(holder)}, one of the moved nodes`; + const what = anchor === holder ? 'is one of the moved nodes' : `is ${ElementMove.isBoundaryOf(holder, anchor) ? 'a boundary event of' : 'inside'} ${idOf(holder)}, one of the moved nodes`; const placement = flow ? `--flow ${idOf(flow)}` : [...(placementOptions.after ? [`--after ${placementOptions.after}`] : []), ...(placementOptions.before ? [`--before ${placementOptions.before}`] : [])].join(' '); const itself = `The chain would be placed ${anchor === before ? 'inside' : 'after'} itself.`; let hint = `${itself} Pick an anchor outside the moved nodes, or move ${first} alone: \`bpmn move ${first} ${placement}\`.`; if (this.inPlace(nodes.slice(0, 1), placementOptions)) { - const reorder = mode === 'after' && this.doc.incoming(anchor).length === 1; - hint = `${itself} ${first} is already ${ElementMoveService.describePlacement(placementOptions)}. Pick an anchor outside the moved nodes${reorder ? `, or put ${first} before ${idOf(anchor)}: \`bpmn move ${first} --before ${idOf(anchor)}\`` : ''}.`; + const reorder = mode === 'after' && this.edit.doc.incoming(anchor).length === 1; + hint = `${itself} ${first} is already ${ElementMove.describePlacement(placementOptions)}. Pick an anchor outside the moved nodes${reorder ? `, or put ${first} before ${idOf(anchor)}: \`bpmn move ${first} --before ${idOf(anchor)}\`` : ''}.`; } throw modelError('E_INVALID_PLACEMENT', `${flow ? `${idOf(flow)} cannot anchor the move of ${ids.join(', ')}: its source ${idOf(anchor)}` : `${idOf(anchor)} cannot anchor the move of ${ids.join(', ')}: it`} ${what}`, { element: flow ? idOf(flow) : idOf(anchor), @@ -168,15 +164,15 @@ export class ElementMoveService { } /** A move in place: flow options apply to the flow into the first node; a flowId renames it. */ - private optionsOnInFlow(flow: El | undefined, placement: PlacementOptions, cs: ChangeSet): void { + private optionsOnInFlow(flow: El | undefined, placement: PlacementOptions, changes: ChangeSet): void { if (!flow) return; const old = idOf(flow); const renamed = placement.flowId !== undefined && placement.flowId !== old; - if (renamed) new IdRenameService(this.doc, this.edit).rename(flow, placement.flowId!); + if (renamed) new IdRename(this.edit).rename(flow, placement.flowId!); const options = new ChangeSet(); - new FlowOptions(placement).apply(this.doc, this.edit, flow, options); + new FlowOptions(placement).apply(this.edit, flow, options); if (!renamed && !options.changed.length) return; - cs.change(ChangeSet.flowEntryOf(flow, `${ChangeSet.flowEntryOf(flow).detail}${renamed ? ` (renamed from ${old})` : ''}`)); + changes.change(ChangeSet.flowEntryOf(flow, `${ChangeSet.flowEntryOf(flow).detail}${renamed ? ` (renamed from ${old})` : ''}`)); } /** @@ -185,45 +181,45 @@ export class ElementMoveService { */ private inPlace(nodes: El[], placement: PlacementOptions): boolean { if (nodes.some((node) => is(node, 'bpmn:BoundaryEvent')) || new Set(nodes).size !== nodes.length) return false; - if (new FlowOptions(placement).flags.length && this.doc.incoming(nodes[0]!).length !== 1) return false; + if (new FlowOptions(placement).flags.length && this.edit.doc.incoming(nodes[0]!).length !== 1) return false; const onlyIn = (node: El): El | undefined => { - const flows = this.doc.incoming(node); + const flows = this.edit.doc.incoming(node); return flows.length === 1 ? flows[0]!.get('sourceRef') : undefined; }; const onlyOut = (node: El): El | undefined => { - const flows = this.doc.outgoing(node); + const flows = this.edit.doc.outgoing(node); return flows.length === 1 ? flows[0]!.get('targetRef') : undefined; }; for (let index = 1; index < nodes.length; index++) if (is(nodes[index - 1]!, 'bpmn:Gateway') || onlyIn(nodes[index]!) !== nodes[index - 1]) return false; const first = nodes[0]!; const last = nodes[nodes.length - 1]!; - const mode = NodePlacementService.modeOf(placement); + const mode = NodePlacement.modeOf(placement); if (mode === 'after') { const afterNode = this.edit.require(placement.after!, 'bpmn:FlowNode'); return !is(afterNode, 'bpmn:Gateway') && onlyIn(first) === afterNode; } if (mode === 'before') { const beforeNode = this.edit.require(placement.before!, 'bpmn:FlowNode'); - return !(is(beforeNode, 'bpmn:Gateway') && this.doc.incoming(beforeNode).length >= 2) && onlyOut(last) === beforeNode; + return !(is(beforeNode, 'bpmn:Gateway') && this.edit.doc.incoming(beforeNode).length >= 2) && onlyOut(last) === beforeNode; } if (mode === 'between') return onlyIn(first) === this.edit.require(placement.after!, 'bpmn:FlowNode') && onlyOut(last) === this.edit.require(placement.before!, 'bpmn:FlowNode'); return false; } private deepestLane(el: El): El | undefined { - const lanes = this.doc.lanesOf(el); + const lanes = this.edit.doc.lanesOf(el); return lanes[lanes.length - 1]; } /** Lane membership is dropped when the process changes or the target is a sub-process. */ - private leaveScope(moved: El[], oldScope: El | undefined, targetScope: El, cs: ChangeSet): void { - const processChanged = !oldScope || this.doc.processOf(oldScope) !== this.doc.processOf(targetScope); + private leaveScope(moved: El[], oldScope: El | undefined, targetScope: El, changes: ChangeSet): void { + const processChanged = !oldScope || this.edit.doc.processOf(oldScope) !== this.edit.doc.processOf(targetScope); const dropLanes = oldScope !== targetScope && (processChanged || !is(targetScope, 'bpmn:Process')); for (const movedNode of moved) { if (dropLanes) { - for (const lane of this.doc.lanesOf(movedNode)) { + for (const lane of this.edit.doc.lanesOf(movedNode)) { removeFrom(lane, 'flowNodeRef', movedNode); - cs.change(ChangeSet.entryOf(lane, `member ${idOf(movedNode)} removed`)); + changes.change(ChangeSet.entryOf(lane, `member ${idOf(movedNode)} removed`)); } } if (oldScope) removeFrom(oldScope, 'flowElements', movedNode); @@ -231,15 +227,15 @@ export class ElementMoveService { } /** Re-declares flows within `scope`; flows that would cross scopes are removed and reported. */ - private fixFlows(moved: El[], scope: El, cs: ChangeSet): void { + private fixFlows(moved: El[], scope: El, changes: ChangeSet): void { const seen = new Set(); for (const movedNode of moved) { - for (const flow of [...this.doc.incoming(movedNode), ...this.doc.outgoing(movedNode)]) { + for (const flow of [...this.edit.doc.incoming(movedNode), ...this.edit.doc.outgoing(movedNode)]) { if (seen.has(flow)) continue; seen.add(flow); const src = flow.get('sourceRef'); const tgt = flow.get('targetRef'); - if (this.doc.scopeOf(src) === scope && this.doc.scopeOf(tgt) === scope) { + if (this.edit.doc.scopeOf(src) === scope && this.edit.doc.scopeOf(tgt) === scope) { const declaredIn = flow.$parent as El | undefined; if (declaredIn !== scope) { if (declaredIn) removeFrom(declaredIn, 'flowElements', flow); @@ -247,9 +243,9 @@ export class ElementMoveService { } continue; } - new SequenceFlowService(this.doc, this.edit).remove(flow, cs); - cs.remove(ChangeSet.flowEntryOf(flow)); - cs.warn({ + new SequenceFlows(this.edit).remove(flow, changes); + changes.remove(ChangeSet.flowEntryOf(flow)); + changes.warn({ code: 'W_FLOW_DROPPED', message: `Sequence flow ${idOf(flow)} (${idOf(src)} -> ${idOf(tgt)}) was removed: it would cross scopes after the move`, element: idOf(flow), @@ -261,7 +257,7 @@ export class ElementMoveService { } private inheritLane(node: El, boundaries: El[], anchor: El | undefined, scope: El): void { - if (!anchor || !is(scope, 'bpmn:Process') || this.doc.lanesOf(node).length) return; + if (!anchor || !is(scope, 'bpmn:Process') || this.edit.doc.lanesOf(node).length) return; const lane = this.deepestLane(anchor); if (!lane) return; this.joinLane(lane, node, boundaries); @@ -269,12 +265,12 @@ export class ElementMoveService { private joinLane(lane: El, node: El, boundaries: El[]): void { addTo(lane, 'flowNodeRef', node); - for (const boundary of boundaries) if (!this.doc.lanesOf(boundary).length) addTo(lane, 'flowNodeRef', boundary); + for (const boundary of boundaries) if (!this.edit.doc.lanesOf(boundary).length) addTo(lane, 'flowNodeRef', boundary); } /** A reused id would show up as both removed and created in the result. */ private reserveIds(ids: string[]): void { - for (const id of ids) if (id && !this.doc.has(id)) this.doc.ids.claim(id); + for (const id of ids) if (id && !this.edit.doc.has(id)) this.edit.doc.ids.claim(id); } /** @@ -283,82 +279,81 @@ export class ElementMoveService { * decided before the placement, as a splice may rename the flow; a gateway's out-flow waits for * the append of the next node (a new branch). */ - private moveNode(node: El, placement: PlacementOptions, cs: ChangeSet, explicitLane: boolean, moved: MovedFlows, chain: { prev?: El; next?: El } = {}): void { + private moveNode(node: El, placement: PlacementOptions, changes: ChangeSet, explicitLane: boolean, moved: MovedFlows, chain: { prev?: El; next?: El } = {}): void { const { prev, next } = chain; - ElementMoveService.assertNotBoundary(node); + ElementMove.assertNotBoundary(node); const anchors = this.anchorsOf(placement); - for (const anchor of anchors) ElementMoveService.assertNotInside(node, anchor); - const oldScope = this.doc.scopeOf(node); - const targetScope = new NodePlacementService(this.doc, this.edit, placement).scope(); - const boundaries = is(node, 'bpmn:Activity') ? this.doc.boundaryEventsOf(node) : []; - const incoming = this.doc.incoming(node); - const outgoing = this.doc.outgoing(node); + for (const anchor of anchors) ElementMove.assertNotInside(node, anchor); + const oldScope = this.edit.doc.scopeOf(node); + const targetScope = new NodePlacement(this.edit, placement).scope(); + const boundaries = is(node, 'bpmn:Activity') ? this.edit.doc.boundaryEventsOf(node) : []; + const incoming = this.edit.doc.incoming(node); + const outgoing = this.edit.doc.outgoing(node); const flowIds = [...incoming, ...outgoing].map(idOf); const oldOut = outgoing.length === 1 && outgoing[0]!.get('targetRef') !== node && this.edit.keepsFlowId(outgoing[0]!) ? outgoing[0] : undefined; const oldTarget = oldOut?.get('targetRef'); const outLabel = oldOut && MovedFlows.labelOf(oldOut, node); const fromPrev = prev && incoming.find((flow) => flow.get('sourceRef') === prev && this.edit.keepsFlowId(flow)); const prevLabel = fromPrev && MovedFlows.labelOf(fromPrev, prev!); - const bridge = new FlowBridgeService(this.doc, this.edit, cs, 'move').single(node, true); + const bridge = new FlowBridge(this.edit, changes, 'move').single(node, true); if (bridge) moved.bridged(node, bridge); this.reserveIds(flowIds); - if (oldOut && oldTarget && !this.doc.has(idOf(oldOut))) moved.takeOut(node, oldOut, oldTarget, bridge ? undefined : outLabel); - if (fromPrev && !this.doc.has(idOf(fromPrev)) && !moved.hasSpare(prev!)) moved.takeOut(prev!, fromPrev, node, prevLabel); - const laneBefore = laneOf(this.doc, node); - this.leaveScope([node, ...boundaries], oldScope, targetScope, cs); - this.doc.invalidate(); + if (oldOut && oldTarget && !this.edit.doc.has(idOf(oldOut))) moved.takeOut(node, oldOut, oldTarget, bridge ? undefined : outLabel); + if (fromPrev && !this.edit.doc.has(idOf(fromPrev)) && !moved.hasSpare(prev!)) moved.takeOut(prev!, fromPrev, node, prevLabel); + const laneBefore = laneOf(this.edit.doc, node); + this.leaveScope([node, ...boundaries], oldScope, targetScope, changes); + this.edit.doc.invalidate(); const own = laneBefore && (laneBefore.get('flowNodeRef') ?? []).includes(node) ? laneBefore : undefined; - const inherited = !explicitLane && is(targetScope, 'bpmn:Process') ? LaneInheritance.of(this.doc, placement) : LaneInheritance.NONE; + const inherited = !explicitLane && is(targetScope, 'bpmn:Process') ? LaneInheritance.of(this.edit.doc, placement) : LaneInheritance.NONE; const holdOwn = !!next && is(node, 'bpmn:Gateway'); - new NodePlacementService(this.doc, this.edit, placement).place(node, cs, (source) => (source === node && holdOwn ? undefined : moved.spareOf(source))); + new NodePlacement(this.edit, placement).place(node, changes, (source) => (source === node && holdOwn ? undefined : moved.spareOf(source))); moved.putBack(); - for (const flow of new Set([...this.doc.incoming(node), ...this.doc.outgoing(node)])) new EventGatewayRule(this.doc, this.edit).warnFlow(flow, cs); + for (const flow of new Set([...this.edit.doc.incoming(node), ...this.edit.doc.outgoing(node)])) new EventGatewayRule(this.edit).warnFlow(flow, changes); if (boundaries.length) insertAfterInScope(targetScope, node, ...boundaries); - this.fixFlows(boundaries, targetScope, cs); + this.fixFlows(boundaries, targetScope, changes); if (own) { const note = explicitLane ? undefined : inherited.crossNote(idOf(node), own); - if (note) cs.note(note); + if (note) changes.note(note); } else if (inherited.lane) { this.joinLane(inherited.lane, node, boundaries); const warning = inherited.warning(idOf(node)); - if (warning) cs.warn(warning); + if (warning) changes.warn(warning); } - cs.change(ChangeSet.entryOf(node, `moved ${ElementMoveService.describePlacement(placement)}`)); - cs.relocated.push(idOf(node)); - this.doc.invalidate(); + changes.change(ChangeSet.entryOf(node, `moved ${ElementMove.describePlacement(placement)}`)); + changes.relocated.push(idOf(node)); + this.edit.doc.invalidate(); } - - private moveInto(nodes: El[], placement: PlacementOptions, cs: ChangeSet): void { - const scope = new NodePlacementService(this.doc, this.edit, placement).scope(); + private moveInto(nodes: El[], placement: PlacementOptions, changes: ChangeSet): void { + const scope = new NodePlacement(this.edit, placement).scope(); const relocating: El[] = []; for (const node of nodes) { if (is(node, 'bpmn:BoundaryEvent')) { throw modelError('E_INVALID_PLACEMENT', `Boundary event ${idOf(node)} is moved with --on `, { element: idOf(node) }); } - ElementMoveService.assertNotInside(node, scope); - if (this.doc.scopeOf(node) === scope) { - cs.note(`${idOf(node)} is already in ${idOf(scope)}`); + ElementMove.assertNotInside(node, scope); + if (this.edit.doc.scopeOf(node) === scope) { + changes.note(`${idOf(node)} is already in ${idOf(scope)}`); continue; } relocating.push(node); } - new FlowBridgeService(this.doc, this.edit, cs, 'move').group(relocating); + new FlowBridge(this.edit, changes, 'move').group(relocating); const moved: El[] = []; for (const node of relocating) { - const oldScope = this.doc.scopeOf(node); - const boundaries = is(node, 'bpmn:Activity') ? this.doc.boundaryEventsOf(node) : []; - this.leaveScope([node, ...boundaries], oldScope, scope, cs); + const oldScope = this.edit.doc.scopeOf(node); + const boundaries = is(node, 'bpmn:Activity') ? this.edit.doc.boundaryEventsOf(node) : []; + this.leaveScope([node, ...boundaries], oldScope, scope, changes); insertAfterInScope(scope, undefined, node, ...boundaries); moved.push(node, ...boundaries); - cs.change(ChangeSet.entryOf(node, `moved into ${idOf(scope)}`)); - cs.relocated.push(idOf(node)); + changes.change(ChangeSet.entryOf(node, `moved into ${idOf(scope)}`)); + changes.relocated.push(idOf(node)); } - this.fixFlows(moved, scope, cs); - this.doc.invalidate(); + this.fixFlows(moved, scope, changes); + this.edit.doc.invalidate(); } - private moveOnto(node: El, placement: PlacementOptions, cs: ChangeSet): void { + private moveOnto(node: El, placement: PlacementOptions, changes: ChangeSet): void { if (!is(node, 'bpmn:BoundaryEvent')) { throw modelError('E_INVALID_PLACEMENT', `--on attaches boundary events only; ${kindLabel(node)} ${idOf(node)} is moved with --after/--before/--flow/--in`, { element: idOf(node), @@ -367,16 +362,16 @@ export class ElementMoveService { const host = this.edit.require(placement.on!, 'bpmn:Activity', 'activity'); const oldHost = node.get('attachedToRef'); if (oldHost === host) { - cs.note(`${idOf(node)} is already attached to ${idOf(host)}`); + changes.note(`${idOf(node)} is already attached to ${idOf(host)}`); return; } - const oldScope = this.doc.scopeOf(node); - const scope = this.doc.scopeOf(host) ?? this.doc.defaultScope(); - this.leaveScope([node], oldScope, scope, cs); - new NodePlacementService(this.doc, this.edit, { on: placement.on! }).place(node, cs); - this.fixFlows([node], scope, cs); + const oldScope = this.edit.doc.scopeOf(node); + const scope = this.edit.doc.scopeOf(host) ?? this.edit.doc.defaultScope(); + this.leaveScope([node], oldScope, scope, changes); + new NodePlacement(this.edit, { on: placement.on! }).place(node, changes); + this.fixFlows([node], scope, changes); this.inheritLane(node, [], host, scope); - cs.change(ChangeSet.entryOf(node, `moved onto ${idOf(host)}${oldHost ? ` (was ${idOf(oldHost)})` : ''}`)); - this.doc.invalidate(); + changes.change(ChangeSet.entryOf(node, `moved onto ${idOf(host)}${oldHost ? ` (was ${idOf(oldHost)})` : ''}`)); + this.edit.doc.invalidate(); } } diff --git a/src/domain/editing/ElementOrderService.ts b/src/domain/editing/elements/ElementOrder.ts similarity index 61% rename from src/domain/editing/ElementOrderService.ts rename to src/domain/editing/elements/ElementOrder.ts index 97389d6c..559580cc 100644 --- a/src/domain/editing/ElementOrderService.ts +++ b/src/domain/editing/elements/ElementOrder.ts @@ -1,38 +1,33 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { insertInto, is, many, removeFrom, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { idOf } from '../document/utils/elements.js'; -import { laneSetOf } from '../document/utils/lanes.js'; -import type { ReorderElements } from '../commands/types.js'; -import { ReorderTarget } from '../commands/ReorderTarget.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { insertInto, is, many, removeFrom, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { idOf } from '../../document/utils/elements.js'; +import { laneSetOf } from '../../document/utils/lanes.js'; +import type { ReorderElements } from '../../commands/types.js'; +import { ReorderTarget } from '../../commands/ReorderTarget.js'; /** * The `order` op: the top-to-bottom order of a node's outgoing flows, of lanes or of pools. * The layouter follows declaration order, so flows are re-declared too; unlisted entries * follow in their old order. */ -export class ElementOrderService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - private readonly op: ReorderElements, - ) {} +export class ElementOrder { + constructor(private readonly edit: EditSession) {} - apply(): ChangeSet { - const target = new ReorderTarget(this.doc, this.op); - if (target.ordersPools()) return this.orderPools(); - if (target.ordersLanes()) return this.orderLanes(); - return this.orderOutgoing(); + reorder(command: ReorderElements): ChangeSet { + const target = new ReorderTarget(this.edit.doc, command); + if (target.ordersPools()) return this.orderPools(command); + if (target.ordersLanes()) return this.orderLanes(command); + return this.orderOutgoing(command); } - private orderPools(): ChangeSet { - const { doc, op } = this; - const cs = new ChangeSet(); - const collab = this.edit.require(op.id, 'bpmn:Collaboration', 'collaboration'); - const ids = op.pools ?? op.flows ?? []; - if (!ids.length) throw usageError('order needs the participant ids in the wanted top-to-bottom order', { element: op.id }); + private orderPools(command: ReorderElements): ChangeSet { + const doc = this.edit.doc; + const changes = new ChangeSet(); + const collab = this.edit.require(command.id, 'bpmn:Collaboration', 'collaboration'); + const ids = command.pools ?? command.flows ?? []; + if (!ids.length) throw usageError('order needs the participant ids in the wanted top-to-bottom order', { element: command.id }); const participants = many(collab, 'participants'); const listed: El[] = []; for (const id of ids) { @@ -50,20 +45,20 @@ export class ElementOrderService { } const ordered = [...listed, ...participants.filter((participant) => !listed.includes(participant))]; if (ordered.every((participant, index) => participants[index] === participant)) { - cs.note(`${idOf(collab)}: pools already declared in this order (the drawing follows it)`); - return cs; + changes.note(`${idOf(collab)}: pools already declared in this order (the drawing follows it)`); + return changes; } participants.splice(0, participants.length, ...ordered); doc.invalidate(); - cs.change(ChangeSet.entryOf(collab, `pool order: ${ordered.map(idOf).join(', ')}`)); - return cs; + changes.change(ChangeSet.entryOf(collab, `pool order: ${ordered.map(idOf).join(', ')}`)); + return changes; } - private orderLanes(): ChangeSet { - const { doc, op } = this; - const cs = new ChangeSet(); - const owner = this.edit.require(op.id, ['bpmn:Process', 'bpmn:Participant', 'bpmn:Lane'], 'process, participant or lane'); - const ids = op.lanes ?? op.flows ?? []; + private orderLanes(command: ReorderElements): ChangeSet { + const doc = this.edit.doc; + const changes = new ChangeSet(); + const owner = this.edit.require(command.id, ['bpmn:Process', 'bpmn:Participant', 'bpmn:Lane'], 'process, participant or lane'); + const ids = command.lanes ?? command.flows ?? []; const set = laneSetOf(owner); const lanes = set ? many(set, 'lanes') : []; const listed: El[] = []; @@ -84,23 +79,23 @@ export class ElementOrderService { } const ordered = [...listed, ...lanes.filter((lane) => !listed.includes(lane))]; if (ordered.every((lane, index) => lanes[index] === lane)) { - cs.note(`${idOf(owner)}: lanes already in this order`); - return cs; + changes.note(`${idOf(owner)}: lanes already in this order`); + return changes; } lanes.splice(0, lanes.length, ...ordered); doc.invalidate(); - cs.change(ChangeSet.entryOf(owner, `lane order: ${ordered.map(idOf).join(', ')}`)); - return cs; + changes.change(ChangeSet.entryOf(owner, `lane order: ${ordered.map(idOf).join(', ')}`)); + return changes; } - private orderOutgoing(): ChangeSet { - const { doc, op } = this; - const cs = new ChangeSet(); - const node = this.edit.require(op.id, 'bpmn:FlowNode'); - if (!op.flows?.length) throw usageError('order needs the flow ids in the wanted order', { element: op.id }); + private orderOutgoing(command: ReorderElements): ChangeSet { + const doc = this.edit.doc; + const changes = new ChangeSet(); + const node = this.edit.require(command.id, 'bpmn:FlowNode'); + if (!command.flows?.length) throw usageError('order needs the flow ids in the wanted order', { element: command.id }); const outgoing = doc.outgoing(node); const listed: El[] = []; - for (const flowId of op.flows) { + for (const flowId of command.flows) { const flow = this.edit.require(flowId, 'bpmn:SequenceFlow', 'sequence flow'); if (!outgoing.includes(flow)) { throw modelError('E_NOT_OUTGOING', `${flowId} is not an outgoing flow of ${idOf(node)}`, { @@ -116,18 +111,18 @@ export class ElementOrderService { const ordered = [...listed, ...outgoing.filter((flow) => !listed.includes(flow))]; const unchanged = ordered.every((flow, index) => outgoing[index] === flow); if (unchanged) { - cs.note(`${idOf(node)}: outgoing flows already in this order`); - return cs; + changes.note(`${idOf(node)}: outgoing flows already in this order`); + return changes; } - this.rewriteOutgoing(node, ordered); + ElementOrder.rewriteOutgoing(node, ordered); this.redeclare(node, ordered); doc.invalidate(); - cs.change(ChangeSet.entryOf(node, `outgoing order: ${ordered.map(idOf).join(', ')}`)); - return cs; + changes.change(ChangeSet.entryOf(node, `outgoing order: ${ordered.map(idOf).join(', ')}`)); + return changes; } /** Non-sequence-flow entries, if any, stay last. */ - private rewriteOutgoing(node: El, ordered: El[]): void { + private static rewriteOutgoing(node: El, ordered: El[]): void { const list = many(node, 'outgoing'); const others = list.filter((flowElement) => !is(flowElement, 'bpmn:SequenceFlow')); list.splice(0, list.length, ...ordered, ...others); @@ -135,7 +130,7 @@ export class ElementOrderService { /** At the position of the first of them, or right after the node when declared before it. */ private redeclare(node: El, ordered: El[]): void { - const scope = this.doc.scopeOf(node) ?? (node.$parent as El | undefined); + const scope = this.edit.doc.scopeOf(node) ?? (node.$parent as El | undefined); if (!scope) return; const list = many(scope, 'flowElements'); const positions = ordered.map((flow) => list.indexOf(flow)).filter((position) => position >= 0); diff --git a/src/domain/editing/ElementRemovalService.ts b/src/domain/editing/elements/ElementRemoval.ts similarity index 58% rename from src/domain/editing/ElementRemovalService.ts rename to src/domain/editing/elements/ElementRemoval.ts index 21dc170a..e21b03f5 100644 --- a/src/domain/editing/ElementRemovalService.ts +++ b/src/domain/editing/elements/ElementRemoval.ts @@ -1,66 +1,62 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { findReferences, is, many, removeFrom, walk, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { FlowBridgeService } from './flows/FlowBridgeService.js'; -import { idOf } from '../document/utils/elements.js'; -import { contains, detachFromParent, nearestWithId, referencesToAny } from './utils/containment.js'; -import type { RemoveElements } from '../commands/types.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { is, many, removeFrom, walk, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { FlowBridge } from '../connections/FlowBridge.js'; +import { idOf } from '../../document/utils/elements.js'; +import { contains, detachFromParent, nearestWithId, referencesToAny } from '../utils/containment.js'; +import type { RemoveElements } from '../../commands/types.js'; +import { SequenceFlows } from '../connections/SequenceFlows.js'; /** * Runs the `remove` op: deletes elements with everything that references them. * The cascade is reference-driven: connections to removed elements go, boundary events * fall with their host, other references are pruned or cleared. */ -export class ElementRemovalService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class ElementRemoval { + constructor(private readonly edit: EditSession) {} - remove(op: RemoveElements): ChangeSet { - const cs = new ChangeSet(); - if (!op.ids?.length) throw usageError('remove needs at least one element id'); - const bridge = op.bridge ?? true; - const removing = new Set(op.ids); + remove(command: RemoveElements): ChangeSet { + const changes = new ChangeSet(); + if (!command.ids?.length) throw usageError('remove needs at least one element id'); + const bridge = command.bridge ?? true; + const removing = new Set(command.ids); const gone = new Set(); - if (op.withBranch && (op.bridgeAll || op.bridge === false)) { - throw usageError(`--with-branch removes the node's whole downstream path and bridges nothing: drop ${op.bridgeAll ? '--bridge-all' : '--no-bridge'}`); + if (command.withBranch && (command.bridgeAll || command.bridge === false)) { + throw usageError(`--with-branch removes the node's whole downstream path and bridges nothing: drop ${command.bridgeAll ? '--bridge-all' : '--no-bridge'}`); } - if (op.bridgeAll && op.bridge === false) throw usageError('--bridge-all and --no-bridge contradict each other'); - if (op.withBranch) { - for (const id of op.ids) { + if (command.bridgeAll && command.bridge === false) throw usageError('--bridge-all and --no-bridge contradict each other'); + if (command.withBranch) { + for (const id of command.ids) { if (gone.has(id)) continue; - if (!this.doc.get(id) && op.ifExists) { - cs.note(`${id} does not exist; skipped`); + if (!this.edit.doc.get(id) && command.ifExists) { + changes.note(`${id} does not exist; skipped`); continue; } const branch = this.branchOf(this.edit.require(id, ['bpmn:FlowNode'], 'flow node')); for (const el of branch.nodes) removing.add(idOf(el)); - cs.note( + changes.note( `branch of ${id}: ${branch.nodes.length} node(s) (${branch.nodes.map(idOf).join(', ')})${branch.stops.length ? `; it stops before ${branch.stops.map(idOf).join(', ')}, which other paths reach (they stay)` : '; it ends there'}`, ); for (const el of branch.nodes) { if (gone.has(idOf(el))) continue; - new FlowBridgeService(this.doc, this.edit, cs).single(el, false); - this.cascade(el, cs); - for (const removal of cs.removed) gone.add(removal.id); + new FlowBridge(this.edit, changes).single(el, false); + this.cascade(el, changes); + for (const removal of changes.removed) gone.add(removal.id); } } - this.doc.invalidate(); - return cs; + this.edit.doc.invalidate(); + return changes; } - for (const id of op.ids) { + for (const id of command.ids) { if (gone.has(id)) { - cs.note(`${id} was already removed together with an earlier element`); + changes.note(`${id} was already removed together with an earlier element`); continue; } - const el = this.doc.get(id); + const el = this.edit.doc.get(id); if (!el) { - if (op.ifExists) { - cs.note(`${id} does not exist; skipped`); + if (command.ifExists) { + changes.note(`${id} does not exist; skipped`); continue; } this.edit.require(id); @@ -70,21 +66,21 @@ export class ElementRemovalService { throw modelError('E_INVALID_REMOVE', 'The definitions element cannot be removed', { element: id, hint: 'Delete the file instead.' }); } if (is(el, 'bpmn:FlowNode')) { - new FlowBridgeService(this.doc, this.edit, cs).forRemoval(el, bridge, !!op.bridgeAll, removing); + new FlowBridge(this.edit, changes).forRemoval(el, bridge, !!command.bridgeAll, removing); } - this.cascade(el, cs); - for (const removal of cs.removed) gone.add(removal.id); + this.cascade(el, changes); + for (const removal of changes.removed) gone.add(removal.id); } - this.doc.invalidate(); - return cs; + this.edit.doc.invalidate(); + return changes; } /** * The nodes only this node leads to, up to the next node another path reaches. - * `nodes` is in document order with the root first, `stops` are the nodes it flows into. + * `nodes` is in document order with the root first, `stops` are the nodes it flows into. @internal */ branchOf(root: El): { nodes: El[]; stops: El[] } { - const incoming = this.doc.incoming(root); + const incoming = this.edit.doc.incoming(root); if (incoming.length > 1) { throw modelError('E_AMBIGUOUS_BRANCH', `${idOf(root)} is reached by ${incoming.length} paths (${incoming.map((flow) => idOf(flow.get('sourceRef'))).join(', ')}); its downstream is not the branch of one path`, { element: idOf(root), @@ -94,13 +90,13 @@ export class ElementRemovalService { } const inBranch = new Set([root]); const next = (node: El): El[] => [ - ...this.doc.outgoing(node).map((flow) => flow.get('targetRef')), - ...(is(node, 'bpmn:Activity') ? this.doc.boundaryEventsOf(node) : []), + ...this.edit.doc.outgoing(node).map((flow) => flow.get('targetRef')), + ...(is(node, 'bpmn:Activity') ? this.edit.doc.boundaryEventsOf(node) : []), ...this.compensationHandlers(node), ]; const before = (node: El): El[] => { if (is(node, 'bpmn:BoundaryEvent')) return [node.get('attachedToRef')]; - const preds = this.doc.incoming(node).map((flow) => flow.get('sourceRef')); + const preds = this.edit.doc.incoming(node).map((flow) => flow.get('sourceRef')); if (node.get('isForCompensation')) preds.push(...this.compensationSources(node)); return preds; }; @@ -118,23 +114,23 @@ export class ElementRemovalService { } } } - const order = new Map([...this.doc.byId().values()].map((el, position) => [el, position])); + const order = new Map([...this.edit.doc.byId().values()].map((el, position) => [el, position])); const sorted = [...inBranch].sort((left, right) => (left === root ? -1 : right === root ? 1 : (order.get(left) ?? 0) - (order.get(right) ?? 0))); return { nodes: sorted, stops: [...stops].sort((left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)) }; } private compensationHandlers(node: El): El[] { if (!is(node, 'bpmn:BoundaryEvent')) return []; - return [...this.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && association.get('sourceRef') === node).map((association) => association.get('targetRef')).filter((target) => !!target && !!target.get('isForCompensation')); + return [...this.edit.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && association.get('sourceRef') === node).map((association) => association.get('targetRef')).filter((target) => !!target && !!target.get('isForCompensation')); } private compensationSources(handler: El): El[] { - return [...this.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && association.get('targetRef') === handler).map((association) => association.get('sourceRef')).filter((source): source is El => !!source && is(source, 'bpmn:BoundaryEvent')); + return [...this.edit.doc.byId().values()].filter((association) => is(association, 'bpmn:Association') && association.get('targetRef') === handler).map((association) => association.get('sourceRef')).filter((source): source is El => !!source && is(source, 'bpmn:BoundaryEvent')); } private isAttached(el: El): boolean { let cur: El = el; - while (cur !== this.doc.definitions) { + while (cur !== this.edit.doc.definitions) { const parent = cur.$parent as El | undefined; if (!parent || !contains(parent, cur)) return false; cur = parent; @@ -162,16 +158,16 @@ export class ElementRemovalService { * Cascade-removes one element without bridging. The ids of vendor extension content are not * part of the id space and are never released. */ - cascade(el: El, cs: ChangeSet): void { + cascade(el: El, changes: ChangeSet): void { if (!this.isAttached(el)) return; if (is(el, 'bpmn:SequenceFlow')) { - new SequenceFlowService(this.doc, this.edit).remove(el); - cs.remove(ChangeSet.flowEntryOf(el)); - this.cleanupReferences(new Set([el]), cs); + new SequenceFlows(this.edit).remove(el); + changes.remove(ChangeSet.flowEntryOf(el)); + this.cleanupReferences(new Set([el]), changes); return; } if (is(el, 'bpmn:Participant')) { - this.removeParticipant(el, cs); + this.removeParticipant(el, changes); return; } const subtree = [...walk(el, { bpmnOnly: true })]; @@ -179,60 +175,60 @@ export class ElementRemovalService { const parent = detachFromParent(el); for (const element of subtree) { const id = element.get('id'); - if (id) this.doc.ids.release(id); + if (id) this.edit.doc.ids.release(id); } - if (ElementRemovalService.reportable(el) || el.get('id')) cs.remove(ElementRemovalService.removedEntry(el)); - for (const element of subtree) if (element !== el && ElementRemovalService.reportable(element)) cs.remove(ElementRemovalService.removedEntry(element)); - this.doc.invalidate(); - this.afterDetach(el, parent, cs); - this.cleanupReferences(removed, cs); - this.doc.invalidate(); + if (ElementRemoval.reportable(el) || el.get('id')) changes.remove(ElementRemoval.removedEntry(el)); + for (const element of subtree) if (element !== el && ElementRemoval.reportable(element)) changes.remove(ElementRemoval.removedEntry(element)); + this.edit.doc.invalidate(); + this.afterDetach(el, parent, changes); + this.cleanupReferences(removed, changes); + this.edit.doc.invalidate(); } - private afterDetach(el: El, parent: El | undefined, cs: ChangeSet): void { + private afterDetach(el: El, parent: El | undefined, changes: ChangeSet): void { if (is(el, 'bpmn:Lane') && parent && is(parent, 'bpmn:LaneSet')) { if (!many(parent, 'lanes').length) { const owner = parent.$parent as El | undefined; if (owner && is(owner, 'bpmn:Lane')) owner.set('childLaneSet', undefined); else if (owner) removeFrom(owner, 'laneSets', parent); const lsId = parent.get('id'); - if (lsId) this.doc.ids.release(lsId); - cs.note(`lane set${lsId ? ` ${lsId}` : ''} removed: no lanes left`); + if (lsId) this.edit.doc.ids.release(lsId); + changes.note(`lane set${lsId ? ` ${lsId}` : ''} removed: no lanes left`); } return; } if (is(el, 'bpmn:DataInputAssociation') && parent) { const target = el.get('targetRef'); - if (target && is(target, 'bpmn:Property') && target.$parent === parent && !findReferences(this.doc.definitions, target).length) { + if (target && is(target, 'bpmn:Property') && target.$parent === parent && !referencesToAny(this.edit.doc.definitions, new Set([target])).length) { removeFrom(parent, 'properties', target); const pid = target.get('id'); - if (pid) this.doc.ids.release(pid); + if (pid) this.edit.doc.ids.release(pid); } return; } if (is(el, 'bpmn:DataObjectReference')) { const backing = el.get('dataObjectRef'); - if (backing && this.isAttached(backing) && !findReferences(this.doc.definitions, backing).length) this.cascade(backing, cs); + if (backing && this.isAttached(backing) && !referencesToAny(this.edit.doc.definitions, new Set([backing])).length) this.cascade(backing, changes); return; } if (is(el, 'bpmn:DataStoreReference')) { const backing = el.get('dataStoreRef'); - if (backing && this.isAttached(backing) && !findReferences(this.doc.definitions, backing).length) this.cascade(backing, cs); + if (backing && this.isAttached(backing) && !referencesToAny(this.edit.doc.definitions, new Set([backing])).length) this.cascade(backing, changes); return; } if (is(el, 'bpmn:Group')) { const value = el.get('categoryValueRef'); - if (!value || !this.isAttached(value) || findReferences(this.doc.definitions, value).some((reference) => is(reference.holder, 'bpmn:Group'))) return; + if (!value || !this.isAttached(value) || referencesToAny(this.edit.doc.definitions, new Set([value])).some((reference) => is(reference.holder, 'bpmn:Group'))) return; const category = value.$parent as El; - this.cascade(value, cs); - if (is(category, 'bpmn:Category') && !many(category, 'categoryValue').length) this.cascade(category, cs); + this.cascade(value, changes); + if (is(category, 'bpmn:Category') && !many(category, 'categoryValue').length) this.cascade(category, changes); return; } if (is(el, 'bpmn:Process')) { const pid = idOf(el); - for (const element of walk(this.doc.definitions)) { + for (const element of walk(this.edit.doc.definitions)) { if (is(element, 'bpmn:CallActivity') && element.get('calledElement') === pid) { - cs.warn({ + changes.warn({ code: 'W_DANGLING_CALLED_ELEMENT', message: `${idOf(element)} still calls the removed process ${pid}`, element: idOf(element), @@ -244,20 +240,20 @@ export class ElementRemovalService { } } - private removeParticipant(participant: El, cs: ChangeSet): void { + private removeParticipant(participant: El, changes: ChangeSet): void { const collaboration = participant.$parent as El | undefined; const process = participant.get('processRef'); detachFromParent(participant); - this.doc.ids.release(idOf(participant)); - cs.remove(ChangeSet.entryOf(participant)); - this.doc.invalidate(); - this.cleanupReferences(new Set([participant]), cs); - if (process && this.isAttached(process) && !this.doc.participants().some((otherParticipant) => otherParticipant.get('processRef') === process)) { - this.cascade(process, cs); + this.edit.doc.ids.release(idOf(participant)); + changes.remove(ChangeSet.entryOf(participant)); + this.edit.doc.invalidate(); + this.cleanupReferences(new Set([participant]), changes); + if (process && this.isAttached(process) && !this.edit.doc.participants().some((otherParticipant) => otherParticipant.get('processRef') === process)) { + this.cascade(process, changes); } if (collaboration && is(collaboration, 'bpmn:Collaboration') && this.isAttached(collaboration) && !many(collaboration, 'participants').length) { - this.cascade(collaboration, cs); - cs.note(`collaboration ${idOf(collaboration)} removed: no participants left`); + this.cascade(collaboration, changes); + changes.note(`collaboration ${idOf(collaboration)} removed: no participants left`); } } @@ -265,8 +261,8 @@ export class ElementRemovalService { * Drops the references to removed elements. The ids of a diagram dropped with them are released, * so a later `set id=` of the batch can take the derived DI id along. */ - private cleanupReferences(removed: Set, cs: ChangeSet): void { - for (const { holder, prop, isMany, target } of referencesToAny(this.doc.definitions, removed)) { + private cleanupReferences(removed: Set, changes: ChangeSet): void { + for (const { holder, prop, isMany, target } of referencesToAny(this.edit.doc.definitions, removed)) { if (!this.isAttached(holder)) continue; const targetId = idOf(target); if (holder.$type.startsWith('bpmndi:')) { @@ -274,21 +270,21 @@ export class ElementRemovalService { detachFromParent(gone); for (const element of walk(gone, { bpmnOnly: true })) { const id = idOf(element); - if (id) this.doc.ids.release(id); + if (id) this.edit.doc.ids.release(id); } continue; } if (is(holder, 'bpmn:SequenceFlow') || is(holder, 'bpmn:MessageFlow') || is(holder, 'bpmn:Association') || is(holder, 'bpmn:DataAssociation')) { - this.cascade(holder, cs); + this.cascade(holder, changes); continue; } if (is(holder, 'bpmn:BoundaryEvent') && prop === 'attachedToRef') { - this.cascade(holder, cs); + this.cascade(holder, changes); continue; } if (is(holder, 'bpmn:Participant') && prop === 'processRef') { holder.set(prop, undefined); - cs.warn({ + changes.warn({ code: 'W_BLACK_BOX', message: `Participant ${idOf(holder)} lost its process ${targetId} and is now a black box`, element: idOf(holder), @@ -299,13 +295,13 @@ export class ElementRemovalService { } if (isMany) { removeFrom(holder, prop, target); - if (is(holder, 'bpmn:Lane')) cs.change(ChangeSet.entryOf(holder, `member ${targetId} removed`)); + if (is(holder, 'bpmn:Lane')) changes.change(ChangeSet.entryOf(holder, `member ${targetId} removed`)); continue; } holder.set(prop, undefined); const owner = nearestWithId(holder); - if (owner && prop !== 'default') cs.change(ChangeSet.entryOf(owner, `${prop} cleared (${targetId} removed)`)); + if (owner && prop !== 'default') changes.change(ChangeSet.entryOf(owner, `${prop} cleared (${targetId} removed)`)); } - this.doc.invalidate(); + this.edit.doc.invalidate(); } } diff --git a/src/domain/editing/ElementTypeChangeService.ts b/src/domain/editing/elements/ElementTypeChange.ts similarity index 66% rename from src/domain/editing/ElementTypeChangeService.ts rename to src/domain/editing/elements/ElementTypeChange.ts index 78472fd8..46edf37c 100644 --- a/src/domain/editing/ElementTypeChangeService.ts +++ b/src/domain/editing/elements/ElementTypeChange.ts @@ -1,23 +1,22 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError } from '../document/utils/errors.js'; -import { type Warning } from '../document/CliError.js'; -import { KindError } from '../document/KindError.js'; -import { kindLabel, kindOf, parseKind, triggerOf, type KindDef, type Trigger } from '../document/utils/kinds.js'; -import { findReferences, is, many, removeFrom, walk, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { coversProfileSubjects } from './utils/covers.js'; -import { Camunda8Defaults } from '../document/vendor/Camunda8Defaults.js'; -import { EventTrigger } from './events/EventTrigger.js'; -import { TriggerChange } from './events/TriggerChange.js'; -import { descriptorOf, idOf, isEl, ownValue, vendorAttributes, type PropDescriptor } from '../document/utils/elements.js'; -import type { ChangeElementType, TriggerOptions } from '../commands/types.js'; -import { CAMUNDA } from '../document/vendor/CamundaDescriptor.js'; -import { ZEEBE, ZEEBE_URI } from '../document/vendor/ZeebeDescriptor.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; -import { RootElementService } from './events/RootElementService.js'; -import { ElementRemovalService } from './ElementRemovalService.js'; -import { detachFromParent } from './utils/containment.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError } from '../../document/utils/errors.js'; +import { BpmnError } from '../../document/BpmnError.js'; +import type { Finding } from '../../document/Finding.js'; +import { kindLabel, kindOf, parseKind, triggerOf, type KindDef, type Trigger } from '../../document/Kinds.js'; +import { is, many, removeFrom, walk, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { withUnserialised } from '../../document/utils/finding.js'; +import { Camunda8Defaults } from '../../document/vendor/Camunda8Defaults.js'; +import { EventTrigger } from '../events/EventTrigger.js'; +import { TriggerChange } from '../events/TriggerChange.js'; +import { descriptorOf, idOf, isEl, ownValue, vendorAttributes, type PropDescriptor } from '../../document/utils/elements.js'; +import type { ChangeElementType, TriggerOptions } from '../../commands/types.js'; +import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; +import { ZEEBE, ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; +import { SequenceFlows } from '../connections/SequenceFlows.js'; +import { RootElements } from '../events/RootElements.js'; +import { ElementRemoval } from './ElementRemoval.js'; +import { detachFromParent, referencesToAny } from '../utils/containment.js'; const ACTIVITY_FAMILIES = new Set(['task', 'subProcess', 'callActivity']); @@ -26,11 +25,8 @@ const ACTIVITY_FAMILIES = new Set(['task', 'subProcess', 'callActivity']); * Retyping only stays within a family (activities, gateways, events of one position). * Content the new kind cannot hold is recorded so the write refuses it without --force. */ -export class ElementTypeChangeService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class ElementTypeChange { + constructor(private readonly edit: EditSession) {} private static eventPosition(def: KindDef): string { return def.kind === 'intermediateCatchEvent' || def.kind === 'intermediateThrowEvent' ? 'intermediate' : def.kind; @@ -40,7 +36,7 @@ export class ElementTypeChangeService { if (ACTIVITY_FAMILIES.has(from.family) && ACTIVITY_FAMILIES.has(to.family)) return undefined; if (from.family === 'gateway' && to.family === 'gateway') return undefined; if (from.family === 'event' && to.family === 'event') { - return ElementTypeChangeService.eventPosition(from) === ElementTypeChangeService.eventPosition(to) ? undefined : `a ${from.kind} sits at a different position in the flow than a ${to.kind}`; + return ElementTypeChange.eventPosition(from) === ElementTypeChange.eventPosition(to) ? undefined : `a ${from.kind} sits at a different position in the flow than a ${to.kind}`; } return `${from.family} and ${to.family} are different families`; } @@ -61,9 +57,9 @@ export class ElementTypeChangeService { * Changes the kind of an element. The old trigger state is read before the swap moves the * definitions over; a batch alias of the element follows it to the new one. */ - retype(op: ChangeElementType): ChangeSet { - const cs = new ChangeSet(); - const el = this.edit.require(op.id); + retype(command: ChangeElementType): ChangeSet { + const changes = new ChangeSet(); + const el = this.edit.require(command.id); const id = idOf(el); const from = kindOf(el); if (!from) { @@ -74,13 +70,13 @@ export class ElementTypeChangeService { } let parsed; try { - parsed = parseKind(op.kind); + parsed = parseKind(command.kind); } catch (err) { - if (err instanceof KindError) throw modelError('E_UNKNOWN_KIND', err.message, { element: id, candidates: err.candidates, hint: 'Run `bpmn kinds` for the list of kinds.' }); + if (err instanceof BpmnError) throw modelError('E_UNKNOWN_KIND', err.message, { element: id, candidates: err.details.candidates ?? [], hint: 'Run `bpmn kinds` for the list of kinds.' }); throw err; } const to = parsed.def; - const reason = ElementTypeChangeService.incompatibility(from, to); + const reason = ElementTypeChange.incompatibility(from, to); if (reason) { throw modelError('E_INVALID_RETYPE', `Cannot retype ${kindLabel(el)} ${id} to ${to.kind}: ${reason}`, { element: id, @@ -88,12 +84,12 @@ export class ElementTypeChangeService { }); } const oldLabel = kindLabel(el); - const trigger = ElementTypeChangeService.resolveTrigger(el, to, parsed.trigger); - const given = EventTrigger.optionsOf(op); + const trigger = ElementTypeChange.resolveTrigger(el, to, parsed.trigger); + const given = EventTrigger.optionsOf(command); const givenKeys = Object.keys(given) as Array; const isEvent = to.family === 'event'; if (!isEvent && givenKeys.length) { - cs.warn({ + changes.warn({ code: 'W_OPTION_IGNORED', message: `Trigger options (${givenKeys.join(', ')}) are ignored for ${to.kind} ${id}`, element: id, @@ -104,49 +100,49 @@ export class ElementTypeChangeService { const oldTrigger = triggerOf(el); const sameTrigger = trigger === oldTrigger; if (from === to && sameTrigger && !hasTriggerOptions) { - cs.note(`${id} is already a ${oldLabel}; nothing changed${oldLabel.includes(':') ? ' (to drop the trigger use `:none` or `bpmn set trigger=none`)' : ''}`); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, el); - return cs; + changes.note(`${id} is already a ${oldLabel}; nothing changed${oldLabel.includes(':') ? ' (to drop the trigger use `:none` or `bpmn set trigger=none`)' : ''}`); + RootElements.bindRefAs(changes, command.refAs, el); + return changes; } - if (to.kind === 'eventSubProcess' && (this.doc.incoming(el).length || this.doc.outgoing(el).length)) { + if (to.kind === 'eventSubProcess' && (this.edit.doc.incoming(el).length || this.edit.doc.outgoing(el).length)) { throw modelError('E_INVALID_RETYPE', `${id} has sequence flows; event sub-processes cannot be connected by sequence flows`, { element: id, - candidates: [...this.doc.incoming(el), ...this.doc.outgoing(el)].map(idOf), - hint: `Remove its flows first: \`bpmn remove ${[...this.doc.incoming(el), ...this.doc.outgoing(el)].map(idOf).join(' ')}\`.`, + candidates: [...this.edit.doc.incoming(el), ...this.edit.doc.outgoing(el)].map(idOf), + hint: `Remove its flows first: \`bpmn remove ${[...this.edit.doc.incoming(el), ...this.edit.doc.outgoing(el)].map(idOf).join(' ')}\`.`, }); } - const change = new TriggerChange(this.doc, this.edit, el); - const target = to.type === el.$type ? el : this.swapType(el, to, cs); - ElementTypeChangeService.applyKindProps(target, from, to); + const change = new TriggerChange(this.edit, el); + const target = to.type === el.$type ? el : this.swapType(el, to, changes); + ElementTypeChange.applyKindProps(target, from, to); if (target !== el) { - cs.replaced.push({ from: el, to: target }); + changes.replaced.push({ from: el, to: target }); const inapplicable = this.inapplicableCamundaContent(target, to); - if (inapplicable) cs.warn(inapplicable); - if (new Camunda8Defaults(this.doc, this.edit).userTask(target)) cs.note(`${id} is a Camunda user task now (zeebe:userTask added, like Camunda Modeler)`); + if (inapplicable) changes.warn(inapplicable); + if (new Camunda8Defaults(this.edit).userTask(target)) changes.note(`${id} is a Camunda user task now (zeebe:userTask added, like Camunda Modeler)`); } - new SequenceFlowService(this.doc, this.edit).dropIgnoredConditions(target, cs); - change.change(trigger !== undefined && (!sameTrigger || hasTriggerOptions) ? trigger : undefined, given, cs, { on: target }); - this.doc.invalidate(); + new SequenceFlows(this.edit).dropIgnoredConditions(target, changes); + change.change(trigger !== undefined && (!sameTrigger || hasTriggerOptions) ? trigger : undefined, given, changes, { on: target }); + this.edit.doc.invalidate(); const detail = from === to && sameTrigger ? `trigger options updated (${givenKeys.map((givenKey) => `${givenKey}=${String(given[givenKey])}`).join(' ')})` : `retyped from ${oldLabel} to ${kindLabel(target)}`; - cs.change(ChangeSet.entryOf(target, detail)); - new RootElementService(this.doc, this.edit).bindRefAs(cs, op.refAs, target); - return cs; + changes.change(ChangeSet.entryOf(target, detail)); + RootElements.bindRefAs(changes, command.refAs, target); + return changes; } /** Operaton's namespace counts as camunda too. */ private camundaName(name: string): string | undefined { const idx = name.indexOf(':'); - if (idx <= 0 || !CAMUNDA.reads(this.doc.namespaceUri(name.slice(0, idx)))) return undefined; + if (idx <= 0 || !CAMUNDA.reads(this.edit.doc.namespaceUri(name.slice(0, idx)))) return undefined; return `camunda:${name.slice(idx + 1)}`; } private zeebeLocal(name: string): string | undefined { const idx = name.indexOf(':'); - if (idx <= 0 || this.doc.namespaceUri(name.slice(0, idx)) !== ZEEBE_URI) return undefined; + if (idx <= 0 || this.edit.doc.namespaceUri(name.slice(0, idx)) !== ZEEBE_URI) return undefined; return name.slice(idx + 1); } @@ -154,7 +150,7 @@ export class ElementTypeChangeService { * Vendor content the new kind cannot use is kept (dropping it would lose data), only named. * The Camunda 7 / 8 profile reports these item by item, so a write drops this summary there. */ - private inapplicableCamundaContent(el: El, to: KindDef): Warning | undefined { + private inapplicableCamundaContent(el: El, to: KindDef): Finding | undefined { const attrs = Object.keys(vendorAttributes(el)).filter((key) => { const name = this.camundaName(key); if (name) return CAMUNDA.attrAppliesTo(name, el) === false; @@ -176,13 +172,13 @@ export class ElementTypeChangeService { ...(attrs.length ? [`\`bpmn set ${id} ${attrs.map((attr) => `${attr}=`).join(' ')}\``] : []), ...exts.map((extType) => `\`bpmn ext remove ${id} ${extType}\``), ]; - const warning: Warning = { + const warning: Finding = { code: 'W_PROPERTY_INAPPLICABLE', message: `${names.join(', ')} of ${id} ${names.length > 1 ? 'have' : 'has'} no effect on a ${to.kind} (kept as ${names.length > 1 ? 'they are' : 'it is'})`, element: id, hint: `The ${names.every((extName) => this.zeebeLocal(extName)) ? 'Zeebe (Camunda 8)' : names.some((extName) => this.zeebeLocal(extName)) ? 'Camunda 7 and Zeebe' : 'Camunda'} descriptor does not allow ${names.length > 1 ? 'them' : 'it'} on a ${to.kind}; remove with ${fixes.join(' and ')}, or retype back.`, }; - return coversProfileSubjects(warning, [...attrs.map((attr) => `attr:${attr}`), ...exts.map((extType) => `ext:${extType.slice(extType.indexOf(':') + 1)}`)]); + return withUnserialised(warning, { covers: [...attrs.map((attr) => `attr:${attr}`), ...exts.map((extType) => `ext:${extType.slice(extType.indexOf(':') + 1)}`)] }); } private static applyKindProps(el: El, from: KindDef, to: KindDef): void { @@ -209,16 +205,16 @@ export class ElementTypeChangeService { return String(value); } - private dropProp(el: El, property: PropDescriptor, value: unknown, to: KindDef, cs: ChangeSet): void { - const detail = ElementTypeChangeService.describeValue(value); + private dropProp(el: El, property: PropDescriptor, value: unknown, to: KindDef, changes: ChangeSet): void { + const detail = ElementTypeChange.describeValue(value); if (!property.isReference) { const children = (Array.isArray(value) ? value : [value]).filter((child): child is El => isEl(child) && !!child.get('id')); - const before = cs.removed.length; - for (const child of children) new ElementRemovalService(this.doc, this.edit).cascade(child, cs); - const removed = cs.removed.slice(before).map((change) => change.id); + const before = changes.removed.length; + for (const child of children) new ElementRemoval(this.edit).cascade(child, changes); + const removed = changes.removed.slice(before).map((change) => change.id); if (removed.length) this.edit.recordDroppedContent({ element: idOf(el), kind: to.kind, removed }); } - cs.warn({ + changes.warn({ code: 'W_PROPERTY_DROPPED', message: `${property.name} of ${idOf(el)} (${detail}) was dropped: a ${to.kind} has no ${property.name}`, element: idOf(el), @@ -228,10 +224,10 @@ export class ElementTypeChangeService { private dropDiagram(diagram: El | undefined): void { if (!diagram || !is(diagram, 'bpmndi:BPMNDiagram')) return; - removeFrom(this.doc.definitions, 'diagrams', diagram); + removeFrom(this.edit.doc.definitions, 'diagrams', diagram); for (const element of walk(diagram)) { const id = element.get('id'); - if (id) this.doc.ids.release(id); + if (id) this.edit.doc.ids.release(id); } } @@ -244,20 +240,20 @@ export class ElementTypeChangeService { * Replaces the element by one of the new type. A diagram plane it roots is dropped unless the * new one is a sub-process: only processes, sub-processes and collaborations may root a plane. */ - private swapType(el: El, to: KindDef, cs: ChangeSet): El { - const next = this.doc.moddle.create(to.type, { id: idOf(el) }); + private swapType(el: El, to: KindDef, changes: ChangeSet): El { + const next = this.edit.doc.moddle.create(to.type, { id: idOf(el) }); const nextProps = descriptorOf(next).propertiesByName ?? {}; for (const property of descriptorOf(el).properties ?? []) { if (property.name === 'id') continue; const value = ownValue(el, property.name); if (value === undefined || value === null || (Array.isArray(value) && !value.length)) continue; - if (ElementTypeChangeService.isReappliedKindProp(property.name)) continue; - if (nextProps[property.name]) ElementTypeChangeService.copyProp(next, property, value); - else this.dropProp(el, property, value, to, cs); + if (ElementTypeChange.isReappliedKindProp(property.name)) continue; + if (nextProps[property.name]) ElementTypeChange.copyProp(next, property, value); + else this.dropProp(el, property, value, to, changes); } Object.assign(next.$attrs, el.$attrs); detachFromParent(el, next); - for (const ref of findReferences(this.doc.definitions, el)) { + for (const ref of referencesToAny(this.edit.doc.definitions, new Set([el]))) { if (is(ref.holder, 'bpmndi:BPMNPlane') && !is(next, 'bpmn:SubProcess')) { this.dropDiagram(ref.holder.$parent as El | undefined); continue; @@ -271,7 +267,7 @@ export class ElementTypeChangeService { } } this.edit.replaceElement(el, next); - this.doc.invalidate(); + this.edit.doc.invalidate(); return next; } } diff --git a/src/domain/editing/participants/LaneInheritance.ts b/src/domain/editing/elements/LaneInheritance.ts similarity index 95% rename from src/domain/editing/participants/LaneInheritance.ts rename to src/domain/editing/elements/LaneInheritance.ts index 270431f9..3f1a9b89 100644 --- a/src/domain/editing/participants/LaneInheritance.ts +++ b/src/domain/editing/elements/LaneInheritance.ts @@ -1,8 +1,8 @@ import type { Doc } from '../../document/Doc.js'; -import type { Warning } from '../../document/CliError.js'; +import type { Finding } from '../../document/Finding.js'; import { is, type El, idOf } from '../../document/utils/moddle.js'; import { laneOf } from '../../document/utils/lanes.js'; -import { NodePlacementService } from '../NodePlacementService.js'; +import { NodePlacement } from './NodePlacement.js'; import type { Placement } from '../../commands/types.js'; /** `branching`: the source has several outgoing flows. */ @@ -42,7 +42,7 @@ export class LaneInheritance { return new LaneInheritance(branching ? to : lane, { source, target, ...(from ? { from } : {}), ...(to ? { to } : {}), branching }); } - warning(id: string): Warning | undefined { + warning(id: string): Finding | undefined { const { lane, cross } = this; const other = cross && lane ? (lane === cross.from ? cross.to : cross.from) : undefined; if (!cross || !lane || !other) return undefined; @@ -76,7 +76,7 @@ export class LaneInheritance { private static splicedFlow(doc: Doc, placement: Placement): El | undefined { const get = (id: string | undefined): El | undefined => (id ? doc.get(id) : undefined); - switch (NodePlacementService.modeOf(placement)) { + switch (NodePlacement.modeOf(placement)) { case 'flow': return get(placement.flow); case 'between': { diff --git a/src/domain/editing/participants/LaneService.ts b/src/domain/editing/elements/Lanes.ts similarity index 60% rename from src/domain/editing/participants/LaneService.ts rename to src/domain/editing/elements/Lanes.ts index 94c8c009..ffe33c77 100644 --- a/src/domain/editing/participants/LaneService.ts +++ b/src/domain/editing/elements/Lanes.ts @@ -1,33 +1,28 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError } from '../../document/utils/errors.js'; -import { kindByName, kindLabel } from '../../document/utils/kinds.js'; +import { kindByName, kindLabel } from '../../document/Kinds.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; import { kindRequest, typeRequest } from '../../document/utils/requests.js'; -import { addTo, is, many, nameOf, removeFrom, type El, idOf } from '../../document/utils/moddle.js'; +import { addTo, is, many, type El, idOf, nameOf, removeFrom } from '../../document/utils/moddle.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; -import { allocateId } from '../utils/allocate-id.js'; import type { AddElement } from '../../commands/types.js'; /** * Creates lanes and assigns flow nodes to them. A lane with child lanes has no direct * members, and nodes inside sub-processes are never lane members. */ -export class LaneService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class Lanes { + constructor(private readonly edit: EditSession) {} /** The first child lane of a lane inherits all of the parent's members. */ - createLane(op: AddElement, cs: ChangeSet): El { - const doc = this.doc; - const { process, parentLane } = this.resolveLaneContainer(op); - const id = allocateId(this.edit, kindRequest(kindByName('lane')!, { ...(op.name ? { name: op.name } : {}), context: new ElementLabels(parentLane ?? process).context('In') }), op.id); - const laneSet = parentLane ? this.ensureChildLaneSet(parentLane, cs) : this.ensureLaneSet(process, cs); - const lane = doc.create('bpmn:Lane', { id, ...(op.name ? { name: op.name } : {}) }); + createLane(command: AddElement, changes: ChangeSet): El { + const doc = this.edit.doc; + const { process, parentLane } = this.resolveLaneContainer(command); + const id = this.edit.allocator.explicitOr(command.id, kindRequest(kindByName('lane')!, { ...(command.name ? { name: command.name } : {}), context: new ElementLabels(parentLane ?? process).context('In') })); + const laneSet = parentLane ? this.ensureChildLaneSet(parentLane, changes) : this.ensureLaneSet(process, changes); + const lane = doc.create('bpmn:Lane', { id, ...(command.name ? { name: command.name } : {}) }); addTo(laneSet, 'lanes', lane); - cs.create({ id, kind: 'lane', ...(op.name ? { name: op.name } : {}), detail: parentLane ? `in lane ${idOf(parentLane)}` : `in ${idOf(process)}` }); + changes.create({ id, kind: 'lane', ...(command.name ? { name: command.name } : {}), detail: parentLane ? `in lane ${idOf(parentLane)}` : `in ${idOf(process)}` }); let extra: El[] = []; if (parentLane && many(laneSet, 'lanes').length === 1) { const inherited = [...many(parentLane, 'flowNodeRef')]; @@ -36,26 +31,26 @@ export class LaneService { removeFrom(parentLane, 'flowNodeRef', node); addTo(lane, 'flowNodeRef', node); } - cs.note(`moved ${inherited.length} member(s) of ${idOf(parentLane)} into its first child lane ${id}`); - if (op.members?.length) extra = inherited.filter((node) => !op.members!.includes(idOf(node))); + changes.note(`moved ${inherited.length} member(s) of ${idOf(parentLane)} into its first child lane ${id}`); + if (command.members?.length) extra = inherited.filter((node) => !command.members!.includes(idOf(node))); } } if (!doc.participantOf(process)) { - cs.warn({ + changes.warn({ code: 'W_LANES_WITHOUT_POOL', message: `Process ${idOf(process)} has lanes but no participant; lanes are only drawn inside a pool`, element: id, hint: `Add a pool with \`add participant ""\` (it wraps the existing process).`, }); } - for (const memberId of op.members ?? []) { - this.assign(this.edit.require(memberId, 'bpmn:FlowNode', 'flow node'), lane, cs); + for (const memberId of command.members ?? []) { + this.assign(this.edit.require(memberId, 'bpmn:FlowNode', 'flow node'), lane, changes); } if (extra.length && parentLane) { const extraIds = extra.map(idOf); - cs.warn({ + changes.warn({ code: 'W_OPTION_IGNORED', - message: `--members ${op.members!.join(',')}: ${id} also received the other ${extra.length} member(s) of ${idOf(parentLane)} (${extraIds.join(', ')}), because a lane with child lanes has no direct members`, + message: `--members ${command.members!.join(',')}: ${id} also received the other ${extra.length} member(s) of ${idOf(parentLane)} (${extraIds.join(', ')}), because a lane with child lanes has no direct members`, element: id, related: extraIds, hint: `Move them into a sibling lane: \`bpmn add lane "" --in ${idOf(parentLane)} --members ${extraIds.join(',')}\`.`, @@ -66,8 +61,8 @@ export class LaneService { } /** `undefined` removes the lane membership; attached boundary events follow. */ - assign(node: El, lane: El | undefined, cs: ChangeSet): void { - const doc = this.doc; + assign(node: El, lane: El | undefined, changes: ChangeSet): void { + const doc = this.edit.doc; const process = this.assertLaneMember(node); if (lane) { if (!is(lane, 'bpmn:Lane')) { @@ -96,56 +91,56 @@ export class LaneService { for (const other of current) removeFrom(other, 'flowNodeRef', node); if (lane) addTo(lane, 'flowNodeRef', node); const name = nameOf(node); - cs.change({ id: idOf(node), kind: kindLabel(node), ...(name ? { name } : {}), detail: lane ? `lane ${idOf(lane)}` : 'removed from lanes' }); + changes.change({ id: idOf(node), kind: kindLabel(node), ...(name ? { name } : {}), detail: lane ? `lane ${idOf(lane)}` : 'removed from lanes' }); } if (is(node, 'bpmn:Activity')) { - for (const boundary of doc.boundaryEventsOf(node)) this.assign(boundary, lane, cs); + for (const boundary of doc.boundaryEventsOf(node)) this.assign(boundary, lane, changes); } doc.invalidate(); } - private ensureLaneSet(process: El, cs: ChangeSet): El { + private ensureLaneSet(process: El, changes: ChangeSet): El { const sets = many(process, 'laneSets'); if (sets[0]) return sets[0]; - const laneSet = this.doc.create('bpmn:LaneSet', { id: this.edit.allocator.allocate(typeRequest('bpmn:LaneSet', { context: new ElementLabels(process).label() })).id }); + const laneSet = this.edit.doc.create('bpmn:LaneSet', { id: this.edit.allocator.allocate(typeRequest('bpmn:LaneSet', { context: new ElementLabels(process).label() })).id }); addTo(process, 'laneSets', laneSet); - cs.note(`created laneSet ${idOf(laneSet)} in ${idOf(process)}`); + changes.note(`created laneSet ${idOf(laneSet)} in ${idOf(process)}`); return laneSet; } - private ensureChildLaneSet(lane: El, cs: ChangeSet): El { + private ensureChildLaneSet(lane: El, changes: ChangeSet): El { const existing = lane.get('childLaneSet'); if (existing) return existing; - const laneSet = this.doc.create('bpmn:LaneSet', { id: this.edit.allocator.allocate(typeRequest('bpmn:LaneSet', { context: new ElementLabels(lane).label() })).id }); + const laneSet = this.edit.doc.create('bpmn:LaneSet', { id: this.edit.allocator.allocate(typeRequest('bpmn:LaneSet', { context: new ElementLabels(lane).label() })).id }); lane.set('childLaneSet', laneSet); laneSet.$parent = lane; - cs.note(`created nested laneSet ${idOf(laneSet)} in lane ${idOf(lane)}`); + changes.note(`created nested laneSet ${idOf(laneSet)} in lane ${idOf(lane)}`); return laneSet; } - private resolveLaneContainer(op: AddElement): { process: El; parentLane?: El } { - const doc = this.doc; - if (!op.in) return { process: doc.defaultScope() }; - const container = this.edit.require(op.in, ['bpmn:Process', 'bpmn:Participant', 'bpmn:Lane', 'bpmn:SubProcess'], 'lane container'); + private resolveLaneContainer(command: AddElement): { process: El; parentLane?: El } { + const doc = this.edit.doc; + if (!command.in) return { process: doc.defaultScope() }; + const container = this.edit.require(command.in, ['bpmn:Process', 'bpmn:Participant', 'bpmn:Lane', 'bpmn:SubProcess'], 'lane container'); if (is(container, 'bpmn:SubProcess')) { - throw modelError('E_INVALID_SCOPE', `Lanes can only be created at process level, not inside sub-process ${op.in}`, { - element: op.in, + throw modelError('E_INVALID_SCOPE', `Lanes can only be created at process level, not inside sub-process ${command.in}`, { + element: command.in, hint: 'Use --in .', }); } if (is(container, 'bpmn:Lane')) { const process = doc.processOf(container); - if (!process) throw modelError('E_INVALID_SCOPE', `Lane ${op.in} is not inside a process`, { element: op.in }); + if (!process) throw modelError('E_INVALID_SCOPE', `Lane ${command.in} is not inside a process`, { element: command.in }); return { process, parentLane: container }; } - return { process: this.edit.requireScope(op.in) }; + return { process: this.edit.requireScope(command.in) }; } private assertLaneMember(node: El): El { if (!is(node, 'bpmn:FlowNode')) { throw modelError('E_INVALID_LANE_MEMBERSHIP', `${idOf(node)} is a ${kindLabel(node)}; only flow nodes can be lane members`, { element: idOf(node) }); } - const scope = this.doc.scopeOf(node); + const scope = this.edit.doc.scopeOf(node); if (!scope || !is(scope, 'bpmn:Process')) { throw modelError('E_INVALID_LANE_MEMBERSHIP', `${idOf(node)} is inside sub-process ${scope ? idOf(scope) : '?'}; nodes inside sub-processes cannot be lane members`, { element: idOf(node), diff --git a/src/domain/editing/NodePlacementService.ts b/src/domain/editing/elements/NodePlacement.ts similarity index 74% rename from src/domain/editing/NodePlacementService.ts rename to src/domain/editing/elements/NodePlacement.ts index 41c6489c..90a7fa75 100644 --- a/src/domain/editing/NodePlacementService.ts +++ b/src/domain/editing/elements/NodePlacement.ts @@ -1,16 +1,15 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { FlowRules } from '../document/FlowRules.js'; -import { modelError } from '../document/utils/errors.js'; -import { kindLabel } from '../document/utils/kinds.js'; -import { is, type El, idOf } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { renamedNote } from '../document/utils/renames.js'; -import { FlowOptions } from './flows/FlowOptions.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; -import { insertAfterInScope, insertBeforeInScope, lastFlowOf } from './utils/declaration-order.js'; -import { labelOf } from '../document/utils/elements.js'; -import type { FlowOptions as FlowOptionValues, Placement } from '../commands/types.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { FlowRules } from '../../document/FlowRules.js'; +import { modelError } from '../../document/utils/errors.js'; +import { kindLabel } from '../../document/Kinds.js'; +import { is, type El, idOf } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { renamedNote } from '../../document/utils/renames.js'; +import { FlowOptions } from '../connections/FlowOptions.js'; +import { SequenceFlows } from '../connections/SequenceFlows.js'; +import { insertAfterInScope, insertBeforeInScope, lastFlowOf } from '../utils/declaration-order.js'; +import { labelOf } from '../../document/utils/elements.js'; +import type { FlowOptions as FlowOptionValues, Placement } from '../../commands/types.js'; export type PlacementOptions = Placement & FlowOptionValues & { to?: string }; @@ -20,16 +19,11 @@ export type PlacementMode = 'after' | 'before' | 'between' | 'flow' | 'in' | 'on * The one place that knows the placement grammar (--after / --before / --flow / --in / --on) * and wires a node in by it: append, prepend, splice or attachment to a host. */ -export class NodePlacementService { - private readonly flows: SequenceFlowService; - +export class NodePlacement { constructor( - private readonly doc: Doc, - private readonly edit: EditContext, + private readonly edit: EditSession, private readonly placement: PlacementOptions, - ) { - this.flows = new SequenceFlowService(doc, edit); - } + ) {} static modeOf(placement: Placement): PlacementMode { if (placement.after && placement.before) return 'between'; @@ -42,11 +36,12 @@ export class NodePlacementService { } get mode(): PlacementMode { - return NodePlacementService.modeOf(this.placement); + return NodePlacement.modeOf(this.placement); } scope(): El { - const { doc, placement } = this; + const { placement } = this; + const doc = this.edit.doc; switch (this.mode) { case 'after': case 'between': @@ -70,8 +65,9 @@ export class NodePlacementService { * `before` mirrors it. `reuse`: a moved node's old out-flow, put back instead of a new one. * On a prepend the flow options go to the one flow it creates (node -> anchor). */ - place(node: El, cs: ChangeSet, reuse?: (source: El) => El | undefined): El | undefined { - const { doc, placement } = this; + place(node: El, changes: ChangeSet, reuse?: (source: El) => El | undefined): El | undefined { + const { placement } = this; + const doc = this.edit.doc; const mode = this.mode; const scope = this.scope(); let entryFlow: El | undefined; @@ -93,29 +89,29 @@ export class NodePlacementService { node.set('attachedToRef', host); const boundaries = doc.boundaryEventsOf(host); insertAfterInScope(scope, boundaries[boundaries.length - 1] ?? host, node); - cs.note(`attached to ${idOf(host)}`); + changes.note(`attached to ${idOf(host)}`); } else if (is(node, 'bpmn:BoundaryEvent')) { throw modelError('E_INVALID_PLACEMENT', 'Boundary events must be placed with --on ', { element: idOf(node) }); } else if (mode === 'in' || mode === 'none') { insertAfterInScope(scope, undefined, node); } else if (mode === 'flow') { const flow = this.edit.require(placement.flow!, 'bpmn:SequenceFlow', 'sequence flow'); - entryFlow = this.spliceWithOptions(node, flow, cs, outFlow); + entryFlow = this.spliceWithOptions(node, flow, changes, outFlow); } else if (mode === 'between') { const afterNode = this.edit.require(placement.after!, 'bpmn:FlowNode'); const beforeNode = this.edit.require(placement.before!, 'bpmn:FlowNode'); const flow = this.requireFlowBetween(afterNode, beforeNode); - entryFlow = this.spliceWithOptions(node, flow, cs, outFlow); + entryFlow = this.spliceWithOptions(node, flow, changes, outFlow); } else if (mode === 'after') { const anchor = this.edit.require(placement.after!, 'bpmn:FlowNode'); const out = doc.outgoing(anchor); if (is(anchor, 'bpmn:Gateway') || out.length === 0) { insertAfterInScope(scope, lastFlowOf(scope, anchor) ?? anchor, node); - entryFlow = this.flows.create(anchor, node, { ...new FlowOptions(placement).toAttrs(), ...reused(anchor) }, node); - cs.create(ChangeSet.flowEntryOf(entryFlow)); - cs.note(`appended after ${idOf(anchor)}`); + entryFlow = new SequenceFlows(this.edit).create(anchor, node, { ...new FlowOptions(placement).toAttrs(), ...reused(anchor) }, node); + changes.create(ChangeSet.flowEntryOf(entryFlow)); + changes.note(`appended after ${idOf(anchor)}`); } else if (out.length === 1) { - entryFlow = this.spliceWithOptions(node, out[0]!, cs, outFlow); + entryFlow = this.spliceWithOptions(node, out[0]!, changes, outFlow); } else { throw modelError('E_HAS_SUCCESSOR', `${idOf(anchor)} already has ${out.length} outgoing flows; say which one to insert into`, { element: idOf(anchor), @@ -129,15 +125,15 @@ export class NodePlacementService { const isJoin = is(anchor, 'bpmn:Gateway') && inc.length >= 2; if (inc.length === 0 || isJoin) { insertBeforeInScope(scope, anchor, node); - const flow = this.flows.create(node, anchor, { ...new FlowOptions(placement).toAttrs(), ...reused(node) }, node); - cs.create(ChangeSet.flowEntryOf(flow)); - cs.note(`prepended before ${idOf(anchor)}`); + const flow = new SequenceFlows(this.edit).create(node, anchor, { ...new FlowOptions(placement).toAttrs(), ...reused(node) }, node); + changes.create(ChangeSet.flowEntryOf(flow)); + changes.note(`prepended before ${idOf(anchor)}`); const used = new FlowOptions(placement).flags; if (used.length) { - cs.note(`${used.join(', ')} applied to ${idOf(flow)} (${idOf(node)} -> ${idOf(anchor)}): ${idOf(anchor)} ${isJoin ? 'is a join gateway' : 'had no incoming flow'}, so no flow into ${idOf(node)} was created`); + changes.note(`${used.join(', ')} applied to ${idOf(flow)} (${idOf(node)} -> ${idOf(anchor)}): ${idOf(anchor)} ${isJoin ? 'is a join gateway' : 'had no incoming flow'}, so no flow into ${idOf(node)} was created`); } } else if (inc.length === 1) { - entryFlow = this.spliceWithOptions(node, inc[0]!, cs, outFlow); + entryFlow = this.spliceWithOptions(node, inc[0]!, changes, outFlow); } else { throw modelError('E_HAS_PREDECESSOR', `${idOf(anchor)} already has ${inc.length} incoming flows; say which one to insert into`, { element: idOf(anchor), @@ -149,10 +145,10 @@ export class NodePlacementService { if (placement.to) { const target = this.edit.require(placement.to, 'bpmn:FlowNode'); - const flow = this.flows.create(node, target, {}, undefined); - cs.create(ChangeSet.flowEntryOf(flow)); + const flow = new SequenceFlows(this.edit).create(node, target, {}, undefined); + changes.create(ChangeSet.flowEntryOf(flow)); if (!is(target, 'bpmn:Gateway') && doc.incoming(target).length > 1) { - cs.warn({ + changes.warn({ code: 'W_IMPLICIT_JOIN', message: `${idOf(target)} now has ${doc.incoming(target).length} incoming flows (implicit join)`, element: idOf(target), @@ -186,7 +182,7 @@ export class NodePlacementService { } private requireFlowBetween(from: El, to: El): El { - const flows = this.doc.outgoing(from).filter((flow) => flow.get('targetRef') === to); + const flows = this.edit.doc.outgoing(from).filter((flow) => flow.get('targetRef') === to); if (flows.length === 1) return flows[0]!; if (!flows.length) { throw modelError('E_NO_FLOW', `There is no sequence flow ${idOf(from)} -> ${idOf(to)}`, { element: idOf(from), related: [idOf(to)] }); @@ -198,10 +194,10 @@ export class NodePlacementService { } private assertSpliceable(node: El, flow: El): void { - const rules = new FlowRules(this.doc); + const rules = new FlowRules(this.edit.doc); const src = flow.get('sourceRef'); const tgt = flow.get('targetRef'); - const scope = this.doc.scopeOf(flow); + const scope = this.edit.doc.scopeOf(flow); const scopeId = scope ? idOf(scope) : ''; const kind = kindLabel(node); if (!rules.canStart(node)) { @@ -221,15 +217,15 @@ export class NodePlacementService { } /** `flow` (now source -> node) is the flow into the node, so it takes the flow options. */ - private spliceWithOptions(node: El, flow: El, cs: ChangeSet, outFlow?: El): El { + private spliceWithOptions(node: El, flow: El, changes: ChangeSet, outFlow?: El): El { this.assertSpliceable(node, flow); const src = flow.get('sourceRef'); const tgt = flow.get('targetRef'); - const { second, renamedFrom } = this.flows.splice(flow, node, outFlow, this.placement.flowId); - cs.create(ChangeSet.flowEntryOf(second)); - cs.change(ChangeSet.flowEntryOf(flow, `${idOf(src)} -> ${idOf(node)} (was -> ${idOf(tgt)})${renamedNote(renamedFrom)}`)); - cs.note(`inserted between ${idOf(src)} and ${idOf(tgt)}`); - new FlowOptions(this.placement).apply(this.doc, this.edit, flow, cs); + const { second, renamedFrom } = new SequenceFlows(this.edit).splice(flow, node, outFlow, this.placement.flowId); + changes.create(ChangeSet.flowEntryOf(second)); + changes.change(ChangeSet.flowEntryOf(flow, `${idOf(src)} -> ${idOf(node)} (was -> ${idOf(tgt)})${renamedNote(renamedFrom)}`)); + changes.note(`inserted between ${idOf(src)} and ${idOf(tgt)}`); + new FlowOptions(this.placement).apply(this.edit, flow, changes); return flow; } } diff --git a/src/domain/editing/participants/PoolService.ts b/src/domain/editing/elements/Pools.ts similarity index 61% rename from src/domain/editing/participants/PoolService.ts rename to src/domain/editing/elements/Pools.ts index aa2a927a..627b214a 100644 --- a/src/domain/editing/participants/PoolService.ts +++ b/src/domain/editing/elements/Pools.ts @@ -1,87 +1,82 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError } from '../../document/utils/errors.js'; -import { kindByName } from '../../document/utils/kinds.js'; +import { kindByName } from '../../document/Kinds.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; import { kindRequest, typeRequest } from '../../document/utils/requests.js'; import { addTo, insertInto, is, many, type El, idOf } from '../../document/utils/moddle.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; -import { allocateId } from '../utils/allocate-id.js'; import type { AddElement } from '../../commands/types.js'; /** * Creates pools. The first participant in a file with one process and no collaboration * wraps that process; further ones get a new empty process unless bound or black-box. */ -export class PoolService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class Pools { + constructor(private readonly edit: EditSession) {} - createParticipant(op: AddElement, cs: ChangeSet): El { - const doc = this.doc; - const resolved = this.resolveParticipantProcess(op); + createParticipant(command: AddElement, changes: ChangeSet): El { + const doc = this.edit.doc; + const resolved = this.resolveParticipantProcess(command); const context = resolved.process ? new ElementLabels(resolved.process).label() : resolved.mode === 'blackBox' ? 'Black box' : 'Pool'; - const id = allocateId(this.edit, kindRequest(kindByName('participant')!, { ...(op.name ? { name: op.name } : {}), context }), op.id); - const collab = this.ensureCollaboration(cs); + const id = this.edit.allocator.explicitOr(command.id, kindRequest(kindByName('participant')!, { ...(command.name ? { name: command.name } : {}), context })); + const collab = this.ensureCollaboration(changes); let process = resolved.process; if (resolved.mode === 'new') { - const processId = this.edit.allocator.allocate(typeRequest('bpmn:Process', { ...(op.name ? { name: op.name } : {}), context: id.slice(id.indexOf('_') + 1) })).id; + const processId = this.edit.allocator.allocate(typeRequest('bpmn:Process', { ...(command.name ? { name: command.name } : {}), context: id.slice(id.indexOf('_') + 1) })).id; process = doc.create('bpmn:Process', { id: processId, isExecutable: false }); this.insertProcess(process); - cs.create({ id: processId, kind: 'process', detail: `for participant ${id} (not executable)` }); + changes.create({ id: processId, kind: 'process', detail: `for participant ${id} (not executable)` }); } const participant = doc.create('bpmn:Participant', { id, - ...(op.name ? { name: op.name } : {}), + ...(command.name ? { name: command.name } : {}), ...(process ? { processRef: process } : {}), }); addTo(collab, 'participants', participant); const detail = resolved.mode === 'blackBox' ? 'black box (no process)' : resolved.mode === 'wrap' ? `wraps process ${idOf(process!)}` : `process ${idOf(process!)}`; - cs.create({ id, kind: 'participant', ...(op.name ? { name: op.name } : {}), detail }); - if (resolved.mode === 'wrap') cs.note(`collaboration ${idOf(collab)} created; ${id} wraps the existing process ${idOf(process!)}`); + changes.create({ id, kind: 'participant', ...(command.name ? { name: command.name } : {}), detail }); + if (resolved.mode === 'wrap') changes.note(`collaboration ${idOf(collab)} created; ${id} wraps the existing process ${idOf(process!)}`); doc.invalidate(); return participant; } /** Created as the first root element when missing. */ - private ensureCollaboration(cs: ChangeSet): El { - const doc = this.doc; + private ensureCollaboration(changes: ChangeSet): El { + const doc = this.edit.doc; const existing = doc.collaboration(); if (existing) return existing; const first = doc.processes()[0]; const collab = doc.create('bpmn:Collaboration', { id: this.edit.allocator.allocate(typeRequest('bpmn:Collaboration', { context: first ? new ElementLabels(first).label() : '' })).id }); insertInto(doc.definitions, 'rootElements', 0, collab); - cs.create({ id: idOf(collab), kind: 'collaboration' }); + changes.create({ id: idOf(collab), kind: 'collaboration' }); doc.invalidate(); return collab; } private insertProcess(process: El): void { - const roots = many(this.doc.definitions, 'rootElements'); + const roots = many(this.edit.doc.definitions, 'rootElements'); let idx = -1; roots.forEach((root, index) => { if (is(root, 'bpmn:Process') || is(root, 'bpmn:Collaboration')) idx = index; }); - insertInto(this.doc.definitions, 'rootElements', idx + 1, process); + insertInto(this.edit.doc.definitions, 'rootElements', idx + 1, process); } - private resolveParticipantProcess(op: AddElement): { process?: El; mode: 'wrap' | 'bind' | 'new' | 'blackBox' } { - const doc = this.doc; - if (op.blackBox) { - if (op.process) { + private resolveParticipantProcess(command: AddElement): { process?: El; mode: 'wrap' | 'bind' | 'new' | 'blackBox' } { + const doc = this.edit.doc; + if (command.blackBox) { + if (command.process) { throw modelError('E_INVALID_VALUE', '--black-box and --process are mutually exclusive', { hint: 'A black box participant has no process.' }); } return { mode: 'blackBox' }; } - if (op.process) { - const process = this.edit.require(op.process, 'bpmn:Process', 'process'); + if (command.process) { + const process = this.edit.require(command.process, 'bpmn:Process', 'process'); const bound = doc.participantOf(process); if (bound) { - throw modelError('E_PROCESS_BOUND', `Process ${op.process} is already bound to participant ${idOf(bound)}`, { - element: op.process, + throw modelError('E_PROCESS_BOUND', `Process ${command.process} is already bound to participant ${idOf(bound)}`, { + element: command.process, related: [idOf(bound)], hint: 'Omit --process to create a new process for the participant, or use --black-box.', }); diff --git a/src/domain/editing/events/EventDefinitionService.ts b/src/domain/editing/events/EventDefinitions.ts similarity index 63% rename from src/domain/editing/events/EventDefinitionService.ts rename to src/domain/editing/events/EventDefinitions.ts index 1606fbbd..e26c2629 100644 --- a/src/domain/editing/events/EventDefinitionService.ts +++ b/src/domain/editing/events/EventDefinitions.ts @@ -1,15 +1,14 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError } from '../../document/utils/errors.js'; -import { type Warning } from '../../document/CliError.js'; -import { isEventSubProcess, kindOf, TRIGGER_TYPES, type Trigger } from '../../document/utils/kinds.js'; +import type { Finding } from '../../document/Finding.js'; +import { isEventSubProcess, kindOf, TRIGGERS, type Trigger } from '../../document/Kinds.js'; import { addTo, is, walk, type El, idOf } from '../../document/utils/moddle.js'; import { Camunda8Defaults } from '../../document/vendor/Camunda8Defaults.js'; import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; import type { TriggerOptions } from '../../commands/types.js'; import { vendorContent } from '../../document/utils/elements.js'; import { ALWAYS_INTERRUPTING, TIMER_PROPS } from './EventTrigger.js'; -import { RootElementService, type RootRefType } from './RootElementService.js'; +import { RootElements, type RootRefType } from './RootElements.js'; const EVENT_SUBPROCESS_ONLY: Trigger[] = ['error', 'escalation', 'compensate']; @@ -30,56 +29,48 @@ const REF_TRIGGERS: Record' : `${trigger} `}\` (or eventSubProcess:${trigger}); or add the start with --in in the same \`apply\` batch as the eventSubProcess.`, }); } - this.applyInterrupting(trigger, opts); - const defs = this.event.get('eventDefinitions') ?? []; - const matching = trigger !== 'none' ? defs.filter((definition) => is(definition, TRIGGER_TYPES[trigger])) : []; + this.applyInterrupting(event, trigger, opts); + const defs = event.get('eventDefinitions') ?? []; + const matching = trigger !== 'none' ? defs.filter((definition) => is(definition, TRIGGERS[trigger].definition)) : []; const same = matching.length === 1 ? matching[0] : undefined; if (same && trigger !== 'none') { - const others = defs.length > 1 ? this.dropOtherDefinitions(same, trigger) : []; - const warnings = this.updateDefinition(same, trigger, opts); - this.doc.invalidate(); + const others = defs.length > 1 ? this.dropOtherDefinitions(event, same, trigger) : []; + const warnings = this.updateDefinition(event, same, trigger, opts); + this.edit.doc.invalidate(); return [...others, ...warnings]; } - const fresh = trigger !== 'none' ? this.buildDefinition(trigger, opts) : undefined; - const warnings = this.dropDefinitions(trigger); + const fresh = trigger !== 'none' ? this.buildDefinition(event, trigger, opts) : undefined; + const warnings = this.dropDefinitions(event, trigger); if (fresh) { - addTo(this.event, 'eventDefinitions', fresh); - new Camunda8Defaults(this.doc, this.edit).definitionId(this.event, fresh); + addTo(event, 'eventDefinitions', fresh); + new Camunda8Defaults(this.edit).definitionId(event, fresh); } - this.doc.invalidate(); + this.edit.doc.invalidate(); return warnings; } @@ -105,44 +96,44 @@ export class EventDefinitionService { } private expression(body: string): El { - return this.doc.moddle.create('bpmn:FormalExpression', { body }); + return this.edit.doc.moddle.create('bpmn:FormalExpression', { body }); } - private buildDefinition(trigger: Exclude, opts: TriggerOptions): El { - const type = TRIGGER_TYPES[trigger]; + private buildDefinition(event: El, trigger: Exclude, opts: TriggerOptions): El { + const type = TRIGGERS[trigger].definition; switch (trigger) { case 'timer': { - const def = this.doc.moddle.create(type, {}); + const def = this.edit.doc.moddle.create(type, {}); if (opts.timer !== undefined) { const value = opts.timer.trim(); if (!value) throw modelError('E_INVALID_VALUE', 'The timer value is empty', { hint: 'Use ISO 8601: PT5M (duration), R/PT1H (cycle) or 2030-01-01T00:00:00Z (date).' }); - def.set(TIMER_PROPS[EventDefinitionService.classifyTimer(value, opts.timerKind)], this.expression(value)); + def.set(TIMER_PROPS[EventDefinitions.classifyTimer(value, opts.timerKind)], this.expression(value)); } return def; } case 'message': - return this.doc.moddle.create(type, opts.message ? { messageRef: this.roots.ensure('bpmn:Message', opts.message).el } : {}); + return this.edit.doc.moddle.create(type, opts.message ? { messageRef: new RootElements(this.edit).ensure('bpmn:Message', opts.message).el } : {}); case 'error': - return this.doc.moddle.create(type, opts.error ? { errorRef: this.roots.ensure('bpmn:Error', opts.error, { errorCode: opts.errorCode }).el } : {}); + return this.edit.doc.moddle.create(type, opts.error ? { errorRef: new RootElements(this.edit).ensure('bpmn:Error', opts.error, { errorCode: opts.errorCode }).el } : {}); case 'signal': - return this.doc.moddle.create(type, opts.signal ? { signalRef: this.roots.ensure('bpmn:Signal', opts.signal).el } : {}); + return this.edit.doc.moddle.create(type, opts.signal ? { signalRef: new RootElements(this.edit).ensure('bpmn:Signal', opts.signal).el } : {}); case 'escalation': - return this.doc.moddle.create( + return this.edit.doc.moddle.create( type, - opts.escalation ? { escalationRef: this.roots.ensure('bpmn:Escalation', opts.escalation, { escalationCode: opts.escalationCode }).el } : {}, + opts.escalation ? { escalationRef: new RootElements(this.edit).ensure('bpmn:Escalation', opts.escalation, { escalationCode: opts.escalationCode }).el } : {}, ); case 'conditional': { if (opts.when !== undefined && !opts.when.trim()) { throw modelError('E_INVALID_VALUE', 'The condition expression is empty', { hint: "Quote expressions with single quotes, e.g. --when '${approved}'." }); } - return this.doc.moddle.create(type, opts.when ? { condition: this.expression(opts.when) } : {}); + return this.edit.doc.moddle.create(type, opts.when ? { condition: this.expression(opts.when) } : {}); } case 'link': { - const name = opts.link ?? this.event.get('name'); - return this.doc.moddle.create(type, name ? { name } : {}); + const name = opts.link ?? event.get('name'); + return this.edit.doc.moddle.create(type, name ? { name } : {}); } default: - return this.doc.moddle.create(type, {}); + return this.edit.doc.moddle.create(type, {}); } } @@ -151,7 +142,7 @@ export class EventDefinitionService { const name = opts[trigger]; if (name === undefined) return; const extra = spec.code ? { [spec.code]: opts[spec.code] } : {}; - const { el } = this.roots.ensure(spec.type, name, extra); + const { el } = new RootElements(this.edit).ensure(spec.type, name, extra); if (def.get(spec.prop) !== el) def.set(spec.prop, el); } @@ -174,7 +165,7 @@ export class EventDefinitionService { if (opts.timer === undefined) return; const value = opts.timer.trim(); if (!value) throw modelError('E_INVALID_VALUE', 'The timer value is empty', { hint: 'Use ISO 8601: PT5M (duration), R/PT1H (cycle) or 2030-01-01T00:00:00Z (date).' }); - const prop = TIMER_PROPS[EventDefinitionService.classifyTimer(value, opts.timerKind)]; + const prop = TIMER_PROPS[EventDefinitions.classifyTimer(value, opts.timerKind)]; const current = Object.values(TIMER_PROPS).find((timerProp) => def.get(timerProp)) ?? prop; this.updateExpression(def, prop, value, current); for (const timerProp of Object.values(TIMER_PROPS)) if (timerProp !== prop && def.get(timerProp)) def.set(timerProp, undefined); @@ -184,7 +175,7 @@ export class EventDefinitionService { * Like a sequence flow's condition: a new inline condition replaces a script resource, * and a `${...}` body does not keep a script language. */ - private updateCondition(def: El, opts: TriggerOptions): Warning[] { + private updateCondition(event: El, def: El, opts: TriggerOptions): Finding[] { if (opts.when === undefined) return []; if (!opts.when.trim()) { throw modelError('E_INVALID_VALUE', 'The condition expression is empty', { hint: "Quote expressions with single quotes, e.g. --when '${approved}'." }); @@ -192,7 +183,7 @@ export class EventDefinitionService { const expr = def.get('condition'); const dropped: string[] = []; if (expr && expr.get('body') !== opts.when) { - const resourceKey = Object.keys(expr.$attrs ?? {}).find((attrKey) => /^[^:]+:resource$/.test(attrKey) && CAMUNDA.reads(this.doc.namespaceUri(attrKey.split(':')[0]!))); + const resourceKey = Object.keys(expr.$attrs ?? {}).find((attrKey) => /^[^:]+:resource$/.test(attrKey) && CAMUNDA.reads(this.edit.doc.namespaceUri(attrKey.split(':')[0]!))); const language = expr.get('language'); if (resourceKey) { dropped.push(`${resourceKey} ${String(expr.$attrs[resourceKey])}`); @@ -208,51 +199,51 @@ export class EventDefinitionService { return [ { code: 'W_PROPERTY_DROPPED', - message: `${dropped.join(' and ')} of the condition of ${idOf(this.event)} ${dropped.length > 1 ? 'were' : 'was'} dropped: the new inline condition replaces ${dropped[0]!.startsWith('language') ? 'the script' : 'the script resource'}`, - element: idOf(this.event), + message: `${dropped.join(' and ')} of the condition of ${idOf(event)} ${dropped.length > 1 ? 'were' : 'was'} dropped: the new inline condition replaces ${dropped[0]!.startsWith('language') ? 'the script' : 'the script resource'}`, + element: idOf(event), hint: 'To keep a script condition set condition.camunda:resource= or condition.language= after when=.', }, ]; } - private updateDefinition(def: El, trigger: Exclude, opts: TriggerOptions): Warning[] { - if (EventDefinitionService.isRefTrigger(trigger)) this.updateReference(def, trigger, opts); + private updateDefinition(event: El, def: El, trigger: Exclude, opts: TriggerOptions): Finding[] { + if (EventDefinitions.isRefTrigger(trigger)) this.updateReference(def, trigger, opts); else if (trigger === 'timer') this.updateTimer(def, opts); - else if (trigger === 'conditional') return this.updateCondition(def, opts); + else if (trigger === 'conditional') return this.updateCondition(event, def, opts); else if (trigger === 'link' && opts.link !== undefined && def.get('name') !== opts.link) def.set('name', opts.link || undefined); return []; } - private dropDefinitions(trigger: Trigger): Warning[] { - const defs = [...(this.event.get('eventDefinitions') ?? [])]; - this.event.set('eventDefinitions', undefined); - const warnings: Warning[] = []; + private dropDefinitions(event: El, trigger: Trigger): Finding[] { + const defs = [...(event.get('eventDefinitions') ?? [])]; + event.set('eventDefinitions', undefined); + const warnings: Finding[] = []; for (const def of defs) { for (const element of walk(def, { bpmnOnly: true })) { const id = element.get('id'); - if (id) this.doc.ids.release(id); + if (id) this.edit.doc.ids.release(id); } const lost = vendorContent(def); if (!lost.length) continue; const defId = def.get('id'); warnings.push({ code: 'W_PROPERTY_DROPPED', - message: `${lost.join(', ')} of the ${def.$type.replace('bpmn:', '')}${defId ? ` ${defId}` : ''} of ${idOf(this.event)} ${lost.length > 1 ? 'were' : 'was'} dropped: the trigger changed to ${trigger}`, - element: idOf(this.event), + message: `${lost.join(', ')} of the ${def.$type.replace('bpmn:', '')}${defId ? ` ${defId}` : ''} of ${idOf(event)} ${lost.length > 1 ? 'were' : 'was'} dropped: the trigger changed to ${trigger}`, + element: idOf(event), hint: 'Vendor attributes and extension elements belong to the old event definition; re-create what the new trigger still needs in the XML, or keep the old trigger and change only its details (e.g. `bpmn set message=`).', }); } return warnings; } - private dropOtherDefinitions(keep: El, trigger: Trigger): Warning[] { - const defs = [...(this.event.get('eventDefinitions') ?? [])]; + private dropOtherDefinitions(event: El, keep: El, trigger: Trigger): Finding[] { + const defs = [...(event.get('eventDefinitions') ?? [])]; const others = defs.filter((definition) => definition !== keep); - this.event.set('eventDefinitions', [keep]); + event.set('eventDefinitions', [keep]); for (const def of others) { for (const element of walk(def, { bpmnOnly: true })) { const id = element.get('id'); - if (id) this.doc.ids.release(id); + if (id) this.edit.doc.ids.release(id); } } const text = (definition: El): string => { @@ -264,53 +255,53 @@ export class EventDefinitionService { return [ { code: 'W_PROPERTY_DROPPED', - message: `${others.map(text).join(', ')} of ${idOf(this.event)} ${others.length > 1 ? 'were' : 'was'} dropped: trigger=${trigger} keeps only its ${text(keep).replace(/ \(with .*\)$/, '')} (an event acts on one event definition)`, - element: idOf(this.event), + message: `${others.map(text).join(', ')} of ${idOf(event)} ${others.length > 1 ? 'were' : 'was'} dropped: trigger=${trigger} keeps only its ${text(keep).replace(/ \(with .*\)$/, '')} (an event acts on one event definition)`, + element: idOf(event), hint: 'Set the trigger of the definition the event should keep; the others cannot be kept on the same event.', }, ]; } - private applyInterrupting(trigger: Trigger, opts: TriggerOptions): void { + private applyInterrupting(event: El, trigger: Trigger, opts: TriggerOptions): void { const want = opts.nonInterrupting; const interruptingOnly = ALWAYS_INTERRUPTING.includes(trigger); - if (is(this.event, 'bpmn:BoundaryEvent')) { + if (is(event, 'bpmn:BoundaryEvent')) { if (want === true) { if (interruptingOnly) { throw modelError('E_INVALID_TRIGGER', `A ${trigger} boundary event cannot be non-interrupting`, { - element: idOf(this.event), + element: idOf(event), hint: 'Only message, timer, signal, escalation and conditional boundary events can be non-interrupting.', }); } - this.event.set('cancelActivity', false); - } else if (want === false || (interruptingOnly && this.event.get('cancelActivity') === false)) { - this.event.set('cancelActivity', undefined); + event.set('cancelActivity', false); + } else if (want === false || (interruptingOnly && event.get('cancelActivity') === false)) { + event.set('cancelActivity', undefined); } return; } - if (is(this.event, 'bpmn:StartEvent')) { + if (is(event, 'bpmn:StartEvent')) { if (want === true) { - if (!isEventSubProcess(this.doc.scopeOf(this.event))) { - throw modelError('E_INVALID_TRIGGER', `Start event ${idOf(this.event)} is not inside an event sub-process and cannot be non-interrupting`, { - element: idOf(this.event), + if (!isEventSubProcess(this.edit.doc.scopeOf(event))) { + throw modelError('E_INVALID_TRIGGER', `Start event ${idOf(event)} is not inside an event sub-process and cannot be non-interrupting`, { + element: idOf(event), hint: 'Only start events of an eventSubProcess can be non-interrupting.', }); } if (interruptingOnly || trigger === 'none') { throw modelError('E_INVALID_TRIGGER', `A ${trigger} start event cannot be non-interrupting`, { - element: idOf(this.event), + element: idOf(event), hint: 'Only message, timer, signal, escalation and conditional start events can be non-interrupting.', }); } - this.event.set('isInterrupting', false); - } else if (want === false || (interruptingOnly && this.event.get('isInterrupting') === false)) { - this.event.set('isInterrupting', undefined); + event.set('isInterrupting', false); + } else if (want === false || (interruptingOnly && event.get('isInterrupting') === false)) { + event.set('isInterrupting', undefined); } return; } if (want === true) { - throw modelError('E_INVALID_TRIGGER', `nonInterrupting only applies to boundary events and event sub-process start events (${idOf(this.event)} is a ${kindOf(this.event)?.kind ?? this.event.$type})`, { - element: idOf(this.event), + throw modelError('E_INVALID_TRIGGER', `nonInterrupting only applies to boundary events and event sub-process start events (${idOf(event)} is a ${kindOf(event)?.kind ?? event.$type})`, { + element: idOf(event), }); } } diff --git a/src/domain/editing/events/EventGatewayRule.ts b/src/domain/editing/events/EventGatewayRule.ts index 86f0bac1..317ca550 100644 --- a/src/domain/editing/events/EventGatewayRule.ts +++ b/src/domain/editing/events/EventGatewayRule.ts @@ -1,7 +1,6 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; -import { EVENT_GATEWAY_TRIGGERS, triggerOf } from '../../document/utils/kinds.js'; +import { EVENT_GATEWAY_TRIGGERS, triggerOf } from '../../document/Kinds.js'; import { is, type El, idOf } from '../../document/utils/moddle.js'; import { labelOf } from '../../document/utils/elements.js'; @@ -12,10 +11,7 @@ import { labelOf } from '../../document/utils/elements.js'; * Other files follow BPMN 2.0 (10.6.6): receive tasks too, but not mixed with message events. */ export class EventGatewayRule { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} + constructor(private readonly edit: EditSession) {} get rule(): 'engines' | 'bpmn' { return this.edit.platform() === 'c7' ? 'engines' : 'bpmn'; @@ -30,13 +26,13 @@ export class EventGatewayRule { * An explicit edge is the agent's call and only warned about (a bridge is refused). * Camunda 7 files are left to the platform profile, which reports the engines' rule. */ - warnFlow(flow: El, cs: ChangeSet): void { + warnFlow(flow: El, changes: ChangeSet): void { const gateway = flow.get('sourceRef'); const target = flow.get('targetRef'); if (!gateway || !target || !is(gateway, 'bpmn:EventBasedGateway') || this.rule === 'engines') return; const problem = this.bpmnTargetProblem(gateway, target, [flow]); if (!problem) return; - cs.warn({ + changes.warn({ code: 'W_EVENT_GATEWAY_TARGET', message: `Event-based gateway ${idOf(gateway)} now leads to ${idOf(target)} (${idOf(flow)}): ${problem}`, element: idOf(gateway), @@ -46,7 +42,7 @@ export class EventGatewayRule { } private otherIncoming(target: El, leaving: El[], what: string): string | undefined { - const others = this.doc.incoming(target).filter((flow) => !leaving.includes(flow)); + const others = this.edit.doc.incoming(target).filter((flow) => !leaving.includes(flow)); if (!others.length) return undefined; return `${labelOf(target)} already has the incoming flow ${others.map(idOf).join(', ')}; ${what} after an event-based gateway can have no other incoming flow`; } @@ -63,7 +59,7 @@ export class EventGatewayRule { if (incoming) return incoming; const mine = EventGatewayRule.subscriptionOf(target); if (mine) { - for (const flow of this.doc.outgoing(gateway)) { + for (const flow of this.edit.doc.outgoing(gateway)) { if (leaving.includes(flow)) continue; const flowTarget = flow.get('targetRef'); if (!flowTarget || flowTarget === target) continue; @@ -91,11 +87,11 @@ export class EventGatewayRule { } const incoming = this.otherIncoming(target, leaving, receive ? 'a receive task' : 'a catch event'); if (incoming) return incoming; - if (receive && this.doc.boundaryEventsOf(target).length) { - return `${labelOf(target)} has boundary events (${this.doc.boundaryEventsOf(target).map(idOf).join(', ')}); BPMN 2.0 does not allow them on a receive task after an event-based gateway`; + if (receive && this.edit.doc.boundaryEventsOf(target).length) { + return `${labelOf(target)} has boundary events (${this.edit.doc.boundaryEventsOf(target).map(idOf).join(', ')}); BPMN 2.0 does not allow them on a receive task after an event-based gateway`; } if (receive || EventGatewayRule.waitsForMessage(target)) { - for (const flow of this.doc.outgoing(gateway)) { + for (const flow of this.edit.doc.outgoing(gateway)) { if (leaving.includes(flow)) continue; const flowTarget = flow.get('targetRef'); if (!flowTarget || flowTarget === target) continue; diff --git a/src/domain/editing/events/EventTrigger.ts b/src/domain/editing/events/EventTrigger.ts index 0ecad5ed..ea23bd80 100644 --- a/src/domain/editing/events/EventTrigger.ts +++ b/src/domain/editing/events/EventTrigger.ts @@ -1,6 +1,7 @@ -import { triggerOf, type Trigger } from '../../document/utils/kinds.js'; +import { triggerOf, TRIGGERS, type RootReference, type Trigger } from '../../document/Kinds.js'; import { is, many, type El, idOf } from '../../document/utils/moddle.js'; import type { TriggerOptions } from '../../commands/types.js'; +import { TRIGGER_KEYS } from '../../commands/utils/op-fields.js'; export interface TriggerDetails { trigger: Trigger; @@ -17,8 +18,6 @@ export interface TriggerDetails { /** Triggers that always interrupt: never on a non-interrupting boundary or start event. */ export const ALWAYS_INTERRUPTING: Trigger[] = ['error', 'cancel', 'compensate']; -export const TRIGGER_OPTION_KEYS: ReadonlyArray = ['timer', 'timerKind', 'message', 'error', 'errorCode', 'signal', 'escalation', 'escalationCode', 'when', 'link', 'nonInterrupting']; - export const TIMER_PROPS: Record<'cycle' | 'duration' | 'date', string> = { cycle: 'timeCycle', duration: 'timeDuration', @@ -43,7 +42,7 @@ export class EventTrigger { static optionsOf(source: TriggerOptions): TriggerOptions { const out: Record = {}; - for (const key of TRIGGER_OPTION_KEYS) if (source[key] !== undefined) out[key] = source[key]; + for (const key of TRIGGER_KEYS) if (source[key] !== undefined) out[key] = source[key]; return out as TriggerOptions; } @@ -53,8 +52,6 @@ export class EventTrigger { const opts: TriggerOptions = {}; const def = (el.get('eventDefinitions') ?? [])[0]; const body = (propName: string): string | undefined => def?.get(propName)?.get('body'); - const ref = (propName: string): El | undefined => def?.get(propName); - const named = (propName: string): string | undefined => ref(propName)?.get('name') ?? ref(propName)?.get('id'); if (def) { switch (trigger) { case 'timer': { @@ -66,28 +63,17 @@ export class EventTrigger { else if (date) Object.assign(opts, { timer: date, timerKind: 'date' }); break; } - case 'message': - opts.message = named('messageRef'); - break; - case 'error': - opts.error = named('errorRef'); - opts.errorCode = ref('errorRef')?.get('errorCode'); - break; - case 'signal': - opts.signal = named('signalRef'); - break; - case 'escalation': - opts.escalation = named('escalationRef'); - opts.escalationCode = ref('escalationRef')?.get('escalationCode'); - break; case 'conditional': opts.when = body('condition'); break; case 'link': opts.link = def.get('name'); break; - default: + default: { + const root = EventTrigger.rootReferenceOf(trigger); + if (root) EventTrigger.readRoot(def, root, opts); break; + } } } if (is(el, 'bpmn:BoundaryEvent') && el.get('cancelActivity') === false) opts.nonInterrupting = true; @@ -111,26 +97,6 @@ export class EventTrigger { } break; } - case 'message': { - const info = EventTrigger.refInfo(def.get('messageRef')); - if (info) details.message = info; - break; - } - case 'error': { - const info = EventTrigger.refInfo(def.get('errorRef'), 'errorCode'); - if (info) details.error = info; - break; - } - case 'signal': { - const info = EventTrigger.refInfo(def.get('signalRef')); - if (info) details.signal = info; - break; - } - case 'escalation': { - const info = EventTrigger.refInfo(def.get('escalationRef'), 'escalationCode'); - if (info) details.escalation = info; - break; - } case 'conditional': { const condition = EventTrigger.bodyOf(def.get('condition')); if (condition) details.condition = condition; @@ -141,8 +107,12 @@ export class EventTrigger { if (link) details.link = link; break; } - default: + default: { + const root = EventTrigger.rootReferenceOf(trigger); + const info = root && EventTrigger.refInfo(def.get(root.refProperty), root.codeProperty); + if (root && info) details[root.option] = info; break; + } } } if (is(event, 'bpmn:BoundaryEvent') && event.get('cancelActivity') === false) details.nonInterrupting = true; @@ -156,23 +126,27 @@ export class EventTrigger { switch (details.trigger) { case 'timer': return details.timer ? details.timer.value : 'timer'; - case 'message': - return EventTrigger.named(details.message, 'message'); - case 'error': - return EventTrigger.named(details.error, 'error'); - case 'signal': - return EventTrigger.named(details.signal, 'signal'); - case 'escalation': - return EventTrigger.named(details.escalation, 'escalation'); case 'conditional': return details.condition ? `condition ${details.condition}` : 'conditional'; case 'link': return details.link ? `link ${details.link}` : 'link'; - default: - return details.trigger; + default: { + const root = EventTrigger.rootReferenceOf(details.trigger); + return root ? EventTrigger.named(details[root.option], root.option) : details.trigger; + } } } + private static readRoot(def: El, root: RootReference, opts: TriggerOptions): void { + const ref = def.get(root.refProperty); + opts[root.option] = ref?.get('name') ?? ref?.get('id'); + if (root.codeProperty) opts[root.codeProperty] = ref?.get(root.codeProperty); + } + + private static rootReferenceOf(trigger: Trigger): RootReference | undefined { + return trigger === 'none' ? undefined : TRIGGERS[trigger].root; + } + private static refInfo(ref: El | undefined, codeProp?: string): RefInfo | undefined { if (!ref) return undefined; const name = ref.get('name'); diff --git a/src/domain/editing/events/RootElementService.ts b/src/domain/editing/events/RootElements.ts similarity index 77% rename from src/domain/editing/events/RootElementService.ts rename to src/domain/editing/events/RootElements.ts index 39b9719b..632fc585 100644 --- a/src/domain/editing/events/RootElementService.ts +++ b/src/domain/editing/events/RootElements.ts @@ -1,9 +1,8 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import { modelError, usageError } from '../../document/utils/errors.js'; -import { typeRequest } from '../../document/utils/requests.js'; -import { kindLabel } from '../../document/utils/kinds.js'; +import { TRIGGERS, kindLabel } from '../../document/Kinds.js'; import { addTo, is, type El, idOf } from '../../document/utils/moddle.js'; +import { typeRequest } from '../../document/utils/requests.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; export type RootRefType = 'bpmn:Message' | 'bpmn:Error' | 'bpmn:Signal' | 'bpmn:Escalation'; @@ -15,26 +14,16 @@ const ROOT_PREFIX: Record = { 'bpmn:Escalation': 'Escalation', }; -const DEFINITION_REFS: Array<[string, string]> = [ - ['bpmn:MessageEventDefinition', 'messageRef'], - ['bpmn:ErrorEventDefinition', 'errorRef'], - ['bpmn:SignalEventDefinition', 'signalRef'], - ['bpmn:EscalationEventDefinition', 'escalationRef'], -]; - /** * Finds or creates the root bpmn:Message / Error / Signal / Escalation that events, * send / receive tasks and message flows reference. */ -export class RootElementService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class RootElements { + constructor(private readonly edit: EditSession) {} /** `extra` (errorCode / escalationCode) is applied to a found element too. */ ensure(type: RootRefType, nameOrId: string, extra: Record = {}): { el: El; created: boolean } { - const doc = this.doc; + const doc = this.edit.doc; const key = nameOrId.trim(); if (!key) { throw modelError('E_INVALID_VALUE', `The ${ROOT_PREFIX[type].toLowerCase()} name is empty`, { hint: 'Pass a name, e.g. --message OrderReceived.' }); @@ -54,9 +43,9 @@ export class RootElementService { return { el, created }; } - bindRefAs(cs: ChangeSet, alias: string | undefined, el: El | undefined): void { + static bindRefAs(changes: ChangeSet, alias: string | undefined, el: El | undefined): void { if (!alias || !el) return; - const roots = RootElementService.referencedBy(el); + const roots = RootElements.referencedBy(el); const id = el.get('id') ?? el.$type; if (roots.length !== 1) { throw usageError(`refAs ${alias}: ${kindLabel(el)} ${id} references ${roots.length ? `${roots.length} root elements (${roots.map((root) => root.get('id')).join(', ')})` : 'no message, error, signal or escalation'}`, { @@ -64,7 +53,7 @@ export class RootElementService { hint: roots.length ? 'Refer to one of them by its id.' : 'refAs names the message / error / signal / escalation the element references: give it one in the same op ("message": "", "error": ..., "signal": ..., "escalation": ...).', }); } - cs.bind(alias, roots[0]); + changes.bind(alias, roots[0]); } private static referencedBy(el: El): El[] { @@ -75,7 +64,7 @@ export class RootElementService { if (is(el, 'bpmn:SendTask') || is(el, 'bpmn:ReceiveTask') || is(el, 'bpmn:MessageFlow')) add(el.get('messageRef')); if (is(el, 'bpmn:Event')) { for (const def of el.get('eventDefinitions') ?? []) { - for (const [type, prop] of DEFINITION_REFS) if (is(def, type)) add(def.get(prop)); + for (const { definition, root } of Object.values(TRIGGERS)) if (root && is(def, definition)) add(def.get(root.refProperty)); } } return out; diff --git a/src/domain/editing/events/TriggerChange.ts b/src/domain/editing/events/TriggerChange.ts index 11fd9b20..1534ff46 100644 --- a/src/domain/editing/events/TriggerChange.ts +++ b/src/domain/editing/events/TriggerChange.ts @@ -1,14 +1,14 @@ import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; +import type { EditSession } from '../../document/EditSession.js'; import type { ChangeSet } from '../../document/ChangeSet.js'; import type { IdState } from '../../document/ids/IdStyle.js'; import { modelError } from '../../document/utils/errors.js'; -import { kindLabel, triggerOf, type Trigger } from '../../document/utils/kinds.js'; +import { kindLabel, triggerOf, type Trigger } from '../../document/Kinds.js'; import { is, type El } from '../../document/utils/moddle.js'; -import { EventDefinitionService } from './EventDefinitionService.js'; +import { EventDefinitions } from './EventDefinitions.js'; import { EventTrigger, ALWAYS_INTERRUPTING } from './EventTrigger.js'; -import { IdRenameService } from '../IdRenameService.js'; -import { PropertyCoercer } from '../PropertyCoercer.js'; +import { IdRename } from '../ids/IdRename.js'; +import { PropertyCoercer } from '../properties/PropertyCoercer.js'; import { idOf } from '../../document/utils/elements.js'; import type { TriggerOptions } from '../../commands/types.js'; @@ -25,14 +25,13 @@ export class TriggerChange { private readonly label: string; constructor( - private readonly doc: Doc, - private readonly edit: EditContext, + private readonly edit: EditSession, private readonly el: El, ) { this.before = is(el, 'bpmn:Event') ? new EventTrigger(el).read() : { trigger: 'none', opts: {} }; this.was = triggerOf(el); - this.conflicts = new Set(TriggerChange.messageFlowConflicts(doc, el).map((conflict) => idOf(conflict.flow))); - this.state = IdRenameService.stateOf(el); + this.conflicts = new Set(TriggerChange.messageFlowConflicts(edit.doc, el).map((conflict) => idOf(conflict.flow))); + this.state = IdRename.stateOf(el); this.label = kindLabel(el); } @@ -44,7 +43,7 @@ export class TriggerChange { * `on` is the element or the one a retype built in its place; no `trigger` means * only the kind changed. `strict` (set) checks a re-given non-interrupting flag first. */ - change(trigger: Trigger | undefined, given: TriggerOptions, cs: ChangeSet, { on = this.el, strict = false }: { on?: El; strict?: boolean } = {}): void { + change(trigger: Trigger | undefined, given: TriggerOptions, changes: ChangeSet, { on = this.el, strict = false }: { on?: El; strict?: boolean } = {}): void { let dropNonInterrupting = false; if (trigger !== undefined) { const merged: TriggerOptions = trigger === this.was ? { ...this.before.opts, ...given } : { ...given }; @@ -56,11 +55,11 @@ export class TriggerChange { } else if (strict) merged.nonInterrupting = true; } if (strict && merged.nonInterrupting) this.assertNonInterruptingAllowed(on, trigger); - for (const warning of new EventDefinitionService(this.doc, this.edit, on).apply(trigger, merged)) cs.warn(warning); + for (const warning of new EventDefinitions(this.edit).setTrigger(on, trigger, merged)) changes.warn(warning); } this.assertMessageFlowsFit(on); - if (dropNonInterrupting && trigger !== undefined) this.warnNonInterruptingDropped(cs, on, trigger); - new IdRenameService(this.doc, this.edit).recordState(on, this.state, this.label); + if (dropNonInterrupting && trigger !== undefined) this.warnNonInterruptingDropped(changes, on, trigger); + new IdRename(this.edit).recordState(on, this.state, this.label); } setNonInterrupting(value: boolean): void { @@ -91,7 +90,7 @@ export class TriggerChange { /** So the model never ends up with a message flow `connect` would refuse. */ private assertMessageFlowsFit(event: El): void { - const broken = TriggerChange.messageFlowConflicts(this.doc, event).filter((conflict) => !this.conflicts.has(idOf(conflict.flow))); + const broken = TriggerChange.messageFlowConflicts(this.edit.doc, event).filter((conflict) => !this.conflicts.has(idOf(conflict.flow))); const first = broken[0]; if (!first) return; const src = first.flow.get('sourceRef'); @@ -107,9 +106,9 @@ export class TriggerChange { ); } - private warnNonInterruptingDropped(cs: ChangeSet, el: El, trigger: Trigger): void { + private warnNonInterruptingDropped(changes: ChangeSet, el: El, trigger: Trigger): void { const position = is(el, 'bpmn:BoundaryEvent') ? 'boundary' : 'start'; - cs.warn({ + changes.warn({ code: 'W_PROPERTY_DROPPED', message: `nonInterrupting of ${idOf(el)} was dropped: ${trigger === 'none' ? 'untyped' : trigger} ${position} events are always interrupting`, element: idOf(el), @@ -128,7 +127,7 @@ export class TriggerChange { return; } if (is(el, 'bpmn:StartEvent')) { - const scope = this.doc.scopeOf(el); + const scope = this.edit.doc.scopeOf(el); if (!scope || !is(scope, 'bpmn:SubProcess') || !scope.get('triggeredByEvent')) { throw modelError('E_INVALID_TRIGGER', `Start event ${idOf(el)} is not inside an event sub-process and cannot be non-interrupting`, { element: idOf(el), diff --git a/src/domain/editing/events/ZeebeSubscription.ts b/src/domain/editing/events/ZeebeSubscription.ts index 81f04dba..d123af3e 100644 --- a/src/domain/editing/events/ZeebeSubscription.ts +++ b/src/domain/editing/events/ZeebeSubscription.ts @@ -2,12 +2,12 @@ import type { Doc } from '../../document/Doc.js'; import { modelError } from '../../document/utils/errors.js'; import { is, walk, type El } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; -import { kindLabel } from '../../document/utils/kinds.js'; +import { kindLabel } from '../../document/Kinds.js'; import { definitionsOf } from '../../document/utils/nested-slots.js'; import { idOf } from '../../document/utils/elements.js'; import { ExtensionInserter } from '../extensions/ExtensionInserter.js'; -import { NESTED_HOSTS } from '../NestedHosts.js'; -import { VendorElements } from '../VendorElements.js'; +import { NESTED_HOSTS } from '../properties/NestedHosts.js'; +import { VendorElements } from '../properties/VendorElements.js'; import type { EditExtensionElements } from '../../commands/types.js'; import { ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; @@ -24,17 +24,17 @@ export class ZeebeSubscription { } /** Undefined when the op is no `ext add zeebe:subscription` and stays where it is. */ - messageFor(owner: El, op: EditExtensionElements): El | undefined { - if (op.action !== 'add' || this.addedType(op) !== 'zeebe:subscription') return undefined; - const slot = op.slot ?? (op.type ? this.hosts.splitSlot(op.type)?.slot : undefined); + messageFor(owner: El, command: EditExtensionElements): El | undefined { + if (command.action !== 'add' || this.addedType(command) !== 'zeebe:subscription') return undefined; + const slot = command.slot ?? (command.type ? this.hosts.splitSlot(command.type)?.slot : undefined); if (slot !== undefined && !slot.startsWith('definition')) return undefined; const { catching, message } = this.caughtMessage(owner); if (!catching) return undefined; if (!message) { const id = idOf(owner); - throw modelError('E_WRONG_HOST', `${this.addedTypeText(op)} belongs on the bpmn:Message ${kindLabel(owner)} ${id} waits for (Camunda 8 reads the correlation key there), and ${id} has no message`, { + throw modelError('E_WRONG_HOST', `${this.addedTypeText(command)} belongs on the bpmn:Message ${kindLabel(owner)} ${id} waits for (Camunda 8 reads the correlation key there), and ${id} has no message`, { element: id, - hint: `Give it one first: \`bpmn set ${id} message=\` (in an apply batch with "refAs": "$msg" to name the message), then \`bpmn ext add ${id} ${this.addedTypeText(op)} correlationKey==\`.`, + hint: `Give it one first: \`bpmn set ${id} message=\` (in an apply batch with "refAs": "$msg" to name the message), then \`bpmn ext add ${id} ${this.addedTypeText(command)} correlationKey==\`.`, }); } return message; @@ -50,12 +50,12 @@ export class ZeebeSubscription { return `${what} goes to message ${idOf(message)}, which ${idOf(owner)} waits for: Camunda 8 reads the correlation key in the message's zeebe:subscription${shared}`; } - addedTypeText(op: EditExtensionElements): string { - return op.type ? (this.hosts.splitSlot(op.type)?.rest ?? op.type).split('/')[0]! : 'zeebe:subscription'; + addedTypeText(command: EditExtensionElements): string { + return command.type ? (this.hosts.splitSlot(command.type)?.rest ?? command.type).split('/')[0]! : 'zeebe:subscription'; } /** False when `key` is no zeebe correlationKey or the element waits for no message. */ - setCorrelationKey(el: El, key: string, value: string, cs: ChangeSet): boolean { + setCorrelationKey(el: El, key: string, value: string, changes: ChangeSet): boolean { const doc = this.doc; const idx = key.indexOf(':'); if (idx <= 0 || key.slice(idx + 1) !== 'correlationKey' || doc.namespaceUri(key.slice(0, idx)) !== ZEEBE_URI) return false; @@ -67,12 +67,12 @@ export class ZeebeSubscription { const sub = existing[0]! as unknown as Record; if (sub['correlationKey'] !== value) { sub['correlationKey'] = value; - cs.change(ChangeSet.entryOf(message, `${existing[0]!.$type} correlationKey=${value}`)); + changes.change(ChangeSet.entryOf(message, `${existing[0]!.$type} correlationKey=${value}`)); } } else { - cs.merge(new ExtensionInserter(doc).add(message, { op: 'ext', id: idOf(message), action: 'add', type, attrs: { correlationKey: value } })); + changes.merge(new ExtensionInserter(doc).add(message, { op: 'ext', id: idOf(message), action: 'add', type, attrs: { correlationKey: value } })); } - cs.note(this.note(el, message, key)); + changes.note(this.note(el, message, key)); doc.invalidate(); return true; } @@ -84,9 +84,9 @@ export class ZeebeSubscription { return def ? { catching: true, message: def.get('messageRef') } : { catching: false }; } - private addedType(op: EditExtensionElements): string | undefined { - if (op.type) return this.names.canonicalName((this.hosts.splitSlot(op.type)?.rest ?? op.type).split('/')[0]!); - const match = op.xml ? /^\s*<([A-Za-z_][\w.-]*):([A-Za-z_][\w.-]*)([^>]*)>/.exec(op.xml) : null; + private addedType(command: EditExtensionElements): string | undefined { + if (command.type) return this.names.canonicalName((this.hosts.splitSlot(command.type)?.rest ?? command.type).split('/')[0]!); + const match = command.xml ? /^\s*<([A-Za-z_][\w.-]*):([A-Za-z_][\w.-]*)([^>]*)>/.exec(command.xml) : null; if (!match) return undefined; const inline = new RegExp(`xmlns:${match[1]!.replace(/[.-]/g, '\\$&')}\\s*=\\s*["']([^"']*)["']`).exec(match[3] ?? '')?.[1]; return this.names.canonicalName(`${match[1]}:${match[2]}`, inline ? { [match[1]!]: inline } : {}); diff --git a/src/domain/editing/extensions/ExtensionElementService.ts b/src/domain/editing/extensions/ExtensionElementService.ts deleted file mode 100644 index 3291fc08..00000000 --- a/src/domain/editing/extensions/ExtensionElementService.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Doc } from '../../document/Doc.js'; -import type { EditContext } from '../../document/EditContext.js'; -import { usageError } from '../../document/utils/errors.js'; -import type { ChangeSet } from '../../document/ChangeSet.js'; -import { idOf } from '../../document/utils/elements.js'; -import { ExtensionInserter } from './ExtensionInserter.js'; -import { ExtensionRemover } from './ExtensionRemover.js'; -import { NESTED_HOSTS } from '../NestedHosts.js'; -import { ZeebeSubscription } from '../events/ZeebeSubscription.js'; -import type { EditExtensionElements } from '../../commands/types.js'; - -/** - * `ext add` / `ext remove`: vendor extension elements inside . - * Vendor content stays generic in the model; this knows just enough of its structure - * never to write a file the engines reject or ignore. - */ -export class ExtensionElementService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} - - /** Adds or removes extension elements; a correlation key goes to the message the event waits for, where Camunda 8 reads it. */ - apply(op: EditExtensionElements): ChangeSet { - const doc = this.doc; - let owner = this.edit.require(op.id); - if (op.action !== 'add' && op.action !== 'remove') throw usageError(`Unknown ext action "${String(op.action)}"; use add or remove`, { element: op.id }); - const notes: string[] = []; - const subscription = new ZeebeSubscription(doc); - const message = subscription.messageFor(owner, op); - if (message) { - notes.push(subscription.note(owner, message, `${subscription.addedTypeText(op)}`)); - const split = op.type ? NESTED_HOSTS.splitSlot(op.type) : undefined; - const { slot: _slot, ...rest } = op; - op = { ...rest, id: idOf(message), ...(split ? { type: split.rest } : {}) }; - owner = message; - } - const { host, op: inner } = NESTED_HOSTS.resolve(doc, this.edit, owner, NESTED_HOSTS.normalizeIndex(op), notes); - const cs = inner.action === 'add' ? new ExtensionInserter(doc).add(host, inner) : new ExtensionRemover(doc).remove(host, inner); - for (const note of notes) cs.note(note); - return cs; - } -} diff --git a/src/domain/editing/extensions/ExtensionElements.ts b/src/domain/editing/extensions/ExtensionElements.ts new file mode 100644 index 00000000..1554c4fa --- /dev/null +++ b/src/domain/editing/extensions/ExtensionElements.ts @@ -0,0 +1,39 @@ +import type { EditSession } from '../../document/EditSession.js'; +import { usageError } from '../../document/utils/errors.js'; +import type { ChangeSet } from '../../document/ChangeSet.js'; +import { idOf } from '../../document/utils/elements.js'; +import { ExtensionInserter } from './ExtensionInserter.js'; +import { ExtensionRemover } from './ExtensionRemover.js'; +import { NESTED_HOSTS } from '../properties/NestedHosts.js'; +import { ZeebeSubscription } from '../events/ZeebeSubscription.js'; +import type { EditExtensionElements } from '../../commands/types.js'; + +/** + * `ext add` / `ext remove`: vendor extension elements inside . + * Vendor content stays generic in the model; this knows just enough of its structure + * never to write a file the engines reject or ignore. + */ +export class ExtensionElements { + constructor(private readonly edit: EditSession) {} + + /** Adds or removes extension elements; a correlation key goes to the message the event waits for, where Camunda 8 reads it. */ + apply(command: EditExtensionElements): ChangeSet { + const doc = this.edit.doc; + let owner = this.edit.require(command.id); + if (command.action !== 'add' && command.action !== 'remove') throw usageError(`Unknown ext action "${String(command.action)}"; use add or remove`, { element: command.id }); + const notes: string[] = []; + const subscription = new ZeebeSubscription(doc); + const message = subscription.messageFor(owner, command); + if (message) { + notes.push(subscription.note(owner, message, `${subscription.addedTypeText(command)}`)); + const split = command.type ? NESTED_HOSTS.splitSlot(command.type) : undefined; + const { slot: _slot, ...rest } = command; + command = { ...rest, id: idOf(message), ...(split ? { type: split.rest } : {}) }; + owner = message; + } + const { host, command: inner } = NESTED_HOSTS.resolve(this.edit, owner, NESTED_HOSTS.normalizeIndex(command), notes); + const changes = inner.action === 'add' ? new ExtensionInserter(doc).add(host, inner) : new ExtensionRemover(doc).remove(host, inner); + for (const note of notes) changes.note(note); + return changes; + } +} diff --git a/src/domain/editing/extensions/ExtensionInserter.ts b/src/domain/editing/extensions/ExtensionInserter.ts index 7226e077..e0fbc95a 100644 --- a/src/domain/editing/extensions/ExtensionInserter.ts +++ b/src/domain/editing/extensions/ExtensionInserter.ts @@ -1,16 +1,16 @@ import type { Doc } from '../../document/Doc.js'; import { modelError, usageError } from '../../document/utils/errors.js'; -import { type Warning } from '../../document/CliError.js'; +import type { Finding } from '../../document/Finding.js'; import { is, type El } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; -import { coversProfileSubjects } from '../utils/covers.js'; +import { withUnserialised } from '../../document/utils/finding.js'; import { familyPath, isCamunda, ruleName } from '../utils/vendor-family.js'; import { EXTENSION_STRUCTURE } from './ExtensionStructure.js'; import { ExtensionLevel } from './ExtensionLevel.js'; import { ExtensionSnippetReader } from './ExtensionSnippetReader.js'; -import { NESTED_HOSTS } from '../NestedHosts.js'; +import { NESTED_HOSTS } from '../properties/NestedHosts.js'; import { SelectorParser, type Step } from './SelectorParser.js'; -import { VendorElements } from '../VendorElements.js'; +import { VendorElements } from '../properties/VendorElements.js'; import type { EditExtensionElements } from '../../commands/types.js'; import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; import { ZEEBE } from '../../document/vendor/ZeebeDescriptor.js'; @@ -42,17 +42,17 @@ export class ExtensionInserter { } /** Adds extension values; they are built and checked before anything in the document changes. */ - add(el: El, op: EditExtensionElements): ChangeSet { - const cs = new ChangeSet(); + add(el: El, command: EditExtensionElements): ChangeSet { + const changes = new ChangeSet(); this.assertCanHoldExtensions(el); - const hasXml = op.xml !== undefined && op.xml.trim() !== ''; - if (hasXml && ((op.attrs && Object.keys(op.attrs).length) || (op.body !== undefined && op.body !== ''))) { + const hasXml = command.xml !== undefined && command.xml.trim() !== ''; + if (hasXml && ((command.attrs && Object.keys(command.attrs).length) || (command.body !== undefined && command.body !== ''))) { throw usageError('--xml cannot be combined with attr=value or --body', { element: this.hosts.idOf(el), hint: 'Put everything into the XML snippet, or use attr=value --body .' }); } - if (!hasXml && !op.type) { + if (!hasXml && !command.type) { throw usageError('ext add needs --type (with --attr key=value / --body) or --xml ', { element: this.hosts.idOf(el) }); } - const steps = op.type ? this.selectors.parse(op.type, el) : []; + const steps = command.type ? this.selectors.parse(command.type, el) : []; const last = steps[steps.length - 1]; const out: Outcome[] = []; const notes: string[] = []; @@ -60,7 +60,7 @@ export class ExtensionInserter { let values: El[]; if (hasXml) { - values = this.snippets.parse(el, op.xml!, nested); + values = this.snippets.parse(el, command.xml!, nested); if (last) { const wanted = this.names.canonicalName(last.name); const wrong = values.filter((snippet) => this.names.typeOf(snippet) !== wanted); @@ -73,29 +73,29 @@ export class ExtensionInserter { for (const snippet of values) this.applyStepKey(snippet, last, el); } } else { - const attrs = { ...(op.attrs ?? {}) }; - if (last!.index !== undefined) this.selectors.fail(op.type!, 'an [index] only selects existing elements; give the attributes as attr=value', el); + const attrs = { ...(command.attrs ?? {}) }; + if (last!.index !== undefined) this.selectors.fail(command.type!, 'an [index] only selects existing elements; give the attributes as attr=value', el); if (last!.attr !== undefined) { if (attrs[last!.attr] !== undefined && attrs[last!.attr] !== last!.value) { throw modelError('E_INVALID_VALUE', `${last!.text} and ${last!.attr}=${attrs[last!.attr]} disagree`, { element: this.hosts.idOf(el) }); } attrs[last!.attr] = last!.value!; } - values = [this.names.createGeneric(el, last!.name, attrs, op.body, {}, nested)]; + values = [this.names.createGeneric(el, last!.name, attrs, command.body, {}, nested)]; } const level = this.parentLevel(el, steps.slice(0, -1), out, notes); - const drops: Warning[] = []; + const drops: Finding[] = []; for (const snippet of values) { - this.checkInsert(el, level.items(), level.type, snippet, !!op.replace); - this.insert(level, snippet, !!op.replace, out, notes, drops); - this.misplacedWarning(el, snippet, cs); + this.checkInsert(el, level.items(), level.type, snippet, !!command.replace); + this.insert(level, snippet, !!command.replace, out, notes, drops); + this.misplacedWarning(el, snippet, changes); } - for (const warning of drops) cs.warn(warning); + for (const warning of drops) changes.warn(warning); this.doc.invalidate(); - for (const note of notes) cs.note(note); - if (out.some((outcome) => outcome.verb !== 'unchanged')) cs.change(ChangeSet.entryOf(this.hosts.ownerOf(el), this.describeOutcomes(out.filter((outcome) => outcome.verb !== 'unchanged'), this.hosts.slotPrefix(el)))); - return cs; + for (const note of notes) changes.note(note); + if (out.some((outcome) => outcome.verb !== 'unchanged')) changes.change(ChangeSet.entryOf(this.hosts.ownerOf(el), this.describeOutcomes(out.filter((outcome) => outcome.verb !== 'unchanged'), this.hosts.slotPrefix(el)))); + return changes; } private assertCanHoldExtensions(el: El): void { @@ -152,7 +152,7 @@ export class ExtensionInserter { } /** Inserts a value at its level; a missing container is written with the prefix the file binds to its namespace. */ - private insert(level: ExtensionLevel, value: El, replace: boolean, out: Outcome[], notes: string[], drops: Warning[] = []): void { + private insert(level: ExtensionLevel, value: El, replace: boolean, out: Outcome[], notes: string[], drops: Finding[] = []): void { const typeName = this.names.typeOf(value); const where = level.parent ? ` in ${level.label}` : ''; const home = this.structure.homeAt(typeName, level.type); @@ -253,7 +253,7 @@ export class ExtensionInserter { return filed ? value.$type : `${this.names.pathOf(level.parent)}/${value.$type}`; } - private keyedDrop(level: ExtensionLevel, match: El[], value: El): Warning | undefined { + private keyedDrop(level: ExtensionLevel, match: El[], value: El): Finding | undefined { const old = match[0]!; const lost: string[] = []; const now = this.names.attrsOf(value); @@ -280,7 +280,7 @@ export class ExtensionInserter { }; } - private merge(level: ExtensionLevel, existing: El, incoming: El, notes: string[], drops: Warning[] = []): Outcome { + private merge(level: ExtensionLevel, existing: El, incoming: El, notes: string[], drops: Finding[] = []): Outcome { const raw = existing as unknown as Raw; const changes: string[] = []; const have = this.names.attrsOf(existing); @@ -359,7 +359,7 @@ export class ExtensionInserter { * Vendor content the engines never read where it was put. In a Camunda 8 file the profile's * W_C8_MISPLACED_EXTENSION replaces this warning for zeebe elements. */ - private misplacedWarning(host: El, snippet: El, cs: ChangeSet): void { + private misplacedWarning(host: El, snippet: El, changes: ChangeSet): void { if (!snippet.$parent || snippet.$parent !== host.get('extensionElements')) return; const typeName = this.names.typeOf(snippet); if (typeName.startsWith('zeebe:')) { @@ -367,15 +367,15 @@ export class ExtensionInserter { const zeebeType = ZEEBE.type(local); if (!zeebeType || ZEEBE.allowedOn(local, host) !== false) return; const hosts = zeebeType.allowedIn.filter((allowedType) => allowedType.startsWith('bpmn:')).map((allowedType) => allowedType.slice(5).replace(/^./, (firstChar) => firstChar.toLowerCase())); - cs.warn( - coversProfileSubjects( + changes.warn( + withUnserialised( { code: 'W_MISPLACED_EXTENSION', message: `${snippet.$type} on ${this.hosts.labelOf(host)} (${host.$type}) is not read by Camunda 8: it belongs on ${hosts.length > 5 ? `${hosts.slice(0, 5).join(', ')}, ...` : hosts.join(', ')}`, element: this.hosts.idOf(host), hint: `Remove it (\`bpmn ext remove ${this.hosts.idOf(host)} ${this.hosts.slotPrefix(host)}${snippet.$type}\`) and add it where it is read (\`bpmn kinds\` lists the zeebe elements per kind).`, }, - [`ext:${local}`], + { covers: [`ext:${local}`] }, ), ); return; @@ -383,7 +383,7 @@ export class ExtensionInserter { if (!isCamunda(typeName)) return; if (this.structure.nestedOnly(typeName)) { const containers = this.structure.containersOf(typeName); - cs.warn({ + changes.warn({ code: 'W_MISPLACED_EXTENSION', message: `${snippet.$type} was added at the top level of the extensionElements of ${this.hosts.labelOf(host)}, where the engines do not read it; it belongs inside ${containers.join(' / ')}`, element: this.hosts.idOf(host), @@ -392,7 +392,7 @@ export class ExtensionInserter { return; } if (ruleName(typeName) === 'camunda:field' && CAMUNDA.allowedOn(ruleName(typeName), host) === false) { - cs.warn({ + changes.warn({ code: 'W_MISPLACED_EXTENSION', message: `${snippet.$type} on ${this.hosts.labelOf(host)} (${host.$type}) is not read by the engines: field injection belongs to service, send and business rule tasks, a message event definition or a listener`, element: this.hosts.idOf(host), diff --git a/src/domain/editing/extensions/ExtensionLevel.ts b/src/domain/editing/extensions/ExtensionLevel.ts index 98722050..d6975cb2 100644 --- a/src/domain/editing/extensions/ExtensionLevel.ts +++ b/src/domain/editing/extensions/ExtensionLevel.ts @@ -2,7 +2,7 @@ import type { Doc } from '../../document/Doc.js'; import { addTo, many, removeFrom, type El } from '../../document/utils/moddle.js'; import { EXTENSION_STRUCTURE } from './ExtensionStructure.js'; import type { Step } from './SelectorParser.js'; -import type { VendorElements } from '../VendorElements.js'; +import type { VendorElements } from '../properties/VendorElements.js'; import { ruleName } from '../utils/vendor-family.js'; type Raw = Record; diff --git a/src/domain/editing/extensions/ExtensionRemover.ts b/src/domain/editing/extensions/ExtensionRemover.ts index c8d7242d..610cd372 100644 --- a/src/domain/editing/extensions/ExtensionRemover.ts +++ b/src/domain/editing/extensions/ExtensionRemover.ts @@ -4,9 +4,9 @@ import { is, many, removeFrom, type El } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; import { EXTENSION_STRUCTURE } from './ExtensionStructure.js'; import { ExtensionLevel } from './ExtensionLevel.js'; -import { NESTED_HOSTS } from '../NestedHosts.js'; +import { NESTED_HOSTS } from '../properties/NestedHosts.js'; import { SelectorParser, type Step } from './SelectorParser.js'; -import { VendorElements } from '../VendorElements.js'; +import { VendorElements } from '../properties/VendorElements.js'; import type { EditExtensionElements } from '../../commands/types.js'; export class ExtensionRemover { @@ -20,8 +20,8 @@ export class ExtensionRemover { this.selectors = new SelectorParser(this.hosts); } - remove(el: El, op: EditExtensionElements): ChangeSet { - const cs = new ChangeSet(); + remove(el: El, command: EditExtensionElements): ChangeSet { + const changes = new ChangeSet(); const container = el.get('extensionElements'); const list = container ? many(container, 'values') : []; const present = list.map((value) => value.$type); @@ -30,17 +30,17 @@ export class ExtensionRemover { } const top = new ExtensionLevel(this.doc, this.names, el); let victims: Array<{ el: El; level: ExtensionLevel }>; - if (op.index !== undefined) { - const victim = list[op.index]; + if (command.index !== undefined) { + const victim = list[command.index]; if (!victim) { - throw modelError('E_NO_EXTENSION', `${this.hosts.labelOf(el)} has no extension element at index ${op.index} (0..${list.length - 1})`, { + throw modelError('E_NO_EXTENSION', `${this.hosts.labelOf(el)} has no extension element at index ${command.index} (0..${list.length - 1})`, { element: this.hosts.idOf(el), hint: `Present: ${present.map((typeName, index) => `${index}: ${typeName}`).join(', ')}.`, }); } victims = [{ el: victim, level: top }]; - } else if (op.type) { - victims = this.select(el, top, this.selectors.parse(op.type, el), op.type); + } else if (command.type) { + victims = this.select(el, top, this.selectors.parse(command.type, el), command.type); } else { throw usageError('ext remove needs --type or --index ', { element: this.hosts.idOf(el) }); } @@ -48,13 +48,13 @@ export class ExtensionRemover { for (const { el: victim, level } of victims) { level.remove(victim); texts.push(`${this.names.itemLabel(victim)}${level.parent ? ` from ${level.label}` : ''}`); - this.pruneEmpty(el, level, cs); + this.pruneEmpty(el, level, changes); } const extensionElements = el.get('extensionElements'); if (extensionElements && !many(extensionElements, 'values').length) el.set('extensionElements', undefined); this.doc.invalidate(); - cs.change(ChangeSet.entryOf(this.hosts.ownerOf(el), `ext removed ${texts.map((text) => this.hosts.slotPrefix(el) + text).join(', ')}`)); - return cs; + changes.change(ChangeSet.entryOf(this.hosts.ownerOf(el), `ext removed ${texts.map((text) => this.hosts.slotPrefix(el) + text).join(', ')}`)); + return changes; } /** @@ -97,7 +97,7 @@ export class ExtensionRemover { } /** Bottom up, e.g. a camunda:inputOutput left without parameters. */ - private pruneEmpty(host: El, level: ExtensionLevel, cs: ChangeSet): void { + private pruneEmpty(host: El, level: ExtensionLevel, changes: ChangeSet): void { let cur: El | undefined = level.parent; while (cur && this.names.isGeneric(cur)) { if (this.names.kidsOf(cur).length || this.names.bodyOf(cur) !== undefined || Object.keys(this.names.attrsOf(cur)).length) return; @@ -106,11 +106,11 @@ export class ExtensionRemover { if (!parent) return; if (is(parent, 'bpmn:ExtensionElements')) { removeFrom(parent, 'values', cur); - cs.note(`removed the empty ${cur.$type} of ${this.hosts.labelOf(host)}`); + changes.note(`removed the empty ${cur.$type} of ${this.hosts.labelOf(host)}`); return; } level.inside(parent).remove(cur); - cs.note(`removed the empty ${cur.$type} of ${this.hosts.labelOf(host)}`); + changes.note(`removed the empty ${cur.$type} of ${this.hosts.labelOf(host)}`); cur = parent; } } diff --git a/src/domain/editing/extensions/ExtensionSnippetReader.ts b/src/domain/editing/extensions/ExtensionSnippetReader.ts index 3d1688f1..3c6328a4 100644 --- a/src/domain/editing/extensions/ExtensionSnippetReader.ts +++ b/src/domain/editing/extensions/ExtensionSnippetReader.ts @@ -1,9 +1,9 @@ import { modelError } from '../../document/utils/errors.js'; import type { El } from '../../document/utils/moddle.js'; import { decodeXml } from '../../document/utils/text-reader.js'; -import type { NestedHosts } from '../NestedHosts.js'; +import type { NestedHosts } from '../properties/NestedHosts.js'; import { SnippetError } from './SnippetError.js'; -import type { VendorElements } from '../VendorElements.js'; +import type { VendorElements } from '../properties/VendorElements.js'; interface RawNode { name: string; diff --git a/src/domain/editing/extensions/SelectorParser.ts b/src/domain/editing/extensions/SelectorParser.ts index 27f8cc4d..2b89c280 100644 --- a/src/domain/editing/extensions/SelectorParser.ts +++ b/src/domain/editing/extensions/SelectorParser.ts @@ -1,6 +1,6 @@ import { modelError } from '../../document/utils/errors.js'; import type { El } from '../../document/utils/moddle.js'; -import type { NestedHosts } from '../NestedHosts.js'; +import type { NestedHosts } from '../properties/NestedHosts.js'; export interface Step { name: string; diff --git a/src/domain/editing/IdRenameService.ts b/src/domain/editing/ids/IdRename.ts similarity index 72% rename from src/domain/editing/IdRenameService.ts rename to src/domain/editing/ids/IdRename.ts index fb476710..588166a7 100644 --- a/src/domain/editing/IdRenameService.ts +++ b/src/domain/editing/ids/IdRename.ts @@ -1,26 +1,22 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import type { ChangeSet } from '../document/ChangeSet.js'; -import { followDiIds } from '../diagram/utils/di-ids.js'; -import { cutAt, MAX_ID } from '../document/utils/slug.js'; -import { IdRegistry } from '../document/ids/IdRegistry.js'; -import { kindRequest, type IdRequest } from '../document/utils/requests.js'; -import type { IdState } from '../document/ids/IdStyle.js'; -import { kindLabel, kindOf, triggerOf } from '../document/utils/kinds.js'; -import { is, walk, type El } from '../document/utils/moddle.js'; -import { CAMUNDA } from '../document/vendor/CamundaDescriptor.js'; -import { FlowIdFollower } from './flows/FlowIdFollower.js'; -import { descriptorOf, idOf } from '../document/utils/elements.js'; +import type { EditSession } from '../../document/EditSession.js'; +import type { ChangeSet } from '../../document/ChangeSet.js'; +import { followDiIds } from '../../diagram/utils/di-ids.js'; +import { cutAt, MAX_ID } from '../../document/utils/slug.js'; +import { IdRegistry } from '../../document/ids/IdRegistry.js'; +import { kindRequest, type IdRequest } from '../../document/utils/requests.js'; +import type { IdState } from '../../document/ids/IdStyle.js'; +import { kindLabel, kindOf, triggerOf } from '../../document/Kinds.js'; +import { is, walk, type El } from '../../document/utils/moddle.js'; +import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; +import { FlowIdFollower } from '../connections/FlowIdFollower.js'; +import { descriptorOf, idOf } from '../../document/utils/elements.js'; /** * Renames ids (`set id=`) and handles ids a retype or trigger change leaves with a stale * prefix: a batch-generated id follows the new type, any other keeps its id with a warning. */ -export class IdRenameService { - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) {} +export class IdRename { + constructor(private readonly edit: EditSession) {} static stateOf(el: El): IdState { const trigger = triggerOf(el); @@ -33,7 +29,7 @@ export class IdRenameService { * Id-valued attributes of vendor extension elements, plain strings in the model, are re-pointed too. */ rename(el: El, newId: string): void { - const doc = this.doc; + const doc = this.edit.doc; const old = idOf(el); if (old === newId) return; this.edit.allocator.claim(newId); @@ -68,7 +64,6 @@ export class IdRenameService { * not the new one's. */ recordState(el: El, before: IdState, wasLabel: string): void { - const doc = this.doc; const style = this.edit.idStyle; if (!style.declared) return; const id = idOf(el); @@ -78,40 +73,39 @@ export class IdRenameService { this.edit.recordIdState(id, before, wasLabel); return; } - const stale = style.stalePrefix(base, before, IdRenameService.stateOf(el), IdRenameService.requestOf(el)); + const stale = style.stalePrefix(base, before, IdRename.stateOf(el), IdRename.requestOf(el)); if (!stale) return; const wanted = cutAt(stale.suggested, MAX_ID); const next = allocator.regenerated(id, wanted, style.suffixSep(kindOf(el)?.kind ?? el.$type)); - const follower = new FlowIdFollower(doc, this.edit); - const flows = follower.namedAfter(el).map((flow) => ({ flow, was: `${idOf(flow.get('sourceRef'))} -> ${idOf(flow.get('targetRef'))}` })); + const flows = new FlowIdFollower(this.edit).namedAfter(el).map((flow) => ({ flow, was: `${idOf(flow.get('sourceRef'))} -> ${idOf(flow.get('targetRef'))}` })); this.rename(el, next); allocator.markGenerated(next, wanted); this.edit.recordRegenerated(id, next, undefined, el); - for (const { flow, was } of flows) follower.followRenamedEnd(flow, was); + for (const { flow, was } of flows) new FlowIdFollower(this.edit).followRenamedEnd(flow, was); } /** * Checked against the final state, so an element a later op renamed, removed or made * fit again gets no warning. Ids are API: the warning suggests a rename, never does one. */ - reportStalePrefixes(cs: ChangeSet): void { - const doc = this.doc; + reportStalePrefixes(changes: ChangeSet): void { + const doc = this.edit.doc; const states = this.edit.takeIdStates(); if (!states.size) return; const style = this.edit.idStyle; const hinted = new IdRegistry(doc.ids.values()); for (const [id, { before, wasLabel }] of states) { const el = doc.get(id); - const stale = el && style.stalePrefix(id, before, IdRenameService.stateOf(el), IdRenameService.requestOf(el)); + const stale = el && style.stalePrefix(id, before, IdRename.stateOf(el), IdRename.requestOf(el)); if (!el || !stale) continue; const sep = style.suffixSep(kindOf(el)?.kind ?? el.$type); const suggested = this.edit.allocator.suffixed(stale.suggested, sep, (taken) => hinted.has(taken), () => stale.suggested); hinted.claim(suggested); const rule = `element-id-naming (${this.edit.conventions?.source ?? 'the host'})`; - const expects = `${stale.heads.join(' or ')} for ${IdRenameService.aKind(kindLabel(el))}`; - cs.warn({ + const expects = `${stale.heads.join(' or ')} for ${IdRename.aKind(kindLabel(el))}`; + changes.warn({ code: 'W_ID_PREFIX_STALE', - message: stale.was !== undefined ? `${id} keeps its id, whose prefix ${stale.was} is that of ${IdRenameService.aKind(wasLabel)}; ${rule} expects ${expects}` : `${id} keeps its id, which ${rule} did not check on ${IdRenameService.aKind(wasLabel)}; it expects ${expects}`, + message: stale.was !== undefined ? `${id} keeps its id, whose prefix ${stale.was} is that of ${IdRename.aKind(wasLabel)}; ${rule} expects ${expects}` : `${id} keeps its id, which ${rule} did not check on ${IdRename.aKind(wasLabel)}; it expects ${expects}`, element: id, hint: `Code, tests and links that use the id keep working; to follow the convention: \`bpmn set ${id} id=${suggested}\`.`, }); diff --git a/src/domain/document/DecisionLinkReader.ts b/src/domain/editing/properties/DecisionLinkReader.ts similarity index 89% rename from src/domain/document/DecisionLinkReader.ts rename to src/domain/editing/properties/DecisionLinkReader.ts index 3d542b68..14f46573 100644 --- a/src/domain/document/DecisionLinkReader.ts +++ b/src/domain/editing/properties/DecisionLinkReader.ts @@ -1,8 +1,8 @@ -import type { Doc } from './Doc.js'; -import { is, type El } from './utils/moddle.js'; -import { textValue } from './utils/text-value.js'; -import { OPERATON_URI, C7_URIS } from './vendor/CamundaDescriptor.js'; -import { ZEEBE_URI } from './vendor/ZeebeDescriptor.js'; +import type { Doc } from '../../document/Doc.js'; +import { is, type El } from '../../document/utils/moddle.js'; +import { textValue } from '../../document/utils/text-value.js'; +import { OPERATON_URI, C7_URIS } from '../../document/vendor/CamundaDescriptor.js'; +import { ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; export interface DecisionLink { /** the id of the decision in the DMN file */ diff --git a/src/domain/editing/DecisionLinkWriter.ts b/src/domain/editing/properties/DecisionLinkWriter.ts similarity index 66% rename from src/domain/editing/DecisionLinkWriter.ts rename to src/domain/editing/properties/DecisionLinkWriter.ts index 7050c5d0..44f5d85c 100644 --- a/src/domain/editing/DecisionLinkWriter.ts +++ b/src/domain/editing/properties/DecisionLinkWriter.ts @@ -1,14 +1,13 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError } from '../document/utils/errors.js'; -import { kindLabel } from '../document/utils/kinds.js'; -import { addTo, is, many, removeFrom, walk, type El } from '../document/utils/moddle.js'; -import type { ChangeSet } from '../document/ChangeSet.js'; -import { coversProfileSubjects } from './utils/covers.js'; -import { textValue } from '../document/utils/text-value.js'; -import { DecisionLinkReader } from '../document/DecisionLinkReader.js'; -import { CAMUNDA_URI, OPERATON_URI } from '../document/vendor/CamundaDescriptor.js'; -import { ZEEBE_URI } from '../document/vendor/ZeebeDescriptor.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError } from '../../document/utils/errors.js'; +import { kindLabel } from '../../document/Kinds.js'; +import { addTo, is, many, removeFrom, shownId, walk, type El } from '../../document/utils/moddle.js'; +import type { ChangeSet } from '../../document/ChangeSet.js'; +import { withUnserialised } from '../../document/utils/finding.js'; +import { textValue } from '../../document/utils/text-value.js'; +import { DecisionLinkReader } from './DecisionLinkReader.js'; +import { CAMUNDA_URI, OPERATON_URI } from '../../document/vendor/CamundaDescriptor.js'; +import { ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; type EngineSpelling = { kind: 'c7'; uri: string; prefix: string } | { kind: 'c8'; prefix: string }; @@ -18,14 +17,7 @@ type EngineSpelling = { kind: 'c7'; uri: string; prefix: string } | { kind: 'c8' * refuse the unprefixed attribute against the BPMN schema. */ export class DecisionLinkWriter { - private readonly reader: DecisionLinkReader; - - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) { - this.reader = new DecisionLinkReader(doc); - } + constructor(private readonly edit: EditSession) {} /** * Writes the decision link of a business rule task in the platform's spelling. The task's own @@ -35,16 +27,17 @@ export class DecisionLinkWriter { * namespace first. Camunda 8 keeps an existing zeebe:calledDecision's resultVariable; its * missing-resultVariable warning yields to the Camunda 8 profile's when that runs. */ - write(el: El, value: string, cs: ChangeSet): string { - const doc = this.doc; + write(el: El, value: string, changes: ChangeSet): string { + const doc = this.edit.doc; + const reader = new DecisionLinkReader(doc); if (!is(el, 'bpmn:BusinessRuleTask')) { - throw modelError('E_UNKNOWN_KEY', `Unknown key "calledDecision" for ${kindLabel(el)} ${this.idOf(el)} (the decision link of a business rule task)`, { - element: this.idOf(el), - hint: `Only a businessRuleTask calls a decision: \`bpmn retype ${this.idOf(el)} businessRuleTask\` first. An unprefixed calledDecision="..." attribute on another element is removed with \`bpmn set ${this.idOf(el)} calledDecision=\`.`, + throw modelError('E_UNKNOWN_KEY', `Unknown key "calledDecision" for ${kindLabel(el)} ${shownId(el)} (the decision link of a business rule task)`, { + element: shownId(el), + hint: `Only a businessRuleTask calls a decision: \`bpmn retype ${shownId(el)} businessRuleTask\` first. An unprefixed calledDecision="..." attribute on another element is removed with \`bpmn set ${shownId(el)} calledDecision=\`.`, }); } const platform = this.edit.platform(); - const ownLink = this.reader.linkOf(el); + const ownLink = reader.linkOf(el); const removed: string[] = []; const dropAttr = (key: string): void => { if (el.$attrs && Object.prototype.hasOwnProperty.call(el.$attrs, key)) { @@ -53,12 +46,12 @@ export class DecisionLinkWriter { } }; const dropC7 = (keep?: string): void => { - for (const [key] of this.reader.c7DecisionRefs(el)) if (key !== keep) dropAttr(key); + for (const [key] of reader.c7DecisionRefs(el)) if (key !== keep) dropAttr(key); }; const dropZeebe = (keep?: El): void => { const container = el.get('extensionElements'); let dropped = false; - for (const call of this.reader.zeebeCalls(el)) { + for (const call of reader.zeebeCalls(el)) { if (call === keep || !container) continue; removeFrom(container, 'values', call); removed.push(call.$type); @@ -105,17 +98,17 @@ export class DecisionLinkWriter { const prefix = operatonOnly ? this.prefixFor(OPERATON_URI, 'operaton') : this.prefixFor(CAMUNDA_URI, 'camunda'); writeC7(`${prefix}:decisionRef`); } else if (platform === 'c8') { - const call = writeZeebe(this.reader.zeebeCalls(el)[0], 'zeebe'); + const call = writeZeebe(reader.zeebeCalls(el)[0], 'zeebe'); if (!textValue((call as unknown as Record)['resultVariable'])) { - cs.warn( - coversProfileSubjects( + changes.warn( + withUnserialised( { code: 'W_DECISION_RESULT_VARIABLE', - message: `${this.idOf(el)} calls decision ${value} without a resultVariable; Camunda 8 requires one to deploy`, - element: this.idOf(el), - hint: `Name the variable that receives the result: \`bpmn ext add ${this.idOf(el)} ${call.$type} decisionId=${value} resultVariable= --replace\`.`, + message: `${shownId(el)} calls decision ${value} without a resultVariable; Camunda 8 requires one to deploy`, + element: shownId(el), + hint: `Name the variable that receives the result: \`bpmn ext add ${shownId(el)} ${call.$type} decisionId=${value} resultVariable= --replace\`.`, }, - ['decision:resultVariable'], + { covers: ['decision:resultVariable'] }, ), ); } @@ -124,13 +117,13 @@ export class DecisionLinkWriter { * without an engine: the task's own engine spelling, else the one the file's links share, * else the unprefixed calledDecision; no engine reads it, so no resultVariable warning */ - const ownC7 = this.reader.c7DecisionRefs(el)[0]?.[0]; - const ownCall = ownC7 ? undefined : this.reader.zeebeCalls(el).find((call) => textValue((call as unknown as Record)['decisionId'])); + const ownC7 = reader.c7DecisionRefs(el)[0]?.[0]; + const ownCall = ownC7 ? undefined : reader.zeebeCalls(el).find((call) => textValue((call as unknown as Record)['decisionId'])); const follow = ownLink ? undefined : this.fileEngineSpelling(el); if (ownC7) writeC7(ownC7); else if (ownCall) writeZeebe(ownCall, 'zeebe'); else if (follow?.kind === 'c7') writeC7(`${this.prefixFor(follow.uri, follow.prefix)}:decisionRef`); - else if (follow?.kind === 'c8') writeZeebe(this.reader.zeebeCalls(el)[0], follow.prefix); + else if (follow?.kind === 'c8') writeZeebe(reader.zeebeCalls(el)[0], follow.prefix); else { dropC7(); dropZeebe(); @@ -144,25 +137,26 @@ export class DecisionLinkWriter { } private prefixFor(uri: string, usual: string): string { - const prefix = this.doc.prefixFor(uri) ?? usual; - this.doc.declareNamespace(prefix, uri); + const prefix = this.edit.doc.prefixFor(uri) ?? usual; + this.edit.doc.declareNamespace(prefix, uri); return prefix; } /** The engine spelling all the file's other decision links share, if they share exactly one. */ private fileEngineSpelling(el: El): EngineSpelling | undefined { + const reader = new DecisionLinkReader(this.edit.doc); const seen = new Map(); - for (const task of walk(this.doc.definitions, { bpmnOnly: true })) { + for (const task of walk(this.edit.doc.definitions, { bpmnOnly: true })) { if (task === el || !is(task, 'bpmn:BusinessRuleTask')) continue; - for (const [key] of this.reader.c7DecisionRefs(task)) { + for (const [key] of reader.c7DecisionRefs(task)) { const prefix = key.slice(0, key.indexOf(':')); - const uri = this.reader.uriOf(task, prefix); + const uri = reader.uriOf(task, prefix); seen.set(uri ? `c7 ${uri}` : key, uri ? { kind: 'c7', uri, prefix } : undefined); } - for (const call of this.reader.zeebeCalls(task)) { + for (const call of reader.zeebeCalls(task)) { if (!textValue((call as unknown as Record)['decisionId'])) continue; const prefix = call.$type.split(':')[0]!; - const declared = ((call.$descriptor as { ns?: { uri?: string } }).ns?.uri ?? this.reader.uriOf(task, prefix)) === ZEEBE_URI; + const declared = ((call.$descriptor as { ns?: { uri?: string } }).ns?.uri ?? reader.uriOf(task, prefix)) === ZEEBE_URI; seen.set(declared ? 'zeebe' : call.$type, declared ? { kind: 'c8', prefix } : undefined); } for (const key of ['calledDecision', 'calledElement']) if (textValue(task.$attrs?.[key])) seen.set(key, undefined); @@ -170,8 +164,4 @@ export class DecisionLinkWriter { } return [...seen.values()][0]; } - - private idOf(el: El): string { - return el.get('id') ?? '?'; - } } diff --git a/src/domain/editing/NestedHosts.ts b/src/domain/editing/properties/NestedHosts.ts similarity index 58% rename from src/domain/editing/NestedHosts.ts rename to src/domain/editing/properties/NestedHosts.ts index 8c1a6200..aa640d80 100644 --- a/src/domain/editing/NestedHosts.ts +++ b/src/domain/editing/properties/NestedHosts.ts @@ -1,12 +1,11 @@ -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import type { El } from '../document/utils/moddle.js'; -import { kindLabel } from '../document/utils/kinds.js'; -import { idOf } from '../document/utils/elements.js'; -import { NESTED_SLOTS, parseSlotRef, resolveNested, SLOT_TEXT, slotsOf, type NestedSlot } from '../document/utils/nested-slots.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import type { El } from '../../document/utils/moddle.js'; +import { kindLabel } from '../../document/Kinds.js'; +import { idOf } from '../../document/utils/elements.js'; +import { NESTED_SLOTS, parseSlotRef, resolveNested, SLOT_TEXT, slotsOf, type NestedSlot } from '../../document/utils/nested-slots.js'; import { VendorElements } from './VendorElements.js'; -import type { EditExtensionElements } from '../commands/types.js'; +import type { EditExtensionElements } from '../../commands/types.js'; /** * Extension elements on nested elements without an id (event definition, loop, condition), @@ -44,15 +43,15 @@ export class NestedHosts { } /** The ops JSON may give the `ext list` index of a remove (`loop.2`) as "type". */ - normalizeIndex(op: EditExtensionElements): EditExtensionElements { - if (op.action !== 'remove' || op.index !== undefined || op.type === undefined) return op; - const ref = this.indexRef(op.type); - if (!ref) return op; - if (ref.slot !== undefined && op.slot !== undefined && op.slot !== ref.slot) { - throw usageError(`"slot": "${op.slot}" and the type prefix ${ref.slot}. disagree`, { element: op.id, hint: 'Give the slot once: as the prefix (loop.0) or as "slot".' }); + normalizeIndex(command: EditExtensionElements): EditExtensionElements { + if (command.action !== 'remove' || command.index !== undefined || command.type === undefined) return command; + const ref = this.indexRef(command.type); + if (!ref) return command; + if (ref.slot !== undefined && command.slot !== undefined && command.slot !== ref.slot) { + throw usageError(`"slot": "${command.slot}" and the type prefix ${ref.slot}. disagree`, { element: command.id, hint: 'Give the slot once: as the prefix (loop.0) or as "slot".' }); } - const { type: _type, ...rest } = op; - const slot = ref.slot ?? op.slot; + const { type: _type, ...rest } = command; + const slot = ref.slot ?? command.slot; return { ...rest, index: ref.index, ...(slot !== undefined ? { slot: slot as EditExtensionElements['slot'] } : {}) }; } @@ -60,13 +59,13 @@ export class NestedHosts { * Adding a zeebe:loopCharacteristics to an activity without loop creates a parallel * multi-instance loop for it, like `set loop.=` does. */ - resolve(doc: Doc, edit: EditContext, owner: El, op: EditExtensionElements, notes: string[] = []): { host: El; op: EditExtensionElements } { - const split = op.type ? this.splitSlot(op.type) : undefined; - if (split && op.slot && op.slot !== split.slot) { - throw usageError(`"slot": "${op.slot}" and the type prefix ${split.slot}. disagree`, { element: idOf(owner), hint: 'Give the slot once: as the type prefix (loop.camunda:...) or as "slot".' }); + resolve(edit: EditSession, owner: El, command: EditExtensionElements, notes: string[] = []): { host: El; command: EditExtensionElements } { + const split = command.type ? this.splitSlot(command.type) : undefined; + if (split && command.slot && command.slot !== split.slot) { + throw usageError(`"slot": "${command.slot}" and the type prefix ${split.slot}. disagree`, { element: idOf(owner), hint: 'Give the slot once: as the type prefix (loop.camunda:...) or as "slot".' }); } - const slotText: string | undefined = split?.slot ?? op.slot; - if (slotText === undefined) return { host: owner, op }; + const slotText: string | undefined = split?.slot ?? command.slot; + if (slotText === undefined) return { host: owner, command }; const id = idOf(owner); const ref = parseSlotRef(slotText); if (!ref) { @@ -79,11 +78,11 @@ export class NestedHosts { hint: 'definition. addresses the event definition of an event, loop. the loop characteristics of an activity, condition. the condition expression of a sequence flow or conditional event.', }); } - const what = split?.rest ?? (op.index !== undefined ? String(op.index) : (op.type ?? '')); - let nested = resolveNested(owner, ref, `${ref.text}.${what}`, (prefix) => `bpmn ext ${op.action} ${id} '${prefix}.${what}'${op.action === 'add' ? ' ...' : ''}`); - const zeebeLoop = (op.type !== undefined && new VendorElements(doc, this).canonicalName(split?.rest ?? op.type) === 'zeebe:loopCharacteristics') || (op.xml !== undefined && /^\s*<[\w.-]+:loopCharacteristics[\s/>]/.test(op.xml) && op.type === undefined); - if (!nested && op.action === 'add' && slot === 'loop' && zeebeLoop && !owner.get('loopCharacteristics')) { - nested = doc.moddle.create('bpmn:MultiInstanceLoopCharacteristics'); + const what = split?.rest ?? (command.index !== undefined ? String(command.index) : (command.type ?? '')); + let nested = resolveNested(owner, ref, `${ref.text}.${what}`, (prefix) => `bpmn ext ${command.action} ${id} '${prefix}.${what}'${command.action === 'add' ? ' ...' : ''}`); + const zeebeLoop = (command.type !== undefined && new VendorElements(edit.doc, this).canonicalName(split?.rest ?? command.type) === 'zeebe:loopCharacteristics') || (command.xml !== undefined && /^\s*<[\w.-]+:loopCharacteristics[\s/>]/.test(command.xml) && command.type === undefined); + if (!nested && command.action === 'add' && slot === 'loop' && zeebeLoop && !owner.get('loopCharacteristics')) { + nested = edit.doc.moddle.create('bpmn:MultiInstanceLoopCharacteristics'); nested.$parent = owner; owner.set('loopCharacteristics', nested); notes.push(`${id}: created a parallel multi-instance loop for loop.${split?.rest ?? 'zeebe:loopCharacteristics'} (\`bpmn set ${id} loop=sequential\` runs the items one after the other)`); @@ -97,14 +96,14 @@ export class NestedHosts { : `make it a loop first: \`bpmn set ${id} loop=parallel\` (or sequential, or 'loop.camunda:collection=\${items}')`, condition: `give it a condition first: \`bpmn set ${id} 'condition=\${...}'\` (conditional events: when=)`, }; - throw modelError(op.action === 'remove' ? 'E_NO_EXTENSION' : 'E_NO_NESTED_ELEMENT', `${kindLabel(owner)} ${id} has no ${SLOT_TEXT[slot]}${op.action === 'remove' ? ', so no extension elements there' : ' to hold extension elements'}`, { + throw modelError(command.action === 'remove' ? 'E_NO_EXTENSION' : 'E_NO_NESTED_ELEMENT', `${kindLabel(owner)} ${id} has no ${SLOT_TEXT[slot]}${command.action === 'remove' ? ', so no extension elements there' : ' to hold extension elements'}`, { element: id, - hint: op.action === 'remove' ? 'Nothing to remove; `bpmn ext list ` shows what is there.' : `${first[slot][0]!.toUpperCase()}${first[slot].slice(1)}.`, + hint: command.action === 'remove' ? 'Nothing to remove; `bpmn ext list ` shows what is there.' : `${first[slot][0]!.toUpperCase()}${first[slot].slice(1)}.`, }); } this.nested.set(nested, { owner, slot, prefix: ref.text }); - const { slot: _slot, ...rest } = op; - return { host: nested, op: { ...rest, ...(split ? { type: split.rest } : {}) } }; + const { slot: _slot, ...rest } = command; + return { host: nested, command: { ...rest, ...(split ? { type: split.rest } : {}) } }; } /** `2`, `loop.2`, `definition[1].0`: an index from `ext list`. */ diff --git a/src/domain/editing/NestedSlots.ts b/src/domain/editing/properties/NestedSlots.ts similarity index 72% rename from src/domain/editing/NestedSlots.ts rename to src/domain/editing/properties/NestedSlots.ts index 514fc661..5b691d0d 100644 --- a/src/domain/editing/NestedSlots.ts +++ b/src/domain/editing/properties/NestedSlots.ts @@ -1,16 +1,15 @@ import { PropertyCoercer } from './PropertyCoercer.js'; -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError } from '../document/utils/errors.js'; -import { IdRenameService } from './IdRenameService.js'; -import { kindLabel, kindOf, TRIGGER_TYPES } from '../document/utils/kinds.js'; -import { is, many, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { descriptorOf, idOf, isEl, typeLabel } from '../document/utils/elements.js'; -import { parseSlotRef, resolveNested, slotsOf, SLOT_TEXT, type NestedSlot } from '../document/utils/nested-slots.js'; -import { CAMUNDA } from '../document/vendor/CamundaDescriptor.js'; -import { VendorDescriptor } from '../document/vendor/VendorDescriptor.js'; -import { ZEEBE, ZEEBE_URI } from '../document/vendor/ZeebeDescriptor.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError } from '../../document/utils/errors.js'; +import { IdRename } from '../ids/IdRename.js'; +import { kindLabel, kindOf, TRIGGERS } from '../../document/Kinds.js'; +import { is, many, typeLabel, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { descriptorOf, idOf, isEl } from '../../document/utils/elements.js'; +import { parseSlotRef, resolveNested, slotsOf, SLOT_TEXT, type NestedSlot } from '../../document/utils/nested-slots.js'; +import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; +import { VendorDescriptor } from '../../document/vendor/VendorDescriptor.js'; +import { ZEEBE, ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; /** Keys of a nested element that have their own set key (or are generated). */ const NESTED_BLOCKED: Record = { @@ -23,46 +22,39 @@ const NESTED_BLOCKED: Record = { * descriptors; the nested element is created where a key needs it. */ export class NestedSlots { - private readonly coercer: PropertyCoercer; - - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) { - this.coercer = new PropertyCoercer(doc, edit); - } + constructor(private readonly edit: EditSession) {} /** Other vendors' attributes are accepted too, unchecked. */ keysOf(slot: NestedSlot, target: El): string[] { - const keys = ['id', ...this.coercer.genericKeys(target).filter((genericKey) => !NESTED_BLOCKED[genericKey]), ...CAMUNDA.attrsFor(target).map((attr) => attr.name)]; + const keys = ['id', ...PropertyCoercer.genericKeys(this.edit.doc, target).filter((genericKey) => !NESTED_BLOCKED[genericKey]), ...CAMUNDA.attrsFor(target).map((attr) => attr.name)]; return [...new Set(keys)].map((slotKey) => `${slot}.${slotKey}`); } - private create(el: El, slot: NestedSlot, key: string, cs: ChangeSet): El { + private create(el: El, slot: NestedSlot, key: string, changes: ChangeSet): El { const id = idOf(el); - const local = this.coercer.camundaLocal(key); + const local = new PropertyCoercer(this.edit).camundaLocal(key); if (slot === 'loop') { - const multi = this.doc.moddle.create('bpmn:MultiInstanceLoopCharacteristics'); + const multi = this.edit.doc.moddle.create('bpmn:MultiInstanceLoopCharacteristics'); this.checkKey(el, slot, multi, key); multi.$parent = el; el.set('loopCharacteristics', multi); - cs.note(`${id}: created a parallel multi-instance loop for loop.${key}`); + changes.note(`${id}: created a parallel multi-instance loop for loop.${key}`); return multi; } if (slot === 'condition' && is(el, 'bpmn:SequenceFlow') && local === 'resource') { - const expr = this.doc.moddle.create('bpmn:FormalExpression'); + const expr = this.edit.doc.moddle.create('bpmn:FormalExpression'); expr.$parent = el; el.set('conditionExpression', expr); const source = el.get('sourceRef'); if (source?.get('default') === el) { source.set('default', undefined); - cs.change(ChangeSet.entryOf(el, `no longer the default flow of ${idOf(source)}`)); + changes.change(ChangeSet.entryOf(el, `no longer the default flow of ${idOf(source)}`)); } return expr; } if (slot === 'definition') { const name = local ? `camunda:${local}` : undefined; - const trigger = name ? (kindOf(el)?.triggers ?? []).find((candidate) => candidate !== 'none' && CAMUNDA.attrAppliesTo(name, TRIGGER_TYPES[candidate])) : undefined; + const trigger = name ? (kindOf(el)?.triggers ?? []).find((candidate) => candidate !== 'none' && CAMUNDA.attrAppliesTo(name, TRIGGERS[candidate].definition)) : undefined; throw modelError('E_NO_NESTED_ELEMENT', `${kindLabel(el)} ${id} has no event definition to set ${slot}.${key} on (it has no trigger)`, { element: id, hint: `Give it a trigger first: \`bpmn set ${id} trigger=${trigger ?? ''}\` (or in the same command, e.g. trigger=${trigger ?? 'message'} ${slot}.${key}=).`, @@ -81,7 +73,7 @@ export class NestedSlots { const id = idOf(el); const full = `${prefix}.${key}`; const zi = key.indexOf(':'); - if (zi > 0 && this.doc.namespaceUri(key.slice(0, zi)) === ZEEBE_URI && !ZEEBE.attr(key.slice(zi + 1))) { + if (zi > 0 && this.edit.doc.namespaceUri(key.slice(0, zi)) === ZEEBE_URI && !ZEEBE.attr(key.slice(zi + 1))) { const local = key.slice(zi + 1); const owner = ZEEBE.typeNames().find((typeName) => ZEEBE.allowedOn(typeName, target) === true && ZEEBE.type(typeName)!.attributes.some((attr) => attr.name === local)); if (owner) { @@ -92,7 +84,7 @@ export class NestedSlots { } } if (key.includes(':')) { - const local = this.coercer.camundaLocal(key); + const local = new PropertyCoercer(this.edit).camundaLocal(key); if (!local || CAMUNDA.attrAppliesTo(`camunda:${local}`, target) !== false) return; const owners = CAMUNDA.attr(local)?.owners ?? []; const onElement = CAMUNDA.attrAppliesTo(`camunda:${local}`, el); @@ -109,8 +101,8 @@ export class NestedSlots { if (key === 'id') return; const property = descriptorOf(target).propertiesByName?.[key]; const blocked = NESTED_BLOCKED[key]; - if (!property || blocked || !this.coercer.isSettable(property)) { - const keys = ['id', ...this.coercer.genericKeys(target).filter((genericKey) => !NESTED_BLOCKED[genericKey])].map((slotKey) => `${prefix}.${slotKey}`); + if (!property || blocked || !PropertyCoercer.isSettable(this.edit.doc, property)) { + const keys = ['id', ...PropertyCoercer.genericKeys(this.edit.doc, target).filter((genericKey) => !NESTED_BLOCKED[genericKey])].map((slotKey) => `${prefix}.${slotKey}`); throw modelError('E_UNKNOWN_KEY', `Unknown key "${full}" for ${kindLabel(el)} ${id}: a ${typeLabel(target)} has no settable attribute ${key}${blocked ? ` (${blocked})` : ''}`, { element: id, candidates: keys, @@ -120,11 +112,11 @@ export class NestedSlots { } /** A script resource condition has no inline body. */ - private static dropConditionBody(el: El, expr: El, cs: ChangeSet, key = 'camunda:resource'): void { + private static dropConditionBody(el: El, expr: El, changes: ChangeSet, key = 'camunda:resource'): void { const body = expr.get('body'); if (!body) return; expr.set('body', undefined); - cs.warn({ + changes.warn({ code: 'W_PROPERTY_DROPPED', message: `The condition "${body}" of ${idOf(el)} was dropped: a script resource condition has no inline body`, element: idOf(el), @@ -137,44 +129,42 @@ export class NestedSlots { * so removing the only script resource needs an inline condition in the same command. */ private assertConditionKept(el: El, target: El, key: string, full: string): void { - if (!this.coercer.isResourceKey(key) || this.coercer.resourceKeyOf(target) !== key) return; + if (!new PropertyCoercer(this.edit).isResourceKey(key) || new PropertyCoercer(this.edit).resourceKeyOf(target) !== key) return; if (target.get('body')?.trim()) return; const id = idOf(el); const flow = is(el, 'bpmn:SequenceFlow'); - throw PropertyCoercer.invalid( - el, + throw PropertyCoercer.invalid(el, full, `removing the script resource ${String(target.$attrs[key])} would leave ${flow ? 'the condition' : 'the condition of the conditional event'} empty (it has no inline body); the engines would fail every evaluation of an empty condition`, flow ? `Replace it with an inline condition in the same command: \`bpmn set ${id} ${full}= 'condition=\${...}'\`, or remove the whole condition: \`bpmn set ${id} condition=\`.` - : `Replace it with an inline condition in the same command: \`bpmn set ${id} ${full}= 'when=\${...}'\` (a conditional event always needs a condition), or point it at another script: \`bpmn set ${id} ${full}=\`.`, - ); + : `Replace it with an inline condition in the same command: \`bpmn set ${id} ${full}= 'when=\${...}'\` (a conditional event always needs a condition), or point it at another script: \`bpmn set ${id} ${full}=\`.`); } /** `replacing`: the same command sets the inline condition, so nothing is left empty. */ - apply(el: El, slot: string, key: string, value: string, cs: ChangeSet, replacing = false): void { + setKey(el: El, slot: string, key: string, value: string, changes: ChangeSet, replacing = false): void { const full = `${slot}.${key}`; - if (key.includes(':')) value = this.coercer.vendorValue(el, key, value, full); + if (key.includes(':')) value = new PropertyCoercer(this.edit).vendorValue(el, key, value, full); const slots = slotsOf(el); const ref = parseSlotRef(slot); if (!ref || !slots.includes(ref.slot) || !key) { - throw this.coercer.unknownKey(el, full, slots.length ? `nested keys of a ${kindLabel(el)} start with ${slots.map((slotName) => `${slotName}.`).join(' or ')}` : `a ${kindLabel(el)} has no nested element to address`); + throw PropertyCoercer.unknownKey(this.edit.doc, el, full, slots.length ? `nested keys of a ${kindLabel(el)} start with ${slots.map((slotName) => `${slotName}.`).join(' or ')}` : `a ${kindLabel(el)} has no nested element to address`); } const refSlot = ref.slot; let target = resolveNested(el, ref, full); if (!target) { const nothingToRemove = !value; if (nothingToRemove) return; - target = this.create(el, refSlot, key, cs); + target = this.create(el, refSlot, key, changes); } if (value) this.checkKey(el, refSlot, target, key, ref.text); - if (refSlot === 'condition' && value && this.coercer.isResourceKey(key)) NestedSlots.dropConditionBody(el, target, cs, key); + if (refSlot === 'condition' && value && new PropertyCoercer(this.edit).isResourceKey(key)) NestedSlots.dropConditionBody(el, target, changes, key); if (refSlot === 'condition' && !value && !replacing) this.assertConditionKept(el, target, key, full); if (key.includes(':')) { - this.coercer.setVendorAttribute(target, key, value); + new PropertyCoercer(this.edit).setVendorAttribute(target, key, value); } else if (key === 'id') { if (!value) throw PropertyCoercer.invalid(el, full, 'an id cannot be empty'); - if (idOf(target)) new IdRenameService(this.doc, this.edit).rename(target, value); + if (idOf(target)) new IdRename(this.edit).rename(target, value); else { this.edit.allocator.claim(value); target.set('id', value); @@ -182,11 +172,11 @@ export class NestedSlots { } else if (!PropertyCoercer.removeUndefined(target, key, value)) { this.checkKey(el, refSlot, target, key, ref.text); const property = descriptorOf(target).propertiesByName![key]!; - const coerced = value ? this.coercer.coerce(el, property, value, full) : undefined; + const coerced = value ? new PropertyCoercer(this.edit).coerce(el, property, value, full) : undefined; if (is(target, 'bpmn:CompensateEventDefinition') && key === 'activityRef' && isEl(coerced)) this.assertCompensationInScope(el, coerced, full); target.set(key, coerced); } - cs.change(ChangeSet.entryOf(el, value ? `${full}=${value}` : `${full} removed`)); + changes.change(ChangeSet.entryOf(el, value ? `${full}=${value}` : `${full} removed`)); } /** @@ -195,14 +185,14 @@ export class NestedSlots { */ private assertCompensationInScope(event: El, activity: El, full: string): void { if (!is(activity, 'bpmn:Activity')) return; - const scope = this.doc.scopeOf(event); + const scope = this.edit.doc.scopeOf(event); if (!scope) return; const scopes = [scope]; if (is(scope, 'bpmn:SubProcess') && scope.get('triggeredByEvent')) { - const outer = this.doc.scopeOf(scope); + const outer = this.edit.doc.scopeOf(scope); if (outer) scopes.push(outer); } - const actual = this.doc.scopeOf(activity); + const actual = this.edit.doc.scopeOf(activity); if (actual && scopes.includes(actual)) return; const candidates = scopes.flatMap((sc) => many(sc, 'flowElements').filter((flowElement) => is(flowElement, 'bpmn:Activity') && !(is(flowElement, 'bpmn:SubProcess') && flowElement.get('triggeredByEvent'))).map(idOf)); const id = idOf(event); diff --git a/src/domain/editing/PropertyService.ts b/src/domain/editing/properties/Properties.ts similarity index 70% rename from src/domain/editing/PropertyService.ts rename to src/domain/editing/properties/Properties.ts index 62325a98..80acd33d 100644 --- a/src/domain/editing/PropertyService.ts +++ b/src/domain/editing/properties/Properties.ts @@ -1,26 +1,25 @@ import { NestedSlots } from './NestedSlots.js'; import { PropertyCoercer } from './PropertyCoercer.js'; -import type { Doc } from '../document/Doc.js'; -import type { EditContext } from '../document/EditContext.js'; -import { modelError, usageError } from '../document/utils/errors.js'; -import { kindLabel, kindOf, normalizeTrigger, triggerOf, TRIGGER_TYPES, type Trigger } from '../document/utils/kinds.js'; -import { is, walk, type El } from '../document/utils/moddle.js'; -import { ChangeSet } from '../document/ChangeSet.js'; -import { descriptorOf, idOf, typeLabel, vendorContent } from '../document/utils/elements.js'; -import { definitionsOf, slotsOf, SLOT_TEXT, splitNestedKey, type NestedSlot } from '../document/utils/nested-slots.js'; +import type { EditSession } from '../../document/EditSession.js'; +import { modelError, usageError } from '../../document/utils/errors.js'; +import { kindLabel, kindOf, normalizeTrigger, triggerOf, TRIGGERS, type Trigger } from '../../document/Kinds.js'; +import { is, typeLabel, walk, type El } from '../../document/utils/moddle.js'; +import { ChangeSet } from '../../document/ChangeSet.js'; +import { descriptorOf, idOf, vendorContent } from '../../document/utils/elements.js'; +import { definitionsOf, slotsOf, SLOT_TEXT, splitNestedKey, type NestedSlot } from '../../document/utils/nested-slots.js'; import { SetKeyCatalog } from './SetKeyCatalog.js'; -import { TRIGGER_OPTION_KEYS } from './events/EventTrigger.js'; -import { TriggerChange } from './events/TriggerChange.js'; -import { ZeebeSubscription } from './events/ZeebeSubscription.js'; -import type { SetProperties, TriggerOptions } from '../commands/types.js'; -import { CAMUNDA } from '../document/vendor/CamundaDescriptor.js'; -import { ZEEBE, ZEEBE_URI } from '../document/vendor/ZeebeDescriptor.js'; -import { SequenceFlowService } from './flows/SequenceFlowService.js'; -import { FlowRules } from '../document/FlowRules.js'; -import { RootElementService } from './events/RootElementService.js'; +import { TRIGGER_KEYS } from '../../commands/utils/op-fields.js'; +import { TriggerChange } from '../events/TriggerChange.js'; +import { ZeebeSubscription } from '../events/ZeebeSubscription.js'; +import type { SetProperties, TriggerOptions } from '../../commands/types.js'; +import { CAMUNDA } from '../../document/vendor/CamundaDescriptor.js'; +import { ZEEBE, ZEEBE_URI } from '../../document/vendor/ZeebeDescriptor.js'; +import { SequenceFlows } from '../connections/SequenceFlows.js'; +import { FlowRules } from '../../document/FlowRules.js'; +import { RootElements } from '../events/RootElements.js'; // timerKind is left out: set classifies a timer value itself -const EVENT_KEYS = new Set(['trigger', ...TRIGGER_OPTION_KEYS.filter((optionKey) => optionKey !== 'timerKind')]); +const EVENT_KEYS = new Set(['trigger', ...TRIGGER_KEYS.filter((optionKey) => optionKey !== 'timerKind')]); const TRIGGER_OF_KEY: Record = { timer: 'timer', @@ -39,17 +38,8 @@ const TRIGGER_OF_KEY: Record = { * Nested elements without an id are addressed by prefix (`definition.`, `loop.`, `condition.`); * an empty value or --unset removes the property. */ -export class PropertyService { - private readonly coercer: PropertyCoercer; - private readonly slots: NestedSlots; - - constructor( - private readonly doc: Doc, - private readonly edit: EditContext, - ) { - this.coercer = new PropertyCoercer(doc, edit); - this.slots = new NestedSlots(doc, edit); - } +export class Properties { + constructor(private readonly edit: EditSession) {} private static normalizeKey(key: string): string { const trimmed = key.trim(); @@ -59,21 +49,21 @@ export class PropertyService { } private expression(body: string, parent: El, language?: string): El { - const expr = this.doc.moddle.create('bpmn:FormalExpression', { body, ...(language ? { language } : {}) }); + const expr = this.edit.doc.moddle.create('bpmn:FormalExpression', { body, ...(language ? { language } : {}) }); expr.$parent = parent; return expr; } /** Applies the keys of the op; a conditional event's script resource is removed first, so that when= fills the condition. */ - set(op: SetProperties): ChangeSet { - const cs = new ChangeSet(); - const el = this.edit.require(op.id); + set(command: SetProperties): ChangeSet { + const changes = new ChangeSet(); + const el = this.edit.require(command.id); const entries: Array<[string, string]> = [ - ...Object.entries(op.values ?? {}).map(([entryKey, entryValue]) => [PropertyService.normalizeKey(entryKey), entryValue === undefined || entryValue === null ? '' : String(entryValue)] as [string, string]), - ...(op.unset ?? []).map((unsetKey) => [PropertyService.normalizeKey(unsetKey), ''] as [string, string]), + ...Object.entries(command.values ?? {}).map(([entryKey, entryValue]) => [Properties.normalizeKey(entryKey), entryValue === undefined || entryValue === null ? '' : String(entryValue)] as [string, string]), + ...(command.unset ?? []).map((unsetKey) => [Properties.normalizeKey(unsetKey), ''] as [string, string]), ]; if (!entries.length) { - throw usageError('Nothing to set: pass key=value pairs (or --unset )', { element: op.id, hint: `Settable keys: ${this.coercer.settableKeys(el).join(', ')}` }); + throw usageError('Nothing to set: pass key=value pairs (or --unset )', { element: command.id, hint: `Settable keys: ${PropertyCoercer.settableKeys(this.edit.doc, el).join(', ')}` }); } const eventKeys = new Map(); const conditionKeys = new Map(); @@ -99,14 +89,14 @@ export class PropertyService { conditionKeys.set(key, value); continue; } - this.applyKey(el, key, value, cs); + this.applyKey(el, key, value, changes); } /** * Nested keys run after the keys that create their element (loop=, trigger=, when=); * a flow's condition.* keys run first, so that a script resource condition can take a language= */ const flowCondition = is(el, 'bpmn:SequenceFlow') ? nested.filter((nestedEntry) => nestedEntry.slot === 'condition') : []; - const newResource = flowCondition.some((nestedEntry) => nestedEntry.value && this.coercer.isResourceKey(nestedEntry.key)); + const newResource = flowCondition.some((nestedEntry) => nestedEntry.value && new PropertyCoercer(this.edit).isResourceKey(nestedEntry.key)); if (conditionKeys.get('condition') && newResource) { throw PropertyCoercer.invalid(el, 'condition', 'a condition is either an inline expression (condition=) or a script resource (condition.camunda:resource=), not both', 'Pass one of them; a script resource also needs language=