1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 */78import watcher from '@parcel/watcher';9import path from 'path';10import ts from 'typescript';11import {12 FIXTURES_PATH,13 BABEL_PLUGIN_ROOT,14 BABEL_PLUGIN_RUST_ROOT,15 CRATES_PATH,16} from './constants';17import {TestFilter, getFixtures} from './fixture-utils';18import {execSync} from 'child_process';19import fs from 'fs';2021export 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 afterProgramCreate40 () => {}, // we manually report watch status41 );4243 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 tsc51 * compilation output52 */5354 // syntactic diagnostics refer to javascript syntax55 const errors = program56 .getSyntacticDiagnostics()57 .filter(diag => diag.category === ts.DiagnosticCategory.Error);58 // semantic diagnostics refer to typescript semantics59 errors.push(60 ...program61 .getSemanticDiagnostics()62 .filter(diag => diag.category === ts.DiagnosticCategory.Error),63 );6465 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-L67470 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 }9394 const isSuccess = errors.length === 0;95 onComplete(isSuccess);96 };9798 // `createWatchProgram` creates an initial program, watches files, and updates99 // the program over time.100 return ts.createWatchProgram(host);101}102103/**104 * Watch mode helpers105 */106export enum RunnerAction {107 Test = 'Test',108 Update = 'Update',109}110111type RunnerMode = {112 action: RunnerAction;113 filter: boolean;114};115116export 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 when119 // to reset its module cache (compared to using its cached compiler version)120 compilerVersion: number;121 isCompilerBuildValid: boolean;122 // timestamp of the last update123 lastUpdate: number;124 mode: RunnerMode;125 filter: TestFilter | null;126 debug: boolean;127 // Input mode for interactive pattern entry128 inputMode: 'none' | 'pattern';129 inputBuffer: string;130 // Autocomplete state131 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};137138function subscribeFixtures(139 state: RunnerState,140 onChange: (state: RunnerState) => void,141) {142 // Watch the fixtures directory for changes143 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 updating149 // fixtures in update().150 // Currently keeps a timestamp of last known changes, and ignore events that occurred151 // around that timestamp.152 const isRealUpdate = performance.now() - state.lastUpdate > 5000;153 if (isRealUpdate) {154 // Fixtures changed, re-run tests155 state.mode.action = RunnerAction.Test;156 onChange(state);157 }158 });159}160161function subscribeTsc(162 state: RunnerState,163 onChange: (state: RunnerState) => void,164 enableRust: boolean = false,165) {166 // Run TS in incremental watch mode167 watchSrc(168 function onStart() {169 // Notify the user when compilation starts but don't clear the screen yet170 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 succeeds184 if (isCompilerBuildValid && enableRust) {185 isCompilerBuildValid = buildRust();186 }187 // Bump the compiler version after a build finishes188 // and re-run tests189 if (isCompilerBuildValid) {190 state.compilerVersion++;191 }192 state.isCompilerBuildValid = isCompilerBuildValid;193 state.mode.action = RunnerAction.Test;194 onChange(state);195 },196 );197}198199export 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 }210211 // Copy the built native module to the babel plugin package212 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');220221 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 }230231 // Build the TypeScript wrapper232 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 }239240 return true;241}242243function 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 changes253 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}269270/**271 * Levenshtein edit distance between two strings272 */273function editDistance(a: string, b: string): number {274 const m = a.length;275 const n = b.length;276277 // Create a 2D array for memoization278 const dp: number[][] = Array.from({length: m + 1}, () =>279 Array(n + 1).fill(0),280 );281282 // Base cases283 for (let i = 0; i <= m; i++) dp[i][0] = i;284 for (let j = 0; j <= n; j++) dp[0][j] = j;285286 // Fill in the rest287 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 }296297 return dp[m][n];298}299300function 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}319320const MAX_DISPLAY = 15;321322function renderAutocomplete(state: RunnerState): void {323 // Clear terminal324 console.log('\u001Bc');325326 // Show current input327 console.log(`Pattern: ${state.inputBuffer}`);328 console.log('');329330 // Get current filter pattern if active331 const currentFilterPattern =332 state.mode.filter && state.filter ? state.filter.paths[0] : null;333334 // Show matching fixtures (limit to MAX_DISPLAY)335 const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);336337 toShow.forEach((name, i) => {338 const isSelected = i === state.selectedIndex;339 const matchesCurrentFilter =340 currentFilterPattern != null &&341 name.toLowerCase().includes(currentFilterPattern.toLowerCase());342343 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 });353354 if (state.matchingFixtures.length > MAX_DISPLAY) {355 console.log(356 ` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,357 );358 }359360 console.log('');361 console.log('↑/↓/Tab navigate | Enter select | Esc cancel');362}363364function 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 text373 let pattern: string;374 if (375 state.selectedIndex >= 0 &&376 state.selectedIndex < state.matchingFixtures.length377 ) {378 pattern = state.matchingFixtures[state.selectedIndex];379 } else {380 pattern = state.inputBuffer.trim();381 }382383 state.inputMode = 'none';384 state.inputBuffer = '';385 state.allFixtureNames = [];386 state.matchingFixtures = [];387 state.selectedIndex = -1;388389 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 mode398 state.inputMode = 'none';399 state.inputBuffer = '';400 state.allFixtureNames = [];401 state.matchingFixtures = [];402 state.selectedIndex = -1;403 // Redraw normal UI404 onChange(state);405 return;406 } else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {407 // Navigate up in autocomplete list408 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 list420 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 render444 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 mode454 }455456 // Normal mode keypress handling457 if (key.name === 'u') {458 // u => update fixtures459 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 tests464 state.mode.filter = false;465 state.filter = null;466 state.mode.action = RunnerAction.Test;467 } else if (key.name === 'd') {468 // d => toggle debug logging469 state.debug = !state.debug;470 state.mode.action = RunnerAction.Test;471 } else if (key.name === 'p') {472 // p => enter pattern input mode with autocomplete473 state.inputMode = 'pattern';474 state.inputBuffer = '';475476 // Load all fixtures for autocomplete477 const fixtures = await getFixtures(null);478 state.allFixtureNames = Array.from(fixtures.keys()).sort();479 // Show failed fixtures first when no pattern entered480 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;487488 renderAutocomplete(state);489 return; // Don't trigger onChange yet490 } else {491 // any other key re-runs tests492 state.mode.action = RunnerAction.Test;493 }494 onChange(state);495 });496}497498export 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 state505 let filter: TestFilter | null = null;506 let filterEnabled = false;507508 if (initialPattern) {509 filter = {paths: [initialPattern]};510 filterEnabled = true;511 }512513 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 };530531 subscribeTsc(state, onChange, enableRust);532 subscribeFixtures(state, onChange);533 subscribeKeyEvents(state, onChange);534 if (enableRust) {535 subscribeRustCrates(state, onChange);536 }537}
Findings
✓ No findings reported for this file.