From 0cfced4a4ea91b9e7ebd8f3efee86d54e558ed37 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sun, 23 Nov 2025 01:01:57 +0530 Subject: [PATCH 1/5] test(linter/plugins): stub token method tests --- apps/oxlint/test/tokens.test.ts | 139 +++++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 13 deletions(-) diff --git a/apps/oxlint/test/tokens.test.ts b/apps/oxlint/test/tokens.test.ts index 52cf7b498a4a7..99c41bed01526 100644 --- a/apps/oxlint/test/tokens.test.ts +++ b/apps/oxlint/test/tokens.test.ts @@ -1,30 +1,40 @@ import assert from 'node:assert'; import { describe, it, vi } from 'vitest'; -import { getTokens } from '../src-js/plugins/tokens.js'; +import { + getTokens, + getTokensBefore, + getTokenBefore, + getTokensAfter, + getTokenAfter, + getFirstTokens, +} from '../src-js/plugins/tokens.js'; +import { resetSourceAndAst } from '../src-js/plugins/source_code.js'; import type { Node } from '../src-js/plugins/types.js'; -let sourceText = 'null;'; +let sourceText = '/*A*/var answer/*B*/=/*C*/a/*D*/* b/*E*///F\n call();\n/*Z*/'; -vi.mock('../src-js/plugins/source_code.ts', () => { +vi.mock('../src-js/plugins/source_code.ts', async (importOriginal) => { + const original: any = await importOriginal(); return { + ...original, get sourceText() { return sourceText; }, }; }); +// TODO: We are lying about `Program`'s range here. +// The range provided by `@typescript-eslint/typescript-estree` does not match the assertions for that of `espree`. +// The deviation is being corrected in upcoming releases of ESLint and TS-ESLint. +// 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; +/* oxlint-disable-next-line no-unused-vars */ +const VariableDeclaratorIdentifier = { range: [9, 15] } 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', () => { - sourceText = '/*A*/var answer/*B*/=/*C*/a/*D*/* b/*E*///F\n call();\n/*Z*/'; - - // TODO: We are lying about `Program`'s range here. - // The range provided by `@typescript-eslint/typescript-estree` does not match the assertions for that of `espree`. - // The deviation is being corrected in upcoming releases of ESLint and TS-ESLint. - // 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; - it('should retrieve all tokens for root node', () => { assert.deepStrictEqual( getTokens(Program).map((token) => token.value), @@ -104,3 +114,106 @@ describe('when calling getTokens', () => { ); }); }); + +// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L157 +describe('when calling getTokensBefore', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); + /* oxlint-disable-next-line no-unused-expressions */ + getTokensBefore; +}); + +describe('when calling getTokenBefore', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); + /* oxlint-disable-next-line no-unused-expressions */ + getTokenBefore; + /* oxlint-disable-next-line no-unused-expressions */ + resetSourceAndAst; +}); + +describe('when calling getTokenAfter', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); + /* oxlint-disable-next-line no-unused-expressions */ + getTokenAfter; +}); + +describe('when calling getTokensAfter', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); + /* oxlint-disable-next-line no-unused-expressions */ + getTokensAfter; +}); + +describe('when calling getFirstTokens', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); + /* oxlint-disable-next-line no-unused-expressions */ + getFirstTokens; +}); + +describe('when calling getFirstToken', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getLastTokens', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getLastToken', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getFirstTokensBetween', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getFirstTokenBetween', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getLastTokensBetween', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getLastTokenBetween', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getTokensBetween', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getTokenByRangeStart', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getTokenOrCommentBefore', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getTokenOrCommentAfter', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getFirstToken & getTokenAfter', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); + +describe('when calling getLastToken & getTokenBefore', () => { + /* oxlint-disable-next-line no-disabled-tests expect-expect */ + it('is to be implemented'); +}); From b075a6518c1c4ff65cb998020612a26416d1687c Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:41:17 +0530 Subject: [PATCH 2/5] feat(linter/plugins): implement `SourceCode#getTokensBefore()` --- apps/oxlint/src-js/plugins/tokens.ts | 64 ++++++++++++++++++- apps/oxlint/test/tokens.test.ts | 93 ++++++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/apps/oxlint/src-js/plugins/tokens.ts b/apps/oxlint/src-js/plugins/tokens.ts index 14776534c3608..28e983fb61540 100644 --- a/apps/oxlint/src-js/plugins/tokens.ts +++ b/apps/oxlint/src-js/plugins/tokens.ts @@ -357,7 +357,69 @@ export function getTokensBefore( nodeOrToken: NodeOrToken | Comment, countOptions?: CountOptions | number | FilterFn | null, ): Token[] { - throw new Error('`sourceCode.getTokensBefore` not implemented yet'); // TODO + if (tokens === null) initTokens(); + debugAssertIsNonNull(tokens); + debugAssertIsNonNull(comments); + + // Maximum number of tokens to return + const count = + typeof countOptions === 'number' + ? max(0, countOptions) + : typeof countOptions === 'object' && countOptions !== null + ? countOptions.count + : null; + + // Function to filter tokens + const filter = + typeof countOptions === 'function' + ? countOptions + : typeof countOptions === 'object' && countOptions !== null + ? countOptions.filter + : null; + + // Whether to return comment tokens + const includeComments = + typeof countOptions === 'object' && + countOptions !== null && + 'includeComments' in countOptions && + countOptions.includeComments; + + // Source array of tokens to search in + let nodeTokens: Token[] | null = null; + if (includeComments) { + if (tokensWithComments === null) { + tokensWithComments = [...tokens, ...comments].sort((a, b) => a.range[0] - b.range[0]); + } + nodeTokens = tokensWithComments; + } else { + nodeTokens = tokens; + } + + const targetStart = nodeOrToken.range[0]; + + let sliceEnd = 0; + let hi = nodeTokens.length; + while (sliceEnd < hi) { + const mid = (sliceEnd + hi) >> 1; + if (nodeTokens[mid].range[0] < targetStart) { + sliceEnd = mid + 1; + } else { + hi = mid; + } + } + + let tokensBefore = nodeTokens.slice(0, sliceEnd); + // TODO(perf): we slice so we can call `.filter()` but we could manually iterate instead and only slice once later. + if (filter) tokensBefore = tokensBefore.filter(filter); + + if (typeof count === 'number') { + if (count === 0) return []; + if (count < tokensBefore.length) { + tokensBefore = tokensBefore.slice(tokensBefore.length - count); + } + } + + return tokensBefore; } /* oxlint-enable no-unused-vars */ diff --git a/apps/oxlint/test/tokens.test.ts b/apps/oxlint/test/tokens.test.ts index 99c41bed01526..3d5e2e1901c5f 100644 --- a/apps/oxlint/test/tokens.test.ts +++ b/apps/oxlint/test/tokens.test.ts @@ -117,10 +117,95 @@ describe('when calling getTokens', () => { // https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L157 describe('when calling getTokensBefore', () => { - /* oxlint-disable-next-line no-disabled-tests expect-expect */ - it('is to be implemented'); - /* oxlint-disable-next-line no-unused-expressions */ - getTokensBefore; + it('should retrieve zero tokens before a node', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, 0).map((token) => token.value), + [], + ); + }); + + it('should retrieve one token before a node', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, 1).map((token) => token.value), + ['='], + ); + }); + + it('should retrieve more than one token before a node', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, 2).map((token) => token.value), + ['answer', '='], + ); + }); + + it('should retrieve all tokens before a node', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, 9e9).map((token) => token.value), + ['var', 'answer', '='], + ); + }); + + it('should retrieve more than one token before a node with count option', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { count: 2 }).map((token) => token.value), + ['answer', '='], + ); + }); + + it('should retrieve matched tokens before a node with count and filter options', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { + count: 1, + filter: (t) => t.value !== '=', + }).map((token) => token.value), + ['answer'], + ); + }); + + it('should retrieve all matched tokens before a node with filter option', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { + filter: (t) => t.value !== 'answer', + }).map((token) => token.value), + ['var', '='], + ); + }); + + it('should retrieve no tokens before the root node', () => { + assert.deepStrictEqual( + getTokensBefore(Program, { count: 1 }).map((token) => token.value), + [], + ); + }); + + it('should retrieve tokens and comments before a node with count and includeComments option', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { + count: 3, + includeComments: true, + }).map((token) => token.value), + ['B', '=', 'C'], + ); + }); + + it('should retrieve all tokens and comments before a node with includeComments option only', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { + includeComments: true, + }).map((token) => token.value), + ['A', 'var', 'answer', 'B', '=', 'C'], + ); + }); + + it('should retrieve all tokens and comments before a node with includeComments and filter options', () => { + assert.deepStrictEqual( + getTokensBefore(BinaryExpression, { + includeComments: true, + filter: (t) => t.type.startsWith('Block'), + }).map((token) => token.value), + ['A', 'B', 'C'], + ); + }); }); describe('when calling getTokenBefore', () => { From 68ef3c049c262c681d06be4568d1b3efc51d3135 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 22 Nov 2025 11:27:24 +0530 Subject: [PATCH 3/5] perf: do less work for easier options --- apps/oxlint/src-js/plugins/tokens.ts | 40 ++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/oxlint/src-js/plugins/tokens.ts b/apps/oxlint/src-js/plugins/tokens.ts index 28e983fb61540..069ec97a41adb 100644 --- a/apps/oxlint/src-js/plugins/tokens.ts +++ b/apps/oxlint/src-js/plugins/tokens.ts @@ -408,15 +408,39 @@ export function getTokensBefore( } } - let tokensBefore = nodeTokens.slice(0, sliceEnd); - // TODO(perf): we slice so we can call `.filter()` but we could manually iterate instead and only slice once later. - if (filter) tokensBefore = tokensBefore.filter(filter); - - if (typeof count === 'number') { - if (count === 0) return []; - if (count < tokensBefore.length) { - tokensBefore = tokensBefore.slice(tokensBefore.length - count); + let tokensBefore: Token[]; + // Fast path for the common case + if (typeof filter !== 'function' && typeof count !== 'number') { + tokensBefore = nodeTokens.slice(0, sliceEnd); + } else if (typeof filter !== 'function' && typeof count === 'number') { + tokensBefore = nodeTokens.slice(sliceEnd - count, sliceEnd); + } else if (typeof filter === 'function' && typeof count !== 'number') { + tokensBefore = []; + for (let i = 0; i < sliceEnd; i++) { + const token = nodeTokens[i]; + if (filter(token)) { + tokensBefore.push(token); + } } + } else if (typeof filter === 'function' && typeof count === 'number') { + tokensBefore = []; + // Count is the number of preceding tokens so we iterate in reverse + for (let i = sliceEnd - 1; i >= 0; i--) { + const token = nodeTokens[i]; + if (filter(token)) { + tokensBefore.unshift(token); + } + if (tokensBefore.length === count) { + break; + } + } + // unreachable + } else { + if (DEBUG) { + throw new Error('Unexpected case'); + } + // Also unreachable, but having this line quells the type checker + tokensBefore = []; } return tokensBefore; From bde552a74abb862d23820387cee5e0189397bd39 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:52:41 +0530 Subject: [PATCH 4/5] refactor: nested but simple `if`/`else` branches for type inference --- apps/oxlint/src-js/plugins/tokens.ts | 51 +++++++++++++--------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/apps/oxlint/src-js/plugins/tokens.ts b/apps/oxlint/src-js/plugins/tokens.ts index 069ec97a41adb..f02acf873b5dd 100644 --- a/apps/oxlint/src-js/plugins/tokens.ts +++ b/apps/oxlint/src-js/plugins/tokens.ts @@ -410,37 +410,34 @@ export function getTokensBefore( let tokensBefore: Token[]; // Fast path for the common case - if (typeof filter !== 'function' && typeof count !== 'number') { - tokensBefore = nodeTokens.slice(0, sliceEnd); - } else if (typeof filter !== 'function' && typeof count === 'number') { - tokensBefore = nodeTokens.slice(sliceEnd - count, sliceEnd); - } else if (typeof filter === 'function' && typeof count !== 'number') { - tokensBefore = []; - for (let i = 0; i < sliceEnd; i++) { - const token = nodeTokens[i]; - if (filter(token)) { - tokensBefore.push(token); - } + if (typeof filter !== 'function') { + if (typeof count !== 'number') { + tokensBefore = nodeTokens.slice(0, sliceEnd); + } else { + tokensBefore = nodeTokens.slice(sliceEnd - count, sliceEnd); } - } else if (typeof filter === 'function' && typeof count === 'number') { - tokensBefore = []; - // Count is the number of preceding tokens so we iterate in reverse - for (let i = sliceEnd - 1; i >= 0; i--) { - const token = nodeTokens[i]; - if (filter(token)) { - tokensBefore.unshift(token); + } else { + if (typeof count !== 'number') { + tokensBefore = []; + for (let i = 0; i < sliceEnd; i++) { + const token = nodeTokens[i]; + if (filter(token)) { + tokensBefore.push(token); + } } - if (tokensBefore.length === count) { - break; + } else { + tokensBefore = []; + // Count is the number of preceding tokens so we iterate in reverse + for (let i = sliceEnd - 1; i >= 0; i--) { + const token = nodeTokens[i]; + if (filter(token)) { + tokensBefore.unshift(token); + } + if (tokensBefore.length === count) { + break; + } } } - // unreachable - } else { - if (DEBUG) { - throw new Error('Unexpected case'); - } - // Also unreachable, but having this line quells the type checker - tokensBefore = []; } return tokensBefore; From 86daea61d93b6e058a7023cfa14c203a82d57b0d Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Sun, 23 Nov 2025 02:31:31 +0530 Subject: [PATCH 5/5] chore: remove now-unnecessary `oxlint-disable` --- apps/oxlint/src-js/plugins/tokens.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/oxlint/src-js/plugins/tokens.ts b/apps/oxlint/src-js/plugins/tokens.ts index f02acf873b5dd..59bf8961ec278 100644 --- a/apps/oxlint/src-js/plugins/tokens.ts +++ b/apps/oxlint/src-js/plugins/tokens.ts @@ -352,7 +352,6 @@ export function getTokenOrCommentBefore(nodeOrToken: NodeOrToken | Comment, skip * @param countOptions? - Options object. Same options as `getFirstTokens()`. * @returns Array of `Token`s. */ -/* oxlint-disable no-unused-vars */ export function getTokensBefore( nodeOrToken: NodeOrToken | Comment, countOptions?: CountOptions | number | FilterFn | null, @@ -442,7 +441,6 @@ export function getTokensBefore( return tokensBefore; } -/* oxlint-enable no-unused-vars */ /** * Get the token that follows a given node or token.