Skip to content

Commit c891db1

Browse files
dario-piotrowicztargos
authored andcommitted
repl: improve tab completion on computed properties
improve the tab completion capabilities around computed properties by replacing the use of brittle and error prone Regex checks with more robust AST based analysis PR-URL: #58775 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Yagiz Nizipli <[email protected]>
1 parent a78385c commit c891db1

File tree

2 files changed

+130
-18
lines changed

2 files changed

+130
-18
lines changed

lib/repl.js

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,8 +1225,6 @@ REPLServer.prototype.setPrompt = function setPrompt(prompt) {
12251225
const importRE = /\bimport\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/;
12261226
const requireRE = /\brequire\s*\(\s*['"`](([\w@./:-]+\/)?(?:[\w@./:-]*))(?![^'"`])$/;
12271227
const fsAutoCompleteRE = /fs(?:\.promises)?\.\s*[a-z][a-zA-Z]+\(\s*["'](.*)/;
1228-
const simpleExpressionRE =
1229-
/(?:[\w$'"`[{(](?:(\w| |\t)*?['"`]|\$|['"`\]})])*\??(?:\.|])?)*?(?:[a-zA-Z_$])?(?:\w|\$)*\??\.?$/;
12301228
const versionedFileNamesRe = /-\d+\.\d+/;
12311229

12321230
function isIdentifier(str) {
@@ -1480,29 +1478,20 @@ function complete(line, callback) {
14801478
} else if ((match = RegExpPrototypeExec(fsAutoCompleteRE, line)) !== null &&
14811479
this.allowBlockingCompletions) {
14821480
({ 0: completionGroups, 1: completeOn } = completeFSFunctions(match));
1483-
// Handle variable member lookup.
1484-
// We support simple chained expressions like the following (no function
1485-
// calls, etc.). That is for simplicity and also because we *eval* that
1486-
// leading expression so for safety (see WARNING above) don't want to
1487-
// eval function calls.
1488-
//
1489-
// foo.bar<|> # completions for 'foo' with filter 'bar'
1490-
// spam.eggs.<|> # completions for 'spam.eggs' with filter ''
1491-
// foo<|> # all scope vars with filter 'foo'
1492-
// foo.<|> # completions for 'foo' with filter ''
14931481
} else if (line.length === 0 ||
14941482
RegExpPrototypeExec(/\w|\.|\$/, line[line.length - 1]) !== null) {
1495-
const { 0: match } = RegExpPrototypeExec(simpleExpressionRE, line) || [''];
1496-
if (line.length !== 0 && !match) {
1483+
const completeTarget = line.length === 0 ? line : findExpressionCompleteTarget(line);
1484+
1485+
if (line.length !== 0 && !completeTarget) {
14971486
completionGroupsLoaded();
14981487
return;
14991488
}
15001489
let expr = '';
1501-
completeOn = match;
1490+
completeOn = completeTarget;
15021491
if (StringPrototypeEndsWith(line, '.')) {
1503-
expr = StringPrototypeSlice(match, 0, -1);
1492+
expr = StringPrototypeSlice(completeTarget, 0, -1);
15041493
} else if (line.length !== 0) {
1505-
const bits = StringPrototypeSplit(match, '.');
1494+
const bits = StringPrototypeSplit(completeTarget, '.');
15061495
filter = ArrayPrototypePop(bits);
15071496
expr = ArrayPrototypeJoin(bits, '.');
15081497
}
@@ -1531,7 +1520,7 @@ function complete(line, callback) {
15311520
}
15321521

15331522
return includesProxiesOrGetters(
1534-
StringPrototypeSplit(match, '.'),
1523+
StringPrototypeSplit(completeTarget, '.'),
15351524
this.eval,
15361525
this.context,
15371526
(includes) => {
@@ -1642,6 +1631,100 @@ function complete(line, callback) {
16421631
}
16431632
}
16441633

1634+
/**
1635+
* This function tries to extract a target for tab completion from code representing an expression.
1636+
*
1637+
* Such target is basically the last piece of the expression that can be evaluated for the potential
1638+
* tab completion.
1639+
*
1640+
* Some examples:
1641+
* - The complete target for `const a = obj.b` is `obj.b`
1642+
* (because tab completion will evaluate and check the `obj.b` object)
1643+
* - The complete target for `tru` is `tru`
1644+
* (since we'd ideally want to complete that to `true`)
1645+
* - The complete target for `{ a: tru` is `tru`
1646+
* (like the last example, we'd ideally want that to complete to true)
1647+
* - There is no complete target for `{ a: true }`
1648+
* (there is nothing to complete)
1649+
* @param {string} code the code representing the expression to analyze
1650+
* @returns {string|null} a substring of the code representing the complete target is there was one, `null` otherwise
1651+
*/
1652+
function findExpressionCompleteTarget(code) {
1653+
if (!code) {
1654+
return null;
1655+
}
1656+
1657+
if (code.at(-1) === '.') {
1658+
if (code.at(-2) === '?') {
1659+
// The code ends with the optional chaining operator (`?.`),
1660+
// such code can't generate a valid AST so we need to strip
1661+
// the suffix, run this function's logic and add back the
1662+
// optional chaining operator to the result if present
1663+
const result = findExpressionCompleteTarget(code.slice(0, -2));
1664+
return !result ? result : `${result}?.`;
1665+
}
1666+
1667+
// The code ends with a dot, such code can't generate a valid AST
1668+
// so we need to strip the suffix, run this function's logic and
1669+
// add back the dot to the result if present
1670+
const result = findExpressionCompleteTarget(code.slice(0, -1));
1671+
return !result ? result : `${result}.`;
1672+
}
1673+
1674+
let ast;
1675+
try {
1676+
ast = acornParse(code, { __proto__: null, sourceType: 'module', ecmaVersion: 'latest' });
1677+
} catch {
1678+
const keywords = code.split(' ');
1679+
1680+
if (keywords.length > 1) {
1681+
// Something went wrong with the parsing, however this can be due to incomplete code
1682+
// (that is for example missing a closing bracket, as for example `{ a: obj.te`), in
1683+
// this case we take the last code keyword and try again
1684+
// TODO(dario-piotrowicz): make this more robust, right now we only split by spaces
1685+
// but that's not always enough, for example it doesn't handle
1686+
// this code: `{ a: obj['hello world'].te`
1687+
return findExpressionCompleteTarget(keywords.at(-1));
1688+
}
1689+
1690+
// The ast parsing has legitimately failed so we return null
1691+
return null;
1692+
}
1693+
1694+
const lastBodyStatement = ast.body[ast.body.length - 1];
1695+
1696+
if (!lastBodyStatement) {
1697+
return null;
1698+
}
1699+
1700+
// If the last statement is a block we know there is not going to be a potential
1701+
// completion target (e.g. in `{ a: true }` there is no completion to be done)
1702+
if (lastBodyStatement.type === 'BlockStatement') {
1703+
return null;
1704+
}
1705+
1706+
// If the last statement is an expression and it has a right side, that's what we
1707+
// want to potentially complete on, so let's re-run the function's logic on that
1708+
if (lastBodyStatement.type === 'ExpressionStatement' && lastBodyStatement.expression.right) {
1709+
const exprRight = lastBodyStatement.expression.right;
1710+
const exprRightCode = code.slice(exprRight.start, exprRight.end);
1711+
return findExpressionCompleteTarget(exprRightCode);
1712+
}
1713+
1714+
// If the last statement is a variable declaration statement the last declaration is
1715+
// what we can potentially complete on, so let's re-run the function's logic on that
1716+
if (lastBodyStatement.type === 'VariableDeclaration') {
1717+
const lastDeclarationInit = lastBodyStatement.declarations.at(-1).init;
1718+
const lastDeclarationInitCode = code.slice(lastDeclarationInit.start, lastDeclarationInit.end);
1719+
return findExpressionCompleteTarget(lastDeclarationInitCode);
1720+
}
1721+
1722+
// If any of the above early returns haven't activated then it means that
1723+
// the potential complete target is the full code (e.g. the code represents
1724+
// a simple partial identifier, a member expression, etc...)
1725+
return code;
1726+
}
1727+
16451728
function includesProxiesOrGetters(exprSegments, evalFn, context, callback, currentExpr = '', idx = 0) {
16461729
const currentSegment = exprSegments[idx];
16471730
currentExpr += `${currentExpr.length === 0 ? '' : '.'}${currentSegment}`;

test/parallel/test-repl-tab-complete-computed-props.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ describe('REPL tab object completion on computed properties', () => {
109109
[oneStr]: 1,
110110
['Hello World']: 'hello world!',
111111
};
112+
113+
const lookupObj = {
114+
stringLookup: helloWorldStr,
115+
['number lookup']: oneStr,
116+
};
112117
`,
113118
]);
114119
});
@@ -126,5 +131,29 @@ describe('REPL tab object completion on computed properties', () => {
126131
input: 'obj[helloWorldStr].tolocaleup',
127132
expectedCompletions: ['obj[helloWorldStr].toLocaleUpperCase'],
128133
}));
134+
135+
it('works with a simple inlined computed property', () => testCompletion(replServer, {
136+
input: 'obj["Hello " + "World"].tolocaleup',
137+
expectedCompletions: ['obj["Hello " + "World"].toLocaleUpperCase'],
138+
}));
139+
140+
it('works with a ternary inlined computed property', () => testCompletion(replServer, {
141+
input: 'obj[(1 + 2 > 5) ? oneStr : "Hello " + "World"].toLocaleUpperCase',
142+
expectedCompletions: ['obj[(1 + 2 > 5) ? oneStr : "Hello " + "World"].toLocaleUpperCase'],
143+
}));
144+
145+
it('works with an inlined computed property with a nested property lookup', () =>
146+
testCompletion(replServer, {
147+
input: 'obj[lookupObj.stringLookup].tolocaleupp',
148+
expectedCompletions: ['obj[lookupObj.stringLookup].toLocaleUpperCase'],
149+
})
150+
);
151+
152+
it('works with an inlined computed property with a nested inlined computer property lookup', () =>
153+
testCompletion(replServer, {
154+
input: 'obj[lookupObj["number" + " lookup"]].toFi',
155+
expectedCompletions: ['obj[lookupObj["number" + " lookup"]].toFixed'],
156+
})
157+
);
129158
});
130159
});

0 commit comments

Comments
 (0)