Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 91 additions & 9 deletions apps/oxlint/src-js/plugins/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 62 additions & 1 deletion apps/oxlint/test/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*/';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
Loading