@tanishqkancharla/diffmap 0.3.0 → 0.4.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,289 @@
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
+ steps: ServiceStep[];
11
+ };
12
+
13
+ export type Service = {
14
+ name: string;
15
+ label: string | undefined;
16
+ description: string | undefined;
17
+ state: ServiceState[];
18
+ children: Service[];
19
+ annotation: SourceAnnotation | undefined;
20
+ };
21
+
22
+ export type ServiceState = {
23
+ key: string;
24
+ value: string;
25
+ description: string | undefined;
26
+ };
27
+
28
+ export type ServiceStep = {
29
+ /** What happens in this step, in a sentence. */
30
+ caption: string;
31
+ description: string | undefined;
32
+ events: ServiceEvent[];
33
+ updates: ServiceUpdate[];
34
+ };
35
+
36
+ export type ServiceEvent = {
37
+ from: string;
38
+ to: string;
39
+ label: string;
40
+ /** Shown beside the event's label while its step is current. */
41
+ note: string | undefined;
42
+ annotation: SourceAnnotation | undefined;
43
+ };
44
+
45
+ export type ServiceUpdate = {
46
+ service: string;
47
+ key: string;
48
+ value: string;
49
+ /** Shown beside the changed state while its step is current. */
50
+ note: string | undefined;
51
+ };
52
+
53
+ export type ServicesParse =
54
+ | { ok: true; diagram: ServiceDiagram }
55
+ | { ok: false; line: number; reason: string };
56
+
57
+ const name = String.raw`[A-Za-z_]\w*`;
58
+ const serviceLine = new RegExp(
59
+ String.raw`^service\s+(${name})(?:\s+"([^"]*)")?\s*$`,
60
+ );
61
+ const stateLine = new RegExp(String.raw`^state\s+(${name})\s*=\s*(.+)$`);
62
+ const stepLine = /^step\b(?:\s+"([^"]*)")?\s*$/;
63
+ const eventLine = new RegExp(
64
+ String.raw`^(${name})\s*->\s*(${name})(?:\s*:\s*(.*))?$`,
65
+ );
66
+ const updateLine = new RegExp(String.raw`^(${name})\.(${name})\s*=\s*(.+)$`);
67
+
68
+ /**
69
+ * ```services
70
+ * service API "HTTP server" [[src/api.ts]]
71
+ * state requests = 0
72
+ * service Auth
73
+ * state attempts = 0
74
+ *
75
+ * step "Check the password"
76
+ * API -> Auth: verify(alice) [[src/api.ts#login]]
77
+ * Auth.attempts = 1
78
+ * ```
79
+ */
80
+ export function parseServiceDiagram(source: string): ServicesParse {
81
+ const services: Service[] = [];
82
+ const byName = new Map<string, Service>();
83
+ const steps: ServiceStep[] = [];
84
+ // Open services, innermost last, with the indent of their `service` line.
85
+ const open: { indent: number; service: Service }[] = [];
86
+ // Where a `>` line's text goes: a description on a service, state, or
87
+ // step, or a note on an event or update.
88
+ let described: { indent: number; append: (text: string) => void } | undefined;
89
+ const describe = (indent: number, item: { description?: string }) => {
90
+ described = {
91
+ indent,
92
+ append: (more) => {
93
+ item.description =
94
+ item.description === undefined ? more : `${item.description} ${more}`;
95
+ },
96
+ };
97
+ };
98
+ const annotate = (indent: number, item: { note?: string }) => {
99
+ described = {
100
+ indent,
101
+ append: (more) => {
102
+ item.note = item.note === undefined ? more : `${item.note} ${more}`;
103
+ },
104
+ };
105
+ };
106
+ const lines = source.split("\n");
107
+ for (const [index, raw] of lines.entries()) {
108
+ const line = index + 1;
109
+ const fail = (reason: string): ServicesParse => ({
110
+ ok: false,
111
+ line,
112
+ reason,
113
+ });
114
+ const text = raw.trim();
115
+ if (text.length === 0 || text.startsWith("%%")) continue;
116
+ const indent = raw.replace(/\t/g, " ").search(/\S/);
117
+ if (text.startsWith(">")) {
118
+ if (described === undefined || indent <= described.indent)
119
+ return fail(
120
+ "Put a > line directly below the service, state, step, event, or update it describes, indented further.",
121
+ );
122
+ described.append(text.slice(1).trim());
123
+ continue;
124
+ }
125
+ const { text: body, references } = parseAnnotation(text);
126
+ const annotation =
127
+ references.length === 0 ? undefined : { text: body.trim(), references };
128
+ const step = steps.at(-1);
129
+
130
+ const serviceMatch = serviceLine.exec(body.trim());
131
+ if (serviceMatch !== null) {
132
+ if (step !== undefined)
133
+ return fail("Declare every service before the first step.");
134
+ const serviceName = serviceMatch[1]!;
135
+ if (byName.has(serviceName))
136
+ return fail(`Service ${serviceName} is declared twice.`);
137
+ while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
138
+ const service: Service = {
139
+ name: serviceName,
140
+ label: serviceMatch[2],
141
+ description: undefined,
142
+ state: [],
143
+ children: [],
144
+ annotation:
145
+ annotation === undefined
146
+ ? undefined
147
+ : { ...annotation, text: serviceName },
148
+ };
149
+ (open.at(-1)?.service.children ?? services).push(service);
150
+ byName.set(serviceName, service);
151
+ open.push({ indent, service });
152
+ describe(indent, service);
153
+ continue;
154
+ }
155
+
156
+ const stateMatch = stateLine.exec(text);
157
+ if (stateMatch !== null) {
158
+ if (step !== undefined)
159
+ return fail(
160
+ "Declare state under its service, before the first step. Change it in a step with Service.key = value.",
161
+ );
162
+ while (open.length > 0 && open.at(-1)!.indent >= indent) open.pop();
163
+ const owner = open.at(-1)?.service;
164
+ if (owner === undefined)
165
+ return fail("Indent state under the service that owns it.");
166
+ const key = stateMatch[1]!;
167
+ if (owner.state.some((entry) => entry.key === key))
168
+ return fail(`${owner.name}.${key} is declared twice.`);
169
+ const entry = {
170
+ key,
171
+ value: stateMatch[2]!.trim(),
172
+ description: undefined,
173
+ };
174
+ owner.state.push(entry);
175
+ describe(indent, entry);
176
+ continue;
177
+ }
178
+
179
+ const stepMatch = stepLine.exec(text);
180
+ if (stepMatch !== null) {
181
+ if (indent !== 0) return fail("Start step lines at column 0.");
182
+ const caption = stepMatch[1]?.trim();
183
+ if (caption === undefined || caption === "")
184
+ return fail(
185
+ 'Give the step a caption that says what happens, like step "Auth checks the password".',
186
+ );
187
+ const next: ServiceStep = {
188
+ caption,
189
+ description: undefined,
190
+ events: [],
191
+ updates: [],
192
+ };
193
+ steps.push(next);
194
+ describe(indent, next);
195
+ continue;
196
+ }
197
+
198
+ if (step !== undefined && indent > 0) {
199
+ const event = eventLine.exec(body.trim());
200
+ if (event !== null) {
201
+ for (const service of [event[1]!, event[2]!])
202
+ if (!byName.has(service)) return fail(`Unknown service ${service}.`);
203
+ const label = (event[3] ?? "").trim();
204
+ const entry: ServiceEvent = {
205
+ from: event[1]!,
206
+ to: event[2]!,
207
+ label,
208
+ note: undefined,
209
+ annotation:
210
+ annotation === undefined
211
+ ? undefined
212
+ : {
213
+ ...annotation,
214
+ text: `${event[1]} → ${event[2]}${label === "" ? "" : `: ${label}`}`,
215
+ },
216
+ };
217
+ step.events.push(entry);
218
+ annotate(indent, entry);
219
+ continue;
220
+ }
221
+ const update = updateLine.exec(text);
222
+ if (update !== null) {
223
+ const owner = byName.get(update[1]!);
224
+ if (owner === undefined) return fail(`Unknown service ${update[1]}.`);
225
+ if (!owner.state.some((entry) => entry.key === update[2]))
226
+ return fail(
227
+ `${owner.name} has no state ${update[2]}. Declare it with state ${update[2]} = <initial value>.`,
228
+ );
229
+ const entry: ServiceUpdate = {
230
+ service: owner.name,
231
+ key: update[2]!,
232
+ value: update[3]!.trim(),
233
+ note: undefined,
234
+ };
235
+ step.updates.push(entry);
236
+ annotate(indent, entry);
237
+ continue;
238
+ }
239
+ return fail(
240
+ `Expected an event (From -> To: label) or a state update (Service.key = value), got "${text}".`,
241
+ );
242
+ }
243
+
244
+ return fail(
245
+ `Expected service <Name>, state <key> = <value>, or step "<caption>", got "${text}".`,
246
+ );
247
+ }
248
+ if (services.length === 0)
249
+ return { ok: false, line: 1, reason: "Declare at least one service." };
250
+ return { ok: true, diagram: { services, steps } };
251
+ }
252
+
253
+ /** Every service in declaration order, with its parent. */
254
+ export function flattenServices(
255
+ services: Service[],
256
+ parent?: Service,
257
+ ): { service: Service; parent: Service | undefined }[] {
258
+ return services.flatMap((service) => [
259
+ { service, parent },
260
+ ...flattenServices(service.children, service),
261
+ ]);
262
+ }
263
+
264
+ /** State after the first `step` steps; step 0 is the declared state. */
265
+ export function serviceStateAt(diagram: ServiceDiagram, step: number) {
266
+ const state = new Map(
267
+ flattenServices(diagram.services).map(({ service }) => [
268
+ service.name,
269
+ new Map(service.state.map((entry) => [entry.key, entry.value])),
270
+ ]),
271
+ );
272
+ for (const { updates } of diagram.steps.slice(0, step))
273
+ for (const update of updates)
274
+ state.get(update.service)!.set(update.key, update.value);
275
+ return state;
276
+ }
277
+
278
+ export function serviceAnnotations(diagram: ServiceDiagram) {
279
+ return [
280
+ ...flattenServices(diagram.services).flatMap(({ service }) =>
281
+ service.annotation === undefined ? [] : [service.annotation],
282
+ ),
283
+ ...diagram.steps.flatMap((step) =>
284
+ step.events.flatMap((event) =>
285
+ event.annotation === undefined ? [] : [event.annotation],
286
+ ),
287
+ ),
288
+ ];
289
+ }
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;
@@ -5,17 +5,24 @@ import {
5
5
  LARGE_SCALE,
6
6
  spacing,
7
7
  text,
8
+ Tooltip,
8
9
  } from "maui";
9
10
  import { defineVars, style, useStyles } from "purse-styles";
10
11
  import { codeFontFamily } from "../codeFont.js";
11
12
  import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
13
+ import { lineSymbolMatches, type CodeNote } from "../codeNotes.js";
14
+ import { NoteText, NoteTokens } from "./notes.tsx";
12
15
  import { pierreShell } from "./pierre.js";
13
16
 
14
17
  export function CallStackDiff(
15
- props: { lines: SourceAnnotation[] } & SourceNavigation,
18
+ props: {
19
+ lines: SourceAnnotation[];
20
+ highlights: number[];
21
+ notes: CodeNote[];
22
+ } & SourceNavigation,
16
23
  ) {
17
24
  const shell = useStyles(pierreShell, styles.shell);
18
- const row = useStyles(styles.row);
25
+ const rowClassName = useStyles(styles.row);
19
26
  return (
20
27
  <div className={shell} data-diffmap-kind="callstack">
21
28
  {props.lines.map((line, index) => {
@@ -28,6 +35,32 @@ export function CallStackDiff(
28
35
  const tree = /^[ \t│┃├└─]*/u.exec(label)![0];
29
36
  const description = label.slice(tree.length);
30
37
  const comment = description.indexOf("#");
38
+ const linked = line.references.length > 0;
39
+ const row = index + 1;
40
+ const lineNote = props.notes.find(
41
+ (note) =>
42
+ note.target.kind === "lines" &&
43
+ note.target.start <= row &&
44
+ row <= note.target.end,
45
+ );
46
+ const matches = lineSymbolMatches(description, props.notes);
47
+ const rowProps = {
48
+ className: rowClassName,
49
+ "data-change": sign,
50
+ "data-highlighted": props.highlights.includes(row) || undefined,
51
+ "data-note":
52
+ lineNote === undefined
53
+ ? undefined
54
+ : lineNote.target.kind === "lines" &&
55
+ lineNote.target.start === row
56
+ ? "start"
57
+ : "",
58
+ // A native title would show beside the note tooltip.
59
+ title:
60
+ lineNote === undefined && matches.length === 0
61
+ ? description
62
+ : undefined,
63
+ };
31
64
  const content = (
32
65
  <>
33
66
  <span className="diff-sign" aria-hidden="true">
@@ -40,12 +73,21 @@ export function CallStackDiff(
40
73
  ))}
41
74
  </span>
42
75
  <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
- )}
76
+ <NoteTokens
77
+ tokens={
78
+ comment === -1
79
+ ? [{ content: description }]
80
+ : [
81
+ { content: description.slice(0, comment) },
82
+ {
83
+ content: description.slice(comment),
84
+ className: "stack-comment",
85
+ },
86
+ ]
87
+ }
88
+ matches={matches}
89
+ focusable={!linked}
90
+ />
49
91
  </span>
50
92
  </span>
51
93
  {line.references.length > 0 && (
@@ -55,32 +97,32 @@ export function CallStackDiff(
55
97
  )}
56
98
  </>
57
99
  );
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 (
100
+ const element = linked ? (
71
101
  <button
72
102
  key={index}
73
103
  type="button"
74
- className={row}
75
- data-change={sign}
104
+ {...rowProps}
76
105
  aria-pressed={props.selectedAnnotation === line}
77
106
  aria-controls="source-diff-panel"
78
- title={description}
79
107
  aria-label={description}
80
108
  onClick={() => props.onSelectAnnotation(line)}
81
109
  >
82
110
  {content}
83
111
  </button>
112
+ ) : (
113
+ <div
114
+ key={index}
115
+ {...rowProps}
116
+ tabIndex={lineNote === undefined ? undefined : 0}
117
+ >
118
+ {content}
119
+ </div>
120
+ );
121
+ if (lineNote === undefined) return element;
122
+ return (
123
+ <Tooltip key={index} content={<NoteText text={lineNote.text} />}>
124
+ {element}
125
+ </Tooltip>
84
126
  );
85
127
  })}
86
128
  </div>
@@ -120,6 +162,30 @@ const styles = {
120
162
  border: 0,
121
163
  "&[data-change='+']": { backgroundColor: colors.green[3] },
122
164
  "&[data-change='-']": { backgroundColor: colors.red[3] },
165
+ "&[data-highlighted]": {
166
+ backgroundColor: colors.blue[3],
167
+ boxShadow: `inset 3px 0 ${colors.blue[9]}`,
168
+ },
169
+ "&[data-change='+'][data-highlighted]": {
170
+ backgroundColor: colors.green[5],
171
+ boxShadow: `inset 3px 0 ${colors.green[9]}`,
172
+ },
173
+ "&[data-change='-'][data-highlighted]": {
174
+ backgroundColor: colors.red[5],
175
+ boxShadow: `inset 3px 0 ${colors.red[9]}`,
176
+ },
177
+ "&[data-note]": { cursor: "help" },
178
+ // The sign fills the row gutter, so the dot follows the label instead.
179
+ "&[data-note='start'] .stack-label::after": {
180
+ content: '""',
181
+ display: "inline-block",
182
+ width: "5px",
183
+ height: "5px",
184
+ marginLeft: "8px",
185
+ verticalAlign: "middle",
186
+ borderRadius: "50%",
187
+ backgroundColor: colors.blue[9],
188
+ },
123
189
  "&[aria-pressed]": { cursor: "pointer" },
124
190
  "&[aria-pressed]:hover": { backgroundColor: colors.blue[2] },
125
191
  "&[aria-pressed='true'], &[aria-pressed='true']:hover": {