@abreen/tada 1.13.9 → 1.14.1

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) {
@@ -43,13 +53,101 @@ function buildTraceOutputPath(
43
53
  .join('/');
44
54
  }
45
55
 
56
+ function escapeAttr(value: string): string {
57
+ return value
58
+ .replaceAll('&', '&')
59
+ .replaceAll('"', '"')
60
+ .replaceAll('<', '&lt;')
61
+ .replaceAll('>', '&gt;');
62
+ }
63
+
64
+ function framesMatchCaller(previous: TraceStep, current: TraceStep): boolean {
65
+ if (current.stack.length !== previous.stack.length - 1) {
66
+ return false;
67
+ }
68
+
69
+ return current.stack.every((frame, index) => {
70
+ const previousFrame = previous.stack[index + 1];
71
+ return (
72
+ frame.method === previousFrame.method &&
73
+ frame.class === previousFrame.class
74
+ );
75
+ });
76
+ }
77
+
78
+ function addReachableHeapObjects(
79
+ id: string,
80
+ source: Record<string, TraceHeapObject>,
81
+ target: Record<string, TraceHeapObject>,
82
+ ): void {
83
+ const queue = [id];
84
+ const seen = new Set<string>();
85
+
86
+ while (queue.length > 0) {
87
+ const currentId = queue.shift()!;
88
+ if (seen.has(currentId) || !source[currentId]) {
89
+ continue;
90
+ }
91
+ seen.add(currentId);
92
+ target[currentId] ??= source[currentId];
93
+
94
+ const obj = source[currentId];
95
+ if ('elements' in obj) {
96
+ for (const element of obj.elements) {
97
+ if (element.type === 'ref') {
98
+ queue.push(element.id);
99
+ }
100
+ }
101
+ } else if ('fields' in obj) {
102
+ for (const value of Object.values(obj.fields)) {
103
+ if (value.type === 'ref') {
104
+ queue.push(value.id);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ export function bridgeConstructorReturnValues(steps: TraceStep[]): TraceStep[] {
112
+ const bridgedSteps = steps.map(step => ({
113
+ ...step,
114
+ heap: { ...step.heap },
115
+ transientHeapRoots: step.transientHeapRoots
116
+ ? [...step.transientHeapRoots]
117
+ : undefined,
118
+ }));
119
+
120
+ for (let i = 1; i < bridgedSteps.length; i++) {
121
+ const previous = bridgedSteps[i - 1];
122
+ const current = bridgedSteps[i];
123
+ const poppedFrame = previous.stack[0];
124
+ const thisValue = poppedFrame?.locals.this;
125
+
126
+ if (
127
+ poppedFrame?.method !== '<init>' ||
128
+ thisValue?.type !== 'ref' ||
129
+ !framesMatchCaller(previous, current)
130
+ ) {
131
+ continue;
132
+ }
133
+
134
+ addReachableHeapObjects(thisValue.id, previous.heap, current.heap);
135
+ current.transientHeapRoots ??= [];
136
+ if (!current.transientHeapRoots.includes(thisValue.id)) {
137
+ current.transientHeapRoots.push(thisValue.id);
138
+ }
139
+ }
140
+
141
+ return bridgedSteps;
142
+ }
143
+
46
144
  export function chunkTraceOutput(
47
145
  output: string,
48
146
  traceOutputDir: string,
49
147
  relDir: string,
50
148
  traceName: string,
51
- sourceFile: string,
52
- source: string,
149
+ primaryFile: string,
150
+ sources: { file: string; source: string }[],
53
151
  options: ChunkTraceOutputOptions = {},
54
152
  ): ChunkTraceOutputResult {
55
153
  const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
@@ -60,18 +158,29 @@ export function chunkTraceOutput(
60
158
  .split('\n')
61
159
  .filter(l => l.length > 0);
62
160
 
63
- const allSteps: TraceStep[] = lines.map(l => JSON.parse(l));
161
+ const allSteps: TraceStep[] = bridgeConstructorReturnValues(
162
+ lines.map(l => JSON.parse(l)),
163
+ );
64
164
 
65
- const lineToSteps: Record<number, number[]> = {};
165
+ const lineToStepsByFile = new Map<string, Record<number, number[]>>();
166
+ for (const source of sources) {
167
+ lineToStepsByFile.set(source.file, {});
168
+ }
66
169
  for (let i = 0; i < allSteps.length; i++) {
67
170
  const step = allSteps[i];
171
+ const file = path.basename(step.file);
172
+ const lineToSteps = lineToStepsByFile.get(file);
173
+ if (!lineToSteps) {
174
+ continue;
175
+ }
68
176
  if (!lineToSteps[step.line]) {
69
177
  lineToSteps[step.line] = [];
70
178
  }
71
179
  lineToSteps[step.line].push(i);
72
180
  }
73
181
 
74
- const layout = computeLayout(allSteps, ignoreFields);
182
+ const layoutSteps = allSteps.map(step => ({ ...step, ...filterStep(step) }));
183
+ const layout = computeLayout(layoutSteps, ignoreFields);
75
184
 
76
185
  let chunkEntries: TraceChunkEntry[] = [];
77
186
  let chunkIndex = 0;
@@ -79,7 +188,12 @@ export function chunkTraceOutput(
79
188
 
80
189
  for (const step of allSteps) {
81
190
  const svg = generateStepSvg(step, layout);
82
- chunkEntries.push({ line: step.line, stdout: step.stdout, svg });
191
+ chunkEntries.push({
192
+ file: path.basename(step.file),
193
+ line: step.line,
194
+ output: stepOutputEvents(step),
195
+ svg,
196
+ });
83
197
 
84
198
  if (chunkEntries.length >= chunkSize) {
85
199
  chunkFiles.push({
@@ -101,9 +215,11 @@ export function chunkTraceOutput(
101
215
  const manifest: TraceManifest = {
102
216
  totalSteps: allSteps.length,
103
217
  chunkSize,
104
- sourceFile,
105
- source,
106
- lineToSteps,
218
+ primaryFile,
219
+ sources: sources.map(source => ({
220
+ ...source,
221
+ lineToSteps: lineToStepsByFile.get(source.file) ?? {},
222
+ })),
107
223
  };
108
224
 
109
225
  const manifestFile = {
@@ -218,11 +334,11 @@ export function buildManifestUrl({
218
334
  }
219
335
 
220
336
  export function renderTraceWidgetHtml({
221
- highlightedSource,
337
+ highlightedSources,
222
338
  manifestUrl,
223
339
  totalSteps,
224
340
  }: {
225
- highlightedSource: string;
341
+ highlightedSources: { file: string; highlightedSource: string }[];
226
342
  manifestUrl?: string;
227
343
  totalSteps?: number;
228
344
  }): string {
@@ -248,5 +364,11 @@ export function renderTraceWidgetHtml({
248
364
  const wrapperClass = disabled
249
365
  ? 'trace-widget trace-disabled'
250
366
  : '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>`;
367
+ const sourcePanels = highlightedSources
368
+ .map((source, index) => {
369
+ const hidden = index === 0 ? '' : ' hidden';
370
+ return `<div class="trace-source" data-trace-source-file="${escapeAttr(source.file)}"${hidden}>${source.highlightedSource}</div>`;
371
+ })
372
+ .join('');
373
+ 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
374
  }
@@ -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
  );