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
fix: getFirstToken
/getLastToken
on comment-only node
#16889
Conversation
✅ 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. |
lib/source-code/token-store/utils.js
Outdated
|
||
/* | ||
* For the map of "comment's location -> token's index", it points the next token of a comment. | ||
* 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
lib/source-code/token-store/utils.js
Outdated
|
||
/* | ||
* For the map of "comment's location -> token's index", it points the next token of a comment. | ||
* 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same comment as above. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tests/lib/source-code/token-store.js
Outdated
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
tests/lib/source-code/token-store.js
Outdated
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, "``"); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as #16889 (comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as #16889 (comment).
tests/lib/source-code/token-store.js
Outdated
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree.
lib/source-code/token-store/utils.js
Outdated
|
||
/* | ||
* For the map of "comment's location -> token's index", it points the next token of a comment. | ||
* 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks! Just want @mdjermanovic to verify his suggestions.
lib/source-code/token-store/utils.js
Outdated
// 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM, thanks!
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 espree@9.5.1 ([#​17031](eslint/eslint#17031)) (Milos Djermanovic) - [`f5f9a88`](eslint/eslint@f5f9a88) chore: upgrade eslint-visitor-keys@3.4.0 ([#​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 <cabr2.help@gmail.com> Reviewed-on: https://codeberg.org/Calciumdibromid/CaBr2/pulls/1834 Reviewed-by: Epsilon_02 <epsilon_02@noreply.codeberg.org> Co-authored-by: Calciumdibromid Bot <cabr2_bot@noreply.codeberg.org> Co-committed-by: Calciumdibromid Bot <cabr2_bot@noreply.codeberg.org>
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
SourceCode
methods that is causing unexpected behavior when the source code contains no tokens but one or more comments. TheSourceCode
type 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 theProgram
node (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 returnnull
as per documentation. If afilter
callback is passed, the callback should never be called.The actual case is that
sourceCode.getFirstToken(ast)
returnsundefined
. If afilter
callback is passed, the callback is called with anundefined
argument.My original, incomplete analysis of this problem can be found in issue #16742.
Closes #16742
What changes did you make? (Give an overview)
Program
nodes when the source code contains just comments.Is there anything you'd like reviewers to focus on?
I am open to any suggestions.