@@ -9,7 +9,7 @@ import watcher from '@parcel/watcher';
99import path from 'path' ;
1010import ts from 'typescript' ;
1111import { FIXTURES_PATH , PROJECT_ROOT } from './constants' ;
12- import { TestFilter } from './fixture-utils' ;
12+ import { TestFilter , getFixtures } from './fixture-utils' ;
1313import { execSync } from 'child_process' ;
1414
1515export function watchSrc (
@@ -121,6 +121,12 @@ export type RunnerState = {
121121 // Input mode for interactive pattern entry
122122 inputMode : 'none' | 'pattern' ;
123123 inputBuffer : string ;
124+ // Autocomplete state
125+ allFixtureNames : Array < string > ;
126+ matchingFixtures : Array < string > ;
127+ selectedIndex : number ;
128+ // Track last run status of each fixture (for autocomplete suggestions)
129+ fixtureLastRunStatus : Map < string , 'pass' | 'fail' > ;
124130} ;
125131
126132function subscribeFixtures (
@@ -179,46 +185,187 @@ function subscribeTsc(
179185 ) ;
180186}
181187
188+ /**
189+ * Levenshtein edit distance between two strings
190+ */
191+ function editDistance ( a : string , b : string ) : number {
192+ const m = a . length ;
193+ const n = b . length ;
194+
195+ // Create a 2D array for memoization
196+ const dp : number [ ] [ ] = Array . from ( { length : m + 1 } , ( ) =>
197+ Array ( n + 1 ) . fill ( 0 ) ,
198+ ) ;
199+
200+ // Base cases
201+ for ( let i = 0 ; i <= m ; i ++ ) dp [ i ] [ 0 ] = i ;
202+ for ( let j = 0 ; j <= n ; j ++ ) dp [ 0 ] [ j ] = j ;
203+
204+ // Fill in the rest
205+ for ( let i = 1 ; i <= m ; i ++ ) {
206+ for ( let j = 1 ; j <= n ; j ++ ) {
207+ if ( a [ i - 1 ] === b [ j - 1 ] ) {
208+ dp [ i ] [ j ] = dp [ i - 1 ] [ j - 1 ] ;
209+ } else {
210+ dp [ i ] [ j ] = 1 + Math . min ( dp [ i - 1 ] [ j ] , dp [ i ] [ j - 1 ] , dp [ i - 1 ] [ j - 1 ] ) ;
211+ }
212+ }
213+ }
214+
215+ return dp [ m ] [ n ] ;
216+ }
217+
218+ function filterFixtures (
219+ allNames : Array < string > ,
220+ pattern : string ,
221+ ) : Array < string > {
222+ if ( pattern === '' ) {
223+ return allNames ;
224+ }
225+ const lowerPattern = pattern . toLowerCase ( ) ;
226+ const matches = allNames . filter ( name =>
227+ name . toLowerCase ( ) . includes ( lowerPattern ) ,
228+ ) ;
229+ // Sort by edit distance (lower = better match)
230+ matches . sort ( ( a , b ) => {
231+ const distA = editDistance ( lowerPattern , a . toLowerCase ( ) ) ;
232+ const distB = editDistance ( lowerPattern , b . toLowerCase ( ) ) ;
233+ return distA - distB ;
234+ } ) ;
235+ return matches ;
236+ }
237+
238+ const MAX_DISPLAY = 15 ;
239+
240+ function renderAutocomplete ( state : RunnerState ) : void {
241+ // Clear terminal
242+ console . log ( '\u001Bc' ) ;
243+
244+ // Show current input
245+ console . log ( `Pattern: ${ state . inputBuffer } ` ) ;
246+ console . log ( '' ) ;
247+
248+ // Get current filter pattern if active
249+ const currentFilterPattern =
250+ state . mode . filter && state . filter ? state . filter . paths [ 0 ] : null ;
251+
252+ // Show matching fixtures (limit to MAX_DISPLAY)
253+ const toShow = state . matchingFixtures . slice ( 0 , MAX_DISPLAY ) ;
254+
255+ toShow . forEach ( ( name , i ) => {
256+ const isSelected = i === state . selectedIndex ;
257+ const matchesCurrentFilter =
258+ currentFilterPattern != null &&
259+ name . toLowerCase ( ) . includes ( currentFilterPattern . toLowerCase ( ) ) ;
260+
261+ let prefix : string ;
262+ if ( isSelected ) {
263+ prefix = '> ' ;
264+ } else if ( matchesCurrentFilter ) {
265+ prefix = '* ' ;
266+ } else {
267+ prefix = ' ' ;
268+ }
269+ console . log ( `${ prefix } ${ name } ` ) ;
270+ } ) ;
271+
272+ if ( state . matchingFixtures . length > MAX_DISPLAY ) {
273+ console . log (
274+ ` ... and ${ state . matchingFixtures . length - MAX_DISPLAY } more` ,
275+ ) ;
276+ }
277+
278+ console . log ( '' ) ;
279+ console . log ( '↑/↓/Tab navigate | Enter select | Esc cancel' ) ;
280+ }
281+
182282function subscribeKeyEvents (
183283 state : RunnerState ,
184284 onChange : ( state : RunnerState ) => void ,
185285) {
186286 process . stdin . on ( 'keypress' , async ( str , key ) => {
187- // Handle input mode (pattern entry)
287+ // Handle input mode (pattern entry with autocomplete )
188288 if ( state . inputMode !== 'none' ) {
189289 if ( key . name === 'return' ) {
190- // Enter pressed - process input
191- const pattern = state . inputBuffer . trim ( ) ;
290+ // Enter pressed - use selected fixture or typed text
291+ let pattern : string ;
292+ if (
293+ state . selectedIndex >= 0 &&
294+ state . selectedIndex < state . matchingFixtures . length
295+ ) {
296+ pattern = state . matchingFixtures [ state . selectedIndex ] ;
297+ } else {
298+ pattern = state . inputBuffer . trim ( ) ;
299+ }
300+
192301 state . inputMode = 'none' ;
193302 state . inputBuffer = '' ;
194- process . stdout . write ( '\n' ) ;
303+ state . allFixtureNames = [ ] ;
304+ state . matchingFixtures = [ ] ;
305+ state . selectedIndex = - 1 ;
195306
196307 if ( pattern !== '' ) {
197- // Set the pattern as filter
198308 state . filter = { paths : [ pattern ] } ;
199309 state . mode . filter = true ;
200310 state . mode . action = RunnerAction . Test ;
201311 onChange ( state ) ;
202312 }
203- // If empty, just exit input mode without changes
204313 return ;
205314 } else if ( key . name === 'escape' ) {
206315 // Cancel input mode
207316 state . inputMode = 'none' ;
208317 state . inputBuffer = '' ;
209- process . stdout . write ( ' (cancelled)\n' ) ;
318+ state . allFixtureNames = [ ] ;
319+ state . matchingFixtures = [ ] ;
320+ state . selectedIndex = - 1 ;
321+ // Redraw normal UI
322+ onChange ( state ) ;
323+ return ;
324+ } else if ( key . name === 'up' || ( key . name === 'tab' && key . shift ) ) {
325+ // Navigate up in autocomplete list
326+ if ( state . matchingFixtures . length > 0 ) {
327+ if ( state . selectedIndex <= 0 ) {
328+ state . selectedIndex =
329+ Math . min ( state . matchingFixtures . length , MAX_DISPLAY ) - 1 ;
330+ } else {
331+ state . selectedIndex -- ;
332+ }
333+ renderAutocomplete ( state ) ;
334+ }
335+ return ;
336+ } else if ( key . name === 'down' || ( key . name === 'tab' && ! key . shift ) ) {
337+ // Navigate down in autocomplete list
338+ if ( state . matchingFixtures . length > 0 ) {
339+ const maxIndex =
340+ Math . min ( state . matchingFixtures . length , MAX_DISPLAY ) - 1 ;
341+ if ( state . selectedIndex >= maxIndex ) {
342+ state . selectedIndex = 0 ;
343+ } else {
344+ state . selectedIndex ++ ;
345+ }
346+ renderAutocomplete ( state ) ;
347+ }
210348 return ;
211349 } else if ( key . name === 'backspace' ) {
212350 if ( state . inputBuffer . length > 0 ) {
213351 state . inputBuffer = state . inputBuffer . slice ( 0 , - 1 ) ;
214- // Erase character: backspace, space, backspace
215- process . stdout . write ( '\b \b' ) ;
352+ state . matchingFixtures = filterFixtures (
353+ state . allFixtureNames ,
354+ state . inputBuffer ,
355+ ) ;
356+ state . selectedIndex = - 1 ;
357+ renderAutocomplete ( state ) ;
216358 }
217359 return ;
218360 } else if ( str && ! key . ctrl && ! key . meta ) {
219- // Regular character - accumulate and echo
361+ // Regular character - accumulate, filter, and render
220362 state . inputBuffer += str ;
221- process . stdout . write ( str ) ;
363+ state . matchingFixtures = filterFixtures (
364+ state . allFixtureNames ,
365+ state . inputBuffer ,
366+ ) ;
367+ state . selectedIndex = - 1 ;
368+ renderAutocomplete ( state ) ;
222369 return ;
223370 }
224371 return ; // Ignore other keys in input mode
@@ -240,10 +387,23 @@ function subscribeKeyEvents(
240387 state . debug = ! state . debug ;
241388 state . mode . action = RunnerAction . Test ;
242389 } else if ( key . name === 'p' ) {
243- // p => enter pattern input mode
390+ // p => enter pattern input mode with autocomplete
244391 state . inputMode = 'pattern' ;
245392 state . inputBuffer = '' ;
246- process . stdout . write ( 'Pattern: ' ) ;
393+
394+ // Load all fixtures for autocomplete
395+ const fixtures = await getFixtures ( null ) ;
396+ state . allFixtureNames = Array . from ( fixtures . keys ( ) ) . sort ( ) ;
397+ // Show failed fixtures first when no pattern entered
398+ const failedFixtures = Array . from ( state . fixtureLastRunStatus . entries ( ) )
399+ . filter ( ( [ _ , status ] ) => status === 'fail' )
400+ . map ( ( [ name ] ) => name )
401+ . sort ( ) ;
402+ state . matchingFixtures =
403+ failedFixtures . length > 0 ? failedFixtures : state . allFixtureNames ;
404+ state . selectedIndex = - 1 ;
405+
406+ renderAutocomplete ( state ) ;
247407 return ; // Don't trigger onChange yet
248408 } else {
249409 // any other key re-runs tests
@@ -279,6 +439,10 @@ export async function makeWatchRunner(
279439 debug : debugMode ,
280440 inputMode : 'none' ,
281441 inputBuffer : '' ,
442+ allFixtureNames : [ ] ,
443+ matchingFixtures : [ ] ,
444+ selectedIndex : - 1 ,
445+ fixtureLastRunStatus : new Map ( ) ,
282446 } ;
283447
284448 subscribeTsc ( state , onChange ) ;
0 commit comments