diff --git a/apps/oxlint/src-js/plugins/tokens.ts b/apps/oxlint/src-js/plugins/tokens.ts index 6fb052043b806..a7a683981fe5d 100644 --- a/apps/oxlint/src-js/plugins/tokens.ts +++ b/apps/oxlint/src-js/plugins/tokens.ts @@ -1181,24 +1181,106 @@ export function getFirstTokensBetween( /** * Get the last token between two non-overlapping nodes. - * @param nodeOrToken1 - Node before the desired token range. - * @param nodeOrToken2 - Node after the desired token range. + * @param left - Node or token before the desired token range. + * @param right - Node or token after the desired token range. * @param skipOptions? - Options object. * If is a number, equivalent to `{ skip: n }`. * If is a function, equivalent to `{ filter: fn }`. * @returns `Token`, or `null` if all were skipped. */ -/* oxlint-disable no-unused-vars */ export function getLastTokenBetween( - nodeOrToken1: NodeOrToken | Comment, - nodeOrToken2: NodeOrToken | Comment, + left: NodeOrToken | Comment, + right: NodeOrToken | Comment, skipOptions?: SkipOptions | number | FilterFn | null, ): Token | null { - // TODO: Check that `skipOptions` being a number or a function is supported by ESLint in this method. - // Original type def was `SkipOptions | null`. I (@overlookmotel) assume that was a mistake. - throw new Error('`sourceCode.getLastTokenBetween` not implemented yet'); // TODO + if (tokens === null) initTokens(); + debugAssertIsNonNull(tokens); + debugAssertIsNonNull(comments); + + let skip = + typeof skipOptions === 'number' + ? skipOptions + : typeof skipOptions === 'object' && skipOptions !== null + ? skipOptions.skip + : null; + + const filter = + typeof skipOptions === 'function' + ? skipOptions + : typeof skipOptions === 'object' && skipOptions !== null + ? skipOptions.filter + : null; + + const includeComments = + typeof skipOptions === 'object' && + skipOptions !== null && + 'includeComments' in skipOptions && + skipOptions.includeComments; + + let nodeTokens: Token[] | null = null; + if (includeComments) { + if (tokensWithComments === null) initTokensWithComments(); + debugAssertIsNonNull(tokensWithComments); + nodeTokens = tokensWithComments; + } else { + nodeTokens = tokens; + } + + // This range is not invariant over node order. + // The first argument must be the left node. + // Same as ESLint's implementation. + const rangeStart = left.range[1], + rangeEnd = right.range[0]; + + // Binary search for the token preceding the right node. + // The found token may be within the left node if there are no tokens between the nodes. + let lastTokenIndex = -1; + for (let lo = 0, hi = nodeTokens.length - 1; lo <= hi; ) { + const mid = (lo + hi) >> 1; + if (nodeTokens[mid].range[0] < rangeEnd) { + lastTokenIndex = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // Fast path for the common case + if (typeof filter !== 'function') { + const token = nodeTokens[typeof skip === 'number' ? lastTokenIndex - skip : lastTokenIndex]; + if (token === undefined || token.range[0] < rangeStart) return null; + return token; + } else { + if (typeof skip !== 'number') { + for (let i = lastTokenIndex; i >= 0; i--) { + const token = nodeTokens[i]; + if (token.range[0] < rangeStart) { + break; + } + if (filter(token)) { + return token; + } + } + } else { + for (let i = lastTokenIndex; i >= 0; i--) { + const token = nodeTokens[i]; + if (token.range[0] < rangeStart) { + break; + } + if (filter(token)) { + // `<=` because user input may be negative + // TODO: gracefully handle the negative case in other methods + if (skip <= 0) { + return token; + } + skip--; + } + } + } + } + + return null; } -/* oxlint-enable no-unused-vars */ /** * Get the last tokens between two non-overlapping nodes. diff --git a/apps/oxlint/test/tokens.test.ts b/apps/oxlint/test/tokens.test.ts index a2d50fe9dde1c..75f468ab59794 100644 --- a/apps/oxlint/test/tokens.test.ts +++ b/apps/oxlint/test/tokens.test.ts @@ -20,6 +20,7 @@ import { } from '../src-js/plugins/tokens.js'; import { resetSourceAndAst } from '../src-js/plugins/source_code.js'; import type { Node } from '../src-js/plugins/types.js'; +import type { BinaryExpression } from '../src-js/generated/types.js'; // Source text used for most tests const SOURCE_TEXT = '/*A*/var answer/*B*/=/*C*/a/*D*/* b/*E*///F\n call();\n/*Z*/'; @@ -50,9 +51,14 @@ beforeEach(() => { // https://eslint.org/blog/2025/10/whats-coming-in-eslint-10.0.0/#updates-to-program-ast-node-range-coverage // https://github.com/typescript-eslint/typescript-eslint/issues/11026#issuecomment-3421887632 const Program = { range: [5, 55] } as Node; -const BinaryExpression = { range: [26, 35] } as Node; +const BinaryExpression = { + range: [26, 35], + left: { range: [26, 27] } as Node, + right: { range: [34, 35] } as Node, +} as BinaryExpression; const VariableDeclaration = { range: [5, 35] } as Node; const VariableDeclaratorIdentifier = { range: [9, 15] } as Node; +const CallExpression = { range: [48, 54] } as Node; // https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L62 describe('when calling getTokens', () => { @@ -839,6 +845,61 @@ describe('when calling getLastToken', () => { }); }); +// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L1384-L1487 +describe('when calling getLastTokenBetween', () => { + it('should return null between adjacent nodes', () => { + expect(getLastTokenBetween(BinaryExpression, CallExpression)).toBeNull(); + }); + + it('should retrieve the last token between non-adjacent nodes with count option', () => { + expect(getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right)!.value).toBe('*'); + }); + + it('should retrieve one token between non-adjacent nodes with skip option', () => { + expect(getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, 1)!.value).toBe('a'); + expect(getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { skip: 2 })!.value).toBe('='); + }); + + it("should return null if it's skipped beyond the right token", () => { + expect(getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { skip: 3 })).toBeNull(); + expect(getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { skip: 4 })).toBeNull(); + }); + + it('should retrieve the last matched token between non-adjacent nodes with filter option', () => { + expect( + getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, (t) => t.type !== 'Identifier')!.value, + ).toBe('*'); + expect( + getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { + filter: (t) => t.type !== 'Identifier', + })!.value, + ).toBe('*'); + }); + + it('should retrieve last token or comment between non-adjacent nodes with includeComments option', () => { + expect( + getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { includeComments: true })!.value, + ).toBe('*'); + }); + + it('should retrieve last token or comment between non-adjacent nodes with includeComments and skip options', () => { + expect( + getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { includeComments: true, skip: 1 })! + .value, + ).toBe('D'); + }); + + it('should retrieve last token or comment between non-adjacent nodes with includeComments and skip and filter options', () => { + expect( + getLastTokenBetween(VariableDeclaratorIdentifier, BinaryExpression.right, { + includeComments: true, + skip: 1, + filter: (t) => t.type !== 'Punctuator', + })!.value, + ).toBe('a'); + }); +}); + describe('when calling getFirstTokensBetween', () => { /* oxlint-disable-next-line no-disabled-tests expect-expect */ it('is to be implemented');