@tanishqkancharla/diffmap 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -2
- package/package.json +1 -1
- package/src/annotations.ts +17 -1
- package/src/extractDocument.ts +10 -2
- package/src/host/home.md +82 -83
- package/src/host/home.test.ts +0 -1
- package/src/parseFence.ts +81 -12
- package/src/parseViewer.test.ts +147 -4
- package/src/parseViewer.ts +44 -9
- package/src/serve.test.ts +22 -1
- package/src/services.ts +120 -13
- package/src/viewer/CallStackDiff.tsx +30 -32
- package/src/viewer/ChangesCard.tsx +0 -2
- package/src/viewer/CodeBlock.tsx +79 -6
- package/src/viewer/ComarkView.tsx +34 -0
- package/src/viewer/Fence.tsx +4 -0
- package/src/viewer/HtmlBlock.tsx +2 -1
- package/src/viewer/MermaidBlock.tsx +55 -14
- package/src/viewer/ServiceDiagramBlock.tsx +87 -52
- package/src/viewer/ServiceStage.tsx +198 -57
- package/src/viewer/SourceDiffPanel.tsx +138 -42
- package/src/viewer/TableOfContents.tsx +44 -18
- package/src/viewer/TocButton.tsx +28 -0
- package/src/viewer/ViewerApp.tsx +10 -0
- package/src/viewer/diagramDiff.ts +69 -0
- package/src/viewer/diagramLinks.ts +54 -6
- package/src/viewer/pierre.ts +20 -2
- package/src/viewer/serviceLayout.ts +16 -5
- package/vite.config.ts +4 -0
package/src/parseViewer.test.ts
CHANGED
|
@@ -22,9 +22,9 @@ function brokenMermaidSpec(body = "flwochart TD\n A --> B") {
|
|
|
22
22
|
return `# Broken mermaid\n\n\`\`\`mermaid\n${body}\n\`\`\`\n`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function parseServicesSpec(body: string[]) {
|
|
25
|
+
function parseServicesSpec(body: string[], info = "services") {
|
|
26
26
|
return parseViewerDocument(
|
|
27
|
-
["# Flow", "",
|
|
27
|
+
["# Flow", "", `\`\`\`${info}`, ...body, "```", ""].join("\n"),
|
|
28
28
|
"spec.md",
|
|
29
29
|
);
|
|
30
30
|
}
|
|
@@ -196,7 +196,7 @@ test("parseViewerDocument reads commit references and changes fences", () => {
|
|
|
196
196
|
test("parseViewerDocument reads code highlights and trailing notes", () => {
|
|
197
197
|
const document = parseViewerDocument(
|
|
198
198
|
[
|
|
199
|
-
"```py {2}",
|
|
199
|
+
"```py [src/save.py] {2}",
|
|
200
200
|
"def save(record):",
|
|
201
201
|
" storage.save(record)",
|
|
202
202
|
"",
|
|
@@ -225,6 +225,7 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
|
|
|
225
225
|
text: "Writes `record` to disk.",
|
|
226
226
|
},
|
|
227
227
|
],
|
|
228
|
+
title: "src/save.py",
|
|
228
229
|
},
|
|
229
230
|
});
|
|
230
231
|
// Symbols match whole words, so `save` is not found inside `saveAll`.
|
|
@@ -243,7 +244,7 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
|
|
|
243
244
|
// Call stacks number rows, and a symbol must appear in visible row text.
|
|
244
245
|
const stack = parseViewerDocument(
|
|
245
246
|
[
|
|
246
|
-
"```callstack {2}",
|
|
247
|
+
"```callstack {2} [Save flow]",
|
|
247
248
|
" run() [[src/a.ts#run]]",
|
|
248
249
|
"+└── save(record)",
|
|
249
250
|
"# [!note save] Writes the record.",
|
|
@@ -258,6 +259,17 @@ test("parseViewerDocument reads code highlights and trailing notes", () => {
|
|
|
258
259
|
if (fence?.kind !== "callstack") return;
|
|
259
260
|
assert.equal(fence.lines.length, 2);
|
|
260
261
|
assert.deepEqual(fence.highlights, [2]);
|
|
262
|
+
assert.equal(fence.title, "Save flow");
|
|
263
|
+
const mdc = parseViewerDocument(
|
|
264
|
+
'::callstack{title="Save flow"}\n run()\n└── save(record)\n::\n',
|
|
265
|
+
);
|
|
266
|
+
if (mdc instanceof Error) throw mdc;
|
|
267
|
+
assert.deepEqual(
|
|
268
|
+
mdc.nodes[0]?.type === "view" && mdc.nodes[0].fence.kind === "callstack"
|
|
269
|
+
? mdc.nodes[0].fence.title
|
|
270
|
+
: undefined,
|
|
271
|
+
"Save flow",
|
|
272
|
+
);
|
|
261
273
|
assert.deepEqual(fence.notes, [
|
|
262
274
|
{ target: { kind: "symbol", symbol: "save" }, text: "Writes the record." },
|
|
263
275
|
]);
|
|
@@ -284,6 +296,8 @@ test("parseViewerDocument reads services, steps, and their references", () => {
|
|
|
284
296
|
"service Store",
|
|
285
297
|
" state rows = none",
|
|
286
298
|
"",
|
|
299
|
+
'start "Alice has not logged in"',
|
|
300
|
+
" > No rows yet.",
|
|
287
301
|
'step "Verify"',
|
|
288
302
|
" API -> Auth: verify(alice) [[src/api.ts#login]]",
|
|
289
303
|
" > Sends the password hash.",
|
|
@@ -318,6 +332,10 @@ test("parseViewerDocument reads services, steps, and their references", () => {
|
|
|
318
332
|
);
|
|
319
333
|
// A description belongs to the line above it and joins continued lines.
|
|
320
334
|
assert.equal(diagram.services[0]!.description, "Routes requests.");
|
|
335
|
+
assert.deepEqual(diagram.start, {
|
|
336
|
+
caption: "Alice has not logged in",
|
|
337
|
+
description: "No rows yet.",
|
|
338
|
+
});
|
|
321
339
|
assert.equal(
|
|
322
340
|
diagram.services[0]!.children[0]!.state[0]!.description,
|
|
323
341
|
"Failures since the last success. Five locks the account.",
|
|
@@ -353,6 +371,122 @@ test("parseViewerDocument reads services, steps, and their references", () => {
|
|
|
353
371
|
]);
|
|
354
372
|
});
|
|
355
373
|
|
|
374
|
+
test("a services diff replays Today and Proposed separately", () => {
|
|
375
|
+
const document = parseViewerDocument(
|
|
376
|
+
[
|
|
377
|
+
"```services diff",
|
|
378
|
+
" service API",
|
|
379
|
+
"- state count = 0",
|
|
380
|
+
"+ state count = 1",
|
|
381
|
+
"-service Cache",
|
|
382
|
+
" state hits = 0",
|
|
383
|
+
"+service Queue",
|
|
384
|
+
"+ state jobs = 0",
|
|
385
|
+
"",
|
|
386
|
+
' step "Handle"',
|
|
387
|
+
"- API -> Cache: get",
|
|
388
|
+
"+ API -> Queue: push",
|
|
389
|
+
"- Cache.hits = 1",
|
|
390
|
+
"+ Queue.jobs = 1",
|
|
391
|
+
" API.count = 5",
|
|
392
|
+
'-step "Old cleanup"',
|
|
393
|
+
" API.count = 9",
|
|
394
|
+
"```",
|
|
395
|
+
"",
|
|
396
|
+
].join("\n"),
|
|
397
|
+
);
|
|
398
|
+
if (document instanceof Error) throw document;
|
|
399
|
+
const fence = document.nodes[0];
|
|
400
|
+
if (fence?.type !== "view" || fence.fence.kind !== "services")
|
|
401
|
+
throw new Error("expected a services fence");
|
|
402
|
+
if (!fence.fence.parsed.ok) throw new Error(fence.fence.parsed.reason);
|
|
403
|
+
const diagram = fence.fence.parsed.diagram;
|
|
404
|
+
// Unmarked lines inside a removed service or step take its sign.
|
|
405
|
+
assert.equal(diagram.services[1]!.state[0]!.sign, "-");
|
|
406
|
+
assert.equal(diagram.steps[1]!.updates[0]!.sign, "-");
|
|
407
|
+
assert.deepEqual(
|
|
408
|
+
diagram.steps[0]!.events.map((event) => event.sign),
|
|
409
|
+
["-", "+"],
|
|
410
|
+
);
|
|
411
|
+
const at = (step: number, side: "today" | "proposed") =>
|
|
412
|
+
Object.fromEntries(
|
|
413
|
+
[...serviceStateAt(diagram, step, side)].map(([name, state]) => [
|
|
414
|
+
name,
|
|
415
|
+
Object.fromEntries(state),
|
|
416
|
+
]),
|
|
417
|
+
);
|
|
418
|
+
// A - and a + declaration of one key give each side its own start value.
|
|
419
|
+
assert.deepEqual(at(0, "today"), {
|
|
420
|
+
API: { count: "0" },
|
|
421
|
+
Cache: { hits: "0" },
|
|
422
|
+
Queue: {},
|
|
423
|
+
});
|
|
424
|
+
assert.deepEqual(at(0, "proposed"), {
|
|
425
|
+
API: { count: "1" },
|
|
426
|
+
Cache: {},
|
|
427
|
+
Queue: { jobs: "0" },
|
|
428
|
+
});
|
|
429
|
+
assert.deepEqual(at(2, "today"), {
|
|
430
|
+
API: { count: "9" },
|
|
431
|
+
Cache: { hits: "1" },
|
|
432
|
+
Queue: {},
|
|
433
|
+
});
|
|
434
|
+
assert.deepEqual(at(2, "proposed"), {
|
|
435
|
+
API: { count: "5" },
|
|
436
|
+
Cache: {},
|
|
437
|
+
Queue: { jobs: "1" },
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("a services diff rejects unmarked lines and contradictory signs", () => {
|
|
442
|
+
assert.match(
|
|
443
|
+
String(
|
|
444
|
+
parseServicesSpec([" service API", "service Store"], "services diff"),
|
|
445
|
+
),
|
|
446
|
+
/line 5\): Start every line of a services diff/,
|
|
447
|
+
);
|
|
448
|
+
assert.match(
|
|
449
|
+
String(
|
|
450
|
+
parseServicesSpec(
|
|
451
|
+
[" service API", '-step "Old"', "+ API -> API: new"],
|
|
452
|
+
"services diff",
|
|
453
|
+
),
|
|
454
|
+
),
|
|
455
|
+
/line 6\): A \+ line can't sit inside a removed step/,
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a mermaid diff keeps every line and splits out each side", () => {
|
|
460
|
+
const document = parseViewerDocument(
|
|
461
|
+
[
|
|
462
|
+
"```mermaid diff [Request flow]",
|
|
463
|
+
" flowchart LR",
|
|
464
|
+
"- A --> B[Old]",
|
|
465
|
+
"+ A --> B[New]",
|
|
466
|
+
"+ A --> C",
|
|
467
|
+
" %% ref node:A [[src/a.ts]]",
|
|
468
|
+
"```",
|
|
469
|
+
"",
|
|
470
|
+
].join("\n"),
|
|
471
|
+
);
|
|
472
|
+
if (document instanceof Error) throw document;
|
|
473
|
+
const fence = document.nodes[0];
|
|
474
|
+
if (fence?.type !== "view" || fence.fence.kind !== "mermaid")
|
|
475
|
+
throw new Error("expected a mermaid fence");
|
|
476
|
+
// Mermaid keeps a node's first label, so a change's + lines come first.
|
|
477
|
+
assert.equal(
|
|
478
|
+
fence.fence.source,
|
|
479
|
+
"flowchart LR\n A --> B[New]\n A --> C\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
|
|
480
|
+
);
|
|
481
|
+
assert.deepEqual(fence.fence.sides, {
|
|
482
|
+
today: "flowchart LR\n A --> B[Old]\n %% ref node:A [[src/a.ts]]",
|
|
483
|
+
proposed:
|
|
484
|
+
"flowchart LR\n A --> B[New]\n A --> C\n %% ref node:A [[src/a.ts]]",
|
|
485
|
+
});
|
|
486
|
+
assert.equal(fence.fence.annotations.length, 1);
|
|
487
|
+
assert.equal(fence.fence.title, "Request flow");
|
|
488
|
+
});
|
|
489
|
+
|
|
356
490
|
test("parseViewerDocument fails on services errors with the file line", () => {
|
|
357
491
|
const unknown = parseServicesSpec([
|
|
358
492
|
"service API",
|
|
@@ -370,6 +504,15 @@ test("parseViewerDocument fails on services errors with the file line", () => {
|
|
|
370
504
|
assert.match(String(undeclared), /line 6\): API has no state count/);
|
|
371
505
|
const uncaptioned = parseServicesSpec(["service API", "step"]);
|
|
372
506
|
assert.match(String(uncaptioned), /line 5\): Give the step a caption/);
|
|
507
|
+
const lateStart = parseServicesSpec([
|
|
508
|
+
"service API",
|
|
509
|
+
'step "Go"',
|
|
510
|
+
'start "Too late"',
|
|
511
|
+
]);
|
|
512
|
+
assert.match(
|
|
513
|
+
String(lateStart),
|
|
514
|
+
/line 6\): Write one start line, before the first step/,
|
|
515
|
+
);
|
|
373
516
|
const orphan = parseServicesSpec(["service API", "> Not indented."]);
|
|
374
517
|
assert.match(String(orphan), /line 5\): Put a > line directly below/);
|
|
375
518
|
});
|
package/src/parseViewer.ts
CHANGED
|
@@ -84,6 +84,8 @@ export type ViewerElementAttrs = {
|
|
|
84
84
|
task: boolean | undefined;
|
|
85
85
|
alertType: string | undefined;
|
|
86
86
|
align: string | undefined;
|
|
87
|
+
/** The label of an MDC `::details{summary="…"}` toggle. */
|
|
88
|
+
summary: string | undefined;
|
|
87
89
|
};
|
|
88
90
|
|
|
89
91
|
export function parseViewerDocument(source: string, path = "spec") {
|
|
@@ -162,12 +164,21 @@ export function parseViewerDocument(source: string, path = "spec") {
|
|
|
162
164
|
);
|
|
163
165
|
for (const [offset, fence] of mermaidFences.entries()) {
|
|
164
166
|
const mermaidIndex = offset + 1;
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
const where = mermaidWhere(source, mermaidIndex);
|
|
168
|
+
// A diff draws every line, and each side must also stand on its own.
|
|
169
|
+
for (const [side, diagram] of [
|
|
170
|
+
["", fence.source],
|
|
171
|
+
[", Today side", fence.sides?.today],
|
|
172
|
+
[", Proposed side", fence.sides?.proposed],
|
|
173
|
+
] as const) {
|
|
174
|
+
if (diagram === undefined) continue;
|
|
175
|
+
const parsedMermaid = parseMermaidDiagram({
|
|
176
|
+
source: diagram,
|
|
177
|
+
path,
|
|
178
|
+
where: `${where}${side}`,
|
|
179
|
+
});
|
|
180
|
+
if (parsedMermaid instanceof Error) return parsedMermaid;
|
|
181
|
+
}
|
|
171
182
|
const targets = new Set<string>();
|
|
172
183
|
for (const link of fence.annotations) {
|
|
173
184
|
const target = `${link.target}:${link.id}`;
|
|
@@ -374,14 +385,29 @@ function fromElement(
|
|
|
374
385
|
if (tag === "template")
|
|
375
386
|
return children.flatMap((child) => fromNode(child, literalChildren));
|
|
376
387
|
if (tag === "pre") {
|
|
388
|
+
// md4x parses ```ts [src/request.ts] into `filename`; diffmap shows it as the title.
|
|
377
389
|
return fenceView(
|
|
378
390
|
stringAttr(element, "language"),
|
|
379
391
|
comarkText(children),
|
|
380
392
|
highlightsAttr(element),
|
|
393
|
+
stringAttr(element, "filename"),
|
|
394
|
+
stringAttr(element, "meta"),
|
|
381
395
|
);
|
|
382
396
|
}
|
|
383
|
-
if (tag === "mermaid")
|
|
384
|
-
|
|
397
|
+
if (tag === "mermaid")
|
|
398
|
+
return fenceView(
|
|
399
|
+
"mermaid",
|
|
400
|
+
comarkText(children),
|
|
401
|
+
undefined,
|
|
402
|
+
stringAttr(element, "title"),
|
|
403
|
+
);
|
|
404
|
+
if (tag === "callstack")
|
|
405
|
+
return fenceView(
|
|
406
|
+
"callstack",
|
|
407
|
+
comarkText(children),
|
|
408
|
+
undefined,
|
|
409
|
+
stringAttr(element, "title"),
|
|
410
|
+
);
|
|
385
411
|
if (tag === "changes") {
|
|
386
412
|
const rev = stringAttr(element, "rev");
|
|
387
413
|
const body = comarkText(children);
|
|
@@ -425,10 +451,18 @@ function fenceView(
|
|
|
425
451
|
lang: string | undefined,
|
|
426
452
|
source: string,
|
|
427
453
|
highlights?: number[],
|
|
454
|
+
title?: string,
|
|
455
|
+
meta?: string,
|
|
428
456
|
): ViewerView {
|
|
429
457
|
return {
|
|
430
458
|
type: "view",
|
|
431
|
-
fence: parseFence(
|
|
459
|
+
fence: parseFence(
|
|
460
|
+
lang === undefined ? "" : lang,
|
|
461
|
+
source,
|
|
462
|
+
highlights,
|
|
463
|
+
title,
|
|
464
|
+
meta,
|
|
465
|
+
),
|
|
432
466
|
};
|
|
433
467
|
}
|
|
434
468
|
|
|
@@ -479,6 +513,7 @@ function elementAttrs(element: ComarkElement): ViewerElementAttrs {
|
|
|
479
513
|
task: element[1].task === true ? true : undefined,
|
|
480
514
|
alertType: stringAttr(element, "type"),
|
|
481
515
|
align: stringAttr(element, "align"),
|
|
516
|
+
summary: stringAttr(element, "summary"),
|
|
482
517
|
};
|
|
483
518
|
}
|
|
484
519
|
|
package/src/serve.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
-
import test from "node:test";
|
|
10
|
+
import test, { before } from "node:test";
|
|
11
11
|
import { DiffmapServeError } from "./errors.js";
|
|
12
12
|
import { startServer, type DiffmapServer } from "./serve.js";
|
|
13
13
|
|
|
@@ -92,6 +92,27 @@ async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
|
|
|
92
92
|
]);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// These tests run several servers at once. On a cold or stale cache each one
|
|
96
|
+
// prebundles dependencies on its own, at over 1 GB each, which can exhaust the
|
|
97
|
+
// machine's memory. Vite holds requests for a prebundled file until the bundle
|
|
98
|
+
// is on disk, so fetching one here leaves a warm cache for the servers below.
|
|
99
|
+
before(async () => {
|
|
100
|
+
const server = assertStarted(
|
|
101
|
+
await startServer({ filePath: sampleSpec, workspaceRoot: packageRoot }),
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
const entry = await fetch(`${server.url}/src/viewer/main.tsx`);
|
|
105
|
+
const dep = /"(\/node_modules\/\.vite\/deps\/[^"]+)"/.exec(
|
|
106
|
+
await entry.text(),
|
|
107
|
+
)?.[1];
|
|
108
|
+
assert.ok(dep !== undefined, "viewer entry imports no prebundled module");
|
|
109
|
+
assert.equal((await fetch(`${server.url}${dep}`)).ok, true);
|
|
110
|
+
} finally {
|
|
111
|
+
await server.shutdown();
|
|
112
|
+
await waitForClosed(server);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
95
116
|
test(
|
|
96
117
|
"startServer binds distinct free ports when 4177 is taken",
|
|
97
118
|
{ timeout: 90_000 },
|
package/src/services.ts
CHANGED
|
@@ -7,9 +7,22 @@ import { parseAnnotation, type SourceAnnotation } from "./annotations.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export type ServiceDiagram = {
|
|
9
9
|
services: Service[];
|
|
10
|
+
/** From `start "caption"`: what the declared state shows, before step 1. */
|
|
11
|
+
start: { caption: string; description: string | undefined } | undefined;
|
|
10
12
|
steps: ServiceStep[];
|
|
13
|
+
/** From ```services diff: items carry `+` or `-` signs. */
|
|
14
|
+
diff: boolean;
|
|
11
15
|
};
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* In a `services diff`, `-` marks an item that exists only Today and `+` one
|
|
19
|
+
* that exists only in the Proposed version. Items inherit the sign of the
|
|
20
|
+
* service or step they sit in.
|
|
21
|
+
*/
|
|
22
|
+
export type Sign = "+" | "-" | " ";
|
|
23
|
+
|
|
24
|
+
export type Side = "today" | "proposed";
|
|
25
|
+
|
|
13
26
|
export type Service = {
|
|
14
27
|
name: string;
|
|
15
28
|
label: string | undefined;
|
|
@@ -17,12 +30,17 @@ export type Service = {
|
|
|
17
30
|
state: ServiceState[];
|
|
18
31
|
children: Service[];
|
|
19
32
|
annotation: SourceAnnotation | undefined;
|
|
33
|
+
sign: Sign;
|
|
20
34
|
};
|
|
21
35
|
|
|
22
36
|
export type ServiceState = {
|
|
23
37
|
key: string;
|
|
38
|
+
/** The Proposed initial value. */
|
|
24
39
|
value: string;
|
|
40
|
+
/** Set when a `-` and a `+` line declare the key with different values. */
|
|
41
|
+
todayValue: string | undefined;
|
|
25
42
|
description: string | undefined;
|
|
43
|
+
sign: Sign;
|
|
26
44
|
};
|
|
27
45
|
|
|
28
46
|
export type ServiceStep = {
|
|
@@ -31,6 +49,7 @@ export type ServiceStep = {
|
|
|
31
49
|
description: string | undefined;
|
|
32
50
|
events: ServiceEvent[];
|
|
33
51
|
updates: ServiceUpdate[];
|
|
52
|
+
sign: Sign;
|
|
34
53
|
};
|
|
35
54
|
|
|
36
55
|
export type ServiceEvent = {
|
|
@@ -40,6 +59,7 @@ export type ServiceEvent = {
|
|
|
40
59
|
/** Shown beside the event's label while its step is current. */
|
|
41
60
|
note: string | undefined;
|
|
42
61
|
annotation: SourceAnnotation | undefined;
|
|
62
|
+
sign: Sign;
|
|
43
63
|
};
|
|
44
64
|
|
|
45
65
|
export type ServiceUpdate = {
|
|
@@ -48,6 +68,7 @@ export type ServiceUpdate = {
|
|
|
48
68
|
value: string;
|
|
49
69
|
/** Shown beside the changed state while its step is current. */
|
|
50
70
|
note: string | undefined;
|
|
71
|
+
sign: Sign;
|
|
51
72
|
};
|
|
52
73
|
|
|
53
74
|
export type ServicesParse =
|
|
@@ -60,6 +81,7 @@ const serviceLine = new RegExp(
|
|
|
60
81
|
);
|
|
61
82
|
const stateLine = new RegExp(String.raw`^state\s+(${name})\s*=\s*(.+)$`);
|
|
62
83
|
const stepLine = /^step\b(?:\s+"([^"]*)")?\s*$/;
|
|
84
|
+
const startLine = /^start\b(?:\s+"([^"]*)")?\s*$/;
|
|
63
85
|
const eventLine = new RegExp(
|
|
64
86
|
String.raw`^(${name})\s*->\s*(${name})(?:\s*:\s*(.*))?$`,
|
|
65
87
|
);
|
|
@@ -77,10 +99,14 @@ const updateLine = new RegExp(String.raw`^(${name})\.(${name})\s*=\s*(.+)$`);
|
|
|
77
99
|
* Auth.attempts = 1
|
|
78
100
|
* ```
|
|
79
101
|
*/
|
|
80
|
-
export function parseServiceDiagram(
|
|
102
|
+
export function parseServiceDiagram(
|
|
103
|
+
source: string,
|
|
104
|
+
diff = false,
|
|
105
|
+
): ServicesParse {
|
|
81
106
|
const services: Service[] = [];
|
|
82
107
|
const byName = new Map<string, Service>();
|
|
83
108
|
const steps: ServiceStep[] = [];
|
|
109
|
+
let start: ServiceDiagram["start"];
|
|
84
110
|
// Open services, innermost last, with the indent of their `service` line.
|
|
85
111
|
const open: { indent: number; service: Service }[] = [];
|
|
86
112
|
// Where a `>` line's text goes: a description on a service, state, or
|
|
@@ -104,16 +130,35 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
104
130
|
};
|
|
105
131
|
};
|
|
106
132
|
const lines = source.split("\n");
|
|
107
|
-
for (const [index,
|
|
133
|
+
for (const [index, row] of lines.entries()) {
|
|
108
134
|
const line = index + 1;
|
|
109
135
|
const fail = (reason: string): ServicesParse => ({
|
|
110
136
|
ok: false,
|
|
111
137
|
line,
|
|
112
138
|
reason,
|
|
113
139
|
});
|
|
140
|
+
if (row.trim().length === 0 || row.trim().startsWith("%%")) continue;
|
|
141
|
+
// A diff's first column is its sign, so indentation counts from column 1.
|
|
142
|
+
if (diff && !/^[+ -]/.test(row))
|
|
143
|
+
return fail("Start every line of a services diff with +, -, or a space.");
|
|
144
|
+
// SAFETY: the test above limits a diff row's first character to a Sign.
|
|
145
|
+
const own: Sign = diff ? (row[0] as Sign) : " ";
|
|
146
|
+
const raw = diff ? row.slice(1) : row;
|
|
114
147
|
const text = raw.trim();
|
|
115
|
-
if (text.length === 0
|
|
148
|
+
if (text.length === 0) continue;
|
|
116
149
|
const indent = raw.replace(/\t/g, " ").search(/\S/);
|
|
150
|
+
// An item takes the sign of the service or step it sits in.
|
|
151
|
+
const signWithin = (
|
|
152
|
+
container: { sign: Sign } | undefined,
|
|
153
|
+
kind: string,
|
|
154
|
+
) => {
|
|
155
|
+
const outer = container?.sign ?? " ";
|
|
156
|
+
if (outer === " " || own === " " || own === outer)
|
|
157
|
+
return outer === " " ? own : outer;
|
|
158
|
+
return fail(
|
|
159
|
+
`A ${own} line can't sit inside a ${outer === "-" ? "removed" : "added"} ${kind}.`,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
117
162
|
if (text.startsWith(">")) {
|
|
118
163
|
if (described === undefined || indent <= described.indent)
|
|
119
164
|
return fail(
|
|
@@ -135,6 +180,8 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
135
180
|
if (byName.has(serviceName))
|
|
136
181
|
return fail(`Service ${serviceName} is declared twice.`);
|
|
137
182
|
while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
|
|
183
|
+
const sign = signWithin(open.at(-1)?.service, "service");
|
|
184
|
+
if (typeof sign !== "string") return sign;
|
|
138
185
|
const service: Service = {
|
|
139
186
|
name: serviceName,
|
|
140
187
|
label: serviceMatch[2],
|
|
@@ -145,6 +192,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
145
192
|
annotation === undefined
|
|
146
193
|
? undefined
|
|
147
194
|
: { ...annotation, text: serviceName },
|
|
195
|
+
sign,
|
|
148
196
|
};
|
|
149
197
|
(open.at(-1)?.service.children ?? services).push(service);
|
|
150
198
|
byName.set(serviceName, service);
|
|
@@ -164,18 +212,54 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
164
212
|
if (owner === undefined)
|
|
165
213
|
return fail("Indent state under the service that owns it.");
|
|
166
214
|
const key = stateMatch[1]!;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
215
|
+
const value = stateMatch[2]!.trim();
|
|
216
|
+
const sign = signWithin(owner, "service");
|
|
217
|
+
if (typeof sign !== "string") return sign;
|
|
218
|
+
const declared = owner.state.find((entry) => entry.key === key);
|
|
219
|
+
if (declared !== undefined) {
|
|
220
|
+
// A `-` and a `+` declaration of one key change its initial value.
|
|
221
|
+
if (
|
|
222
|
+
declared.todayValue !== undefined ||
|
|
223
|
+
declared.sign === " " ||
|
|
224
|
+
sign === " " ||
|
|
225
|
+
declared.sign === sign
|
|
226
|
+
)
|
|
227
|
+
return fail(`${owner.name}.${key} is declared twice.`);
|
|
228
|
+
declared.todayValue = declared.sign === "-" ? declared.value : value;
|
|
229
|
+
declared.value = declared.sign === "+" ? declared.value : value;
|
|
230
|
+
declared.sign = " ";
|
|
231
|
+
describe(indent, declared);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const entry: ServiceState = {
|
|
170
235
|
key,
|
|
171
|
-
value
|
|
236
|
+
value,
|
|
237
|
+
todayValue: undefined,
|
|
172
238
|
description: undefined,
|
|
239
|
+
sign,
|
|
173
240
|
};
|
|
174
241
|
owner.state.push(entry);
|
|
175
242
|
describe(indent, entry);
|
|
176
243
|
continue;
|
|
177
244
|
}
|
|
178
245
|
|
|
246
|
+
const startMatch = startLine.exec(text);
|
|
247
|
+
if (startMatch !== null) {
|
|
248
|
+
if (indent !== 0) return fail("Start the start line at column 0.");
|
|
249
|
+
if (step !== undefined || start !== undefined)
|
|
250
|
+
return fail("Write one start line, before the first step.");
|
|
251
|
+
if (own !== " ")
|
|
252
|
+
return fail("The start line describes both versions; drop its sign.");
|
|
253
|
+
const caption = startMatch[1]?.trim();
|
|
254
|
+
if (caption === undefined || caption === "")
|
|
255
|
+
return fail(
|
|
256
|
+
'Give the start a caption that says what the initial state shows, like start "Alice is signed out".',
|
|
257
|
+
);
|
|
258
|
+
start = { caption, description: undefined };
|
|
259
|
+
describe(indent, start);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
179
263
|
const stepMatch = stepLine.exec(text);
|
|
180
264
|
if (stepMatch !== null) {
|
|
181
265
|
if (indent !== 0) return fail("Start step lines at column 0.");
|
|
@@ -189,6 +273,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
189
273
|
description: undefined,
|
|
190
274
|
events: [],
|
|
191
275
|
updates: [],
|
|
276
|
+
sign: own,
|
|
192
277
|
};
|
|
193
278
|
steps.push(next);
|
|
194
279
|
describe(indent, next);
|
|
@@ -201,6 +286,8 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
201
286
|
for (const service of [event[1]!, event[2]!])
|
|
202
287
|
if (!byName.has(service)) return fail(`Unknown service ${service}.`);
|
|
203
288
|
const label = (event[3] ?? "").trim();
|
|
289
|
+
const sign = signWithin(step, "step");
|
|
290
|
+
if (typeof sign !== "string") return sign;
|
|
204
291
|
const entry: ServiceEvent = {
|
|
205
292
|
from: event[1]!,
|
|
206
293
|
to: event[2]!,
|
|
@@ -213,6 +300,7 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
213
300
|
...annotation,
|
|
214
301
|
text: `${event[1]} → ${event[2]}${label === "" ? "" : `: ${label}`}`,
|
|
215
302
|
},
|
|
303
|
+
sign,
|
|
216
304
|
};
|
|
217
305
|
step.events.push(entry);
|
|
218
306
|
annotate(indent, entry);
|
|
@@ -226,11 +314,14 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
226
314
|
return fail(
|
|
227
315
|
`${owner.name} has no state ${update[2]}. Declare it with state ${update[2]} = <initial value>.`,
|
|
228
316
|
);
|
|
317
|
+
const sign = signWithin(step, "step");
|
|
318
|
+
if (typeof sign !== "string") return sign;
|
|
229
319
|
const entry: ServiceUpdate = {
|
|
230
320
|
service: owner.name,
|
|
231
321
|
key: update[2]!,
|
|
232
322
|
value: update[3]!.trim(),
|
|
233
323
|
note: undefined,
|
|
324
|
+
sign,
|
|
234
325
|
};
|
|
235
326
|
step.updates.push(entry);
|
|
236
327
|
annotate(indent, entry);
|
|
@@ -242,12 +333,12 @@ export function parseServiceDiagram(source: string): ServicesParse {
|
|
|
242
333
|
}
|
|
243
334
|
|
|
244
335
|
return fail(
|
|
245
|
-
`Expected service <Name>, state <key> = <value>, or step "<caption>", got "${text}".`,
|
|
336
|
+
`Expected service <Name>, state <key> = <value>, start "<caption>", or step "<caption>", got "${text}".`,
|
|
246
337
|
);
|
|
247
338
|
}
|
|
248
339
|
if (services.length === 0)
|
|
249
340
|
return { ok: false, line: 1, reason: "Declare at least one service." };
|
|
250
|
-
return { ok: true, diagram: { services, steps } };
|
|
341
|
+
return { ok: true, diagram: { services, start, steps, diff } };
|
|
251
342
|
}
|
|
252
343
|
|
|
253
344
|
/** Every service in declaration order, with its parent. */
|
|
@@ -261,17 +352,33 @@ export function flattenServices(
|
|
|
261
352
|
]);
|
|
262
353
|
}
|
|
263
354
|
|
|
264
|
-
/**
|
|
265
|
-
|
|
355
|
+
/**
|
|
356
|
+
* State after the first `step` steps; step 0 is the declared state. In a
|
|
357
|
+
* diff, Today skips `+` items and Proposed skips `-` items.
|
|
358
|
+
*/
|
|
359
|
+
export function serviceStateAt(
|
|
360
|
+
diagram: ServiceDiagram,
|
|
361
|
+
step: number,
|
|
362
|
+
side: Side = "proposed",
|
|
363
|
+
) {
|
|
364
|
+
const skip: Sign = side === "today" ? "+" : "-";
|
|
266
365
|
const state = new Map(
|
|
267
366
|
flattenServices(diagram.services).map(({ service }) => [
|
|
268
367
|
service.name,
|
|
269
|
-
new Map(
|
|
368
|
+
new Map(
|
|
369
|
+
service.state
|
|
370
|
+
.filter((entry) => entry.sign !== skip)
|
|
371
|
+
.map((entry) => [
|
|
372
|
+
entry.key,
|
|
373
|
+
side === "today" ? (entry.todayValue ?? entry.value) : entry.value,
|
|
374
|
+
]),
|
|
375
|
+
),
|
|
270
376
|
]),
|
|
271
377
|
);
|
|
272
378
|
for (const { updates } of diagram.steps.slice(0, step))
|
|
273
379
|
for (const update of updates)
|
|
274
|
-
|
|
380
|
+
if (update.sign !== skip)
|
|
381
|
+
state.get(update.service)!.set(update.key, update.value);
|
|
275
382
|
return state;
|
|
276
383
|
}
|
|
277
384
|
|