Skip to content

Commit b502583

Browse files
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 a more robust AST based analysis
1 parent c3b9868 commit b502583

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 : findPotentialExpressionCompleteTarget(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 findPotentialExpressionCompleteTarget(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 = findPotentialExpressionCompleteTarget(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 = findPotentialExpressionCompleteTarget(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 findPotentialExpressionCompleteTarget(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 findPotentialExpressionCompleteTarget(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 findPotentialExpressionCompleteTarget(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)