Skip to content

Commit 3f34b79

Browse files
committed
feat(linter/plugins): implement SourceCode#getIndexFromLoc and getLocFromIndex
1 parent 2e57351 commit 3f34b79

File tree

3 files changed

+219
-29
lines changed

3 files changed

+219
-29
lines changed

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

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -506,28 +506,8 @@ export const SOURCE_CODE = Object.freeze({
506506
throw new Error('`sourceCode.getNodeByRangeIndex` not implemented yet'); // TODO
507507
},
508508

509-
/**
510-
* Convert a source text index into a (line, column) pair.
511-
* @param index The index of a character in a file.
512-
* @returns `{line, column}` location object with 1-indexed line and 0-indexed column.
513-
* @throws {TypeError|RangeError} If non-numeric `index`, or `index` out of range.
514-
*/
515-
// oxlint-disable-next-line no-unused-vars
516-
getLocFromIndex(index: number): LineColumn {
517-
throw new Error('`sourceCode.getLocFromIndex` not implemented yet'); // TODO
518-
},
519-
520-
/**
521-
* Convert a `{ line, column }` pair into a range index.
522-
* @param loc - A line/column location.
523-
* @returns The range index of the location in the file.
524-
* @throws {TypeError|RangeError} If `loc` is not an object with a numeric `line` and `column`,
525-
* or if the `line` is less than or equal to zero, or the line or column is out of the expected range.
526-
*/
527-
// oxlint-disable-next-line no-unused-vars
528-
getIndexFromLoc(loc: LineColumn): number {
529-
throw new Error('`sourceCode.getIndexFromLoc` not implemented yet'); // TODO
530-
},
509+
getLocFromIndex,
510+
getIndexFromLoc,
531511

532512
/**
533513
* Check whether any comments exist or not between the given 2 nodes.
@@ -586,6 +566,95 @@ export const SOURCE_CODE = Object.freeze({
586566

587567
export type SourceCode = typeof SOURCE_CODE;
588568

569+
/**
570+
* Convert a source text index into a (line, column) pair.
571+
* @param offset The index of a character in a file.
572+
* @returns `{line, column}` location object with 1-indexed line and 0-indexed column.
573+
* @throws {TypeError|RangeError} If non-numeric `index`, or `index` out of range.
574+
*/
575+
function getLocFromIndex(offset: number): LineColumn {
576+
if (typeof offset !== 'number') throw new TypeError('Expected `offset` to be a number.');
577+
578+
// Build `lines` and `lineStartOffsets` tables if they haven't been already.
579+
// This also decodes `sourceText` if it wasn't already.
580+
if (lines.length === 0) initLines();
581+
582+
if (offset < 0 || offset > sourceText.length) {
583+
throw new RangeError(
584+
`Index out of range (requested index ${offset}, but source text has length ${sourceText.length}).`,
585+
);
586+
}
587+
588+
// Binary search `lineStartOffsets` for the line containing `offset`
589+
let low = 0, high = lineStartOffsets.length, mid: number;
590+
do {
591+
mid = ((low + high) / 2) | 0; // Use bitwise OR to floor the division
592+
if (offset < lineStartOffsets[mid]) {
593+
high = mid;
594+
} else {
595+
low = mid + 1;
596+
}
597+
} while (low < high);
598+
599+
return { line: low, column: offset - lineStartOffsets[low - 1] };
600+
}
601+
602+
/**
603+
* Convert a `{ line, column }` pair into a range index.
604+
* @param loc - A line/column location.
605+
* @returns The range index of the location in the file.
606+
* @throws {TypeError|RangeError} If `loc` is not an object with a numeric `line` and `column`,
607+
* or if the `line` is less than or equal to zero, or the line or column is out of the expected range.
608+
*/
609+
function getIndexFromLoc(loc: LineColumn): number {
610+
if (loc !== null && typeof loc === 'object') {
611+
const { line, column } = loc;
612+
if (typeof line === 'number' && typeof column === 'number') {
613+
// Build `lines` and `lineStartOffsets` tables if they haven't been already.
614+
// This also decodes `sourceText` if it wasn't already.
615+
if (lines.length === 0) initLines();
616+
617+
const linesCount = lineStartOffsets.length;
618+
if (line <= 0 || line > linesCount) {
619+
throw new RangeError(
620+
`Line number out of range (line ${line} requested). ` +
621+
`Line numbers should be 1-based, and less than or equal to number of lines in file (${linesCount}).`,
622+
);
623+
}
624+
if (column < 0) throw new RangeError(`Invalid column number (column ${column} requested).`);
625+
626+
const lineOffset = lineStartOffsets[line - 1];
627+
const offset = lineOffset + column;
628+
629+
// Comment from ESLint implementation:
630+
/*
631+
* By design, `getIndexFromLoc({ line: lineNum, column: 0 })` should return the start index of
632+
* the given line, provided that the line number is valid element of `lines`. Since the
633+
* last element of `lines` is an empty string for files with trailing newlines, add a
634+
* special case where getting the index for the first location after the end of the file
635+
* will return the length of the file, rather than throwing an error. This allows rules to
636+
* use `getIndexFromLoc` consistently without worrying about edge cases at the end of a file.
637+
*/
638+
639+
let nextLineOffset;
640+
if (line === linesCount) {
641+
nextLineOffset = sourceText.length;
642+
if (offset <= nextLineOffset) return offset;
643+
} else {
644+
nextLineOffset = lineStartOffsets[line];
645+
if (offset < nextLineOffset) return offset;
646+
}
647+
648+
throw new RangeError(
649+
`Column number out of range (column ${column} requested, ` +
650+
`but the length of line ${line} is ${nextLineOffset - lineOffset}).`,
651+
);
652+
}
653+
}
654+
655+
throw new TypeError('Expected `loc` to be an object with numeric `line` and `column` properties.');
656+
}
657+
589658
// Options for various `SourceCode` methods e.g. `getFirstToken`.
590659
export interface SkipOptions {
591660
// Number of skipping tokens

apps/oxlint/test/fixtures/sourceCode/output.snap.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@
77
| text: "let foo, bar;\n\n// x\n// y\n"
88
| getText(): "let foo, bar;\n\n// x\n// y\n"
99
| lines: ["let foo, bar;","","// x","// y",""]
10+
| locs:
11+
| 0 => { line: 1, column: 0 }("l")
12+
| 1 => { line: 1, column: 1 }("e")
13+
| 2 => { line: 1, column: 2 }("t")
14+
| 3 => { line: 1, column: 3 }(" ")
15+
| 4 => { line: 1, column: 4 }("f")
16+
| 5 => { line: 1, column: 5 }("o")
17+
| 6 => { line: 1, column: 6 }("o")
18+
| 7 => { line: 1, column: 7 }(",")
19+
| 8 => { line: 1, column: 8 }(" ")
20+
| 9 => { line: 1, column: 9 }("b")
21+
| 10 => { line: 1, column: 10 }("a")
22+
| 11 => { line: 1, column: 11 }("r")
23+
| 12 => { line: 1, column: 12 }(";")
24+
| 13 => { line: 1, column: 13 }("\n")
25+
| 14 => { line: 2, column: 0 }("\n")
26+
| 15 => { line: 3, column: 0 }("/")
27+
| 16 => { line: 3, column: 1 }("/")
28+
| 17 => { line: 3, column: 2 }(" ")
29+
| 18 => { line: 3, column: 3 }("x")
30+
| 19 => { line: 3, column: 4 }("\n")
31+
| 20 => { line: 4, column: 0 }("/")
32+
| 21 => { line: 4, column: 1 }("/")
33+
| 22 => { line: 4, column: 2 }(" ")
34+
| 23 => { line: 4, column: 3 }("y")
35+
| 24 => { line: 4, column: 4 }("\n")
36+
| 25 => { line: 5, column: 0 }("<EOF>")
1037
| ast: "foo"
1138
| visitorKeys: left, right
1239
,-[files/1.js:1:1]
@@ -27,6 +54,33 @@
2754
| text: "let foo, bar;\n\n// x\n// y\n"
2855
| getText(): "let foo, bar;\n\n// x\n// y\n"
2956
| lines: ["let foo, bar;","","// x","// y",""]
57+
| locs:
58+
| 0 => { line: 1, column: 0 }("l")
59+
| 1 => { line: 1, column: 1 }("e")
60+
| 2 => { line: 1, column: 2 }("t")
61+
| 3 => { line: 1, column: 3 }(" ")
62+
| 4 => { line: 1, column: 4 }("f")
63+
| 5 => { line: 1, column: 5 }("o")
64+
| 6 => { line: 1, column: 6 }("o")
65+
| 7 => { line: 1, column: 7 }(",")
66+
| 8 => { line: 1, column: 8 }(" ")
67+
| 9 => { line: 1, column: 9 }("b")
68+
| 10 => { line: 1, column: 10 }("a")
69+
| 11 => { line: 1, column: 11 }("r")
70+
| 12 => { line: 1, column: 12 }(";")
71+
| 13 => { line: 1, column: 13 }("\n")
72+
| 14 => { line: 2, column: 0 }("\n")
73+
| 15 => { line: 3, column: 0 }("/")
74+
| 16 => { line: 3, column: 1 }("/")
75+
| 17 => { line: 3, column: 2 }(" ")
76+
| 18 => { line: 3, column: 3 }("x")
77+
| 19 => { line: 3, column: 4 }("\n")
78+
| 20 => { line: 4, column: 0 }("/")
79+
| 21 => { line: 4, column: 1 }("/")
80+
| 22 => { line: 4, column: 2 }(" ")
81+
| 23 => { line: 4, column: 3 }("y")
82+
| 24 => { line: 4, column: 4 }("\n")
83+
| 25 => { line: 5, column: 0 }("<EOF>")
3084
| ast: "foo"
3185
| visitorKeys: left, right
3286
,-[files/1.js:1:1]
@@ -56,6 +110,8 @@
56110
| source with before: "t foo"
57111
| source with after: "foo,"
58112
| source with both: "t foo,"
113+
| start loc: {"line":1,"column":4}
114+
| end loc: {"line":1,"column":7}
59115
,-[files/1.js:1:5]
60116
1 | let foo, bar;
61117
: ^^^
@@ -67,6 +123,8 @@
67123
| source with before: "t foo"
68124
| source with after: "foo,"
69125
| source with both: "t foo,"
126+
| start loc: {"line":1,"column":4}
127+
| end loc: {"line":1,"column":7}
70128
,-[files/1.js:1:5]
71129
1 | let foo, bar;
72130
: ^^^
@@ -78,6 +136,8 @@
78136
| source with before: ", bar"
79137
| source with after: "bar;"
80138
| source with both: ", bar;"
139+
| start loc: {"line":1,"column":9}
140+
| end loc: {"line":1,"column":12}
81141
,-[files/1.js:1:10]
82142
1 | let foo, bar;
83143
: ^^^
@@ -89,6 +149,8 @@
89149
| source with before: ", bar"
90150
| source with after: "bar;"
91151
| source with both: ", bar;"
152+
| start loc: {"line":1,"column":9}
153+
| end loc: {"line":1,"column":12}
92154
,-[files/1.js:1:10]
93155
1 | let foo, bar;
94156
: ^^^
@@ -99,6 +161,17 @@
99161
| text: "let qux;\n"
100162
| getText(): "let qux;\n"
101163
| lines: ["let qux;",""]
164+
| locs:
165+
| 0 => { line: 1, column: 0 }("l")
166+
| 1 => { line: 1, column: 1 }("e")
167+
| 2 => { line: 1, column: 2 }("t")
168+
| 3 => { line: 1, column: 3 }(" ")
169+
| 4 => { line: 1, column: 4 }("q")
170+
| 5 => { line: 1, column: 5 }("u")
171+
| 6 => { line: 1, column: 6 }("x")
172+
| 7 => { line: 1, column: 7 }(";")
173+
| 8 => { line: 1, column: 8 }("\n")
174+
| 9 => { line: 2, column: 0 }("<EOF>")
102175
| ast: "qux"
103176
| visitorKeys: left, right
104177
,-[files/2.js:1:1]
@@ -117,6 +190,17 @@
117190
| text: "let qux;\n"
118191
| getText(): "let qux;\n"
119192
| lines: ["let qux;",""]
193+
| locs:
194+
| 0 => { line: 1, column: 0 }("l")
195+
| 1 => { line: 1, column: 1 }("e")
196+
| 2 => { line: 1, column: 2 }("t")
197+
| 3 => { line: 1, column: 3 }(" ")
198+
| 4 => { line: 1, column: 4 }("q")
199+
| 5 => { line: 1, column: 5 }("u")
200+
| 6 => { line: 1, column: 6 }("x")
201+
| 7 => { line: 1, column: 7 }(";")
202+
| 8 => { line: 1, column: 8 }("\n")
203+
| 9 => { line: 2, column: 0 }("<EOF>")
120204
| ast: "qux"
121205
| visitorKeys: left, right
122206
,-[files/2.js:1:1]
@@ -143,6 +227,8 @@
143227
| source with before: "t qux"
144228
| source with after: "qux;"
145229
| source with both: "t qux;"
230+
| start loc: {"line":1,"column":4}
231+
| end loc: {"line":1,"column":7}
146232
,-[files/2.js:1:5]
147233
1 | let qux;
148234
: ^^^
@@ -153,6 +239,8 @@
153239
| source with before: "t qux"
154240
| source with after: "qux;"
155241
| source with both: "t qux;"
242+
| start loc: {"line":1,"column":4}
243+
| end loc: {"line":1,"column":7}
156244
,-[files/2.js:1:5]
157245
1 | let qux;
158246
: ^^^

apps/oxlint/test/fixtures/sourceCode/plugin.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,22 @@ const SPAN = { start: 0, end: 0 };
77

88
const createRule: Rule = {
99
create(context) {
10-
const { ast } = context.sourceCode;
10+
const { ast, lines, text } = context.sourceCode;
11+
12+
let locs = '';
13+
for (let offset = 0; offset <= text.length; offset++) {
14+
const loc = context.sourceCode.getLocFromIndex(offset);
15+
assert(context.sourceCode.getIndexFromLoc(loc) === offset);
16+
locs += `\n ${offset} => { line: ${loc.line}, column: ${loc.column} }` +
17+
`(${JSON.stringify(text[offset] || '<EOF>')})`;
18+
}
1119

1220
context.report({
1321
message: 'create:\n' +
14-
`text: ${JSON.stringify(context.sourceCode.text)}\n` +
22+
`text: ${JSON.stringify(text)}\n` +
1523
`getText(): ${JSON.stringify(context.sourceCode.getText())}\n` +
16-
`lines: ${JSON.stringify(context.sourceCode.lines)}\n` +
24+
`lines: ${JSON.stringify(lines)}\n` +
25+
`locs:${locs}\n` +
1726
// @ts-ignore
1827
`ast: "${ast.body[0].declarations[0].id.name}"\n` +
1928
`visitorKeys: ${context.sourceCode.visitorKeys.BinaryExpression.join(', ')}`,
@@ -31,12 +40,19 @@ const createRule: Rule = {
3140
});
3241
},
3342
Identifier(node) {
43+
const startLoc = context.sourceCode.getLocFromIndex(node.start);
44+
const endLoc = context.sourceCode.getLocFromIndex(node.end);
45+
assert(context.sourceCode.getIndexFromLoc(startLoc) === node.start);
46+
assert(context.sourceCode.getIndexFromLoc(endLoc) === node.end);
47+
3448
context.report({
3549
message: `ident "${node.name}":\n` +
3650
`source: "${context.sourceCode.getText(node)}"\n` +
3751
`source with before: "${context.sourceCode.getText(node, 2)}"\n` +
3852
`source with after: "${context.sourceCode.getText(node, null, 1)}"\n` +
39-
`source with both: "${context.sourceCode.getText(node, 2, 1)}"`,
53+
`source with both: "${context.sourceCode.getText(node, 2, 1)}"\n` +
54+
`start loc: ${JSON.stringify(startLoc)}\n` +
55+
`end loc: ${JSON.stringify(endLoc)}`,
4056
node,
4157
});
4258
},
@@ -51,12 +67,22 @@ const createOnceRule: Rule = {
5167
return {
5268
before() {
5369
ast = context.sourceCode.ast;
70+
const { lines, text } = context.sourceCode;
71+
72+
let locs = '';
73+
for (let offset = 0; offset <= text.length; offset++) {
74+
const loc = context.sourceCode.getLocFromIndex(offset);
75+
assert(context.sourceCode.getIndexFromLoc(loc) === offset);
76+
locs += `\n ${offset} => { line: ${loc.line}, column: ${loc.column} }` +
77+
`(${JSON.stringify(text[offset] || '<EOF>')})`;
78+
}
5479

5580
context.report({
5681
message: 'before:\n' +
57-
`text: ${JSON.stringify(context.sourceCode.text)}\n` +
82+
`text: ${JSON.stringify(text)}\n` +
5883
`getText(): ${JSON.stringify(context.sourceCode.getText())}\n` +
59-
`lines: ${JSON.stringify(context.sourceCode.lines)}\n` +
84+
`lines: ${JSON.stringify(lines)}\n` +
85+
`locs:${locs}\n` +
6086
// @ts-ignore
6187
`ast: "${ast.body[0].declarations[0].id.name}"\n` +
6288
`visitorKeys: ${context.sourceCode.visitorKeys.BinaryExpression.join(', ')}`,
@@ -73,12 +99,19 @@ const createOnceRule: Rule = {
7399
});
74100
},
75101
Identifier(node) {
102+
const startLoc = context.sourceCode.getLocFromIndex(node.start);
103+
const endLoc = context.sourceCode.getLocFromIndex(node.end);
104+
assert(context.sourceCode.getIndexFromLoc(startLoc) === node.start);
105+
assert(context.sourceCode.getIndexFromLoc(endLoc) === node.end);
106+
76107
context.report({
77108
message: `ident "${node.name}":\n` +
78109
`source: "${context.sourceCode.getText(node)}"\n` +
79110
`source with before: "${context.sourceCode.getText(node, 2)}"\n` +
80111
`source with after: "${context.sourceCode.getText(node, null, 1)}"\n` +
81-
`source with both: "${context.sourceCode.getText(node, 2, 1)}"`,
112+
`source with both: "${context.sourceCode.getText(node, 2, 1)}"\n` +
113+
`start loc: ${JSON.stringify(startLoc)}\n` +
114+
`end loc: ${JSON.stringify(endLoc)}`,
82115
node,
83116
});
84117
},

0 commit comments

Comments
 (0)