compiler/packages/snap/src/runner-watch.ts TYPESCRIPT 538 lines View on github.com → Search inside
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.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.