@tsplc/debug 0.0.0-stage → 0.2.16

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,548 @@
1
+ import type { Quality } from "../api.ts";
2
+ import type { DebugEventSite, DebugManifest, DebugType, SourceRange } from "../manifest.ts";
3
+
4
+ /** A conveyor line's debug manifest for `dev.ts`: diverters, scanners, an inbound conveyor and ~5000 paths. */
5
+
6
+ const at = (file: number, line: number, column = 5, length = 24): SourceRange => ({
7
+ file,
8
+ line,
9
+ column,
10
+ endLine: line,
11
+ endColumn: column + length,
12
+ });
13
+
14
+ const record = (
15
+ name: string,
16
+ file: number,
17
+ fields: [string, string, number, true?][],
18
+ ): DebugType => ({
19
+ kind: "record",
20
+ name,
21
+ declaration: at(file, Math.min(...fields.map(([, , line]) => line)) - 1, 1),
22
+ fields: fields.map(([field, type, line, generated]) => ({
23
+ name: field,
24
+ target: field,
25
+ type,
26
+ declaration: at(file, line),
27
+ ...(generated ? { generated } : {}),
28
+ })),
29
+ });
30
+
31
+ const array = (element: string, length: number): DebugType => ({
32
+ kind: "array",
33
+ element,
34
+ length,
35
+ lower: 0,
36
+ });
37
+
38
+ const binSites: DebugEventSite[] = Array.from({ length: 16 }, (_, index) => [
39
+ {
40
+ id: 100 + index,
41
+ kind: "state" as const,
42
+ path: `program.diverters.binTrackers[${index}].bin`,
43
+ function: 1,
44
+ type: "Bin",
45
+ range: at(1, 41, 7, 24),
46
+ text: "this.bin = Bin.Present;",
47
+ task: "MainTask",
48
+ operands: [
49
+ {
50
+ text: "exit",
51
+ range: at(1, 39, 11, 4),
52
+ type: "BOOL",
53
+ path: `line.conveyors.inbound.cards[${index % 10}].input.sensor`,
54
+ stable: true,
55
+ },
56
+ {
57
+ text: "this.count",
58
+ range: at(1, 39, 19, 10),
59
+ type: "DINT",
60
+ path: `program.diverters.binTrackers[${index}].count`,
61
+ stable: false,
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ id: 200 + index,
67
+ kind: "state" as const,
68
+ path: `program.diverters.binTrackers[${index}].bin`,
69
+ function: 1,
70
+ type: "Bin",
71
+ range: at(1, 33, 7, 25),
72
+ text: "this.bin = Bin.Arriving;",
73
+ task: "MainTask",
74
+ operands: [
75
+ { text: "entry", range: at(1, 31, 11, 5), type: "BOOL", stable: true },
76
+ { text: "this.bin === Bin.None", range: at(1, 31, 20, 21), type: "BOOL", stable: true },
77
+ ],
78
+ },
79
+ ]).flat();
80
+
81
+ const routeSites: DebugEventSite[] = Array.from({ length: 4 }, (_, index) => ({
82
+ id: 300 + index,
83
+ kind: "decision",
84
+ path: `program.scanners.routeLookups[${index}].lane`,
85
+ function: 2,
86
+ type: "INT",
87
+ range: at(2, 31, 7, 18),
88
+ text: "return route.lane;",
89
+ task: "ServiceTask",
90
+ operands: [{ text: "route.ok", range: at(2, 29, 9, 8), type: "BOOL", stable: true }],
91
+ }));
92
+
93
+ const resolveSteps = [
94
+ { step: 0, kind: "statement" as const, range: at(2, 22), text: "this.pending = true;" },
95
+ {
96
+ step: 1,
97
+ kind: "await" as const,
98
+ range: at(2, 24, 18, 46),
99
+ text: "await services.routing.resolve({ code: this.code })",
100
+ },
101
+ { step: 2, kind: "statement" as const, range: at(2, 27), text: "this.lane = route.lane;" },
102
+ { step: 3, kind: "return" as const, range: at(2, 31), text: "return route.lane;" },
103
+ ];
104
+
105
+ export const fixtureManifest: DebugManifest = {
106
+ kind: "tsplc-debug",
107
+ version: 1,
108
+ build: { id: "c41f7e2a9b0d", target: "GVL_TsplcDebug.build", compiler: "0.2.13" },
109
+ sources: [
110
+ "src/program.ts",
111
+ "src/diverters/bin-tracker.ts",
112
+ "src/scanners/route-lookup.ts",
113
+ "src/line/inbound.ts",
114
+ "src/tracking.ts",
115
+ "src/settings.ts",
116
+ ],
117
+ types: {
118
+ BOOL: { kind: "scalar", iec: "BOOL" },
119
+ INT: { kind: "scalar", iec: "INT" },
120
+ DINT: { kind: "scalar", iec: "DINT" },
121
+ REAL: { kind: "scalar", iec: "REAL" },
122
+ STRING: { kind: "scalar", iec: "STRING" },
123
+ Bin: { kind: "enum", name: "Bin", members: { None: 0, Arriving: 1, Present: 2 } },
124
+ Mode: { kind: "enum", name: "Mode", members: { Stopped: 0, Running: 1, Faulted: 2 } },
125
+ ParcelState: {
126
+ kind: "enum",
127
+ name: "ParcelState",
128
+ members: { Inducted: 0, Scanned: 1, Routed: 2, Diverted: 3, Lost: 4 },
129
+ },
130
+ BinTracker: record("BinTracker", 1, [
131
+ ["bin", "Bin", 8],
132
+ ["count", "DINT", 9],
133
+ ["exitClear", "BOOL", 10],
134
+ ["lastCode", "STRING", 11],
135
+ ["releaseStep", "DINT", 12, true],
136
+ ]),
137
+ "BinTracker[16]": array("BinTracker", 16),
138
+ RouteLookup: record("RouteLookup", 2, [
139
+ ["code", "STRING", 8],
140
+ ["lane", "INT", 9],
141
+ ["pending", "BOOL", 10],
142
+ ["attempts", "INT", 11],
143
+ ["step", "DINT", 12, true],
144
+ ["busy", "BOOL", 13, true],
145
+ ]),
146
+ "RouteLookup[4]": array("RouteLookup", 4),
147
+ CardInput: record("CardInput", 3, [
148
+ ["sensor", "BOOL", 6],
149
+ ["jam", "BOOL", 7],
150
+ ]),
151
+ CardOutput: record("CardOutput", 3, [
152
+ ["motor", "BOOL", 11],
153
+ ["speed", "REAL", 12],
154
+ ]),
155
+ Card: record("Card", 3, [
156
+ ["input", "CardInput", 16],
157
+ ["output", "CardOutput", 17],
158
+ ]),
159
+ "Card[10]": array("Card", 10),
160
+ Parcel: record("Parcel", 4, [
161
+ ["code", "STRING", 5],
162
+ ["lane", "INT", 6],
163
+ ["zone", "INT", 7],
164
+ ["state", "ParcelState", 8],
165
+ ["weightKg", "REAL", 9],
166
+ ["diverted", "BOOL", 10],
167
+ ]),
168
+ "Parcel[768]": array("Parcel", 768),
169
+ Settings: record("Settings", 5, [
170
+ ["speed", "REAL", 4],
171
+ ["accumulation", "BOOL", 5],
172
+ ]),
173
+ Scratch: record("Scratch", 0, [["buffer", "DINT", 40]]),
174
+ },
175
+ roots: [
176
+ {
177
+ path: "program.mode",
178
+ target: "plc_Program.mode",
179
+ type: "Mode",
180
+ declaration: at(0, 12),
181
+ readable: true,
182
+ },
183
+ {
184
+ path: "program.diverters.binTrackers",
185
+ target: "plc_Program.diverters.binTrackers",
186
+ type: "BinTracker[16]",
187
+ declaration: at(0, 14),
188
+ readable: true,
189
+ },
190
+ {
191
+ path: "program.scanners.routeLookups",
192
+ target: "plc_Program.scanners.routeLookups",
193
+ type: "RouteLookup[4]",
194
+ declaration: at(0, 17),
195
+ readable: true,
196
+ },
197
+ {
198
+ path: "line.conveyors.inbound.cards",
199
+ target: "GVL_Line.inbound_cards",
200
+ type: "Card[10]",
201
+ declaration: at(3, 22),
202
+ readable: true,
203
+ },
204
+ {
205
+ path: "program.tracking.parcels",
206
+ target: "plc_Program.tracking.parcels",
207
+ type: "Parcel[768]",
208
+ declaration: at(4, 14),
209
+ readable: true,
210
+ },
211
+ {
212
+ path: "program.settings",
213
+ target: "plc_Program.settings",
214
+ type: "Settings",
215
+ declaration: at(0, 20),
216
+ readable: true,
217
+ },
218
+ {
219
+ path: "program.diagnostics.scratch",
220
+ target: "plc_Program.diagnostics.scratch",
221
+ type: "Scratch",
222
+ declaration: at(0, 38),
223
+ readable: false,
224
+ },
225
+ ],
226
+ constants: [
227
+ { path: "program.settings.lanes", type: "INT", value: 4, declaration: at(5, 6) },
228
+ {
229
+ path: "program.settings.lineName",
230
+ type: "STRING",
231
+ value: "Inbound A",
232
+ declaration: at(5, 7),
233
+ },
234
+ { path: "program.diverters.maxBins", type: "DINT", value: 16, declaration: at(1, 3) },
235
+ { path: "program.scanners.timeoutMs", type: "DINT", value: 1500, declaration: at(2, 3) },
236
+ ],
237
+ io: [
238
+ ...Array.from({ length: 10 }, (_, index) => ({
239
+ path: `line.conveyors.inbound.cards[${index}].input.sensor`,
240
+ target: `GVL_Io.inbound_di${index}`,
241
+ direction: "input" as const,
242
+ type: "BOOL",
243
+ device: "io-inbound-1",
244
+ channel: `di${index}`,
245
+ address: `%IX${index >> 3}.${index & 7}`,
246
+ })),
247
+ ...Array.from({ length: 10 }, (_, index) => ({
248
+ path: `line.conveyors.inbound.cards[${index}].output.motor`,
249
+ target: `GVL_Io.inbound_do${index}`,
250
+ direction: "output" as const,
251
+ type: "BOOL",
252
+ device: "io-inbound-1",
253
+ channel: `do${index}`,
254
+ address: `%QX${index >> 3}.${index & 7}`,
255
+ })),
256
+ {
257
+ path: "line.conveyors.inbound.cards[0].input.jam",
258
+ target: "GVL_Io.inbound_jam",
259
+ direction: "input",
260
+ type: "BOOL",
261
+ device: "io-inbound-1",
262
+ channel: "di12",
263
+ address: "%IX1.4",
264
+ inverted: true,
265
+ },
266
+ {
267
+ path: "line.conveyors.inbound.cards[0].output.speed",
268
+ target: "GVL_Io.vfd_setpoint",
269
+ direction: "output",
270
+ type: "REAL",
271
+ device: "vfd-inbound",
272
+ channel: "setpoint",
273
+ address: "%QW12",
274
+ },
275
+ ],
276
+ devices: [
277
+ {
278
+ id: "io-inbound-1",
279
+ vendor: "Turck",
280
+ product: "FEN20-16DXP",
281
+ productCode: "6814023",
282
+ network: { address: "192.168.10.21", rpiMs: 10 },
283
+ connection: "Exclusive owner",
284
+ assemblies: [
285
+ { direction: "input", bytes: 4, address: "%IB0", raw: "GVL_TsplcDebug.io_inbound_1_in" },
286
+ { direction: "output", bytes: 2, address: "%QB0", raw: "GVL_TsplcDebug.io_inbound_1_out" },
287
+ ],
288
+ health: {
289
+ state: "GVL_TsplcDebug.io_inbound_1_state",
290
+ diagnostic: "GVL_TsplcDebug.io_inbound_1_diag",
291
+ },
292
+ status: {
293
+ "Module power": "GVL_Io.inbound_power_ok",
294
+ "Bus fault": "GVL_Io.inbound_bus_fault",
295
+ },
296
+ channels: [
297
+ ...Array.from({ length: 10 }, (_, index) => ({
298
+ id: `di${index}`,
299
+ name: `Zone ${index + 1} photo eye`,
300
+ direction: "input" as const,
301
+ type: "BOOL",
302
+ offsetBits: index,
303
+ bits: 1,
304
+ address: `%IX${index >> 3}.${index & 7}`,
305
+ })),
306
+ {
307
+ id: "di12",
308
+ name: "Jam switch",
309
+ direction: "input",
310
+ type: "BOOL",
311
+ offsetBits: 12,
312
+ bits: 1,
313
+ address: "%IX1.4",
314
+ description: "Normally closed",
315
+ },
316
+ {
317
+ id: "diag",
318
+ name: "Module diagnostics",
319
+ direction: "input",
320
+ type: "WORD",
321
+ offsetBits: 16,
322
+ bits: 16,
323
+ address: "%IW1",
324
+ },
325
+ ...Array.from({ length: 10 }, (_, index) => ({
326
+ id: `do${index}`,
327
+ name: `Zone ${index + 1} motor`,
328
+ direction: "output" as const,
329
+ type: "BOOL",
330
+ offsetBits: index,
331
+ bits: 1,
332
+ address: `%QX${index >> 3}.${index & 7}`,
333
+ })),
334
+ ],
335
+ },
336
+ {
337
+ id: "scanner-1",
338
+ vendor: "Cognex",
339
+ product: "DataMan 370",
340
+ productCode: "DMR-370S",
341
+ network: { address: "192.168.10.31", rpiMs: 20 },
342
+ connection: "Exclusive owner",
343
+ assemblies: [
344
+ { direction: "input", bytes: 8, address: "%IB20", raw: "GVL_TsplcDebug.scanner_1_in" },
345
+ { direction: "output", bytes: 2, address: "%QB20" },
346
+ ],
347
+ channels: [
348
+ {
349
+ id: "ready",
350
+ name: "Trigger ready",
351
+ direction: "input",
352
+ type: "BOOL",
353
+ offsetBits: 0,
354
+ bits: 1,
355
+ },
356
+ {
357
+ id: "ack",
358
+ name: "Trigger acknowledge",
359
+ direction: "input",
360
+ type: "BOOL",
361
+ offsetBits: 1,
362
+ bits: 1,
363
+ },
364
+ {
365
+ id: "complete",
366
+ name: "Read complete",
367
+ direction: "input",
368
+ type: "BOOL",
369
+ offsetBits: 2,
370
+ bits: 1,
371
+ },
372
+ {
373
+ id: "resultId",
374
+ name: "Result id",
375
+ direction: "input",
376
+ type: "UINT",
377
+ offsetBits: 16,
378
+ bits: 16,
379
+ },
380
+ {
381
+ id: "length",
382
+ name: "Result length",
383
+ direction: "input",
384
+ type: "UINT",
385
+ offsetBits: 32,
386
+ bits: 16,
387
+ unit: "bytes",
388
+ },
389
+ {
390
+ id: "trigger",
391
+ name: "Trigger",
392
+ direction: "output",
393
+ type: "BOOL",
394
+ offsetBits: 0,
395
+ bits: 1,
396
+ },
397
+ ],
398
+ },
399
+ {
400
+ id: "vfd-inbound",
401
+ vendor: "SEW-Eurodrive",
402
+ product: "MOVIDRIVE B",
403
+ network: { address: "192.168.10.41", rpiMs: 10 },
404
+ connection: "Exclusive owner",
405
+ assemblies: [
406
+ { direction: "input", bytes: 4, address: "%IB40", raw: "GVL_TsplcDebug.vfd_inbound_in" },
407
+ { direction: "output", bytes: 4, address: "%QB40", raw: "GVL_TsplcDebug.vfd_inbound_out" },
408
+ ],
409
+ health: {
410
+ state: "GVL_TsplcDebug.vfd_inbound_state",
411
+ message: "GVL_TsplcDebug.vfd_inbound_message",
412
+ },
413
+ channels: [
414
+ {
415
+ id: "status",
416
+ name: "Status word",
417
+ direction: "input",
418
+ type: "WORD",
419
+ offsetBits: 0,
420
+ bits: 16,
421
+ },
422
+ {
423
+ id: "actual",
424
+ name: "Actual speed",
425
+ direction: "input",
426
+ type: "INT",
427
+ offsetBits: 16,
428
+ bits: 16,
429
+ unit: "rpm",
430
+ },
431
+ {
432
+ id: "control",
433
+ name: "Control word",
434
+ direction: "output",
435
+ type: "WORD",
436
+ offsetBits: 0,
437
+ bits: 16,
438
+ },
439
+ {
440
+ id: "setpoint",
441
+ name: "Speed setpoint",
442
+ direction: "output",
443
+ type: "INT",
444
+ offsetBits: 16,
445
+ bits: 16,
446
+ unit: "rpm",
447
+ },
448
+ ],
449
+ },
450
+ ],
451
+ functions: [
452
+ {
453
+ id: 1,
454
+ name: "BinTracker.update",
455
+ declaration: at(1, 28),
456
+ task: "MainTask",
457
+ target: "BinTracker.update",
458
+ reads: [],
459
+ },
460
+ {
461
+ id: 2,
462
+ name: "RouteLookup.resolve",
463
+ declaration: at(2, 20),
464
+ task: "ServiceTask",
465
+ target: "RouteLookup.resolve",
466
+ reads: [],
467
+ },
468
+ ],
469
+ jobs: [
470
+ ...Array.from({ length: 4 }, (_, index) => ({
471
+ id: index + 1,
472
+ name: "RouteLookup.resolve",
473
+ declaration: at(2, 20),
474
+ instance: `program.scanners.routeLookups[${index}]`,
475
+ target: `plc_Program.scanners.routeLookups[${index}].job`,
476
+ steps: resolveSteps,
477
+ requests: [{ service: "routing.resolve", target: "x", sequence: "y" }],
478
+ })),
479
+ {
480
+ id: 5,
481
+ name: "BinTracker.release",
482
+ declaration: at(1, 52),
483
+ instance: "program.diverters.binTrackers[3]",
484
+ target: "plc_Program.diverters.binTrackers[3].release",
485
+ steps: [
486
+ {
487
+ step: 1,
488
+ kind: "await",
489
+ range: at(1, 55, 7, 40),
490
+ text: "await services.bins.release({ bin: 3 })",
491
+ },
492
+ ],
493
+ requests: [{ service: "bins.release", target: "x", sequence: "y" }],
494
+ },
495
+ ],
496
+ events: [
497
+ ...binSites,
498
+ ...routeSites,
499
+ {
500
+ id: 400,
501
+ kind: "state",
502
+ path: "program.mode",
503
+ type: "Mode",
504
+ range: at(0, 61, 7, 26),
505
+ text: "this.mode = Mode.Faulted;",
506
+ task: "MainTask",
507
+ operands: [{ text: "drive.fault", range: at(0, 60, 9, 11), type: "BOOL", stable: true }],
508
+ },
509
+ ],
510
+ writers: [],
511
+ tasks: [
512
+ {
513
+ name: "MainTask",
514
+ intervalMs: 10,
515
+ priority: 1,
516
+ },
517
+ {
518
+ name: "ServiceTask",
519
+ intervalMs: 50,
520
+ priority: 5,
521
+ },
522
+ ],
523
+ errors: { "1": "Cancelled", "2": "Timeout", "3": "ServiceError" },
524
+ };
525
+
526
+ const hash = (text: string) =>
527
+ [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) >>> 0, 7) % 997;
528
+
529
+ /** A deterministic value for `path` at time `ms`, so samples and raw bytes agree. */
530
+ export const fixtureValue = (path: string, type: string, ms: number): unknown => {
531
+ const seed = hash(path);
532
+ const tick = (period: number) => Math.floor(ms / period + seed);
533
+ if (path === "program.mode") return 1;
534
+ if (type === "Bin") return tick(3100) % 3;
535
+ if (type === "ParcelState") return tick(4300) % 5;
536
+ if (type === "BOOL") return tick(700 + (seed % 900)) % 2 === 0;
537
+ if (type === "REAL") return Math.round((40 + 10 * Math.sin(ms / 3000 + seed)) * 100) / 100;
538
+ if (type === "STRING") return `P${String(tick(5000) % 100000).padStart(5, "0")}`;
539
+ if (path.endsWith(".lane")) return tick(4000) % 4;
540
+ return tick(2000) % 32767;
541
+ };
542
+
543
+ export const fixtureQuality = (path: string): { quality: Quality; status: string } => {
544
+ if (path.includes("routeLookups[3]")) return { quality: "bad", status: "BadCommunicationError" };
545
+ if (path.includes("binTrackers[7]"))
546
+ return { quality: "uncertain", status: "UncertainLastUsableValue" };
547
+ return { quality: "good", status: "Good" };
548
+ };
@@ -0,0 +1,125 @@
1
+ import type { Quality } from "../api.ts";
2
+ import type { DebugManifest, SourceRange } from "../manifest.ts";
3
+ import { typeOf } from "../paths.ts";
4
+
5
+ /** How a value is shown: each state has its own styling and label, so none passes for live. */
6
+ export type ValueState = "live" | "uncertain" | "bad" | "stale" | "unavailable" | "constant";
7
+
8
+ export const valueStateLabels: Record<ValueState, string> = {
9
+ live: "good",
10
+ uncertain: "uncertain",
11
+ bad: "bad",
12
+ stale: "stale",
13
+ unavailable: "unavailable",
14
+ constant: "constant",
15
+ };
16
+
17
+ /** A sample is stale when the controller connection is down or the sample is older than three sample intervals. */
18
+ export const valueState = (
19
+ quality: Quality,
20
+ ageMs: number,
21
+ context: { connected: boolean; intervalMs: number },
22
+ ): ValueState => {
23
+ if (quality === "unavailable") return "unavailable";
24
+ if (!context.connected || ageMs > 3 * context.intervalMs) return "stale";
25
+ if (quality === "bad") return "bad";
26
+ if (quality === "uncertain") return "uncertain";
27
+ return "live";
28
+ };
29
+
30
+ export const formatAge = (ms: number) => {
31
+ if (ms < 1000) return "now";
32
+ if (ms < 60_000) return `${Math.floor(ms / 1000)} s ago`;
33
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
34
+ return `${Math.floor(ms / 3_600_000)} h ago`;
35
+ };
36
+
37
+ export const formatDuration = (ms: number) => {
38
+ if (ms < 1000) return `${Math.round(ms)} ms`;
39
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
40
+ return `${Math.floor(ms / 60_000)} min ${Math.floor((ms % 60_000) / 1000)} s`;
41
+ };
42
+
43
+ /** How often values are read: `250 ms`, `1 s`, `1.5 s`. */
44
+ export const formatInterval = (ms: number) => (ms < 1000 ? `${ms} ms` : `${ms / 1000} s`);
45
+
46
+ /** Local time of day with milliseconds, for ordering events by eye. */
47
+ export const formatClock = (iso: string) => {
48
+ const date = new Date(iso);
49
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
50
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
51
+ };
52
+
53
+ /** When a value was read, to the second: `read 14:03:27`. */
54
+ export const formatReadAt = (iso: string) => `read ${formatClock(iso).slice(0, 8)}`;
55
+
56
+ /** A value of unknown type: booleans as IEC literals, strings quoted. */
57
+ export const formatScalar = (value: unknown): string => {
58
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
59
+ if (typeof value === "number")
60
+ return Number.isInteger(value) ? String(value) : String(Number(value.toPrecision(6)));
61
+ if (typeof value === "string") return JSON.stringify(value);
62
+ if (value === null || value === undefined) return "—";
63
+ return JSON.stringify(value);
64
+ };
65
+
66
+ /** A value in the program's terms: enum members by name, whether the host decoded them already or not. */
67
+ export const formatValue = (manifest: DebugManifest, typeId: string, value: unknown): string => {
68
+ const type = typeOf(manifest, typeId);
69
+ if (type.kind !== "enum") return formatScalar(value);
70
+ if (typeof value === "string" && Object.hasOwn(type.members, value)) return value;
71
+ if (typeof value !== "number") return formatScalar(value);
72
+ const member = Object.entries(type.members).find(([, number]) => number === value)?.[0];
73
+ return member ?? `${value} (not a ${type.name})`;
74
+ };
75
+
76
+ export const typeLabel = (manifest: DebugManifest, typeId: string): string => {
77
+ const type = typeOf(manifest, typeId);
78
+ switch (type.kind) {
79
+ case "scalar":
80
+ return type.iec;
81
+ case "enum":
82
+ case "record":
83
+ return type.name;
84
+ case "array":
85
+ return `${typeLabel(manifest, type.element)}[${type.length}]`;
86
+ }
87
+ };
88
+
89
+ const PROPERTY_TEXT = 16;
90
+
91
+ /** A record's properties as `barcode=CON-MD3-884… scanner=3`, long strings cut short. */
92
+ export const formatProperties = (properties: Record<string, unknown>) =>
93
+ Object.entries(properties)
94
+ .map(([key, value]) => {
95
+ const text = typeof value === "string" ? value : formatScalar(value);
96
+ return `${key}=${text.length > PROPERTY_TEXT ? `${text.slice(0, PROPERTY_TEXT)}…` : text}`;
97
+ })
98
+ .join(" ");
99
+
100
+ /** A VS Code link to a project file, when the project root on the debugger host is known. */
101
+ const fileHref = (projectRoot: string | undefined, file: string, line: number, column: number) => {
102
+ if (projectRoot === undefined) return undefined;
103
+ const root = projectRoot.replaceAll("\\", "/").replace(/\/$/, "");
104
+ const absolute = `${root}/${file}`.replace(/^\/?/, "/");
105
+ return `vscode://file${absolute}:${line}:${column}`;
106
+ };
107
+
108
+ /** `file:line`, and a VS Code link when the project root on the debugger host is known. */
109
+ export const sourceLocation = (
110
+ manifest: DebugManifest,
111
+ projectRoot: string | undefined,
112
+ range: SourceRange,
113
+ ) => {
114
+ const file = manifest.sources[range.file] ?? `source ${range.file}`;
115
+ const href = fileHref(projectRoot, file, range.line, range.column);
116
+ return { label: `${file}:${range.line}`, ...(href && { href }) };
117
+ };
118
+
119
+ /** A record's project-relative `file:line` as `basename:line`, with the same VS Code link. */
120
+ export const recordLocation = (projectRoot: string | undefined, source: string) => {
121
+ const [, file = source, line] = /^(.*):(\d+)$/.exec(source) ?? [];
122
+ const label = `${file.slice(file.lastIndexOf("/") + 1)}${line ? `:${line}` : ""}`;
123
+ const href = line === undefined ? undefined : fileHref(projectRoot, file, Number(line), 1);
124
+ return { label, ...(href && { href }) };
125
+ };