Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add fuzzy matching algorithm for tab completions #1855

Open
wants to merge 27 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dc73372
feat: add fuzzy matching algorithm for tab completions
Snehil-Shah Mar 12, 2024
3cae6a7
refactor: fix function jsdoc
Snehil-Shah Mar 24, 2024
abe7525
Merge branch 'stdlib-js:develop' into repl-fuzzy
Snehil-Shah Mar 31, 2024
dcb6014
feat: rewrite the completer engine
Snehil-Shah Apr 2, 2024
bef5dd3
feat: improve fuzzy algorithm and sort by relevancy
Snehil-Shah Apr 2, 2024
0aee34d
feat: add support for highlighted completions in terminal mode
Snehil-Shah Apr 2, 2024
7efe1a7
fix: wrong completion previews displayed for fuzzy completion
Snehil-Shah Apr 2, 2024
b0ca5c1
fix: suggestions
Snehil-Shah Apr 3, 2024
7cc9270
feat: new tab completions UI with navigation
Snehil-Shah Apr 5, 2024
24da634
refactor: move `fuzzyMatch` to a module & don't fuzzy match for previews
Snehil-Shah Apr 5, 2024
6818055
fix: changes requested
Snehil-Shah Apr 5, 2024
95a72a4
Merge branch 'develop' into repl-fuzzy
Snehil-Shah Apr 5, 2024
0c8b11a
style: lint merged changes
Snehil-Shah Apr 5, 2024
6b7dc99
feat: add a setting to control fuzzy completions
Snehil-Shah Apr 5, 2024
237fb7f
fix: limit height of completions & fix completions with prefixes
Snehil-Shah Apr 5, 2024
576f402
feat: fuzzy completions only when no exact completions
Snehil-Shah Apr 6, 2024
89b5001
fix: bug in completer
Snehil-Shah Apr 6, 2024
c464944
test: add tests for tab completions
Snehil-Shah Apr 7, 2024
6d6d7c9
docs: fix test name and comments
Snehil-Shah Apr 8, 2024
2c15d15
fix: tune fuzzy algorithm
Snehil-Shah Apr 8, 2024
0e54cf5
docs: document setting
Snehil-Shah Apr 21, 2024
7128fac
Merge branch 'develop' into repl-fuzzy
Snehil-Shah Apr 25, 2024
fa22afb
fix: abnormal completer behavior
Snehil-Shah Apr 25, 2024
7b8074a
refactor: move flag to the completer engine namespace
Snehil-Shah Apr 25, 2024
cc2f3c5
refactor: abstract logics into private methods
Snehil-Shah Apr 26, 2024
85efce4
fix: make completer `SIGWINCH` aware
Snehil-Shah Apr 26, 2024
023c31a
test: update tests for TTY
Snehil-Shah Apr 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/node_modules/@stdlib/repl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ The function supports specifying the following settings:
- **autoDeletePairs**: boolean indicating whether to automatically delete adjacent matching brackets, parentheses, and quotes. Default: `true`.
- **autoPage**: boolean indicating whether to automatically page return values having a display size exceeding the visible screen. When streams are TTY, the default is `true`; otherwise, the default is `false`.
- **completionPreviews**: boolean indicating whether to display completion previews for auto-completion. When streams are TTY, the default is `true`; otherwise, the default is `false`.
- **fuzzyCompletions**: boolean indicating whether to include fuzzy results in TAB completions. Default: `true`.

#### REPL.prototype.createContext()

Expand Down
81 changes: 71 additions & 10 deletions lib/node_modules/@stdlib/repl/lib/complete_expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* limitations under the License.
*/

/* eslint-disable max-statements */

'use strict';

// MODULES //
Expand All @@ -32,6 +34,7 @@ var filterByPrefix = require( './filter_by_prefix.js' );
var findLast = require( './complete_walk_find_last.js' );
var resolveLocalScopes = require( './resolve_local_scopes.js' );
var resolveLocalScope = require( './resolve_local_scope.js' );
var sortFuzzyCompletions = require( './sort_fuzzy_completions.js' );
var RESERVED_KEYWORDS_COMMON = require( './reserved_keywords_common.js' );


Expand All @@ -52,16 +55,19 @@ var AOPTS = {
* @param {Array} out - output array for storing completions
* @param {Object} context - REPL context
* @param {string} expression - expression to complete
* @param {boolean} isFuzzy - boolean indicating if the completions should be strictly fuzzy
* @returns {string} filter
*/
function complete( out, context, expression ) {
function complete( out, context, expression, isFuzzy ) {
var fuzzyResults = [];
var filter;
var script;
var node;
var opts;
var ast;
var obj;
var res;
var i;

// Case: `<|>` (a command devoid of expressions/statements)
if ( trim( expression ) === '' ) {
Expand Down Expand Up @@ -100,9 +106,22 @@ function complete( out, context, expression ) {
}
filter = node.expression.name;
debug( 'Identifier auto-completion. Filter: %s', filter );
out = filterByPrefix( out, RESERVED_KEYWORDS_COMMON, filter );
out = filterByPrefix( out, objectKeys( context ), filter );
out = filterByPrefix( out, ast.locals, filter );
out = filterByPrefix( out, RESERVED_KEYWORDS_COMMON, filter, false );
out = filterByPrefix( out, objectKeys( context ), filter, false );
out = filterByPrefix( out, ast.locals, filter, false );
out.sort();

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
fuzzyResults = filterByPrefix( fuzzyResults, RESERVED_KEYWORDS_COMMON, filter, true ); // eslint-disable-line max-len
fuzzyResults = filterByPrefix( fuzzyResults, objectKeys( context ), filter, true ); // eslint-disable-line max-len
fuzzyResults = filterByPrefix( fuzzyResults, ast.locals, filter, true );
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}
// Find the identifier or member expression to be completed:
Expand Down Expand Up @@ -177,7 +196,17 @@ function complete( out, context, expression ) {
// Case: `foo['<|>` || `foo['bar<|>`
if ( node.property.type === 'Literal' ) {
filter = node.property.value.toString(); // handles numeric literals
out = filterByPrefix( out, propertyNamesIn( obj ), filter );
out = filterByPrefix( out, propertyNamesIn( obj ), filter, false ); // eslint-disable-line max-len

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
fuzzyResults = filterByPrefix( fuzzyResults, propertyNamesIn( obj ), filter, true ); // eslint-disable-line max-len
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}
// Case: `foo[<|>` || `foo[bar<|>`
Expand All @@ -190,7 +219,17 @@ function complete( out, context, expression ) {
else {
filter = node.property.name;
}
out = filterByPrefix( out, objectKeys( context ), filter );
out = filterByPrefix( out, objectKeys( context ), filter, false ); // eslint-disable-line max-len

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
fuzzyResults = filterByPrefix( fuzzyResults, objectKeys( context ), filter, true ); // eslint-disable-line max-len
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}
// Case: `foo[bar.<|>` || `foo[bar.beep<|>` || `foo[bar.beep.<|>` || `foo[bar[beep<|>` || etc
Expand All @@ -216,15 +255,37 @@ function complete( out, context, expression ) {
filter = node.property.name;
}
debug( 'Property auto-completion. Filter: %s', filter );
out = filterByPrefix( out, propertyNamesIn( obj ), filter );
out = filterByPrefix( out, propertyNamesIn( obj ), filter, false );

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
fuzzyResults = filterByPrefix( fuzzyResults, propertyNamesIn( obj ), filter, true ); // eslint-disable-line max-len
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}
// Case: `foo<|>` (completing an identifier)
filter = ( node.name === '✖' ) ? '' : node.name;
debug( 'Identifier auto-completion. Filter: %s', filter );
out = filterByPrefix( out, res.keywords, filter );
out = filterByPrefix( out, objectKeys( context ), filter );
out = filterByPrefix( out, resolveLocalScope( ast, node ), filter );
out = filterByPrefix( out, res.keywords, filter, false );
out = filterByPrefix( out, objectKeys( context ), filter, false );
out = filterByPrefix( out, resolveLocalScope( ast, node ), filter, false );

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
fuzzyResults = filterByPrefix( fuzzyResults, res.keywords, filter, true );
fuzzyResults = filterByPrefix( fuzzyResults, objectKeys( context ), filter, true ); // eslint-disable-line max-len
fuzzyResults = filterByPrefix( fuzzyResults, resolveLocalScope( ast, node ), filter, true ); // eslint-disable-line max-len
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}

Expand Down
51 changes: 50 additions & 1 deletion lib/node_modules/@stdlib/repl/lib/complete_fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
// MODULES //

var resolve = require( 'path' ).resolve;
var statSync = require( 'fs' ).statSync; // TODO: replace with stdlib equivalent

Check warning on line 24 in lib/node_modules/@stdlib/repl/lib/complete_fs.js

View workflow job for this annotation

GitHub Actions / Lint Changed Files

Unexpected sync method: 'statSync'

Check warning on line 24 in lib/node_modules/@stdlib/repl/lib/complete_fs.js

View workflow job for this annotation

GitHub Actions / Lint Changed Files

Unexpected 'todo' comment: 'TODO: replace with stdlib equivalent'
var logger = require( 'debug' );
var parse = require( 'acorn-loose' ).parse;
var isRelativePath = require( '@stdlib/assert/is-relative-path' );
Expand All @@ -30,6 +30,8 @@
var startsWith = require( '@stdlib/string/starts-with' );
var pathRegExp = require( './regexp_path.js' );
var fsAliasArgs = require( './fs_alias_args.js' );
var fuzzyMatch = require( './fuzzy_match.js' );
var sortFuzzyCompletions = require( './sort_fuzzy_completions.js' );


// VARIABLES //
Expand All @@ -50,13 +52,16 @@
* @param {string} expression - expression to complete
* @param {string} alias - file system API alias
* @param {string} path - path to complete
* @param {boolean} isFuzzy - boolean indicating if the completions should be strictly fuzzy
* @returns {string} path filter
*/
function complete( out, expression, alias, path ) {
function complete( out, expression, alias, path, isFuzzy ) {
var fuzzyResults = [];
var filter;
var subdir;
var files;
var stats;
var match;
var args;
var ast;
var arg;
Expand Down Expand Up @@ -128,6 +133,50 @@
continue;
}
}
out.sort();

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
// Start searching for fuzzy completions...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could potentially lead to an enormous number of potential completions. File system access isn't cheap, so I am not sure the best approach. It could be that we'd require matches to meet a higher threshold.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this carries performance risks, we can stick to exact matches in this case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be worth seeing how other command-line tools supporting fuzzy walking of the file system handle this. I am sure there have been more than one tool written in Rust for this purpose. Maybe they restrict the number of matches more stringently?

debug( 'Searching path for fuzzy completions...' );
for ( i = 0; i < files.length; i++ ) {
f = files[ i ];
match = fuzzyMatch( f, filter );
if ( !match ) {
debug( '%s does not match fuzzy filter %s. Skipping...', f, filter );
continue;
}
f = resolve( dir, f );
debug( 'Examining path: %s', f );
try {
stats = statSync( f );
if ( stats.isDirectory() ) {
debug( 'Path resolves to a subdirectory.' );
fuzzyResults.push({
'score': match.score,
'completion': match.completion + '/'
});
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );
} else if ( stats.isFile() ) {
debug( 'Path resolves to a file.' );
fuzzyResults.push( match );
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );
} else {
debug( 'Path resolves to neither a directory nor a file. Skipping path...' );
continue;
}
} catch ( err ) {
debug( 'Error: %s', err.message );
debug( 'Skipping path...' );
continue;
}
}
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}

Expand Down
94 changes: 92 additions & 2 deletions lib/node_modules/@stdlib/repl/lib/complete_require.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,25 @@
* limitations under the License.
*/

/* eslint-disable max-statements */

'use strict';

// MODULES //

var resolve = require( 'path' ).resolve;
var statSync = require( 'fs' ).statSync; // TODO: replace with stdlib equivalent

Check warning on line 26 in lib/node_modules/@stdlib/repl/lib/complete_require.js

View workflow job for this annotation

GitHub Actions / Lint Changed Files

Unexpected sync method: 'statSync'

Check warning on line 26 in lib/node_modules/@stdlib/repl/lib/complete_require.js

View workflow job for this annotation

GitHub Actions / Lint Changed Files

Unexpected 'todo' comment: 'TODO: replace with stdlib equivalent'
var logger = require( 'debug' );
var readDir = require( '@stdlib/fs/read-dir' ).sync;
var startsWith = require( '@stdlib/string/starts-with' );
var extname = require( '@stdlib/utils/extname' );
var cwd = require( '@stdlib/process/cwd' );
var indexRegExp = require( './regexp_index.js' ); // eslint-disable-line stdlib/no-require-index
var indexRegExp = require( './regexp_index.js' );
var relativePathRegExp = require( './regexp_relative_require_path.js' );
var pathRegExp = require( './regexp_path.js' );
var contains = require( './contains.js' );
var fuzzyMatch = require( './fuzzy_match.js' );
var sortFuzzyCompletions = require( './sort_fuzzy_completions.js' );


// VARIABLES //
Expand All @@ -49,14 +53,17 @@
* @param {string} path - path to complete
* @param {Array} paths - module search paths
* @param {Array} exts - supported `require` extensions
* @param {boolean} isFuzzy - boolean indicating if the completions should be strictly fuzzy
* @returns {string} path filter
*/
function complete( out, path, paths, exts ) {
function complete( out, path, paths, exts, isFuzzy ) {
var fuzzyResults = [];
var filter;
var sfiles;
var subdir;
var files;
var stats;
var match;
var dir;
var ext;
var re;
Expand Down Expand Up @@ -157,6 +164,89 @@
}
}
}
out.sort();

// Only fuzzy search, when no exact candidates found...
if ( !isFuzzy || out.length !== 0 ) {
return filter;
}
// Start searching for fuzzy completions...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't as potentially perf prohibitive as fs fuzzy auto-completion, but still carries some perf risk.

debug( 'Searching paths for fuzzy completions: %s', paths.join( ', ' ) );
for ( i = 0; i < paths.length; i++ ) {
// Resolve the subdirectory path to a file system path:
dir = resolve( paths[ i ], subdir );
debug( 'Resolved directory: %s', dir );

debug( 'Reading directory contents...' );
files = readDir( dir );
if ( files instanceof Error ) {
debug( 'Unable to read directory: %s. Error: %s', dir, files.message );
continue;
}
for ( j = 0; j < files.length; j++ ) {
f = files[ j ];
match = fuzzyMatch( f, filter );
if ( !match ) {
debug( '%s does not fuzzy match filter %s. Skipping...', f, filter );
continue;
}
f = resolve( dir, f );
debug( 'Examining path: %s', f );
try {
stats = statSync( f );
if ( stats.isDirectory() ) {
debug( 'Path resolves to a subdirectory.' );
fuzzyResults.push({
'score': match.score,
'completion': match.completion + '/'
});
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );

debug( 'Reading subdirectory contents...' );
sfiles = readDir( f );
if ( sfiles instanceof Error ) {
debug( 'Unable to read subdirectory: %s. Error: %s', f, sfiles.message );
continue;
}
for ( k = 0; k < sfiles.length; k++ ) {
if ( re.test( sfiles[ k ] ) ) {
// Since the subdirectory contains an `index` file, one can simply "require" the subdirectory, thus eliding the full file path:
debug( 'Subdirectory contains an `index` file.' );

fuzzyResults.push( match );
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );
} else if ( sfiles[ k ] === 'package.json' ) {
// Since the subdirectory contains a `package.json` file, we **ASSUME** one can simply "require" the subdirectory, thus eliding the full file path (WARNING: we do NOT explicitly check that the main entry point actually exists!):
debug( 'Subdirectory contains a `package.json` file.' );

fuzzyResults.push( match );
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );
}
}
} else if ( stats.isFile() ) {
debug( 'Path resolves to a file.' );
ext = extname( files[ j ] );
if ( contains( exts.length, exts, 1, 0, ext ) ) {
debug( 'File has supported extension: %s', ext );

fuzzyResults.push( match );
debug( 'Found a fuzzy completion: %s', fuzzyResults[ fuzzyResults.length-1 ].completion );
}
} else {
debug( 'Path resolves to neither a directory nor a file. Skipping path...' );
continue;
}
} catch ( err ) {
debug( 'Error: %s', err.message );
debug( 'Skipping path...' );
continue;
}
}
}
fuzzyResults = sortFuzzyCompletions( fuzzyResults );
for ( i = 0; i < fuzzyResults.length; i++ ) {
out.push( fuzzyResults[ i ] );
}
return filter;
}

Expand Down
Loading
Loading