@abreen/tada 1.13.9 → 1.14.2

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.
@@ -5,8 +5,14 @@ import { renderCodeSegment } from './code';
5
5
  import { splitLines } from './literate-java';
6
6
  import { normalizeOutputPath } from './paths';
7
7
  import { computeLayout } from './trace-layout';
8
- import { generateStepSvg } from './trace-svg';
9
- import type { TraceChunkEntry, TraceManifest, TraceStep } from '../types';
8
+ import { filterStep, generateStepSvg } from './trace-svg';
9
+ import type {
10
+ TraceChunkEntry,
11
+ TraceHeapObject,
12
+ TraceManifest,
13
+ TraceOutputEvent,
14
+ TraceStep,
15
+ } from '../types';
10
16
 
11
17
  export const DEFAULT_CHUNK_SIZE = 50;
12
18
 
@@ -21,6 +27,10 @@ export interface ChunkTraceOutputResult {
21
27
  outputPaths: string[];
22
28
  }
23
29
 
30
+ function stepOutputEvents(step: TraceStep): TraceOutputEvent[] {
31
+ return step.output ?? [];
32
+ }
33
+
24
34
  function hashTraceFiles(files: { name: string; content: string }[]): string {
25
35
  const hasher = createHash('sha256');
26
36
  for (const file of files) {
@@ -32,6 +42,10 @@ function hashTraceFiles(files: { name: string; content: string }[]): string {
32
42
  return `sha256-${hasher.digest('hex').slice(0, 16)}`;
33
43
  }
34
44
 
45
+ function safeSvgIdPart(value: string): string {
46
+ return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
47
+ }
48
+
35
49
  function buildTraceOutputPath(
36
50
  relDir: string,
37
51
  traceName: string,
@@ -43,13 +57,101 @@ function buildTraceOutputPath(
43
57
  .join('/');
44
58
  }
45
59
 
60
+ function escapeAttr(value: string): string {
61
+ return value
62
+ .replaceAll('&', '&')
63
+ .replaceAll('"', '"')
64
+ .replaceAll('<', '&lt;')
65
+ .replaceAll('>', '&gt;');
66
+ }
67
+
68
+ function framesMatchCaller(previous: TraceStep, current: TraceStep): boolean {
69
+ if (current.stack.length !== previous.stack.length - 1) {
70
+ return false;
71
+ }
72
+
73
+ return current.stack.every((frame, index) => {
74
+ const previousFrame = previous.stack[index + 1];
75
+ return (
76
+ frame.method === previousFrame.method &&
77
+ frame.class === previousFrame.class
78
+ );
79
+ });
80
+ }
81
+
82
+ function addReachableHeapObjects(
83
+ id: string,
84
+ source: Record<string, TraceHeapObject>,
85
+ target: Record<string, TraceHeapObject>,
86
+ ): void {
87
+ const queue = [id];
88
+ const seen = new Set<string>();
89
+
90
+ while (queue.length > 0) {
91
+ const currentId = queue.shift()!;
92
+ if (seen.has(currentId) || !source[currentId]) {
93
+ continue;
94
+ }
95
+ seen.add(currentId);
96
+ target[currentId] ??= source[currentId];
97
+
98
+ const obj = source[currentId];
99
+ if ('elements' in obj) {
100
+ for (const element of obj.elements) {
101
+ if (element.type === 'ref') {
102
+ queue.push(element.id);
103
+ }
104
+ }
105
+ } else if ('fields' in obj) {
106
+ for (const value of Object.values(obj.fields)) {
107
+ if (value.type === 'ref') {
108
+ queue.push(value.id);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ export function bridgeConstructorReturnValues(steps: TraceStep[]): TraceStep[] {
116
+ const bridgedSteps = steps.map(step => ({
117
+ ...step,
118
+ heap: { ...step.heap },
119
+ transientHeapRoots: step.transientHeapRoots
120
+ ? [...step.transientHeapRoots]
121
+ : undefined,
122
+ }));
123
+
124
+ for (let i = 1; i < bridgedSteps.length; i++) {
125
+ const previous = bridgedSteps[i - 1];
126
+ const current = bridgedSteps[i];
127
+ const poppedFrame = previous.stack[0];
128
+ const thisValue = poppedFrame?.locals.this;
129
+
130
+ if (
131
+ poppedFrame?.method !== '<init>' ||
132
+ thisValue?.type !== 'ref' ||
133
+ !framesMatchCaller(previous, current)
134
+ ) {
135
+ continue;
136
+ }
137
+
138
+ addReachableHeapObjects(thisValue.id, previous.heap, current.heap);
139
+ current.transientHeapRoots ??= [];
140
+ if (!current.transientHeapRoots.includes(thisValue.id)) {
141
+ current.transientHeapRoots.push(thisValue.id);
142
+ }
143
+ }
144
+
145
+ return bridgedSteps;
146
+ }
147
+
46
148
  export function chunkTraceOutput(
47
149
  output: string,
48
150
  traceOutputDir: string,
49
151
  relDir: string,
50
152
  traceName: string,
51
- sourceFile: string,
52
- source: string,
153
+ primaryFile: string,
154
+ sources: { file: string; source: string }[],
53
155
  options: ChunkTraceOutputOptions = {},
54
156
  ): ChunkTraceOutputResult {
55
157
  const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
@@ -60,26 +162,48 @@ export function chunkTraceOutput(
60
162
  .split('\n')
61
163
  .filter(l => l.length > 0);
62
164
 
63
- const allSteps: TraceStep[] = lines.map(l => JSON.parse(l));
165
+ const allSteps: TraceStep[] = bridgeConstructorReturnValues(
166
+ lines.map(l => JSON.parse(l)),
167
+ );
64
168
 
65
- const lineToSteps: Record<number, number[]> = {};
169
+ const lineToStepsByFile = new Map<string, Record<number, number[]>>();
170
+ for (const source of sources) {
171
+ lineToStepsByFile.set(source.file, {});
172
+ }
66
173
  for (let i = 0; i < allSteps.length; i++) {
67
174
  const step = allSteps[i];
175
+ const file = path.basename(step.file);
176
+ const lineToSteps = lineToStepsByFile.get(file);
177
+ if (!lineToSteps) {
178
+ continue;
179
+ }
68
180
  if (!lineToSteps[step.line]) {
69
181
  lineToSteps[step.line] = [];
70
182
  }
71
183
  lineToSteps[step.line].push(i);
72
184
  }
73
185
 
74
- const layout = computeLayout(allSteps, ignoreFields);
186
+ const layoutSteps = allSteps.map(step => ({ ...step, ...filterStep(step) }));
187
+ const layout = computeLayout(layoutSteps, ignoreFields);
188
+ const svgIdPrefix = safeSvgIdPart(traceName) || 'trace';
75
189
 
76
190
  let chunkEntries: TraceChunkEntry[] = [];
77
191
  let chunkIndex = 0;
78
192
  const chunkFiles: { name: string; content: string }[] = [];
79
193
 
80
- for (const step of allSteps) {
81
- const svg = generateStepSvg(step, layout);
82
- chunkEntries.push({ line: step.line, stdout: step.stdout, svg });
194
+ for (const [stepIndex, step] of allSteps.entries()) {
195
+ const svg = generateStepSvg(
196
+ step,
197
+ layout,
198
+ undefined,
199
+ `trace-${svgIdPrefix}-${stepIndex}`,
200
+ );
201
+ chunkEntries.push({
202
+ file: path.basename(step.file),
203
+ line: step.line,
204
+ output: stepOutputEvents(step),
205
+ svg,
206
+ });
83
207
 
84
208
  if (chunkEntries.length >= chunkSize) {
85
209
  chunkFiles.push({
@@ -101,9 +225,11 @@ export function chunkTraceOutput(
101
225
  const manifest: TraceManifest = {
102
226
  totalSteps: allSteps.length,
103
227
  chunkSize,
104
- sourceFile,
105
- source,
106
- lineToSteps,
228
+ primaryFile,
229
+ sources: sources.map(source => ({
230
+ ...source,
231
+ lineToSteps: lineToStepsByFile.get(source.file) ?? {},
232
+ })),
107
233
  };
108
234
 
109
235
  const manifestFile = {
@@ -218,11 +344,11 @@ export function buildManifestUrl({
218
344
  }
219
345
 
220
346
  export function renderTraceWidgetHtml({
221
- highlightedSource,
347
+ highlightedSources,
222
348
  manifestUrl,
223
349
  totalSteps,
224
350
  }: {
225
- highlightedSource: string;
351
+ highlightedSources: { file: string; highlightedSource: string }[];
226
352
  manifestUrl?: string;
227
353
  totalSteps?: number;
228
354
  }): string {
@@ -248,5 +374,11 @@ export function renderTraceWidgetHtml({
248
374
  const wrapperClass = disabled
249
375
  ? 'trace-widget trace-disabled'
250
376
  : 'trace-widget';
251
- return `<div class="${wrapperClass}"${wrapperAttrs}><noscript><p>This interactive trace requires JavaScript.</p></noscript><div class="trace-body"><div class="trace-toolbar"><div class="trace-controls" role="toolbar" aria-label="Trace navigation">${controls}</div></div><div class="trace-content"><div class="trace-diagram"></div><div class="trace-source-wrapper"><div class="trace-source">${highlightedSource}</div></div></div></div></div>`;
377
+ const sourcePanels = highlightedSources
378
+ .map((source, index) => {
379
+ const hidden = index === 0 ? '' : ' hidden';
380
+ return `<div class="trace-source" data-trace-source-file="${escapeAttr(source.file)}"${hidden}>${source.highlightedSource}</div>`;
381
+ })
382
+ .join('');
383
+ return `<div class="${wrapperClass}"${wrapperAttrs}><noscript><p>This interactive trace requires JavaScript.</p></noscript><div class="trace-body"><div class="trace-toolbar"><div class="trace-controls" role="toolbar" aria-label="Trace navigation">${controls}</div></div><div class="trace-content"><div class="trace-diagram"></div><div class="trace-source-wrapper">${sourcePanels}</div></div></div></div>`;
252
384
  }
@@ -0,0 +1,34 @@
1
+ import type { TraceHeapObject } from '../types';
2
+
3
+ export type TraceHeapScalar = string | number | boolean;
4
+
5
+ const BOXED_PRIMITIVE_TYPES = new Set([
6
+ 'java.lang.Boolean',
7
+ 'java.lang.Byte',
8
+ 'java.lang.Character',
9
+ 'java.lang.Double',
10
+ 'java.lang.Float',
11
+ 'java.lang.Integer',
12
+ 'java.lang.Long',
13
+ 'java.lang.Short',
14
+ ]);
15
+
16
+ export function isInlineHeapObject(obj: TraceHeapObject): boolean {
17
+ return (
18
+ 'value' in obj &&
19
+ (obj.type === 'String' || BOXED_PRIMITIVE_TYPES.has(obj.type))
20
+ );
21
+ }
22
+
23
+ export function formatHeapObjectValue(
24
+ type: string,
25
+ value: TraceHeapScalar,
26
+ ): string {
27
+ if (type === 'String') {
28
+ return `"${value}"`;
29
+ }
30
+ if (type === 'java.lang.Character') {
31
+ return `'${value}'`;
32
+ }
33
+ return String(value);
34
+ }
@@ -2,11 +2,17 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import { execFileSync } from 'child_process';
5
+ import { parse as parseJava } from 'java-parser';
5
6
  import { makeLogger } from '../log';
6
7
  import { hasMainMethod, runJavac } from './literate-java';
7
8
 
8
9
  const log = makeLogger(import.meta.url);
9
10
 
11
+ interface CstNode {
12
+ name?: string;
13
+ children?: Record<string, CstNode[]>;
14
+ }
15
+
10
16
  /**
11
17
  * Parse // @trace-ignore comments from Java source code.
12
18
  * Returns a map of className -> fieldName[] for fields that should be
@@ -65,6 +71,42 @@ export function parseIgnoreFields(source: string): Record<string, string[]> {
65
71
  return result;
66
72
  }
67
73
 
74
+ const JAVA_TOP_LEVEL_TYPE_NODES = new Set([
75
+ 'classDeclaration',
76
+ 'interfaceDeclaration',
77
+ 'enumDeclaration',
78
+ 'recordDeclaration',
79
+ ]);
80
+
81
+ export function hasExplicitTopLevelTypeDeclaration(source: string): boolean {
82
+ let cst: CstNode;
83
+ try {
84
+ cst = parseJava(source) as CstNode;
85
+ } catch (err: unknown) {
86
+ log.debug`Failed to parse Java source for unnamed class detection: ${(err as Error).message}`;
87
+ return true;
88
+ }
89
+
90
+ const ordinaryCompilationUnit = cst.children?.ordinaryCompilationUnit?.[0];
91
+ const typeDeclarations =
92
+ ordinaryCompilationUnit?.children?.typeDeclaration ?? [];
93
+
94
+ return typeDeclarations.some(typeDeclaration =>
95
+ Object.keys(typeDeclaration.children ?? {}).some(childName =>
96
+ JAVA_TOP_LEVEL_TYPE_NODES.has(childName),
97
+ ),
98
+ );
99
+ }
100
+
101
+ export function getUnnamedClassNames(javaFilePaths: string[]): string[] {
102
+ return javaFilePaths
103
+ .filter(javaFilePath => {
104
+ const source = fs.readFileSync(javaFilePath, 'utf-8');
105
+ return !hasExplicitTopLevelTypeDeclaration(source);
106
+ })
107
+ .map(javaFilePath => path.parse(javaFilePath).name);
108
+ }
109
+
68
110
  let tracerClassDir: string | null = null;
69
111
 
70
112
  function ensureTracerCompiled(): string {
@@ -87,7 +129,7 @@ function ensureTracerCompiled(): string {
87
129
  return tempDir;
88
130
  }
89
131
 
90
- function compileTargetFile(javaFilePath: string): string {
132
+ function compileTargetFiles(javaFilePaths: string[]): string {
91
133
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-trace-target-'));
92
134
 
93
135
  runJavac(
@@ -96,10 +138,10 @@ function compileTargetFile(javaFilePath: string): string {
96
138
  '-d',
97
139
  tempDir,
98
140
  '-sourcepath',
99
- path.dirname(javaFilePath),
100
- javaFilePath,
141
+ path.dirname(javaFilePaths[0]),
142
+ ...javaFilePaths,
101
143
  ],
102
- { tempDir, label: `Compilation failed for ${javaFilePath}` },
144
+ { tempDir, label: `Compilation failed for ${javaFilePaths[0]}` },
103
145
  );
104
146
 
105
147
  return tempDir;
@@ -116,14 +158,25 @@ export function validateJavaTraceTarget(
116
158
  }
117
159
  }
118
160
 
119
- export function runJavaTrace(javaFilePath: string, className: string): string {
120
- const targetClassDir = compileTargetFile(javaFilePath);
161
+ export function runJavaTrace(
162
+ javaFilePaths: string[],
163
+ className: string,
164
+ ): string {
165
+ const targetClassDir = compileTargetFiles(javaFilePaths);
121
166
 
122
167
  try {
123
168
  const tracerDir = ensureTracerCompiled();
169
+ const unnamedClassNames = getUnnamedClassNames(javaFilePaths);
124
170
  return execFileSync(
125
171
  'java',
126
- ['-cp', tracerDir, 'TraceRunner', className, targetClassDir],
172
+ [
173
+ '-cp',
174
+ tracerDir,
175
+ 'TraceRunner',
176
+ className,
177
+ targetClassDir,
178
+ ...unnamedClassNames,
179
+ ],
127
180
  { timeout: 60000, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 },
128
181
  );
129
182
  } finally {
@@ -5,6 +5,11 @@ import type {
5
5
  TraceLayout,
6
6
  TraceObjectLayout,
7
7
  } from '../types';
8
+ import {
9
+ formatHeapObjectValue,
10
+ isInlineHeapObject,
11
+ type TraceHeapScalar,
12
+ } from './trace-heap';
8
13
 
9
14
  /** Maximum number of array cells to display. */
10
15
  export const ARRAY_MAX_CELLS = 12;
@@ -24,6 +29,9 @@ const ROW_HEIGHT_RATIO = 2;
24
29
  /** Minimum width for a value box in a field object. */
25
30
  const MIN_VALUE_WIDTH = 40;
26
31
 
32
+ /** Minimum width for each displayed array cell. */
33
+ const MIN_ARRAY_CELL_WIDTH = 48;
34
+
27
35
  /** Information collected about a heap object across all trace steps. */
28
36
  export interface ObjectInfo {
29
37
  id: string;
@@ -32,14 +40,18 @@ export interface ObjectInfo {
32
40
  lastStep: number;
33
41
  /** Set of object IDs this object ever references (across all steps). */
34
42
  references: Set<string>;
43
+ /** Step indices where this object is actually present. */
44
+ activeSteps: Set<number>;
35
45
  /** Per-field reference targets (field name -> set of referenced object IDs). */
36
46
  fieldRefs: Map<string, Set<string>>;
37
47
  /** Max number of fields seen on this object (for field objects). */
38
48
  maxFields: number;
39
49
  /** Max number of elements seen on this object (for arrays). */
40
50
  maxElements: number;
41
- /** String value (for string-valued objects). */
42
- value: string | null;
51
+ /** Scalar value (for heap objects represented by a single value). */
52
+ value: TraceHeapScalar | null;
53
+ hasValue: boolean;
54
+ isInlineValue: boolean;
43
55
  isArray: boolean;
44
56
  isString: boolean;
45
57
  /** All field names ever seen on this object. */
@@ -74,10 +86,13 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
74
86
  firstStep: stepIndex,
75
87
  lastStep: stepIndex,
76
88
  references: new Set(),
89
+ activeSteps: new Set(),
77
90
  fieldRefs: new Map(),
78
91
  maxFields: 0,
79
92
  maxElements: 0,
80
93
  value: null,
94
+ hasValue: false,
95
+ isInlineValue: false,
81
96
  isArray: false,
82
97
  isString: false,
83
98
  fieldNames: new Set(),
@@ -87,6 +102,7 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
87
102
  }
88
103
 
89
104
  info.lastStep = stepIndex;
105
+ info.activeSteps.add(stepIndex);
90
106
 
91
107
  if ('elements' in obj) {
92
108
  info.isArray = true;
@@ -125,7 +141,9 @@ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
125
141
  }
126
142
  }
127
143
  } else if ('value' in obj) {
128
- info.isString = true;
144
+ info.hasValue = true;
145
+ info.isString = obj.type === 'String';
146
+ info.isInlineValue ||= isInlineHeapObject(obj);
129
147
  info.value = obj.value;
130
148
  }
131
149
  }
@@ -229,17 +247,24 @@ export function getObjectSize(
229
247
  const charWidth = fontSize * CHAR_WIDTH_RATIO;
230
248
  const rowHeight = fontSize * ROW_HEIGHT_RATIO;
231
249
 
232
- if (info.isString) {
233
- const text = `"${info.value ?? ''}"`;
250
+ if (info.isInlineValue) {
251
+ const text = formatHeapObjectValue(info.type, info.value ?? '');
252
+ const textWidth = text.length * charWidth;
253
+ const minWidth = info.isString ? info.type.length * charWidth + 16 : 16;
254
+ const width = Math.max(minWidth, textWidth + 16);
255
+ return { width, height: rowHeight * 2 };
256
+ }
257
+
258
+ if (info.hasValue) {
259
+ const text = String(info.value ?? '');
234
260
  const textWidth = text.length * charWidth;
235
261
  const width = Math.max(info.type.length * charWidth + 16, textWidth + 16);
236
- const height = rowHeight * 2; // header + value
237
- return { width, height };
262
+ return { width, height: rowHeight * 2 };
238
263
  }
239
264
 
240
265
  if (info.isArray) {
241
266
  const cellCount = Math.min(info.maxElements, ARRAY_MAX_CELLS);
242
- const cellWidth = Math.max(charWidth * 3, MIN_VALUE_WIDTH);
267
+ const cellWidth = Math.max(charWidth * 3, MIN_ARRAY_CELL_WIDTH);
243
268
  const width = Math.max(
244
269
  info.type.length * charWidth + 16,
245
270
  cellCount * cellWidth,
@@ -754,6 +779,13 @@ function layoutArrays(
754
779
  };
755
780
  });
756
781
 
782
+ const minPlacementX = Math.min(...placements.map(p => p.x));
783
+ if (minPlacementX < 0) {
784
+ for (const p of placements) {
785
+ p.x -= minPlacementX;
786
+ }
787
+ }
788
+
757
789
  const gap = Math.round(OBJ_GAP * 0.5);
758
790
  for (let j = 1; j < placements.length; j++) {
759
791
  const prev = placements[j - 1];
@@ -767,7 +799,7 @@ function layoutArrays(
767
799
  const refY = Math.round(yOffset + size.height + OBJ_GAP);
768
800
  let maxRefHeight = 0;
769
801
  for (const p of placements) {
770
- result[p.id] = makeLayout(Math.round(Math.max(0, p.x)), refY, p.size);
802
+ result[p.id] = makeLayout(Math.round(p.x), refY, p.size);
771
803
  maxRefHeight = Math.max(maxRefHeight, p.height);
772
804
  }
773
805
 
@@ -789,7 +821,11 @@ function layoutOrphans(
789
821
  result: Record<string, TraceObjectLayout>,
790
822
  yOffset: number,
791
823
  ) {
792
- type OrphanSlot = { ids: string[]; lastStep: number; maxHeight: number };
824
+ type OrphanSlot = {
825
+ ids: string[];
826
+ activeSteps: Set<number>;
827
+ maxHeight: number;
828
+ };
793
829
 
794
830
  const orphanIds = [...objects.keys()]
795
831
  .filter(id => !(id in result))
@@ -806,14 +842,23 @@ function layoutOrphans(
806
842
  const info = objects.get(id)!;
807
843
  const size = sizes.get(id)!;
808
844
 
809
- let slot = slots.find(existing => existing.lastStep < info.firstStep);
845
+ let slot = slots.find(existing => {
846
+ for (const stepIndex of info.activeSteps) {
847
+ if (existing.activeSteps.has(stepIndex)) {
848
+ return false;
849
+ }
850
+ }
851
+ return true;
852
+ });
810
853
  if (!slot) {
811
- slot = { ids: [], lastStep: info.lastStep, maxHeight: size.height };
854
+ slot = { ids: [], activeSteps: new Set(), maxHeight: size.height };
812
855
  slots.push(slot);
813
856
  } else {
814
- slot.lastStep = info.lastStep;
815
857
  slot.maxHeight = Math.max(slot.maxHeight, size.height);
816
858
  }
859
+ for (const stepIndex of info.activeSteps) {
860
+ slot.activeSteps.add(stepIndex);
861
+ }
817
862
  slot.ids.push(id);
818
863
  }
819
864
 
@@ -1,7 +1,10 @@
1
1
  import path from 'path';
2
2
  import { execFileSyncPython, resolvePythonCommand } from '../../python/command';
3
3
 
4
- export function runPythonTrace(pythonFilePath: string): string {
4
+ export function runPythonTrace(
5
+ pythonFilePath: string,
6
+ tracedFilePaths: string[],
7
+ ): string {
5
8
  const pythonCommand = resolvePythonCommand();
6
9
  if (!pythonCommand) {
7
10
  throw new Error('Python is required to generate Python traces');
@@ -16,10 +19,11 @@ export function runPythonTrace(pythonFilePath: string): string {
16
19
  timeout: 60000,
17
20
  encoding: 'utf-8' as const,
18
21
  maxBuffer: 50 * 1024 * 1024,
22
+ cwd: path.dirname(pythonFilePath),
19
23
  };
20
24
 
21
25
  return execFileSyncPython(
22
- [runnerPath, pythonFilePath],
26
+ [runnerPath, pythonFilePath, ...tracedFilePaths],
23
27
  options,
24
28
  pythonCommand,
25
29
  );