main
ts 537 lines 15.5 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 import watcher from '@parcel/watcher';
9 import path from 'path';
10 import ts from 'typescript';
11 import {
12 FIXTURES_PATH,
13 BABEL_PLUGIN_ROOT,
14 BABEL_PLUGIN_RUST_ROOT,
15 CRATES_PATH,
16 } from './constants';
17 import {TestFilter, getFixtures} from './fixture-utils';
18 import {execSync} from 'child_process';
19 import fs from 'fs';
20
21 export function watchSrc(
22 onStart: () => void,
23 onComplete: (isSuccess: boolean) => void,
24 ): ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram> {
25 const configPath = ts.findConfigFile(
26 /*searchPath*/ BABEL_PLUGIN_ROOT,
27 ts.sys.fileExists,
28 'tsconfig.json',
29 );
30 if (!configPath) {
31 throw new Error("Could not find a valid 'tsconfig.json'.");
32 }
33 const createProgram = ts.createSemanticDiagnosticsBuilderProgram;
34 const host = ts.createWatchCompilerHost(
35 configPath,
36 undefined,
37 ts.sys,
38 createProgram,
39 () => {}, // we manually report errors in afterProgramCreate
40 () => {}, // we manually report watch status
41 );
42
43 const origCreateProgram = host.createProgram;
44 host.createProgram = (rootNames, options, host, oldProgram) => {
45 onStart();
46 return origCreateProgram(rootNames, options, host, oldProgram);
47 };
48 host.afterProgramCreate = program => {
49 /**
50 * Avoid calling original postProgramCreate because it always emits tsc
51 * compilation output
52 */
53
54 // syntactic diagnostics refer to javascript syntax
55 const errors = program
56 .getSyntacticDiagnostics()
57 .filter(diag => diag.category === ts.DiagnosticCategory.Error);
58 // semantic diagnostics refer to typescript semantics
59 errors.push(
60 ...program
61 .getSemanticDiagnostics()
62 .filter(diag => diag.category === ts.DiagnosticCategory.Error),
63 );
64
65 if (errors.length > 0) {
66 for (const diagnostic of errors) {
67 let fileLoc: string;
68 if (diagnostic.file) {
69 // https://github.com/microsoft/TypeScript/blob/ddd5084659c423f4003d2176e12d879b6a5bcf30/src/compiler/program.ts#L663-L674
70 const {line, character} = ts.getLineAndCharacterOfPosition(
71 diagnostic.file,
72 diagnostic.start!,
73 );
74 const fileName = path.relative(
75 ts.sys.getCurrentDirectory(),
76 diagnostic.file.fileName,
77 );
78 fileLoc = `${fileName}:${line + 1}:${character + 1} - `;
79 } else {
80 fileLoc = '';
81 }
82 console.error(
83 `${fileLoc}error TS${diagnostic.code}:`,
84 ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
85 );
86 }
87 console.error(
88 `Compilation failed (${errors.length} ${
89 errors.length > 1 ? 'errors' : 'error'
90 }).\n`,
91 );
92 }
93
94 const isSuccess = errors.length === 0;
95 onComplete(isSuccess);
96 };
97
98 // `createWatchProgram` creates an initial program, watches files, and updates
99 // the program over time.
100 return ts.createWatchProgram(host);
101 }
102
103 /**
104 * Watch mode helpers
105 */
106 export enum RunnerAction {
107 Test = 'Test',
108 Update = 'Update',
109 }
110
111 type RunnerMode = {
112 action: RunnerAction;
113 filter: boolean;
114 };
115
116 export type RunnerState = {
117 // Monotonically increasing integer to describe the 'version' of the compiler.
118 // This is passed to `compile()` when compiling, so that the worker knows when
119 // to reset its module cache (compared to using its cached compiler version)
120 compilerVersion: number;
121 isCompilerBuildValid: boolean;
122 // timestamp of the last update
123 lastUpdate: number;
124 mode: RunnerMode;
125 filter: TestFilter | null;
126 debug: boolean;
127 // Input mode for interactive pattern entry
128 inputMode: 'none' | 'pattern';
129 inputBuffer: string;
130 // Autocomplete state
131 allFixtureNames: Array<string>;
132 matchingFixtures: Array<string>;
133 selectedIndex: number;
134 // Track last run status of each fixture (for autocomplete suggestions)
135 fixtureLastRunStatus: Map<string, 'pass' | 'fail'>;
136 };
137
138 function subscribeFixtures(
139 state: RunnerState,
140 onChange: (state: RunnerState) => void,
141 ) {
142 // Watch the fixtures directory for changes
143 watcher.subscribe(FIXTURES_PATH, async (err, _events) => {
144 if (err) {
145 console.error(err);
146 process.exit(1);
147 }
148 // Try to ignore changes that occurred as a result of our explicitly updating
149 // fixtures in update().
150 // Currently keeps a timestamp of last known changes, and ignore events that occurred
151 // around that timestamp.
152 const isRealUpdate = performance.now() - state.lastUpdate > 5000;
153 if (isRealUpdate) {
154 // Fixtures changed, re-run tests
155 state.mode.action = RunnerAction.Test;
156 onChange(state);
157 }
158 });
159 }
160
161 function subscribeTsc(
162 state: RunnerState,
163 onChange: (state: RunnerState) => void,
164 enableRust: boolean = false,
165 ) {
166 // Run TS in incremental watch mode
167 watchSrc(
168 function onStart() {
169 // Notify the user when compilation starts but don't clear the screen yet
170 console.log('\nCompiling...');
171 },
172 isTypecheckSuccess => {
173 let isCompilerBuildValid = false;
174 if (isTypecheckSuccess) {
175 try {
176 execSync('yarn build', {cwd: BABEL_PLUGIN_ROOT});
177 console.log('Built compiler successfully with tsup');
178 isCompilerBuildValid = true;
179 } catch (e) {
180 console.warn('Failed to build compiler with tsup:', e);
181 }
182 }
183 // When using Rust, also build the Rust compiler after TS build succeeds
184 if (isCompilerBuildValid && enableRust) {
185 isCompilerBuildValid = buildRust();
186 }
187 // Bump the compiler version after a build finishes
188 // and re-run tests
189 if (isCompilerBuildValid) {
190 state.compilerVersion++;
191 }
192 state.isCompilerBuildValid = isCompilerBuildValid;
193 state.mode.action = RunnerAction.Test;
194 onChange(state);
195 },
196 );
197 }
198
199 export function buildRust(): boolean {
200 const compilerRoot = path.join(BABEL_PLUGIN_ROOT, '..', '..');
201 try {
202 execSync('cargo build -p react_compiler_napi', {
203 cwd: compilerRoot,
204 stdio: 'inherit',
205 });
206 } catch (e) {
207 console.error('Failed to build Rust compiler with cargo:', e);
208 return false;
209 }
210
211 // Copy the built native module to the babel plugin package
212 const platform = process.platform;
213 const ext = platform === 'darwin' ? 'dylib' : 'so';
214 const libName =
215 platform === 'darwin'
216 ? 'libreact_compiler_napi.dylib'
217 : 'libreact_compiler_napi.so';
218 const sourcePath = path.join(compilerRoot, 'target', 'debug', libName);
219 const destPath = path.join(BABEL_PLUGIN_RUST_ROOT, 'native', 'index.node');
220
221 try {
222 fs.copyFileSync(sourcePath, destPath);
223 } catch (e) {
224 console.error(
225 `Failed to copy native module (${sourcePath} -> ${destPath}):`,
226 e,
227 );
228 return false;
229 }
230
231 // Build the TypeScript wrapper
232 try {
233 execSync('yarn build', {cwd: BABEL_PLUGIN_RUST_ROOT, stdio: 'inherit'});
234 console.log('Built Rust compiler successfully');
235 } catch (e) {
236 console.error('Failed to build Rust babel plugin with tsc:', e);
237 return false;
238 }
239
240 return true;
241 }
242
243 function subscribeRustCrates(
244 state: RunnerState,
245 onChange: (state: RunnerState) => void,
246 ) {
247 watcher.subscribe(CRATES_PATH, async (err, events) => {
248 if (err) {
249 console.error(err);
250 process.exit(1);
251 }
252 // Only rebuild on .rs file changes
253 const hasRustChanges = events.some(e => e.path.endsWith('.rs'));
254 if (!hasRustChanges) {
255 return;
256 }
257 console.log('\nRust source changed, rebuilding...');
258 if (buildRust()) {
259 state.compilerVersion++;
260 state.isCompilerBuildValid = true;
261 state.mode.action = RunnerAction.Test;
262 onChange(state);
263 } else {
264 state.isCompilerBuildValid = false;
265 console.error('Rust build failed, waiting for changes...');
266 }
267 });
268 }
269
270 /**
271 * Levenshtein edit distance between two strings
272 */
273 function editDistance(a: string, b: string): number {
274 const m = a.length;
275 const n = b.length;
276
277 // Create a 2D array for memoization
278 const dp: number[][] = Array.from({length: m + 1}, () =>
279 Array(n + 1).fill(0),
280 );
281
282 // Base cases
283 for (let i = 0; i <= m; i++) dp[i][0] = i;
284 for (let j = 0; j <= n; j++) dp[0][j] = j;
285
286 // Fill in the rest
287 for (let i = 1; i <= m; i++) {
288 for (let j = 1; j <= n; j++) {
289 if (a[i - 1] === b[j - 1]) {
290 dp[i][j] = dp[i - 1][j - 1];
291 } else {
292 dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
293 }
294 }
295 }
296
297 return dp[m][n];
298 }
299
300 function filterFixtures(
301 allNames: Array<string>,
302 pattern: string,
303 ): Array<string> {
304 if (pattern === '') {
305 return allNames;
306 }
307 const lowerPattern = pattern.toLowerCase();
308 const matches = allNames.filter(name =>
309 name.toLowerCase().includes(lowerPattern),
310 );
311 // Sort by edit distance (lower = better match)
312 matches.sort((a, b) => {
313 const distA = editDistance(lowerPattern, a.toLowerCase());
314 const distB = editDistance(lowerPattern, b.toLowerCase());
315 return distA - distB;
316 });
317 return matches;
318 }
319
320 const MAX_DISPLAY = 15;
321
322 function renderAutocomplete(state: RunnerState): void {
323 // Clear terminal
324 console.log('\u001Bc');
325
326 // Show current input
327 console.log(`Pattern: ${state.inputBuffer}`);
328 console.log('');
329
330 // Get current filter pattern if active
331 const currentFilterPattern =
332 state.mode.filter && state.filter ? state.filter.paths[0] : null;
333
334 // Show matching fixtures (limit to MAX_DISPLAY)
335 const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);
336
337 toShow.forEach((name, i) => {
338 const isSelected = i === state.selectedIndex;
339 const matchesCurrentFilter =
340 currentFilterPattern != null &&
341 name.toLowerCase().includes(currentFilterPattern.toLowerCase());
342
343 let prefix: string;
344 if (isSelected) {
345 prefix = '> ';
346 } else if (matchesCurrentFilter) {
347 prefix = '* ';
348 } else {
349 prefix = ' ';
350 }
351 console.log(`${prefix}${name}`);
352 });
353
354 if (state.matchingFixtures.length > MAX_DISPLAY) {
355 console.log(
356 ` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,
357 );
358 }
359
360 console.log('');
361 console.log('↑/↓/Tab navigate | Enter select | Esc cancel');
362 }
363
364 function subscribeKeyEvents(
365 state: RunnerState,
366 onChange: (state: RunnerState) => void,
367 ) {
368 process.stdin.on('keypress', async (str, key) => {
369 // Handle input mode (pattern entry with autocomplete)
370 if (state.inputMode !== 'none') {
371 if (key.name === 'return') {
372 // Enter pressed - use selected fixture or typed text
373 let pattern: string;
374 if (
375 state.selectedIndex >= 0 &&
376 state.selectedIndex < state.matchingFixtures.length
377 ) {
378 pattern = state.matchingFixtures[state.selectedIndex];
379 } else {
380 pattern = state.inputBuffer.trim();
381 }
382
383 state.inputMode = 'none';
384 state.inputBuffer = '';
385 state.allFixtureNames = [];
386 state.matchingFixtures = [];
387 state.selectedIndex = -1;
388
389 if (pattern !== '') {
390 state.filter = {paths: [pattern]};
391 state.mode.filter = true;
392 state.mode.action = RunnerAction.Test;
393 onChange(state);
394 }
395 return;
396 } else if (key.name === 'escape') {
397 // Cancel input mode
398 state.inputMode = 'none';
399 state.inputBuffer = '';
400 state.allFixtureNames = [];
401 state.matchingFixtures = [];
402 state.selectedIndex = -1;
403 // Redraw normal UI
404 onChange(state);
405 return;
406 } else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
407 // Navigate up in autocomplete list
408 if (state.matchingFixtures.length > 0) {
409 if (state.selectedIndex <= 0) {
410 state.selectedIndex =
411 Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
412 } else {
413 state.selectedIndex--;
414 }
415 renderAutocomplete(state);
416 }
417 return;
418 } else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
419 // Navigate down in autocomplete list
420 if (state.matchingFixtures.length > 0) {
421 const maxIndex =
422 Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
423 if (state.selectedIndex >= maxIndex) {
424 state.selectedIndex = 0;
425 } else {
426 state.selectedIndex++;
427 }
428 renderAutocomplete(state);
429 }
430 return;
431 } else if (key.name === 'backspace') {
432 if (state.inputBuffer.length > 0) {
433 state.inputBuffer = state.inputBuffer.slice(0, -1);
434 state.matchingFixtures = filterFixtures(
435 state.allFixtureNames,
436 state.inputBuffer,
437 );
438 state.selectedIndex = -1;
439 renderAutocomplete(state);
440 }
441 return;
442 } else if (str && !key.ctrl && !key.meta) {
443 // Regular character - accumulate, filter, and render
444 state.inputBuffer += str;
445 state.matchingFixtures = filterFixtures(
446 state.allFixtureNames,
447 state.inputBuffer,
448 );
449 state.selectedIndex = -1;
450 renderAutocomplete(state);
451 return;
452 }
453 return; // Ignore other keys in input mode
454 }
455
456 // Normal mode keypress handling
457 if (key.name === 'u') {
458 // u => update fixtures
459 state.mode.action = RunnerAction.Update;
460 } else if (key.name === 'q') {
461 process.exit(0);
462 } else if (key.name === 'a') {
463 // a => exit filter mode and run all tests
464 state.mode.filter = false;
465 state.filter = null;
466 state.mode.action = RunnerAction.Test;
467 } else if (key.name === 'd') {
468 // d => toggle debug logging
469 state.debug = !state.debug;
470 state.mode.action = RunnerAction.Test;
471 } else if (key.name === 'p') {
472 // p => enter pattern input mode with autocomplete
473 state.inputMode = 'pattern';
474 state.inputBuffer = '';
475
476 // Load all fixtures for autocomplete
477 const fixtures = await getFixtures(null);
478 state.allFixtureNames = Array.from(fixtures.keys()).sort();
479 // Show failed fixtures first when no pattern entered
480 const failedFixtures = Array.from(state.fixtureLastRunStatus.entries())
481 .filter(([_, status]) => status === 'fail')
482 .map(([name]) => name)
483 .sort();
484 state.matchingFixtures =
485 failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames;
486 state.selectedIndex = -1;
487
488 renderAutocomplete(state);
489 return; // Don't trigger onChange yet
490 } else {
491 // any other key re-runs tests
492 state.mode.action = RunnerAction.Test;
493 }
494 onChange(state);
495 });
496 }
497
498 export async function makeWatchRunner(
499 onChange: (state: RunnerState) => void,
500 debugMode: boolean,
501 initialPattern?: string,
502 enableRust: boolean = false,
503 ): Promise<void> {
504 // Determine initial filter state
505 let filter: TestFilter | null = null;
506 let filterEnabled = false;
507
508 if (initialPattern) {
509 filter = {paths: [initialPattern]};
510 filterEnabled = true;
511 }
512
513 const state: RunnerState = {
514 compilerVersion: 0,
515 isCompilerBuildValid: false,
516 lastUpdate: -1,
517 mode: {
518 action: RunnerAction.Test,
519 filter: filterEnabled,
520 },
521 filter,
522 debug: debugMode,
523 inputMode: 'none',
524 inputBuffer: '',
525 allFixtureNames: [],
526 matchingFixtures: [],
527 selectedIndex: -1,
528 fixtureLastRunStatus: new Map(),
529 };
530
531 subscribeTsc(state, onChange, enableRust);
532 subscribeFixtures(state, onChange);
533 subscribeKeyEvents(state, onChange);
534 if (enableRust) {
535 subscribeRustCrates(state, onChange);
536 }
537 }