Skip to content

Commit e7b07dd

Browse files
committed
feat(linter/plugins): add SourceCode API
1 parent 5415924 commit e7b07dd

File tree

11 files changed

+193
-9
lines changed

11 files changed

+193
-9
lines changed

apps/oxlint/src-js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { BeforeHook, Visitor, VisitorWithHooks } from './plugins/types.ts';
55
export type { Context, Diagnostic } from './plugins/context.ts';
66
export type { Fix, Fixer, FixFn, NodeOrToken, Range } from './plugins/fix.ts';
77
export type { CreateOnceRule, CreateRule, Plugin, Rule } from './plugins/load.ts';
8+
export type { SourceCode } from './plugins/source_code.ts';
89
export type { AfterHook, BeforeHook, Node, RuleMeta, Visitor, VisitorWithHooks } from './plugins/types.ts';
910

1011
const { defineProperty, getPrototypeOf, hasOwn, setPrototypeOf, create: ObjectCreate } = Object;

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getFixes } from './fix.js';
2+
import { setupSourceCodeForFile, SourceCode } from './source_code.js';
23

34
import type { Fix, FixFn } from './fix.ts';
45
import type { Node } from './types.ts';
@@ -32,11 +33,13 @@ export const diagnostics: DiagnosticReport[] = [];
3233
* @param context - `Context` object
3334
* @param ruleIndex - Index of this rule within `ruleIds` passed from Rust
3435
* @param filePath - Absolute path of file being linted
36+
* @param sourceText - Source text of file being linted
3537
*/
3638
export let setupContextForFile: (
3739
context: Context,
3840
ruleIndex: number,
3941
filePath: string,
42+
sourceText: string,
4043
) => void;
4144

4245
/**
@@ -65,6 +68,10 @@ export interface InternalContext {
6568
ruleIndex: number;
6669
// Absolute path of file being linted
6770
filePath: string;
71+
// `SourceCode` class instance for this rule.
72+
// Rule has single `SourceCode` instance that is updated for each file
73+
// (NOT new `SourceCode` instance for each file).
74+
sourceCode: SourceCode;
6875
// Options
6976
options: unknown[];
7077
// `true` if rule can provide fixes (`meta.fixable` in `RuleMeta` is 'code' or 'whitespace')
@@ -89,6 +96,7 @@ export class Context {
8996
this.#internal = {
9097
id: fullRuleName,
9198
filePath: '',
99+
sourceCode: new SourceCode(),
92100
ruleIndex: -1,
93101
options: [],
94102
isFixable,
@@ -116,6 +124,11 @@ export class Context {
116124
return getInternal(this, 'access `context.options`').options;
117125
}
118126

127+
// Getter for `SourceCode` for file being linted.
128+
get sourceCode() {
129+
return getInternal(this, 'access `context.sourceCode`').sourceCode;
130+
}
131+
119132
/**
120133
* Report error.
121134
* @param diagnostic - Diagnostic object
@@ -135,11 +148,12 @@ export class Context {
135148
}
136149

137150
static {
138-
setupContextForFile = (context, ruleIndex, filePath) => {
151+
setupContextForFile = (context, ruleIndex, filePath, sourceText) => {
139152
// TODO: Support `options`
140153
const internal = context.#internal;
141154
internal.ruleIndex = ruleIndex;
142155
internal.filePath = filePath;
156+
setupSourceCodeForFile(internal.sourceCode, sourceText);
143157
};
144158

145159
getInternal = (context, actionDescription) => {

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,21 @@ function lintFileImpl(filePath: string, bufferId: number, buffer: Uint8Array | n
104104
throw new Error('Expected `ruleIds` to be a non-zero len array');
105105
}
106106

107+
// Decode source text from buffer
108+
const { uint32 } = buffer,
109+
programPos = uint32[DATA_POINTER_POS_32],
110+
sourceByteLen = uint32[(programPos + SOURCE_LEN_OFFSET) >> 2];
111+
112+
const sourceText = textDecoder.decode(buffer.subarray(0, sourceByteLen));
113+
107114
// Get visitors for this file from all rules
108115
initCompiledVisitor();
109116

110117
for (let i = 0; i < ruleIds.length; i++) {
111118
const ruleId = ruleIds[i],
112119
ruleAndContext = registeredRules[ruleId];
113120
const { rule, context } = ruleAndContext;
114-
setupContextForFile(context, i, filePath);
121+
setupContextForFile(context, i, filePath, sourceText);
115122

116123
let { visitor } = ruleAndContext;
117124
if (visitor === null) {
@@ -139,12 +146,6 @@ function lintFileImpl(filePath: string, bufferId: number, buffer: Uint8Array | n
139146
// Some rules seen in the wild return an empty visitor object from `create` if some initial check fails
140147
// e.g. file extension is not one the rule acts on.
141148
if (needsVisit) {
142-
const { uint32 } = buffer,
143-
programPos = uint32[DATA_POINTER_POS_32],
144-
sourceByteLen = uint32[(programPos + SOURCE_LEN_OFFSET) >> 2];
145-
146-
const sourceText = textDecoder.decode(buffer.subarray(0, sourceByteLen));
147-
148149
// `preserveParens` argument is `false`, to match ESLint.
149150
// ESLint does not include `ParenthesizedExpression` nodes in its AST.
150151
const program = deserializeProgramOnly(buffer, sourceText, sourceByteLen, false);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Node } from './types.ts';
2+
3+
const { max } = Math;
4+
5+
/**
6+
* Update a `SourceCode` with file-specific data.
7+
*
8+
* We have to define this function within class body, as it's not possible to access private property
9+
* `#sourceText` from outside the class.
10+
* We don't use a normal class method, because we don't want to expose this to user.
11+
*
12+
* @param code - `SourceCode` object
13+
* @param sourceText - Source text for file being linted
14+
*/
15+
export let setupSourceCodeForFile: (code: SourceCode, sourceText: string) => void;
16+
17+
/**
18+
* `SourceCode` class.
19+
*
20+
* Each rule has its own `SourceCode` object. It is stored in `Context` for that rule.
21+
*
22+
* A new `SourceCode` instance is NOT generated for each file.
23+
* The `SourceCode` instance for the rule is updated for each file.
24+
*/
25+
export class SourceCode {
26+
// Source text.
27+
// Initially `null`, but set to source string by `setupSourceCodeForFile`.
28+
#sourceText: string = null;
29+
30+
getText(
31+
node?: Node | null | undefined,
32+
beforeCount?: number | null | undefined,
33+
afterCount?: number | null | undefined,
34+
) {
35+
// ESLint treats all falsy values for `node` as undefined
36+
if (!node) return this.#sourceText;
37+
38+
// ESLint ignores falsy values for `beforeCount` and `afterCount`
39+
let { start, end } = node;
40+
if (beforeCount) start = max(start - beforeCount, 0);
41+
if (afterCount) end += afterCount;
42+
return this.#sourceText.slice(start, end);
43+
}
44+
45+
// TODO: Add more methods
46+
47+
static {
48+
setupSourceCodeForFile = (code, sourceText) => {
49+
code.#sourceText = sourceText;
50+
};
51+
}
52+
}

apps/oxlint/test/e2e.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ describe('oxlint CLI', () => {
123123
await testFixture('context_properties');
124124
});
125125

126+
it('should give access to source code via `context.sourceCode`', async () => {
127+
await testFixture('sourceCode');
128+
});
129+
126130
it('should support `createOnce`', async () => {
127131
await testFixture('createOnce');
128132
});

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@
7575
: ^
7676
`----
7777
78+
x create-once-plugin(always-run): createOnce: sourceCode: Cannot access `context.sourceCode` in `createOnce`
79+
,-[files/1.js:1:1]
80+
1 | let x;
81+
: ^
82+
`----
83+
7884
x create-once-plugin(always-run): createOnce: this === rule: true
7985
,-[files/1.js:1:1]
8086
1 | let x;
@@ -201,6 +207,12 @@
201207
: ^
202208
`----
203209
210+
x create-once-plugin(always-run): createOnce: sourceCode: Cannot access `context.sourceCode` in `createOnce`
211+
,-[files/2.js:1:1]
212+
1 | let y;
213+
: ^
214+
`----
215+
204216
x create-once-plugin(always-run): createOnce: this === rule: true
205217
,-[files/2.js:1:1]
206218
1 | let y;
@@ -255,7 +267,7 @@
255267
: ^
256268
`----
257269
258-
Found 0 warnings and 42 errors.
270+
Found 0 warnings and 44 errors.
259271
Finished in Xms on 2 files using X threads.
260272
```
261273

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const alwaysRunRule: Rule = {
2525
const filenameError = tryCatch(() => context.filename);
2626
const physicalFilenameError = tryCatch(() => context.physicalFilename);
2727
const optionsError = tryCatch(() => context.options);
28+
const sourceCodeError = tryCatch(() => context.sourceCode);
2829
const reportError = tryCatch(() => context.report({ message: 'oh no', node: SPAN }));
2930

3031
return {
@@ -35,6 +36,7 @@ const alwaysRunRule: Rule = {
3536
context.report({ message: `createOnce: filename: ${filenameError?.message}`, node: SPAN });
3637
context.report({ message: `createOnce: physicalFilename: ${physicalFilenameError?.message}`, node: SPAN });
3738
context.report({ message: `createOnce: options: ${optionsError?.message}`, node: SPAN });
39+
context.report({ message: `createOnce: sourceCode: ${sourceCodeError?.message}`, node: SPAN });
3840
context.report({ message: `createOnce: report: ${reportError?.message}`, node: SPAN });
3941

4042
context.report({ message: `before hook: id: ${context.id}`, node: SPAN });
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"jsPlugins": ["./plugin.ts"],
3+
"categories": { "correctness": "off" },
4+
"rules": {
5+
"source-code-plugin/create": "error"
6+
}
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
let foo, bar;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Exit code
2+
1
3+
4+
# stdout
5+
```
6+
x source-code-plugin(create): create:
7+
| source: "let foo, bar;\n"
8+
,-[files/index.js:1:1]
9+
1 | let foo, bar;
10+
: ^
11+
`----
12+
13+
x source-code-plugin(create): var decl:
14+
| source: "let foo, bar;"
15+
,-[files/index.js:1:1]
16+
1 | let foo, bar;
17+
: ^^^^^^^^^^^^^
18+
`----
19+
20+
x source-code-plugin(create): ident "foo":
21+
| source: "foo"
22+
| source with before: "t foo"
23+
| source with after: "foo,"
24+
| source with both: "t foo,"
25+
,-[files/index.js:1:5]
26+
1 | let foo, bar;
27+
: ^^^
28+
`----
29+
30+
x source-code-plugin(create): ident "bar":
31+
| source: "bar"
32+
| source with before: ", bar"
33+
| source with after: "bar;"
34+
| source with both: ", bar;"
35+
,-[files/index.js:1:10]
36+
1 | let foo, bar;
37+
: ^^^
38+
`----
39+
40+
Found 0 warnings and 4 errors.
41+
Finished in Xms on 1 file using X threads.
42+
```
43+
44+
# stderr
45+
```
46+
WARNING: JS plugins are experimental and not subject to semver.
47+
Breaking changes are possible while JS plugins support is under development.
48+
```

0 commit comments

Comments
 (0)