Skip to content

Commit 5353e40

Browse files
committed
chore(oxlint/napi): add createWorkspace & destroyWorkspace js callback function
1 parent 9c10d86 commit 5353e40

File tree

17 files changed

+325
-69
lines changed

17 files changed

+325
-69
lines changed

apps/oxlint/src-js/bindings.d.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
/* auto-generated by NAPI-RS */
22
/* eslint-disable */
3+
/** JS callback to create a workspace. */
4+
export type JsCreateWorkspaceCb =
5+
((arg0: string) => Promise<undefined>)
6+
7+
/** JS callback to destroying a workspace. */
8+
export type JsDestroyWorkspaceCb =
9+
((arg0: string) => void)
10+
311
/** JS callback to lint a file. */
412
export type JsLintFileCb =
5-
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>, arg4: string) => string)
13+
((arg0: string, arg1: string, arg2: number, arg3: Uint8Array | undefined | null, arg4: Array<number>, arg5: string) => string)
614

715
/** JS callback to load a JS plugin. */
816
export type JsLoadPluginCb =
9-
((arg0: string, arg1?: string | undefined | null) => Promise<string>)
17+
((arg0: string, arg1: string, arg2?: string | undefined | null) => Promise<string>)
1018

1119
/**
1220
* NAPI entry point.
@@ -15,7 +23,9 @@ export type JsLoadPluginCb =
1523
* 1. `args`: Command line arguments (process.argv.slice(2))
1624
* 2. `load_plugin`: Load a JS plugin from a file path.
1725
* 3. `lint_file`: Lint a file.
26+
* 4. `create_workspace`: Create a new workspace.
27+
* 5. `destroy_workspace`: Destroy a workspace.
1828
*
1929
* Returns `true` if linting succeeded without errors, `false` otherwise.
2030
*/
21-
export declare function lint(args: Array<string>, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb): Promise<boolean>
31+
export declare function lint(args: Array<string>, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb, createWorkspace: JsCreateWorkspaceCb, destroyWorkspace: JsDestroyWorkspaceCb): Promise<boolean>

apps/oxlint/src-js/cli.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,42 @@ import { debugAssertIsNonNull } from "./utils/asserts.js";
66
// are identical. Ditto `lintFile` and `lintFileWrapper`.
77
let loadPlugin: typeof loadPluginWrapper | null = null;
88
let lintFile: typeof lintFileWrapper | null = null;
9+
let createWorkspace: typeof createWorkspaceWrapper | null = null;
10+
let destroyWorkspace: typeof destroyWorkspaceWrapper | null = null;
911

1012
/**
1113
* Load a plugin.
1214
*
1315
* Lazy-loads plugins code on first call, so that overhead is skipped if user doesn't use JS plugins.
1416
*
17+
* @param workspaceDir - Workspace root directory
1518
* @param path - Absolute path of plugin file
1619
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
1720
* @returns Plugin details or error serialized to JSON string
1821
*/
19-
function loadPluginWrapper(path: string, packageName: string | null): Promise<string> {
22+
function loadPluginWrapper(
23+
workspaceDir: string,
24+
path: string,
25+
packageName: string | null,
26+
): Promise<string> {
2027
if (loadPlugin === null) {
2128
// Use promises here instead of making `loadPluginWrapper` an async function,
2229
// to avoid a micro-tick and extra wrapper `Promise` in all later calls to `loadPluginWrapper`
2330
return import("./plugins/index.js").then((mod) => {
24-
({ loadPlugin, lintFile } = mod);
25-
return loadPlugin(path, packageName);
31+
({ loadPlugin, lintFile, createWorkspace, destroyWorkspace } = mod);
32+
return loadPlugin(workspaceDir, path, packageName);
2633
});
2734
}
2835
debugAssertIsNonNull(loadPlugin);
29-
return loadPlugin(path, packageName);
36+
return loadPlugin(workspaceDir, path, packageName);
3037
}
3138

3239
/**
3340
* Lint a file.
3441
*
3542
* Delegates to `lintFile`, which was lazy-loaded by `loadPluginWrapper`.
3643
*
44+
* @param workspaceDir - Directory of the workspace
3745
* @param filePath - Absolute path of file being linted
3846
* @param bufferId - ID of buffer containing file data
3947
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
@@ -42,6 +50,7 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise<st
4250
* @returns Diagnostics or error serialized to JSON string
4351
*/
4452
function lintFileWrapper(
53+
rootDir: string,
4554
filePath: string,
4655
bufferId: number,
4756
buffer: Uint8Array | null,
@@ -51,14 +60,54 @@ function lintFileWrapper(
5160
// `lintFileWrapper` is never called without `loadPluginWrapper` being called first,
5261
// so `lintFile` must be defined here
5362
debugAssertIsNonNull(lintFile);
54-
return lintFile(filePath, bufferId, buffer, ruleIds, settingsJSON);
63+
return lintFile(rootDir, filePath, bufferId, buffer, ruleIds, settingsJSON);
64+
}
65+
66+
/**
67+
* Create a new workspace.
68+
*
69+
* Delegates to `createWorkspace`, which was lazy-loaded by `createWorkspaceWrapper`.
70+
*
71+
* @param rootDir - Root directory of the workspace
72+
* @returns Promise that resolves when workspace is created
73+
*/
74+
function createWorkspaceWrapper(rootDir: string): Promise<undefined> {
75+
if (createWorkspace === null) {
76+
// Use promises here instead of making `createWorkspaceWrapper` an async function,
77+
// to avoid a micro-tick and extra wrapper `Promise` in all later calls to `createWorkspaceWrapper`
78+
return import("./plugins/index.js").then((mod) => {
79+
({ loadPlugin, lintFile, createWorkspace, destroyWorkspace } = mod);
80+
return createWorkspace(rootDir);
81+
});
82+
}
83+
84+
debugAssertIsNonNull(createWorkspace);
85+
return Promise.resolve(createWorkspace(rootDir));
86+
}
87+
88+
/**
89+
* Destroy a workspace.
90+
*
91+
* @param rootDir - Root directory of the workspace
92+
*/
93+
function destroyWorkspaceWrapper(rootDir: string): void {
94+
// `destroyWorkspaceWrapper` is never called without `createWorkspaceWrapper` being called first,
95+
// so `destroyWorkspace` must be defined here
96+
debugAssertIsNonNull(destroyWorkspace);
97+
destroyWorkspace(rootDir);
5598
}
5699

57100
// Get command line arguments, skipping first 2 (node binary and script path)
58101
const args = process.argv.slice(2);
59102

60-
// Call Rust, passing `loadPlugin` and `lintFile` as callbacks, and CLI arguments
61-
const success = await lint(args, loadPluginWrapper, lintFileWrapper);
103+
// Call Rust, passing `loadPlugin`, `lintFile`, and `clearLoadedPlugin` as callbacks, and CLI arguments
104+
const success = await lint(
105+
args,
106+
loadPluginWrapper,
107+
lintFileWrapper,
108+
createWorkspaceWrapper,
109+
destroyWorkspaceWrapper,
110+
);
62111

63112
// Note: It's recommended to set `process.exitCode` instead of calling `process.exit()`.
64113
// `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lintFile } from "./lint.js";
22
import { loadPlugin } from "./load.js";
3+
import { createWorkspace, destroyWorkspace } from "./workspace.js";
34

4-
export { lintFile, loadPlugin };
5+
export { createWorkspace, destroyWorkspace, lintFile, loadPlugin };

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { setupFileContext, resetFileContext } from "./context.js";
1+
import { debugAssert, debugAssertIsNonNull, typeAssertIs } from "../utils/asserts.js";
2+
import { getErrorMessage } from "../utils/utils.js";
3+
import { resetFileContext, setupFileContext } from "./context.js";
24
import { registeredRules } from "./load.js";
35
import { allOptions, DEFAULT_OPTIONS_ID } from "./options.js";
46
import { diagnostics } from "./report.js";
5-
import { setSettingsForFile, resetSettings } from "./settings.js";
7+
import { resetSettings, setSettingsForFile } from "./settings.js";
68
import { ast, initAst, resetSourceAndAst, setupSourceForFile } from "./source_code.js";
7-
import { typeAssertIs, debugAssert, debugAssertIsNonNull } from "../utils/asserts.js";
8-
import { getErrorMessage } from "../utils/utils.js";
99
import {
1010
addVisitorToCompiled,
1111
compiledVisitor,
@@ -42,6 +42,7 @@ const PARSER_SERVICES_DEFAULT: Record<string, unknown> = Object.freeze({});
4242
*
4343
* Main logic is in separate function `lintFileImpl`, because V8 cannot optimize functions containing try/catch.
4444
*
45+
* @param workspaceDir - Directory of the workspace
4546
* @param filePath - Absolute path of file being linted
4647
* @param bufferId - ID of buffer containing file data
4748
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
@@ -50,6 +51,7 @@ const PARSER_SERVICES_DEFAULT: Record<string, unknown> = Object.freeze({});
5051
* @returns Diagnostics or error serialized to JSON string
5152
*/
5253
export function lintFile(
54+
workspaceDir: string,
5355
filePath: string,
5456
bufferId: number,
5557
buffer: Uint8Array | null,
@@ -60,7 +62,7 @@ export function lintFile(
6062
const optionsIds = ruleIds.map((_) => DEFAULT_OPTIONS_ID);
6163

6264
try {
63-
lintFileImpl(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON);
65+
lintFileImpl(workspaceDir, filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON);
6466
return JSON.stringify({ Success: diagnostics });
6567
} catch (err) {
6668
return JSON.stringify({ Failure: getErrorMessage(err) });
@@ -72,6 +74,7 @@ export function lintFile(
7274
/**
7375
* Run rules on a file.
7476
*
77+
* @param workspaceDir - Directory of the workspace
7578
* @param filePath - Absolute path of file being linted
7679
* @param bufferId - ID of buffer containing file data
7780
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
@@ -83,6 +86,7 @@ export function lintFile(
8386
* @throws {*} If any rule throws
8487
*/
8588
function lintFileImpl(
89+
workspaceDir: string,
8690
filePath: string,
8791
bufferId: number,
8892
buffer: Uint8Array | null,
@@ -145,10 +149,12 @@ function lintFileImpl(
145149
"Rule IDs and options IDs arrays must be the same length",
146150
);
147151

152+
const rules = registeredRules.get(workspaceDir)!;
153+
148154
for (let i = 0, len = ruleIds.length; i < len; i++) {
149155
const ruleId = ruleIds[i];
150-
debugAssert(ruleId < registeredRules.length, "Rule ID out of bounds");
151-
const ruleDetails = registeredRules[ruleId];
156+
debugAssert(ruleId < rules.length, "Rule ID out of bounds");
157+
const ruleDetails = rules[ruleId];
152158

153159
// Set `ruleIndex` for rule. It's used when sending diagnostics back to Rust.
154160
ruleDetails.ruleIndex = i;

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

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { createContext } from "./context.js";
21
import { getErrorMessage } from "../utils/utils.js";
2+
import { createContext } from "./context.js";
33

44
import type { Writable } from "type-fest";
5+
import type { SetNullable } from "../utils/types.ts";
56
import type { Context } from "./context.ts";
67
import type { Options } from "./options.ts";
78
import type { RuleMeta } from "./rule_meta.ts";
89
import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from "./types.ts";
9-
import type { SetNullable } from "../utils/types.ts";
1010

1111
const ObjectKeys = Object.keys;
1212

@@ -75,11 +75,11 @@ interface CreateOnceRuleDetails extends RuleDetailsBase {
7575
}
7676

7777
// Absolute paths of plugins which have been loaded
78-
const registeredPluginUrls = new Set<string>();
78+
export const registeredPluginUrls = new Map<string, Set<string>>();
7979

8080
// Rule objects for loaded rules.
8181
// Indexed by `ruleId`, which is passed to `lintFile`.
82-
export const registeredRules: RuleDetails[] = [];
82+
export const registeredRules: Map<string, RuleDetails[]> = new Map();
8383

8484
// `before` hook which makes rule never run.
8585
const neverRunBeforeHook: BeforeHook = () => false;
@@ -99,13 +99,18 @@ interface PluginDetails {
9999
*
100100
* Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch.
101101
*
102+
* @param workspaceDir - Workspace root directory
102103
* @param url - Absolute path of plugin file as a `file://...` URL
103104
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
104105
* @returns Plugin details or error serialized to JSON string
105106
*/
106-
export async function loadPlugin(url: string, packageName: string | null): Promise<string> {
107+
export async function loadPlugin(
108+
workspaceDir: string,
109+
url: string,
110+
packageName: string | null,
111+
): Promise<string> {
107112
try {
108-
const res = await loadPluginImpl(url, packageName);
113+
const res = await loadPluginImpl(workspaceDir, url, packageName);
109114
return JSON.stringify({ Success: res });
110115
} catch (err) {
111116
return JSON.stringify({ Failure: getErrorMessage(err) });
@@ -115,19 +120,27 @@ export async function loadPlugin(url: string, packageName: string | null): Promi
115120
/**
116121
* Load a plugin.
117122
*
123+
* @param workspaceDir - Workspace root directory
118124
* @param url - Absolute path of plugin file as a `file://...` URL
119125
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
120126
* @returns - Plugin details
127+
* @throws {Error} If workspaceDir is invalid
121128
* @throws {Error} If plugin has already been registered
122129
* @throws {Error} If plugin has no name
123130
* @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor
124131
* @throws {TypeError} if `plugin.meta.name` is not a string
125132
* @throws {*} If plugin throws an error during import
126133
*/
127-
async function loadPluginImpl(url: string, packageName: string | null): Promise<PluginDetails> {
134+
async function loadPluginImpl(
135+
workspaceDir: string,
136+
url: string,
137+
packageName: string | null,
138+
): Promise<PluginDetails> {
128139
if (DEBUG) {
129-
if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered");
130-
registeredPluginUrls.add(url);
140+
if (!registeredRules.has(workspaceDir)) throw new Error("Invalid workspaceDir");
141+
if (registeredPluginUrls.get(workspaceDir)?.has(url))
142+
throw new Error("This plugin has already been registered");
143+
registeredPluginUrls.get(workspaceDir)?.add(url);
131144
}
132145

133146
const { default: plugin } = (await import(url)) as { default: Plugin };
@@ -136,7 +149,7 @@ async function loadPluginImpl(url: string, packageName: string | null): Promise<
136149

137150
const pluginName = getPluginName(plugin, packageName);
138151

139-
const offset = registeredRules.length;
152+
const offset = registeredRules.get(workspaceDir)?.length ?? 0;
140153
const { rules } = plugin;
141154
const ruleNames = ObjectKeys(rules);
142155
const ruleNamesLen = ruleNames.length;
@@ -218,7 +231,7 @@ async function loadPluginImpl(url: string, packageName: string | null): Promise<
218231
(ruleDetails as unknown as Writable<CreateOnceRuleDetails>).afterHook = afterHook;
219232
}
220233

221-
registeredRules.push(ruleDetails);
234+
registeredRules.get(workspaceDir)?.push(ruleDetails);
222235
}
223236

224237
return { name: pluginName, offset, ruleNames };
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Isolated Workspaces for linting plugins.
3+
*
4+
* Every Workspace starts with a "workspace root" directory. This directory is
5+
* used to isolate the plugin's dependencies from other plugins and the main
6+
* application.
7+
*
8+
* Each workspace can be created, used, and then cleared to free up resources.
9+
*/
10+
11+
import { registeredPluginUrls, registeredRules } from "./load.js";
12+
13+
/**
14+
* Set of workspace root directories.
15+
*/
16+
const workspaceRoots = new Set<string>();
17+
18+
/**
19+
* the main function to create a new workspace
20+
*/
21+
export const createWorkspace = async (rootDir: string): Promise<undefined> => {
22+
if (DEBUG) {
23+
if (workspaceRoots.has(rootDir))
24+
throw new Error(`Workspace for rootDir "${rootDir}" already exists`);
25+
}
26+
27+
workspaceRoots.add(rootDir);
28+
registeredPluginUrls.set(rootDir, new Set<string>());
29+
registeredRules.set(rootDir, []);
30+
};
31+
32+
export const destroyWorkspace = (rootDir: string) => {
33+
if (DEBUG) {
34+
if (!workspaceRoots.has(rootDir))
35+
throw new Error(`Workspace for rootDir "${rootDir}" does not exist`);
36+
if (!registeredPluginUrls.has(rootDir)) throw new Error("Invalid workspaceDir");
37+
if (!registeredRules.has(rootDir)) throw new Error("Invalid workspaceDir");
38+
}
39+
workspaceRoots.delete(rootDir);
40+
registeredPluginUrls.delete(rootDir);
41+
registeredRules.delete(rootDir);
42+
};

0 commit comments

Comments
 (0)