Skip to content

Commit df0b948

Browse files
authored
feat(linter/plugins): implement SourceCode#getLastToken() (#16003)
- Part of #14829 (comment). - Follow up to #15861.
1 parent b1f4984 commit df0b948

File tree

2 files changed

+210
-7
lines changed

2 files changed

+210
-7
lines changed

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

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -503,11 +503,95 @@ export function getFirstTokens(node: Node, countOptions?: CountOptions | number
503503
* @param skipOptions? - Options object. Same options as `getFirstToken()`.
504504
* @returns `Token`, or `null` if all were skipped.
505505
*/
506-
/* oxlint-disable no-unused-vars */
507506
export function getLastToken(node: Node, skipOptions?: SkipOptions | number | FilterFn | null): Token | null {
508-
throw new Error('`sourceCode.getLastToken` not implemented yet'); // TODO
507+
if (tokens === null) initTokens();
508+
debugAssertIsNonNull(tokens);
509+
debugAssertIsNonNull(comments);
510+
511+
// Number of tokens to skip from the end
512+
let skip =
513+
typeof skipOptions === 'number'
514+
? skipOptions
515+
: typeof skipOptions === 'object' && skipOptions !== null
516+
? skipOptions.skip
517+
: null;
518+
519+
const filter =
520+
typeof skipOptions === 'function'
521+
? skipOptions
522+
: typeof skipOptions === 'object' && skipOptions !== null
523+
? skipOptions.filter
524+
: null;
525+
526+
// Whether to return comment tokens
527+
const includeComments =
528+
typeof skipOptions === 'object' &&
529+
skipOptions !== null &&
530+
'includeComments' in skipOptions &&
531+
skipOptions.includeComments;
532+
533+
// Source array of tokens to search in
534+
let nodeTokens: Token[] | null = null;
535+
if (includeComments) {
536+
if (tokensWithComments === null) initTokensWithComments();
537+
debugAssertIsNonNull(tokensWithComments);
538+
nodeTokens = tokensWithComments;
539+
} else {
540+
nodeTokens = tokens;
541+
}
542+
543+
const { range } = node,
544+
rangeStart = range[0],
545+
rangeEnd = range[1];
546+
547+
// Binary search for the last token within `node`'s range
548+
const nodeTokensLength = nodeTokens.length;
549+
let lastTokenIndex = nodeTokensLength;
550+
for (let lo = 0, hi = nodeTokensLength; lo < hi; ) {
551+
const mid = (lo + hi) >> 1;
552+
if (nodeTokens[mid].range[0] < rangeEnd) {
553+
lastTokenIndex = mid;
554+
lo = mid + 1;
555+
} else {
556+
hi = mid;
557+
}
558+
}
559+
560+
// TODO: this early return feels iffy
561+
if (lastTokenIndex === nodeTokensLength) return null;
562+
563+
if (typeof filter !== 'function') {
564+
if (typeof skip !== 'number') {
565+
return nodeTokens[lastTokenIndex] ?? null;
566+
} else {
567+
const token = nodeTokens[lastTokenIndex - skip];
568+
if (token === undefined || token.range[0] < rangeStart) return null;
569+
return token;
570+
}
571+
} else {
572+
if (typeof skip !== 'number') {
573+
for (let i = lastTokenIndex; i >= 0; i--) {
574+
const token = nodeTokens[i];
575+
576+
if (token.range[0] < rangeStart) break;
577+
if (filter(token)) {
578+
return token;
579+
}
580+
}
581+
} else {
582+
for (let i = lastTokenIndex; i >= 0; i--) {
583+
const token = nodeTokens[i];
584+
if (token.range[0] < rangeStart) break;
585+
if (filter(token)) {
586+
if (skip === 0) return token;
587+
skip--;
588+
}
589+
}
590+
}
591+
}
592+
593+
return null;
509594
}
510-
/* oxlint-enable no-unused-vars */
511595

512596
/**
513597
* Get the last tokens of the given node.

apps/oxlint/test/tokens.test.ts

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ beforeEach(() => {
5151
// https://github.com/typescript-eslint/typescript-eslint/issues/11026#issuecomment-3421887632
5252
const Program = { range: [5, 55] } as Node;
5353
const BinaryExpression = { range: [26, 35] } as Node;
54+
const VariableDeclaration = { range: [5, 35] } as Node;
5455
const VariableDeclaratorIdentifier = { range: [9, 15] } as Node;
5556

5657
// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L62
@@ -713,11 +714,129 @@ describe('when calling getLastTokens', () => {
713714
});
714715
});
715716

717+
// https://github.com/eslint/eslint/blob/v9.39.1/tests/lib/languages/js/source-code/token-store.js#L932-L1105
716718
describe('when calling getLastToken', () => {
717-
/* oxlint-disable-next-line no-disabled-tests expect-expect */
718-
it('is to be implemented');
719-
/* oxlint-disable-next-line no-unused-expressions */
720-
getLastToken;
719+
it("should retrieve the last token of a node's token stream", () => {
720+
expect(getLastToken(BinaryExpression)!.value).toBe('b');
721+
expect(getLastToken(VariableDeclaration)!.value).toBe('b');
722+
});
723+
724+
it('should skip a given number of tokens', () => {
725+
expect(getLastToken(BinaryExpression, 1)!.value).toBe('*');
726+
expect(getLastToken(BinaryExpression, 2)!.value).toBe('a');
727+
});
728+
729+
it('should skip a given number of tokens with skip option', () => {
730+
expect(getLastToken(BinaryExpression, { skip: 1 })!.value).toBe('*');
731+
expect(getLastToken(BinaryExpression, { skip: 2 })!.value).toBe('a');
732+
});
733+
734+
it("should retrieve the last matched token of a node's token stream with filter option", () => {
735+
expect(getLastToken(BinaryExpression, (t) => t.value !== 'b')!.value).toBe('*');
736+
expect(
737+
getLastToken(BinaryExpression, {
738+
filter: (t) => t.value !== 'b',
739+
})!.value,
740+
).toBe('*');
741+
});
742+
743+
it("should retrieve the last matched token of a node's token stream with filter and skip options", () => {
744+
expect(
745+
getLastToken(BinaryExpression, {
746+
skip: 1,
747+
filter: (t) => t.type === 'Identifier',
748+
})!.value,
749+
).toBe('a');
750+
});
751+
752+
it("should retrieve the last token of a node's token stream with includeComments option", () => {
753+
expect(getLastToken(BinaryExpression, { includeComments: true })!.value).toBe('b');
754+
});
755+
756+
it("should retrieve the last token of a node's token stream with includeComments and skip options", () => {
757+
expect(
758+
getLastToken(BinaryExpression, {
759+
includeComments: true,
760+
skip: 2,
761+
})!.value,
762+
).toBe('D');
763+
});
764+
765+
it("should retrieve the last token of a node's token stream with includeComments and skip and filter options", () => {
766+
expect(
767+
getLastToken(BinaryExpression, {
768+
includeComments: true,
769+
skip: 1,
770+
filter: (t) => t.type !== 'Identifier',
771+
})!.value,
772+
).toBe('D');
773+
});
774+
775+
it('should retrieve the last comment if the comment is at the last of nodes', () => {
776+
resetSourceAndAst();
777+
sourceText = 'a + b /*comment*/\nc + d';
778+
779+
/*
780+
* A node must not end with a token: it can end with a comment or be empty.
781+
* This test case is needed for completeness.
782+
*/
783+
expect(
784+
getLastToken(
785+
// TODO: this verbatim range should be replaced with `ast.tokens[0].range[0], ast.comments[0].range[1]`
786+
{ range: [0, 17] } as Node,
787+
{ includeComments: true },
788+
)!.value,
789+
).toBe('comment');
790+
resetSourceAndAst();
791+
});
792+
793+
it('should retrieve the last token (without includeComments option) if the comment is at the last of nodes', () => {
794+
resetSourceAndAst();
795+
sourceText = 'a + b /*comment*/\nc + d';
796+
797+
/*
798+
* A node must not end with a token: it can end with a comment or be empty.
799+
* This test case is needed for completeness.
800+
*/
801+
expect(
802+
getLastToken(
803+
// TODO: this verbatim range should be replaced with `ast.tokens[0].range[0], ast.comments[0].range[1]`
804+
{ range: [0, 17] } as Node,
805+
)!.value,
806+
).toBe('b');
807+
resetSourceAndAst();
808+
});
809+
810+
it('should retrieve the last token if the root node contains a trailing comment', () => {
811+
resetSourceAndAst();
812+
sourceText = 'foo // comment';
813+
814+
expect(getLastToken(Program)!.value).toBe('foo');
815+
resetSourceAndAst();
816+
});
817+
818+
it('should return null if the source contains only comments', () => {
819+
resetSourceAndAst();
820+
sourceText = '// comment';
821+
822+
expect(
823+
getLastToken({ range: [0, 11] } as Node, {
824+
filter() {
825+
expect.fail('Unexpected call to filter callback');
826+
},
827+
}),
828+
).toBeNull();
829+
resetSourceAndAst();
830+
});
831+
832+
it('should return null if the source is empty', () => {
833+
resetSourceAndAst();
834+
sourceText = '';
835+
836+
// TODO: this verbatim range should be replaced with `ast`
837+
expect(getLastToken({ range: [0, 0] } as Node)).toBeNull();
838+
resetSourceAndAst();
839+
});
721840
});
722841

723842
describe('when calling getFirstTokensBetween', () => {

0 commit comments

Comments
 (0)