Skip to content

Commit 709f739

Browse files
committed
feat(linter/plugins): implement SourceCode#getTokenBefore()
1 parent 15365c9 commit 709f739

File tree

2 files changed

+189
-14
lines changed

2 files changed

+189
-14
lines changed

apps/oxlint/src-js/plugins/tokens.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,91 @@ export function getTokenBefore(
324324
nodeOrToken: NodeOrToken | Comment,
325325
skipOptions?: SkipOptions | number | FilterFn | null,
326326
): Token | null {
327-
throw new Error('`sourceCode.getTokenBefore` not implemented yet'); // TODO
327+
if (tokens === null) initTokens();
328+
assertIsNonNull(tokens);
329+
assertIsNonNull(comments);
330+
331+
// Number of tokens preceding the given node to skip
332+
let skip =
333+
typeof skipOptions === 'number'
334+
? skipOptions
335+
: typeof skipOptions === 'object' && skipOptions !== null
336+
? skipOptions.skip
337+
: null;
338+
339+
const filter =
340+
typeof skipOptions === 'function'
341+
? skipOptions
342+
: typeof skipOptions === 'object' && skipOptions !== null
343+
? skipOptions.filter
344+
: null;
345+
346+
// Whether to return comment tokens
347+
const includeComments =
348+
typeof skipOptions === 'object' &&
349+
skipOptions !== null &&
350+
'includeComments' in skipOptions &&
351+
skipOptions.includeComments;
352+
353+
// Source array of tokens to search in
354+
let nodeTokens: Token[] | null = null;
355+
if (includeComments) {
356+
if (tokensWithComments === null) {
357+
// TODO: `tokens` and `comments` are already sorted, so there's a more efficient algorithm to merge them.
358+
// That'd certainly be faster in Rust, but maybe here it's faster to leave it to JS engine to sort them?
359+
// TODO: Once we have our own tokens which have `start` and `end` properties, we can use them instead of `range`.
360+
tokensWithComments = [...tokens, ...comments].sort((a, b) => a.range[0] - b.range[0]);
361+
}
362+
nodeTokens = tokensWithComments;
363+
} else {
364+
nodeTokens = tokens;
365+
}
366+
367+
const nodeStart = nodeOrToken.range[0];
368+
369+
// Index of the token immediately before the given node, token, or comment.
370+
let beforeIndex = 0;
371+
let hi = nodeTokens.length;
372+
373+
while (beforeIndex < hi) {
374+
const mid = (beforeIndex + hi) >> 1;
375+
if (nodeTokens[mid].range[0] < nodeStart) {
376+
beforeIndex = mid + 1;
377+
} else {
378+
hi = mid;
379+
}
380+
}
381+
382+
beforeIndex -= 1;
383+
384+
if (typeof filter !== 'function') {
385+
if (typeof skip !== 'number') {
386+
return nodeTokens[beforeIndex] ?? null;
387+
} else {
388+
return nodeTokens[beforeIndex - skip] ?? null;
389+
}
390+
} else {
391+
if (typeof skip !== 'number') {
392+
while (beforeIndex >= 0) {
393+
const token = nodeTokens[beforeIndex];
394+
if (filter(token)) {
395+
return token;
396+
}
397+
beforeIndex -= 1;
398+
}
399+
} else {
400+
while (beforeIndex >= 0) {
401+
const token = nodeTokens[beforeIndex];
402+
if (filter(token)) {
403+
if (skip === 0) return token;
404+
skip -= 1;
405+
}
406+
beforeIndex -= 1;
407+
}
408+
}
409+
}
410+
411+
return null;
328412
}
329413
/* oxlint-enable no-unused-vars */
330414

apps/oxlint/test/tokens.test.ts

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
11
import assert from 'node:assert';
22
import { describe, it, vi } from 'vitest';
3-
import { getTokens } from '../src-js/plugins/tokens.js';
3+
import { getTokens, getTokenBefore } from '../src-js/plugins/tokens.js';
4+
import { resetSourceAndAst } from '../src-js/plugins/source_code.js';
45
import type { Node } from '../src-js/plugins/types.js';
56

6-
let sourceText = 'null;';
7+
let sourceText = '/*A*/var answer/*B*/=/*C*/a/*D*/* b/*E*///F\n call();\n/*Z*/';
78

8-
vi.mock('../src-js/plugins/source_code.ts', () => {
9+
vi.mock('../src-js/plugins/source_code.ts', async (importOriginal) => {
10+
const original: any = await importOriginal();
911
return {
12+
...original,
1013
get sourceText() {
1114
return sourceText;
1215
},
1316
};
1417
});
1518

19+
// TODO: We are lying about `Program`'s range here.
20+
// The range provided by `@typescript-eslint/typescript-estree` does not match the assertions for that of `espree`.
21+
// The deviation is being corrected in upcoming releases of ESLint and TS-ESLint.
22+
// https://eslint.org/blog/2025/10/whats-coming-in-eslint-10.0.0/#updates-to-program-ast-node-range-coverage
23+
// https://github.com/typescript-eslint/typescript-eslint/issues/11026#issuecomment-3421887632
24+
const Program = { range: [5, 55] } as Node;
25+
const BinaryExpression = { range: [26, 35] } as Node;
26+
1627
// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L62
1728
describe('when calling getTokens', () => {
18-
sourceText = '/*A*/var answer/*B*/=/*C*/a/*D*/* b/*E*///F\n call();\n/*Z*/';
19-
20-
// TODO: We are lying about `Program`'s range here.
21-
// The range provided by `@typescript-eslint/typescript-estree` does not match the assertions for that of `espree`.
22-
// The deviation is being corrected in upcoming releases of ESLint and TS-ESLint.
23-
// https://eslint.org/blog/2025/10/whats-coming-in-eslint-10.0.0/#updates-to-program-ast-node-range-coverage
24-
// https://github.com/typescript-eslint/typescript-eslint/issues/11026#issuecomment-3421887632
25-
const Program = { range: [5, 55] } as Node;
26-
const BinaryExpression = { range: [26, 35] } as Node;
27-
2829
it('should retrieve all tokens for root node', () => {
2930
assert.deepStrictEqual(
3031
getTokens(Program).map((token) => token.value),
@@ -104,3 +105,93 @@ describe('when calling getTokens', () => {
104105
);
105106
});
106107
});
108+
109+
describe('when calling getTokenBefore', () => {
110+
it('should retrieve one token before a node', () => {
111+
assert.strictEqual(getTokenBefore(BinaryExpression)!.value, '=');
112+
});
113+
114+
it('should skip a given number of tokens', () => {
115+
assert.strictEqual(getTokenBefore(BinaryExpression, 1)!.value, 'answer');
116+
assert.strictEqual(getTokenBefore(BinaryExpression, 2)!.value, 'var');
117+
});
118+
119+
it('should skip a given number of tokens with skip option', () => {
120+
assert.strictEqual(getTokenBefore(BinaryExpression, { skip: 1 })!.value, 'answer');
121+
assert.strictEqual(getTokenBefore(BinaryExpression, { skip: 2 })!.value, 'var');
122+
});
123+
124+
it('should retrieve matched token with filter option', () => {
125+
assert.strictEqual(getTokenBefore(BinaryExpression, (t) => t.value !== '=')!.value, 'answer');
126+
});
127+
128+
it('should retrieve matched token with skip and filter options', () => {
129+
assert.strictEqual(
130+
getTokenBefore(BinaryExpression, {
131+
skip: 1,
132+
filter: (t) => t.value !== '=',
133+
})!.value,
134+
'var',
135+
);
136+
});
137+
138+
it('should retrieve one token or comment before a node with includeComments option', () => {
139+
assert.strictEqual(
140+
getTokenBefore(BinaryExpression, {
141+
includeComments: true,
142+
})!.value,
143+
'C',
144+
);
145+
});
146+
147+
it('should retrieve one token or comment before a node with includeComments and skip options', () => {
148+
assert.strictEqual(
149+
getTokenBefore(BinaryExpression, {
150+
includeComments: true,
151+
skip: 1,
152+
})!.value,
153+
'=',
154+
);
155+
});
156+
157+
it('should retrieve one token or comment before a node with includeComments and skip and filter options', () => {
158+
assert.strictEqual(
159+
getTokenBefore(BinaryExpression, {
160+
includeComments: true,
161+
skip: 1,
162+
filter: (t) => t.type.startsWith('Block'),
163+
})!.value,
164+
'B',
165+
);
166+
});
167+
168+
it('should retrieve the previous node if the comment at the end of source code is specified.', () => {
169+
resetSourceAndAst();
170+
sourceText = 'a + b /*comment*/';
171+
// TODO: this verbatim range should be replaced with `ast.comments[0]`
172+
const token = getTokenBefore({ range: [6, 14] } as Node);
173+
174+
assert.strictEqual(token!.value, 'b');
175+
resetSourceAndAst();
176+
});
177+
178+
it('should retrieve the previous comment if the first token is specified.', () => {
179+
resetSourceAndAst();
180+
sourceText = '/*comment*/ a + b';
181+
// TODO: this verbatim range should be replaced with `ast.tokens[0]`
182+
const token = getTokenBefore({ range: [12, 13] } as Node, { includeComments: true });
183+
184+
assert.strictEqual(token!.value, 'comment');
185+
resetSourceAndAst();
186+
});
187+
188+
it('should retrieve null if the first comment is specified.', () => {
189+
resetSourceAndAst();
190+
sourceText = '/*comment*/ a + b';
191+
// TODO: this verbatim range should be replaced with `ast.comments[0]`
192+
const token = getTokenBefore({ range: [0, 11] } as Node, { includeComments: true });
193+
194+
assert.strictEqual(token, null);
195+
resetSourceAndAst();
196+
});
197+
});

0 commit comments

Comments
 (0)