Skip to content

Commit 6442112

Browse files
committed
chore(oxlint/napi): add createWorkspace & destroyWorkspace js callback function
1 parent be65f92 commit 6442112

File tree

17 files changed

+338
-73
lines changed

17 files changed

+338
-73
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 destroy 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: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,34 @@ 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
*
13-
* Lazy-loads plugins code on first call, so that overhead is skipped if user doesn't use JS plugins.
15+
* Delegates to `loadPlugin`, which was lazy-loaded by `createWorkspaceWrapper`.
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> {
20-
if (loadPlugin === null) {
21-
// Use promises here instead of making `loadPluginWrapper` an async function,
22-
// to avoid a micro-tick and extra wrapper `Promise` in all later calls to `loadPluginWrapper`
23-
return import("./plugins/index.js").then((mod) => {
24-
({ loadPlugin, lintFile } = mod);
25-
return loadPlugin(path, packageName);
26-
});
27-
}
22+
function loadPluginWrapper(
23+
workspaceDir: string,
24+
path: string,
25+
packageName: string | null,
26+
): Promise<string> {
2827
debugAssertIsNonNull(loadPlugin);
29-
return loadPlugin(path, packageName);
28+
return loadPlugin(workspaceDir, path, packageName);
3029
}
3130

3231
/**
3332
* Lint a file.
3433
*
35-
* Delegates to `lintFile`, which was lazy-loaded by `loadPluginWrapper`.
34+
* Delegates to `lintFile`, which was lazy-loaded by `createWorkspaceWrapper`.
3635
*
36+
* @param workspaceDir - Directory of the workspace
3737
* @param filePath - Absolute path of file being linted
3838
* @param bufferId - ID of buffer containing file data
3939
* @param buffer - Buffer containing file data, or `null` if buffer with this ID was previously sent to JS
@@ -42,23 +42,64 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise<st
4242
* @returns Diagnostics or error serialized to JSON string
4343
*/
4444
function lintFileWrapper(
45+
rootDir: string,
4546
filePath: string,
4647
bufferId: number,
4748
buffer: Uint8Array | null,
4849
ruleIds: number[],
4950
settingsJSON: string,
5051
): string {
51-
// `lintFileWrapper` is never called without `loadPluginWrapper` being called first,
52+
// `lintFileWrapper` is never called without `createWorkspaceWrapper` being called first,
5253
// so `lintFile` must be defined here
5354
debugAssertIsNonNull(lintFile);
54-
return lintFile(filePath, bufferId, buffer, ruleIds, settingsJSON);
55+
return lintFile(rootDir, filePath, bufferId, buffer, ruleIds, settingsJSON);
56+
}
57+
58+
/**
59+
* Create a new workspace.
60+
*
61+
* Lazy-loads workspace code on first call, so that overhead is skipped if user doesn't use JS plugins.
62+
*
63+
* @param rootDir - Root directory of the workspace
64+
* @returns Promise that resolves when workspace is created
65+
*/
66+
function createWorkspaceWrapper(rootDir: string): Promise<undefined> {
67+
if (createWorkspace === null) {
68+
// Use promises here instead of making `createWorkspaceWrapper` an async function,
69+
// to avoid a micro-tick and extra wrapper `Promise` in all later calls to `createWorkspaceWrapper`
70+
return import("./plugins/index.js").then((mod) => {
71+
({ loadPlugin, lintFile, createWorkspace, destroyWorkspace } = mod);
72+
return createWorkspace(rootDir);
73+
});
74+
}
75+
76+
debugAssertIsNonNull(createWorkspace);
77+
return Promise.resolve(createWorkspace(rootDir));
78+
}
79+
80+
/**
81+
* Destroy a workspace.
82+
*
83+
* @param rootDir - Root directory of the workspace
84+
*/
85+
function destroyWorkspaceWrapper(rootDir: string): void {
86+
// `destroyWorkspaceWrapper` is never called without `createWorkspaceWrapper` being called first,
87+
// so `destroyWorkspace` must be defined here
88+
debugAssertIsNonNull(destroyWorkspace);
89+
destroyWorkspace(rootDir);
5590
}
5691

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

60-
// Call Rust, passing `loadPlugin` and `lintFile` as callbacks, and CLI arguments
61-
const success = await lint(args, loadPluginWrapper, lintFileWrapper);
95+
// Call Rust, passing `loadPlugin`, `lintFile`, `createWorkspace` and `destroyWorkspace` as callbacks, and CLI arguments
96+
const success = await lint(
97+
args,
98+
loadPluginWrapper,
99+
lintFileWrapper,
100+
createWorkspaceWrapper,
101+
destroyWorkspaceWrapper,
102+
);
62103

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

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,14 +1,14 @@
1+
import { getErrorMessage } from "../utils/utils.js";
12
import { createContext } from "./context.js";
23
import { deepFreezeJsonArray } from "./json.js";
34
import { DEFAULT_OPTIONS } from "./options.js";
4-
import { getErrorMessage } from "../utils/utils.js";
55

66
import type { Writable } from "type-fest";
7+
import type { SetNullable } from "../utils/types.ts";
78
import type { Context } from "./context.ts";
89
import type { Options } from "./options.ts";
910
import type { RuleMeta } from "./rule_meta.ts";
1011
import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from "./types.ts";
11-
import type { SetNullable } from "../utils/types.ts";
1212

1313
const ObjectKeys = Object.keys,
1414
{ isArray } = Array;
@@ -79,11 +79,11 @@ interface CreateOnceRuleDetails extends RuleDetailsBase {
7979
}
8080

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

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

8888
// `before` hook which makes rule never run.
8989
const neverRunBeforeHook: BeforeHook = () => false;
@@ -103,19 +103,26 @@ interface PluginDetails {
103103
*
104104
* Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch.
105105
*
106+
* @param workspaceDir - Workspace root directory
106107
* @param url - Absolute path of plugin file as a `file://...` URL
107108
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
108109
* @returns Plugin details or error serialized to JSON string
109110
*/
110-
export async function loadPlugin(url: string, packageName: string | null): Promise<string> {
111+
export async function loadPlugin(
112+
workspaceDir: string,
113+
url: string,
114+
packageName: string | null,
115+
): Promise<string> {
111116
try {
112117
if (DEBUG) {
113-
if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered");
114-
registeredPluginUrls.add(url);
118+
if (!registeredPluginUrls.has(workspaceDir)) throw new Error("Workspace not initialized");
119+
if (registeredPluginUrls.get(workspaceDir)!.has(url))
120+
throw new Error("This plugin has already been registered");
121+
registeredPluginUrls.get(workspaceDir)!.add(url);
115122
}
116123

117124
const plugin = (await import(url)).default as Plugin;
118-
const res = registerPlugin(plugin, packageName);
125+
const res = registerPlugin(workspaceDir, plugin, packageName);
119126
return JSON.stringify({ Success: res });
120127
} catch (err) {
121128
return JSON.stringify({ Failure: getErrorMessage(err) });
@@ -125,19 +132,25 @@ export async function loadPlugin(url: string, packageName: string | null): Promi
125132
/**
126133
* Register a plugin.
127134
*
135+
* @param workspaceDir - Workspace root directory
128136
* @param plugin - Plugin
129137
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
130138
* @returns - Plugin details
139+
* @throws {Error} If workspaceDir is invalid
131140
* @throws {Error} If `plugin.meta.name` is `null` / `undefined` and `packageName` not provided
132141
* @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor
133142
* @throws {TypeError} If `plugin.meta.name` is not a string
134143
*/
135-
function registerPlugin(plugin: Plugin, packageName: string | null): PluginDetails {
144+
function registerPlugin(
145+
workspaceDir: string,
146+
plugin: Plugin,
147+
packageName: string | null,
148+
): PluginDetails {
136149
// TODO: Use a validation library to assert the shape of the plugin, and of rules
137150

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

140-
const offset = registeredRules.length;
153+
const offset = registeredRules.get(workspaceDir)?.length ?? 0;
141154
const { rules } = plugin;
142155
const ruleNames = ObjectKeys(rules);
143156
const ruleNamesLen = ruleNames.length;
@@ -231,7 +244,7 @@ function registerPlugin(plugin: Plugin, packageName: string | null): PluginDetai
231244
(ruleDetails as unknown as Writable<CreateOnceRuleDetails>).afterHook = afterHook;
232245
}
233246

234-
registeredRules.push(ruleDetails);
247+
registeredRules.get(workspaceDir)?.push(ruleDetails);
235248
}
236249

237250
return { name: pluginName, offset, ruleNames };
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
* 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+
/**
33+
* Destroy a workspace.
34+
*/
35+
export const destroyWorkspace = (rootDir: string): undefined => {
36+
if (DEBUG) {
37+
if (!workspaceRoots.has(rootDir))
38+
throw new Error(`Workspace for rootDir "${rootDir}" does not exist`);
39+
if (!registeredPluginUrls.has(rootDir)) throw new Error("Invalid workspaceDir");
40+
if (!registeredRules.has(rootDir)) throw new Error("Invalid workspaceDir");
41+
}
42+
workspaceRoots.delete(rootDir);
43+
registeredPluginUrls.delete(rootDir);
44+
registeredRules.delete(rootDir);
45+
};

0 commit comments

Comments
 (0)