@tanishqkancharla/diffmap 0.3.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.
@@ -0,0 +1,396 @@
1
+ import { parseAnnotation, type SourceAnnotation } from "./annotations.js";
2
+
3
+ /**
4
+ * A `services` fence: services that hold state, nest sub-services, and send
5
+ * each other events. Steps replay the events in order. Every event in a step
6
+ * is in flight at once; the step's state updates land when they arrive.
7
+ */
8
+ export type ServiceDiagram = {
9
+ services: Service[];
10
+ /** From `start "caption"`: what the declared state shows, before step 1. */
11
+ start: { caption: string; description: string | undefined } | undefined;
12
+ steps: ServiceStep[];
13
+ /** From ```services diff: items carry `+` or `-` signs. */
14
+ diff: boolean;
15
+ };
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
+
26
+ export type Service = {
27
+ name: string;
28
+ label: string | undefined;
29
+ description: string | undefined;
30
+ state: ServiceState[];
31
+ children: Service[];
32
+ annotation: SourceAnnotation | undefined;
33
+ sign: Sign;
34
+ };
35
+
36
+ export type ServiceState = {
37
+ key: string;
38
+ /** The Proposed initial value. */
39
+ value: string;
40
+ /** Set when a `-` and a `+` line declare the key with different values. */
41
+ todayValue: string | undefined;
42
+ description: string | undefined;
43
+ sign: Sign;
44
+ };
45
+
46
+ export type ServiceStep = {
47
+ /** What happens in this step, in a sentence. */
48
+ caption: string;
49
+ description: string | undefined;
50
+ events: ServiceEvent[];
51
+ updates: ServiceUpdate[];
52
+ sign: Sign;
53
+ };
54
+
55
+ export type ServiceEvent = {
56
+ from: string;
57
+ to: string;
58
+ label: string;
59
+ /** Shown beside the event's label while its step is current. */
60
+ note: string | undefined;
61
+ annotation: SourceAnnotation | undefined;
62
+ sign: Sign;
63
+ };
64
+
65
+ export type ServiceUpdate = {
66
+ service: string;
67
+ key: string;
68
+ value: string;
69
+ /** Shown beside the changed state while its step is current. */
70
+ note: string | undefined;
71
+ sign: Sign;
72
+ };
73
+
74
+ export type ServicesParse =
75
+ | { ok: true; diagram: ServiceDiagram }
76
+ | { ok: false; line: number; reason: string };
77
+
78
+ const name = String.raw`[A-Za-z_]\w*`;
79
+ const serviceLine = new RegExp(
80
+ String.raw`^service\s+(${name})(?:\s+"([^"]*)")?\s*$`,
81
+ );
82
+ const stateLine = new RegExp(String.raw`^state\s+(${name})\s*=\s*(.+)$`);
83
+ const stepLine = /^step\b(?:\s+"([^"]*)")?\s*$/;
84
+ const startLine = /^start\b(?:\s+"([^"]*)")?\s*$/;
85
+ const eventLine = new RegExp(
86
+ String.raw`^(${name})\s*->\s*(${name})(?:\s*:\s*(.*))?$`,
87
+ );
88
+ const updateLine = new RegExp(String.raw`^(${name})\.(${name})\s*=\s*(.+)$`);
89
+
90
+ /**
91
+ * ```services
92
+ * service API "HTTP server" [[src/api.ts]]
93
+ * state requests = 0
94
+ * service Auth
95
+ * state attempts = 0
96
+ *
97
+ * step "Check the password"
98
+ * API -> Auth: verify(alice) [[src/api.ts#login]]
99
+ * Auth.attempts = 1
100
+ * ```
101
+ */
102
+ export function parseServiceDiagram(
103
+ source: string,
104
+ diff = false,
105
+ ): ServicesParse {
106
+ const services: Service[] = [];
107
+ const byName = new Map<string, Service>();
108
+ const steps: ServiceStep[] = [];
109
+ let start: ServiceDiagram["start"];
110
+ // Open services, innermost last, with the indent of their `service` line.
111
+ const open: { indent: number; service: Service }[] = [];
112
+ // Where a `>` line's text goes: a description on a service, state, or
113
+ // step, or a note on an event or update.
114
+ let described: { indent: number; append: (text: string) => void } | undefined;
115
+ const describe = (indent: number, item: { description?: string }) => {
116
+ described = {
117
+ indent,
118
+ append: (more) => {
119
+ item.description =
120
+ item.description === undefined ? more : `${item.description} ${more}`;
121
+ },
122
+ };
123
+ };
124
+ const annotate = (indent: number, item: { note?: string }) => {
125
+ described = {
126
+ indent,
127
+ append: (more) => {
128
+ item.note = item.note === undefined ? more : `${item.note} ${more}`;
129
+ },
130
+ };
131
+ };
132
+ const lines = source.split("\n");
133
+ for (const [index, row] of lines.entries()) {
134
+ const line = index + 1;
135
+ const fail = (reason: string): ServicesParse => ({
136
+ ok: false,
137
+ line,
138
+ reason,
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;
147
+ const text = raw.trim();
148
+ if (text.length === 0) continue;
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
+ };
162
+ if (text.startsWith(">")) {
163
+ if (described === undefined || indent <= described.indent)
164
+ return fail(
165
+ "Put a > line directly below the service, state, step, event, or update it describes, indented further.",
166
+ );
167
+ described.append(text.slice(1).trim());
168
+ continue;
169
+ }
170
+ const { text: body, references } = parseAnnotation(text);
171
+ const annotation =
172
+ references.length === 0 ? undefined : { text: body.trim(), references };
173
+ const step = steps.at(-1);
174
+
175
+ const serviceMatch = serviceLine.exec(body.trim());
176
+ if (serviceMatch !== null) {
177
+ if (step !== undefined)
178
+ return fail("Declare every service before the first step.");
179
+ const serviceName = serviceMatch[1]!;
180
+ if (byName.has(serviceName))
181
+ return fail(`Service ${serviceName} is declared twice.`);
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;
185
+ const service: Service = {
186
+ name: serviceName,
187
+ label: serviceMatch[2],
188
+ description: undefined,
189
+ state: [],
190
+ children: [],
191
+ annotation:
192
+ annotation === undefined
193
+ ? undefined
194
+ : { ...annotation, text: serviceName },
195
+ sign,
196
+ };
197
+ (open.at(-1)?.service.children ?? services).push(service);
198
+ byName.set(serviceName, service);
199
+ open.push({ indent, service });
200
+ describe(indent, service);
201
+ continue;
202
+ }
203
+
204
+ const stateMatch = stateLine.exec(text);
205
+ if (stateMatch !== null) {
206
+ if (step !== undefined)
207
+ return fail(
208
+ "Declare state under its service, before the first step. Change it in a step with Service.key = value.",
209
+ );
210
+ while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
211
+ const owner = open.at(-1)?.service;
212
+ if (owner === undefined)
213
+ return fail("Indent state under the service that owns it.");
214
+ const key = stateMatch[1]!;
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 = {
235
+ key,
236
+ value,
237
+ todayValue: undefined,
238
+ description: undefined,
239
+ sign,
240
+ };
241
+ owner.state.push(entry);
242
+ describe(indent, entry);
243
+ continue;
244
+ }
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
+
263
+ const stepMatch = stepLine.exec(text);
264
+ if (stepMatch !== null) {
265
+ if (indent !== 0) return fail("Start step lines at column 0.");
266
+ const caption = stepMatch[1]?.trim();
267
+ if (caption === undefined || caption === "")
268
+ return fail(
269
+ 'Give the step a caption that says what happens, like step "Auth checks the password".',
270
+ );
271
+ const next: ServiceStep = {
272
+ caption,
273
+ description: undefined,
274
+ events: [],
275
+ updates: [],
276
+ sign: own,
277
+ };
278
+ steps.push(next);
279
+ describe(indent, next);
280
+ continue;
281
+ }
282
+
283
+ if (step !== undefined && indent > 0) {
284
+ const event = eventLine.exec(body.trim());
285
+ if (event !== null) {
286
+ for (const service of [event[1]!, event[2]!])
287
+ if (!byName.has(service)) return fail(`Unknown service ${service}.`);
288
+ const label = (event[3] ?? "").trim();
289
+ const sign = signWithin(step, "step");
290
+ if (typeof sign !== "string") return sign;
291
+ const entry: ServiceEvent = {
292
+ from: event[1]!,
293
+ to: event[2]!,
294
+ label,
295
+ note: undefined,
296
+ annotation:
297
+ annotation === undefined
298
+ ? undefined
299
+ : {
300
+ ...annotation,
301
+ text: `${event[1]} → ${event[2]}${label === "" ? "" : `: ${label}`}`,
302
+ },
303
+ sign,
304
+ };
305
+ step.events.push(entry);
306
+ annotate(indent, entry);
307
+ continue;
308
+ }
309
+ const update = updateLine.exec(text);
310
+ if (update !== null) {
311
+ const owner = byName.get(update[1]!);
312
+ if (owner === undefined) return fail(`Unknown service ${update[1]}.`);
313
+ if (!owner.state.some((entry) => entry.key === update[2]))
314
+ return fail(
315
+ `${owner.name} has no state ${update[2]}. Declare it with state ${update[2]} = <initial value>.`,
316
+ );
317
+ const sign = signWithin(step, "step");
318
+ if (typeof sign !== "string") return sign;
319
+ const entry: ServiceUpdate = {
320
+ service: owner.name,
321
+ key: update[2]!,
322
+ value: update[3]!.trim(),
323
+ note: undefined,
324
+ sign,
325
+ };
326
+ step.updates.push(entry);
327
+ annotate(indent, entry);
328
+ continue;
329
+ }
330
+ return fail(
331
+ `Expected an event (From -> To: label) or a state update (Service.key = value), got "${text}".`,
332
+ );
333
+ }
334
+
335
+ return fail(
336
+ `Expected service <Name>, state <key> = <value>, start "<caption>", or step "<caption>", got "${text}".`,
337
+ );
338
+ }
339
+ if (services.length === 0)
340
+ return { ok: false, line: 1, reason: "Declare at least one service." };
341
+ return { ok: true, diagram: { services, start, steps, diff } };
342
+ }
343
+
344
+ /** Every service in declaration order, with its parent. */
345
+ export function flattenServices(
346
+ services: Service[],
347
+ parent?: Service,
348
+ ): { service: Service; parent: Service | undefined }[] {
349
+ return services.flatMap((service) => [
350
+ { service, parent },
351
+ ...flattenServices(service.children, service),
352
+ ]);
353
+ }
354
+
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" ? "+" : "-";
365
+ const state = new Map(
366
+ flattenServices(diagram.services).map(({ service }) => [
367
+ service.name,
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
+ ),
376
+ ]),
377
+ );
378
+ for (const { updates } of diagram.steps.slice(0, step))
379
+ for (const update of updates)
380
+ if (update.sign !== skip)
381
+ state.get(update.service)!.set(update.key, update.value);
382
+ return state;
383
+ }
384
+
385
+ export function serviceAnnotations(diagram: ServiceDiagram) {
386
+ return [
387
+ ...flattenServices(diagram.services).flatMap(({ service }) =>
388
+ service.annotation === undefined ? [] : [service.annotation],
389
+ ),
390
+ ...diagram.steps.flatMap((step) =>
391
+ step.events.flatMap((event) =>
392
+ event.annotation === undefined ? [] : [event.annotation],
393
+ ),
394
+ ),
395
+ ];
396
+ }
package/src/share.ts CHANGED
@@ -45,7 +45,7 @@ export async function shareMarkdownFile(input: {
45
45
  const changes = await readDocumentChanges(input.workspaceRoot, parsed);
46
46
  if (changes instanceof Error) return changes;
47
47
  // An uncommitted changes card shows the tree as it is when you share.
48
- for (const rev of parsed.changeRevs) {
48
+ for (const { rev } of parsed.changeCards) {
49
49
  if (changes.has(rev)) continue;
50
50
  const loaded = await readChanges(input.workspaceRoot, rev);
51
51
  if (loaded instanceof Error) return loaded;
@@ -1,33 +1,60 @@
1
- import {
2
- backgroundColor,
3
- colors,
4
- focusRing,
5
- LARGE_SCALE,
6
- spacing,
7
- text,
8
- } from "maui";
1
+ import { colors, focusRing, LARGE_SCALE, text, Tooltip } from "maui";
9
2
  import { defineVars, style, useStyles } from "purse-styles";
10
3
  import { codeFontFamily } from "../codeFont.js";
11
- import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
12
- import { pierreShell } from "./pierre.js";
4
+ import {
5
+ callStackRow,
6
+ type SourceAnnotation,
7
+ type SourceNavigation,
8
+ } from "../annotations.js";
9
+ import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
10
+ import { NoteText, NoteTokens } from "./notes.tsx";
11
+ import { fenceTitle, pierreShell } from "./pierre.js";
13
12
 
14
13
  export function CallStackDiff(
15
- props: { lines: SourceAnnotation[] } & SourceNavigation,
14
+ props: {
15
+ lines: SourceAnnotation[];
16
+ highlights: number[];
17
+ notes: CodeNote[];
18
+ title: string | undefined;
19
+ } & SourceNavigation,
16
20
  ) {
17
21
  const shell = useStyles(pierreShell, styles.shell);
18
- const row = useStyles(styles.row);
22
+ const titleClassName = useStyles(fenceTitle);
23
+ const rowClassName = useStyles(styles.row);
19
24
  return (
20
- <div className={shell} data-diffmap-kind="callstack">
25
+ <figure className={shell} data-diffmap-kind="callstack">
26
+ {props.title !== undefined && (
27
+ <figcaption className={titleClassName}>{props.title}</figcaption>
28
+ )}
21
29
  {props.lines.map((line, index) => {
22
- const sign = line.text.startsWith("+")
23
- ? "+"
24
- : line.text.startsWith("-")
25
- ? "-"
26
- : " ";
27
- const label = line.text.replace(/^[+ -]/, "");
28
- const tree = /^[ \t│┃├└─]*/u.exec(label)![0];
29
- const description = label.slice(tree.length);
30
+ const { sign, tree, description } = callStackRow(line.text);
30
31
  const comment = description.indexOf("#");
32
+ const linked = line.references.length > 0;
33
+ const row = index + 1;
34
+ const lineNote = props.notes.find(
35
+ (note) =>
36
+ note.target.kind === "lines" &&
37
+ note.target.start <= row &&
38
+ row <= note.target.end,
39
+ );
40
+ const matches = lineSymbolMatches(description, props.notes);
41
+ const rowProps = {
42
+ className: rowClassName,
43
+ "data-change": sign,
44
+ "data-highlighted": props.highlights.includes(row) || undefined,
45
+ "data-note":
46
+ lineNote === undefined
47
+ ? undefined
48
+ : lineNote.target.kind === "lines" &&
49
+ lineNote.target.start === row
50
+ ? "start"
51
+ : "",
52
+ // A native title would show beside the note tooltip.
53
+ title:
54
+ lineNote === undefined && matches.length === 0
55
+ ? description
56
+ : undefined,
57
+ };
31
58
  const content = (
32
59
  <>
33
60
  <span className="diff-sign" aria-hidden="true">
@@ -40,12 +67,21 @@ export function CallStackDiff(
40
67
  ))}
41
68
  </span>
42
69
  <span className="stack-label">
43
- {comment === -1 ? description : description.slice(0, comment)}
44
- {comment !== -1 && (
45
- <span className="stack-comment">
46
- {description.slice(comment)}
47
- </span>
48
- )}
70
+ <NoteTokens
71
+ tokens={
72
+ comment === -1
73
+ ? [{ content: description }]
74
+ : [
75
+ { content: description.slice(0, comment) },
76
+ {
77
+ content: description.slice(comment),
78
+ className: "stack-comment",
79
+ },
80
+ ]
81
+ }
82
+ matches={matches}
83
+ focusable={!linked}
84
+ />
49
85
  </span>
50
86
  </span>
51
87
  {line.references.length > 0 && (
@@ -55,55 +91,59 @@ export function CallStackDiff(
55
91
  )}
56
92
  </>
57
93
  );
58
- if (line.references.length === 0) {
59
- return (
60
- <div
61
- key={index}
62
- className={row}
63
- data-change={sign}
64
- title={description}
65
- >
66
- {content}
67
- </div>
68
- );
69
- }
70
- return (
94
+ const element = linked ? (
71
95
  <button
72
96
  key={index}
73
97
  type="button"
74
- className={row}
75
- data-change={sign}
98
+ {...rowProps}
76
99
  aria-pressed={props.selectedAnnotation === line}
77
100
  aria-controls="source-diff-panel"
78
- title={description}
79
101
  aria-label={description}
80
102
  onClick={() => props.onSelectAnnotation(line)}
81
103
  >
82
104
  {content}
83
105
  </button>
106
+ ) : (
107
+ <div
108
+ key={index}
109
+ {...rowProps}
110
+ tabIndex={lineNote === undefined ? undefined : 0}
111
+ >
112
+ {content}
113
+ </div>
114
+ );
115
+ if (lineNote === undefined) return element;
116
+ return (
117
+ <Tooltip key={index} content={<NoteText text={lineNote.text} />}>
118
+ {element}
119
+ </Tooltip>
84
120
  );
85
121
  })}
86
- </div>
122
+ </figure>
87
123
  );
88
124
  }
89
125
 
90
126
  const rowSize = defineVars({
91
- height: { default: "28px", [LARGE_SCALE]: "40px" },
127
+ height: { default: "22px", [LARGE_SCALE]: "32px" },
92
128
  });
93
129
 
94
130
  const styles = {
95
- shell: style(
96
- text({ size: "sm", fontWeight: 500, color: "highContrast" }),
97
- spacing.padding({ y: 2 }),
98
- {
99
- backgroundColor: backgroundColor.app,
100
- overflowX: "hidden",
101
- maxWidth: "100%",
131
+ // Sized like Maui's code block, so stacks and code read as one family.
132
+ shell: style(text({ size: "xs", fontWeight: 400, color: "highContrast" }), {
133
+ fontFamily: codeFontFamily,
134
+ margin: 0,
135
+ paddingBlock: "8px",
136
+ overflowX: "hidden",
137
+ maxWidth: "100%",
138
+ // The shell's top padding goes below the title, between it and the rows.
139
+ "& > figcaption": {
140
+ marginTop: "-8px",
141
+ marginBottom: "8px",
102
142
  },
103
- ),
143
+ }),
104
144
  row: style(
105
- spacing.padding({ x: 3 }),
106
145
  {
146
+ paddingInline: "12px",
107
147
  display: "flex",
108
148
  alignItems: "center",
109
149
  gap: "12px",
@@ -120,6 +160,30 @@ const styles = {
120
160
  border: 0,
121
161
  "&[data-change='+']": { backgroundColor: colors.green[3] },
122
162
  "&[data-change='-']": { backgroundColor: colors.red[3] },
163
+ "&[data-highlighted]": {
164
+ backgroundColor: colors.blue[3],
165
+ boxShadow: `inset 3px 0 ${colors.blue[9]}`,
166
+ },
167
+ "&[data-change='+'][data-highlighted]": {
168
+ backgroundColor: colors.green[5],
169
+ boxShadow: `inset 3px 0 ${colors.green[9]}`,
170
+ },
171
+ "&[data-change='-'][data-highlighted]": {
172
+ backgroundColor: colors.red[5],
173
+ boxShadow: `inset 3px 0 ${colors.red[9]}`,
174
+ },
175
+ "&[data-note]": { cursor: "help" },
176
+ // The sign fills the row gutter, so the dot follows the label instead.
177
+ "&[data-note='start'] .stack-label::after": {
178
+ content: '""',
179
+ display: "inline-block",
180
+ width: "5px",
181
+ height: "5px",
182
+ marginLeft: "8px",
183
+ verticalAlign: "middle",
184
+ borderRadius: "50%",
185
+ backgroundColor: colors.blue[9],
186
+ },
123
187
  "&[aria-pressed]": { cursor: "pointer" },
124
188
  "&[aria-pressed]:hover": { backgroundColor: colors.blue[2] },
125
189
  "&[aria-pressed='true'], &[aria-pressed='true']:hover": {
@@ -138,7 +202,7 @@ const styles = {
138
202
  alignSelf: "center",
139
203
  flex: "1 1 auto",
140
204
  minWidth: 0,
141
- paddingBlock: "3px",
205
+ paddingBlock: "1px",
142
206
  overflow: "hidden",
143
207
  textOverflow: "ellipsis",
144
208
  whiteSpace: "nowrap",