fix: getFirstToken/getLastToken on comment-only node#16889
fix: getFirstToken/getLastToken on comment-only node#16889mdjermanovic merged 4 commits intoeslint:mainfrom fasttime:fix-16742
getFirstToken/getLastToken on comment-only node#16889Conversation
✅ Deploy Preview for docs-eslint canceled.
|
|
Hi everyone, it looks like we lost track of this pull request. Please review and see what the next steps are. This pull request will auto-close in 7 days without an update. |
|
Don't close. WIP: Looks like @fasttime is still working on this. |
|
Thanks @Rec0iL99! Actually I'm done with the fix, but I'm not particularly satisfied with my explanation of the problem and the solution. I realize it takes some patience to grasp how this code works and I would like to elaborate on my description and make it more accessible to reviewers. |
| * In that case, +1 is unnecessary. | ||
| */ | ||
| if (token && token.range[0] >= startLoc) { | ||
| if (!token || token.range[0] >= startLoc) { |
There was a problem hiding this comment.
If token is undefined, then index is already out of bounds, i.e. index == tokens.length. In this case, the returned cursor index should point after the last token, at tokens.length.
Refs:
There was a problem hiding this comment.
I may be reading this wrong, but it seems like this change doesn't have any effect. I think it's just refactoring the boolean expression without changing the result. Can you double-check?
There was a problem hiding this comment.
When token is null or undefined, the new expression evaluates to true, whereas the old expression would evaluate to a falsy (null or undefined) value. Other than this, the result is the same.
There was a problem hiding this comment.
Ah, right you are. Boolean expressions are hard. :)
So you're saying that token is null if we've already moved past the end of the tokens array?
For clarity, I think I'd prefer to split out null token check into its own if statement and return tokens.length directly instead of adding it to this condition.
| * In that case, -1 is necessary. | ||
| */ | ||
| if (token && token.range[1] > endLoc) { | ||
| if (!token || token.range[1] > endLoc) { |
There was a problem hiding this comment.
If token is undefined, then tokens is an empty list and index is 0. In this case, the returned cursor index should point before the first token, at -1.
Refs:
| it("should retrieve a parent node's token if a node has no tokens", () => { | ||
| const code = "foo``"; | ||
| const ast = espree.parse(code, { loc: true, range: true, tokens: true, comment: true, ecmaVersion: 6 }); | ||
| const tokenStore = new TokenStore(ast.tokens, ast.comments); | ||
| const emptyTemplateElementNode = ast.body[0].expression.quasi.quasis[0]; | ||
| const token = tokenStore.getFirstToken(emptyTemplateElementNode); | ||
|
|
||
| assert.strictEqual(token.value, "``"); | ||
| }); |
There was a problem hiding this comment.
If a node has no tokens, I think getFirstToken should return null. Either way, this doesn't seem to be a case where a node has no tokens, because that token does belong to the TemplateElement node (ast.body[0].expression.quasi.quasis[0] node).
There was a problem hiding this comment.
@mdjermanovic you are correct! That node is not empty. I was fooled by AST explorer that shows the same value for both the start and end indices, probably because it uses an outdated version of Espree.
I added this test to make sure that I wouldn't inadvertently change the existing behavior, but now I think we can safely remove it because it's flawed.
| it("should retrieve a parent node's token if a node has no tokens", () => { | ||
| const code = "foo``"; | ||
| const ast = espree.parse(code, { loc: true, range: true, tokens: true, comment: true, ecmaVersion: 6 }); | ||
| const tokenStore = new TokenStore(ast.tokens, ast.comments); | ||
| const emptyTemplateElementNode = ast.body[0].expression.quasi.quasis[0]; | ||
| const token = tokenStore.getLastToken(emptyTemplateElementNode); | ||
|
|
||
| assert.strictEqual(token.value, "``"); | ||
| }); |
| it("should retrieve a parent node's token if a node has no tokens", () => { | ||
| const code = "foo``"; | ||
| const ast = espree.parse(code, { loc: true, range: true, tokens: true, comment: true, ecmaVersion: 6 }); | ||
| const tokenStore = new TokenStore(ast.tokens, ast.comments); | ||
| const emptyTemplateElementNode = ast.body[0].expression.quasi.quasis[0]; | ||
| const token = tokenStore.getFirstToken(emptyTemplateElementNode); | ||
|
|
||
| assert.strictEqual(token.value, "``"); | ||
| }); |
| * In that case, +1 is unnecessary. | ||
| */ | ||
| if (token && token.range[0] >= startLoc) { | ||
| if (!token || token.range[0] >= startLoc) { |
There was a problem hiding this comment.
Ah, right you are. Boolean expressions are hard. :)
So you're saying that token is null if we've already moved past the end of the tokens array?
For clarity, I think I'd prefer to split out null token check into its own if statement and return tokens.length directly instead of adding it to this condition.
| const token = tokens[index]; | ||
|
|
||
| // If the mapped index is out of bounds, the returned cursor index will point after the end of the tokens array. | ||
| if (!token) { |
There was a problem hiding this comment.
Alternatively, we could change this condition into index >= tokens.length and move the const token declaration after the if-block.
| const token = tokens[index]; | ||
|
|
||
| // If the mapped index is out of bounds, the returned cursor index will point before the start of the tokens array. | ||
| if (!token) { |
There was a problem hiding this comment.
Same as https://github.com/eslint/eslint/pull/16889/files#r1131283705. index can exceed the array length, but it should never be negative.
|
Rebased to fix the CI build. |
nzakas
left a comment
There was a problem hiding this comment.
LGTM, thanks! Just want @mdjermanovic to verify his suggestions.
| // If the mapped index is out of bounds, the returned cursor index will point before the start of the tokens array. | ||
| if (!token) { | ||
| return -1; | ||
| } |
There was a problem hiding this comment.
Shouldn't we return tokens.length - 1 (index of the last token) here? Hardcoded -1 would always mean that there are no tokens before endLoc.
I couldn't construct an example where this would be observable with the default parser because it doesn't include trailing comments in the range of the Program node, but here's an example with @typescript-eslint/parser:
const { RuleTester } = require('eslint');
const rule = {
meta: {
docs: { description: 'Require at least one token in the source code' },
messages: { fail: 'no tokens found' },
},
create(context) {
const sourceCode = context.getSourceCode();
return {
Program: node => {
if (!sourceCode.getFirstToken(node)) {
context.report({ node, messageId: 'fail' });
}
}
};
}
};
new RuleTester().run(
'must-have-tokens',
rule,
{
valid:
[
{
code: 'foo // comment',
parser: require.resolve("@typescript-eslint/parser")
}
],
invalid: [],
},
);The expected result is that sourceCode.getFirstToken finds the token foo, but it doesn't because it looks for tokens between indexes 0 and -1.
There was a problem hiding this comment.
You are perfectly right @mdjermanovic, thanks for pointing me to a proper test case to disproof my incorrect assumption. I've applied the change you suggested and added a unit test to ensure the correct behavior when the root node includes a trailing comment in its range.
It strikes me as a peculiarity of Espree that comments at the beginning and at the end of the source code are not included in the root node, while standalone comments are.
This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | devDependencies | minor | [`8.36.0` -> `8.37.0`](https://renovatebot.com/diffs/npm/eslint/8.36.0/8.37.0) | --- ### Release Notes <details> <summary>eslint/eslint</summary> ### [`v8.37.0`](https://github.com/eslint/eslint/releases/tag/v8.37.0) [Compare Source](eslint/eslint@v8.36.0...v8.37.0) #### Features - [`b6ab8b2`](eslint/eslint@b6ab8b2) feat: `require-unicode-regexp` add suggestions ([#​17007](eslint/eslint#17007)) (Josh Goldberg) - [`10022b1`](eslint/eslint@10022b1) feat: Copy getScope() to SourceCode ([#​17004](eslint/eslint#17004)) (Nicholas C. Zakas) - [`1665c02`](eslint/eslint@1665c02) feat: Use plugin metadata for flat config serialization ([#​16992](eslint/eslint#16992)) (Nicholas C. Zakas) - [`b3634f6`](eslint/eslint@b3634f6) feat: docs license ([#​17010](eslint/eslint#17010)) (Samuel Roldan) - [`892e6e5`](eslint/eslint@892e6e5) feat: languageOptions.parser must be an object. ([#​16985](eslint/eslint#16985)) (Nicholas C. Zakas) #### Bug Fixes - [`619f3fd`](eslint/eslint@619f3fd) fix: correctly handle `null` default config in `RuleTester` ([#​17023](eslint/eslint#17023)) (Brad Zacher) - [`1fbf118`](eslint/eslint@1fbf118) fix: `getFirstToken`/`getLastToken` on comment-only node ([#​16889](eslint/eslint#16889)) (Francesco Trotta) - [`129e252`](eslint/eslint@129e252) fix: Fix typo in `logical-assignment-operators` rule description ([#​17000](eslint/eslint#17000)) (Francesco Trotta) #### Documentation - [`75339df`](eslint/eslint@75339df) docs: fix typos and missing info in id-match docs ([#​17029](eslint/eslint#17029)) (Ed Lucas) - [`ec2d830`](eslint/eslint@ec2d830) docs: Fix typos in the `semi` rule docs ([#​17012](eslint/eslint#17012)) (Andrii Lundiak) - [`e39f28d`](eslint/eslint@e39f28d) docs: add back to top button ([#​16979](eslint/eslint#16979)) (Tanuj Kanti) - [`721c717`](eslint/eslint@721c717) docs: Custom Processors cleanup and expansion ([#​16838](eslint/eslint#16838)) (Ben Perlmutter) - [`d049f97`](eslint/eslint@d049f97) docs: 'How ESLint is Maintained' page ([#​16961](eslint/eslint#16961)) (Ben Perlmutter) - [`5251a92`](eslint/eslint@5251a92) docs: Describe guard options for guard-for-in ([#​16986](eslint/eslint#16986)) (alope107) - [`6157d81`](eslint/eslint@6157d81) docs: Add example to guard-for-in docs. ([#​16983](eslint/eslint#16983)) (alope107) - [`fd47998`](eslint/eslint@fd47998) docs: update `Array.prototype.toSorted` specification link ([#​16982](eslint/eslint#16982)) (Milos Djermanovic) - [`3e1cf6b`](eslint/eslint@3e1cf6b) docs: Copy edits on Maintain ESLint docs ([#​16939](eslint/eslint#16939)) (Ben Perlmutter) #### Chores - [`c67f299`](eslint/eslint@c67f299) chore: upgrade [@​eslint/js](https://github.com/eslint/js)[@​8](https://github.com/8).37.0 ([#​17033](eslint/eslint#17033)) (Milos Djermanovic) - [`ee9ddbd`](eslint/eslint@ee9ddbd) chore: package.json update for [@​eslint/js](https://github.com/eslint/js) release (ESLint Jenkins) - [`dddb475`](eslint/eslint@dddb475) chore: upgrade [@​eslint/eslintrc](https://github.com/eslint/eslintrc)[@​2](https://github.com/2).0.2 ([#​17032](eslint/eslint#17032)) (Milos Djermanovic) - [`522431e`](eslint/eslint@522431e) chore: upgrade [email protected] ([#​17031](eslint/eslint#17031)) (Milos Djermanovic) - [`f5f9a88`](eslint/eslint@f5f9a88) chore: upgrade [email protected] ([#​17030](eslint/eslint#17030)) (Milos Djermanovic) - [`4dd8d52`](eslint/eslint@4dd8d52) ci: bump actions/stale from 7 to 8 ([#​17026](eslint/eslint#17026)) (dependabot\[bot]) - [`ad9dd6a`](eslint/eslint@ad9dd6a) chore: remove duplicate scss, ([#​17005](eslint/eslint#17005)) (Strek) - [`ada6a3e`](eslint/eslint@ada6a3e) ci: unpin Node 19 ([#​16993](eslint/eslint#16993)) (Milos Djermanovic) - [`c3da975`](eslint/eslint@c3da975) chore: Remove triage label from template ([#​16990](eslint/eslint#16990)) (Nicholas C. Zakas) - [`69bc0e2`](eslint/eslint@69bc0e2) ci: pin Node 19 to 19.7.0 ([#​16987](eslint/eslint#16987)) (Milos Djermanovic) </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNS4yNC41IiwidXBkYXRlZEluVmVyIjoiMzUuMjQuNSJ9--> Co-authored-by: cabr2-bot <[email protected]> Reviewed-on: https://codeberg.org/Calciumdibromid/CaBr2/pulls/1834 Reviewed-by: Epsilon_02 <[email protected]> Co-authored-by: Calciumdibromid Bot <[email protected]> Co-committed-by: Calciumdibromid Bot <[email protected]>
Prerequisites checklist
What is the purpose of this pull request? (put an "X" next to an item)
[ ] Documentation update
[x] Bug fix (template)
[ ] New rule (template)
[ ] Changes an existing rule (template)
[ ] Add autofix to a rule
[ ] Add a CLI option
[ ] Add something to the core
[ ] Other, please explain:
This PR fixes an issue with some
SourceCodemethods that is causing unexpected behavior when the source code contains no tokens but one or more comments. TheSourceCodetype is part of the public API and is used by virtually all rules, although the issue described here should not affect any built-in rules.The problem shows up when
sourceCode.getFirstToken()orsourceCode.getLastToken()is called with theProgramnode (the AST object) of a source code than contains no tokens but one or more comments, i.e. a comment-only file.The expected behavior is that
sourceCode.getFirstToken(ast)orsourceCode.getLastToken(ast)should returnnullas per documentation. If afiltercallback is passed, the callback should never be called.The actual case is that
sourceCode.getFirstToken(ast)returnsundefined. If afiltercallback is passed, the callback is called with anundefinedargument.My original, incomplete analysis of this problem can be found in issue #16742.
Closes #16742
What changes did you make? (Give an overview)
Programnodes when the source code contains just comments.Is there anything you'd like reviewers to focus on?
I am open to any suggestions.