import watcher from '@parcel/watcher';
import path from 'path';
import ts from 'typescript';
import {FIXTURES_PATH, BABEL_PLUGIN_ROOT} from './constants';
import {TestFilter, getFixtures} from './fixture-utils';
import {execSync} from 'child_process';
export function watchSrc(
onStart: () => void,
onComplete: (isSuccess: boolean) => void,
): ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram> {
const configPath = ts.findConfigFile(
BABEL_PLUGIN_ROOT,
ts.sys.fileExists,
'tsconfig.json',
);
if (!configPath) {
throw new Error("Could not find a valid 'tsconfig.json'.");
}
const createProgram = ts.createSemanticDiagnosticsBuilderProgram;
const host = ts.createWatchCompilerHost(
configPath,
undefined,
ts.sys,
createProgram,
() => {},
() => {},
);
const origCreateProgram = host.createProgram;
host.createProgram = (rootNames, options, host, oldProgram) => {
onStart();
return origCreateProgram(rootNames, options, host, oldProgram);
};
host.afterProgramCreate = program => {
const errors = program
.getSyntacticDiagnostics()
.filter(diag => diag.category === ts.DiagnosticCategory.Error);
errors.push(
...program
.getSemanticDiagnostics()
.filter(diag => diag.category === ts.DiagnosticCategory.Error),
);
if (errors.length > 0) {
for (const diagnostic of errors) {
let fileLoc: string;
if (diagnostic.file) {
const {line, character} = ts.getLineAndCharacterOfPosition(
diagnostic.file,
diagnostic.start!,
);
const fileName = path.relative(
ts.sys.getCurrentDirectory(),
diagnostic.file.fileName,
);
fileLoc = `${fileName}:${line + 1}:${character + 1} - `;
} else {
fileLoc = '';
}
console.error(
`${fileLoc}error TS${diagnostic.code}:`,
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
);
}
console.error(
`Compilation failed (${errors.length} ${
errors.length > 1 ? 'errors' : 'error'
}).\n`,
);
}
const isSuccess = errors.length === 0;
onComplete(isSuccess);
};
return ts.createWatchProgram(host);
}
export enum RunnerAction {
Test = 'Test',
Update = 'Update',
}
type RunnerMode = {
action: RunnerAction;
filter: boolean;
};
export type RunnerState = {
compilerVersion: number;
isCompilerBuildValid: boolean;
lastUpdate: number;
mode: RunnerMode;
filter: TestFilter | null;
debug: boolean;
inputMode: 'none' | 'pattern';
inputBuffer: string;
allFixtureNames: Array<string>;
matchingFixtures: Array<string>;
selectedIndex: number;
fixtureLastRunStatus: Map<string, 'pass' | 'fail'>;
};
function subscribeFixtures(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
watcher.subscribe(FIXTURES_PATH, async (err, _events) => {
if (err) {
console.error(err);
process.exit(1);
}
const isRealUpdate = performance.now() - state.lastUpdate > 5000;
if (isRealUpdate) {
state.mode.action = RunnerAction.Test;
onChange(state);
}
});
}
function subscribeTsc(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
watchSrc(
function onStart() {
console.log('\nCompiling...');
},
isTypecheckSuccess => {
let isCompilerBuildValid = false;
if (isTypecheckSuccess) {
try {
execSync('yarn build', {cwd: BABEL_PLUGIN_ROOT});
console.log('Built compiler successfully with tsup');
isCompilerBuildValid = true;
} catch (e) {
console.warn('Failed to build compiler with tsup:', e);
}
}
if (isCompilerBuildValid) {
state.compilerVersion++;
}
state.isCompilerBuildValid = isCompilerBuildValid;
state.mode.action = RunnerAction.Test;
onChange(state);
},
);
}
function editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({length: m + 1}, () =>
Array(n + 1).fill(0),
);
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
}
function filterFixtures(
allNames: Array<string>,
pattern: string,
): Array<string> {
if (pattern === '') {
return allNames;
}
const lowerPattern = pattern.toLowerCase();
const matches = allNames.filter(name =>
name.toLowerCase().includes(lowerPattern),
);
matches.sort((a, b) => {
const distA = editDistance(lowerPattern, a.toLowerCase());
const distB = editDistance(lowerPattern, b.toLowerCase());
return distA - distB;
});
return matches;
}
const MAX_DISPLAY = 15;
function renderAutocomplete(state: RunnerState): void {
console.log('\u001Bc');
console.log(`Pattern: ${state.inputBuffer}`);
console.log('');
const currentFilterPattern =
state.mode.filter && state.filter ? state.filter.paths[0] : null;
const toShow = state.matchingFixtures.slice(0, MAX_DISPLAY);
toShow.forEach((name, i) => {
const isSelected = i === state.selectedIndex;
const matchesCurrentFilter =
currentFilterPattern != null &&
name.toLowerCase().includes(currentFilterPattern.toLowerCase());
let prefix: string;
if (isSelected) {
prefix = '> ';
} else if (matchesCurrentFilter) {
prefix = '* ';
} else {
prefix = ' ';
}
console.log(`${prefix}${name}`);
});
if (state.matchingFixtures.length > MAX_DISPLAY) {
console.log(
` ... and ${state.matchingFixtures.length - MAX_DISPLAY} more`,
);
}
console.log('');
console.log('↑/↓/Tab navigate | Enter select | Esc cancel');
}
function subscribeKeyEvents(
state: RunnerState,
onChange: (state: RunnerState) => void,
) {
process.stdin.on('keypress', async (str, key) => {
if (state.inputMode !== 'none') {
if (key.name === 'return') {
let pattern: string;
if (
state.selectedIndex >= 0 &&
state.selectedIndex < state.matchingFixtures.length
) {
pattern = state.matchingFixtures[state.selectedIndex];
} else {
pattern = state.inputBuffer.trim();
}
state.inputMode = 'none';
state.inputBuffer = '';
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;
if (pattern !== '') {
state.filter = {paths: [pattern]};
state.mode.filter = true;
state.mode.action = RunnerAction.Test;
onChange(state);
}
return;
} else if (key.name === 'escape') {
state.inputMode = 'none';
state.inputBuffer = '';
state.allFixtureNames = [];
state.matchingFixtures = [];
state.selectedIndex = -1;
onChange(state);
return;
} else if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
if (state.matchingFixtures.length > 0) {
if (state.selectedIndex <= 0) {
state.selectedIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
} else {
state.selectedIndex--;
}
renderAutocomplete(state);
}
return;
} else if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
if (state.matchingFixtures.length > 0) {
const maxIndex =
Math.min(state.matchingFixtures.length, MAX_DISPLAY) - 1;
if (state.selectedIndex >= maxIndex) {
state.selectedIndex = 0;
} else {
state.selectedIndex++;
}
renderAutocomplete(state);
}
return;
} else if (key.name === 'backspace') {
if (state.inputBuffer.length > 0) {
state.inputBuffer = state.inputBuffer.slice(0, -1);
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
}
return;
} else if (str && !key.ctrl && !key.meta) {
state.inputBuffer += str;
state.matchingFixtures = filterFixtures(
state.allFixtureNames,
state.inputBuffer,
);
state.selectedIndex = -1;
renderAutocomplete(state);
return;
}
return;
}
if (key.name === 'u') {
state.mode.action = RunnerAction.Update;
} else if (key.name === 'q') {
process.exit(0);
} else if (key.name === 'a') {
state.mode.filter = false;
state.filter = null;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'd') {
state.debug = !state.debug;
state.mode.action = RunnerAction.Test;
} else if (key.name === 'p') {
state.inputMode = 'pattern';
state.inputBuffer = '';
const fixtures = await getFixtures(null);
state.allFixtureNames = Array.from(fixtures.keys()).sort();
const failedFixtures = Array.from(state.fixtureLastRunStatus.entries())
.filter(([_, status]) => status === 'fail')
.map(([name]) => name)
.sort();
state.matchingFixtures =
failedFixtures.length > 0 ? failedFixtures : state.allFixtureNames;
state.selectedIndex = -1;
renderAutocomplete(state);
return;
} else {
state.mode.action = RunnerAction.Test;
}
onChange(state);
});
}
export async function makeWatchRunner(
onChange: (state: RunnerState) => void,
debugMode: boolean,
initialPattern?: string,
): Promise<void> {
let filter: TestFilter | null = null;
let filterEnabled = false;
if (initialPattern) {
filter = {paths: [initialPattern]};
filterEnabled = true;
}
const state: RunnerState = {
compilerVersion: 0,
isCompilerBuildValid: false,
lastUpdate: -1,
mode: {
action: RunnerAction.Test,
filter: filterEnabled,
},
filter,
debug: debugMode,
inputMode: 'none',
inputBuffer: '',
allFixtureNames: [],
matchingFixtures: [],
selectedIndex: -1,
fixtureLastRunStatus: new Map(),
};
subscribeTsc(state, onChange);
subscribeFixtures(state, onChange);
subscribeKeyEvents(state, onChange);
}