diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 93921c1f0a557..a2efac668b948 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -1,12 +1,20 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** JS callback to create a workspace. */ +export type JsCreateWorkspaceCb = + ((arg0: string) => Promise) + +/** JS callback to destroy a workspace. */ +export type JsDestroyWorkspaceCb = + ((arg0: string) => void) + /** JS callback to lint a file. */ export type JsLintFileCb = - ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: string) => string) + ((arg0: string, arg1: string, arg2: number, arg3: Uint8Array | undefined | null, arg4: Array, arg5: string) => string) /** JS callback to load a JS plugin. */ export type JsLoadPluginCb = - ((arg0: string, arg1?: string | undefined | null) => Promise) + ((arg0: string, arg1: string, arg2?: string | undefined | null) => Promise) /** * NAPI entry point. @@ -15,7 +23,9 @@ export type JsLoadPluginCb = * 1. `args`: Command line arguments (process.argv.slice(2)) * 2. `load_plugin`: Load a JS plugin from a file path. * 3. `lint_file`: Lint a file. + * 4. `create_workspace`: Create a new workspace. + * 5. `destroy_workspace`: Destroy a workspace. * * Returns `true` if linting succeeded without errors, `false` otherwise. */ -export declare function lint(args: Array, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb): Promise +export declare function lint(args: Array, loadPlugin: JsLoadPluginCb, lintFile: JsLintFileCb, createWorkspace: JsCreateWorkspaceCb, destroyWorkspace: JsDestroyWorkspaceCb): Promise diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 34450e0f6c0c9..254e8e44cd8e4 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -6,34 +6,34 @@ import { debugAssertIsNonNull } from "./utils/asserts.js"; // are identical. Ditto `lintFile` and `lintFileWrapper`. let loadPlugin: typeof loadPluginWrapper | null = null; let lintFile: typeof lintFileWrapper | null = null; +let createWorkspace: typeof createWorkspaceWrapper | null = null; +let destroyWorkspace: typeof destroyWorkspaceWrapper | null = null; /** * Load a plugin. * - * Lazy-loads plugins code on first call, so that overhead is skipped if user doesn't use JS plugins. + * Delegates to `loadPlugin`, which was lazy-loaded by `createWorkspaceWrapper`. * + * @param workspaceDir - Workspace root directory * @param path - Absolute path of plugin file * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) * @returns Plugin details or error serialized to JSON string */ -function loadPluginWrapper(path: string, packageName: string | null): Promise { - if (loadPlugin === null) { - // Use promises here instead of making `loadPluginWrapper` an async function, - // to avoid a micro-tick and extra wrapper `Promise` in all later calls to `loadPluginWrapper` - return import("./plugins/index.js").then((mod) => { - ({ loadPlugin, lintFile } = mod); - return loadPlugin(path, packageName); - }); - } +function loadPluginWrapper( + workspaceDir: string, + path: string, + packageName: string | null, +): Promise { debugAssertIsNonNull(loadPlugin); - return loadPlugin(path, packageName); + return loadPlugin(workspaceDir, path, packageName); } /** * Lint a file. * - * Delegates to `lintFile`, which was lazy-loaded by `loadPluginWrapper`. + * Delegates to `lintFile`, which was lazy-loaded by `createWorkspaceWrapper`. * + * @param workspaceDir - Directory of the workspace * @param filePath - Absolute path of file being linted * @param bufferId - ID of buffer containing file data * @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 { + if (createWorkspace === null) { + // Use promises here instead of making `createWorkspaceWrapper` an async function, + // to avoid a micro-tick and extra wrapper `Promise` in all later calls to `createWorkspaceWrapper` + return import("./plugins/index.js").then((mod) => { + ({ loadPlugin, lintFile, createWorkspace, destroyWorkspace } = mod); + return createWorkspace(rootDir); + }); + } + + debugAssertIsNonNull(createWorkspace); + return Promise.resolve(createWorkspace(rootDir)); +} + +/** + * Destroy a workspace. + * + * @param rootDir - Root directory of the workspace + */ +function destroyWorkspaceWrapper(rootDir: string): void { + // `destroyWorkspaceWrapper` is never called without `createWorkspaceWrapper` being called first, + // so `destroyWorkspace` must be defined here + debugAssertIsNonNull(destroyWorkspace); + destroyWorkspace(rootDir); } // Get command line arguments, skipping first 2 (node binary and script path) const args = process.argv.slice(2); -// Call Rust, passing `loadPlugin` and `lintFile` as callbacks, and CLI arguments -const success = await lint(args, loadPluginWrapper, lintFileWrapper); +// Call Rust, passing `loadPlugin`, `lintFile`, `createWorkspace` and `destroyWorkspace` as callbacks, and CLI arguments +const success = await lint( + args, + loadPluginWrapper, + lintFileWrapper, + createWorkspaceWrapper, + destroyWorkspaceWrapper, +); // Note: It's recommended to set `process.exitCode` instead of calling `process.exit()`. // `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies. diff --git a/apps/oxlint/src-js/plugins/index.ts b/apps/oxlint/src-js/plugins/index.ts index 250bdcb23f352..3082ec941f973 100644 --- a/apps/oxlint/src-js/plugins/index.ts +++ b/apps/oxlint/src-js/plugins/index.ts @@ -1,2 +1,3 @@ export { lintFile } from "./lint.js"; export { loadPlugin } from "./load.js"; +export { createWorkspace, destroyWorkspace } from "./workspace.js"; diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index 42666c1cd051b..51b1ed01c6460 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -1,11 +1,11 @@ -import { setupFileContext, resetFileContext } from "./context.js"; +import { debugAssert, debugAssertIsNonNull, typeAssertIs } from "../utils/asserts.js"; +import { getErrorMessage } from "../utils/utils.js"; +import { resetFileContext, setupFileContext } from "./context.js"; import { registeredRules } from "./load.js"; import { allOptions, DEFAULT_OPTIONS_ID } from "./options.js"; import { diagnostics } from "./report.js"; -import { setSettingsForFile, resetSettings } from "./settings.js"; +import { resetSettings, setSettingsForFile } from "./settings.js"; import { ast, initAst, resetSourceAndAst, setupSourceForFile } from "./source_code.js"; -import { typeAssertIs, debugAssert, debugAssertIsNonNull } from "../utils/asserts.js"; -import { getErrorMessage } from "../utils/utils.js"; import { addVisitorToCompiled, compiledVisitor, @@ -42,6 +42,7 @@ const PARSER_SERVICES_DEFAULT: Record = Object.freeze({}); * * Main logic is in separate function `lintFileImpl`, because V8 cannot optimize functions containing try/catch. * + * @param workspaceDir - Directory of the workspace * @param filePath - Absolute path of file being linted * @param bufferId - ID of buffer containing file data * @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 = Object.freeze({}); * @returns Diagnostics or error serialized to JSON string */ export function lintFile( + workspaceDir: string, filePath: string, bufferId: number, buffer: Uint8Array | null, @@ -60,7 +62,7 @@ export function lintFile( const optionsIds = ruleIds.map((_) => DEFAULT_OPTIONS_ID); try { - lintFileImpl(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON); + lintFileImpl(workspaceDir, filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON); return JSON.stringify({ Success: diagnostics }); } catch (err) { return JSON.stringify({ Failure: getErrorMessage(err) }); @@ -72,6 +74,7 @@ export function lintFile( /** * Run rules on a file. * + * @param workspaceDir - Directory of the workspace * @param filePath - Absolute path of file being linted * @param bufferId - ID of buffer containing file data * @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( * @throws {*} If any rule throws */ function lintFileImpl( + workspaceDir: string, filePath: string, bufferId: number, buffer: Uint8Array | null, @@ -145,10 +149,12 @@ function lintFileImpl( "Rule IDs and options IDs arrays must be the same length", ); + const rules = registeredRules.get(workspaceDir)!; + for (let i = 0, len = ruleIds.length; i < len; i++) { const ruleId = ruleIds[i]; - debugAssert(ruleId < registeredRules.length, "Rule ID out of bounds"); - const ruleDetails = registeredRules[ruleId]; + debugAssert(ruleId < rules.length, "Rule ID out of bounds"); + const ruleDetails = rules[ruleId]; // Set `ruleIndex` for rule. It's used when sending diagnostics back to Rust. ruleDetails.ruleIndex = i; diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index 58f5ff4d363d0..1a53066e3f481 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -1,13 +1,13 @@ +import { getErrorMessage } from "../utils/utils.js"; import { createContext } from "./context.js"; import { DEFAULT_OPTIONS } from "./options.js"; -import { getErrorMessage } from "../utils/utils.js"; import type { Writable } from "type-fest"; +import type { SetNullable } from "../utils/types.ts"; import type { Context } from "./context.ts"; import type { Options } from "./options.ts"; import type { RuleMeta } from "./rule_meta.ts"; import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from "./types.ts"; -import type { SetNullable } from "../utils/types.ts"; const ObjectKeys = Object.keys, { isArray } = Array; @@ -78,11 +78,11 @@ interface CreateOnceRuleDetails extends RuleDetailsBase { } // Absolute paths of plugins which have been loaded -const registeredPluginUrls = new Set(); +export const registeredPluginUrls = new Map>(); // Rule objects for loaded rules. // Indexed by `ruleId`, which is passed to `lintFile`. -export const registeredRules: RuleDetails[] = []; +export const registeredRules: Map = new Map(); // `before` hook which makes rule never run. const neverRunBeforeHook: BeforeHook = () => false; @@ -102,13 +102,18 @@ interface PluginDetails { * * Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch. * + * @param workspaceDir - Workspace root directory * @param url - Absolute path of plugin file as a `file://...` URL * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) * @returns Plugin details or error serialized to JSON string */ -export async function loadPlugin(url: string, packageName: string | null): Promise { +export async function loadPlugin( + workspaceDir: string, + url: string, + packageName: string | null, +): Promise { try { - const res = await loadPluginImpl(url, packageName); + const res = await loadPluginImpl(workspaceDir, url, packageName); return JSON.stringify({ Success: res }); } catch (err) { return JSON.stringify({ Failure: getErrorMessage(err) }); @@ -118,19 +123,27 @@ export async function loadPlugin(url: string, packageName: string | null): Promi /** * Load a plugin. * + * @param workspaceDir - Workspace root directory * @param url - Absolute path of plugin file as a `file://...` URL * @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined) * @returns - Plugin details + * @throws {Error} If workspaceDir is invalid * @throws {Error} If plugin has already been registered * @throws {Error} If plugin has no name * @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor * @throws {TypeError} if `plugin.meta.name` is not a string * @throws {*} If plugin throws an error during import */ -async function loadPluginImpl(url: string, packageName: string | null): Promise { +async function loadPluginImpl( + workspaceDir: string, + url: string, + packageName: string | null, +): Promise { if (DEBUG) { - if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered"); - registeredPluginUrls.add(url); + if (!registeredRules.has(workspaceDir)) throw new Error("Invalid workspaceDir"); + if (registeredPluginUrls.get(workspaceDir)?.has(url)) + throw new Error("This plugin has already been registered"); + registeredPluginUrls.get(workspaceDir)?.add(url); } const { default: plugin } = (await import(url)) as { default: Plugin }; @@ -139,7 +152,7 @@ async function loadPluginImpl(url: string, packageName: string | null): Promise< const pluginName = getPluginName(plugin, packageName); - const offset = registeredRules.length; + const offset = registeredRules.get(workspaceDir)?.length ?? 0; const { rules } = plugin; const ruleNames = ObjectKeys(rules); const ruleNamesLen = ruleNames.length; @@ -232,7 +245,7 @@ async function loadPluginImpl(url: string, packageName: string | null): Promise< (ruleDetails as unknown as Writable).afterHook = afterHook; } - registeredRules.push(ruleDetails); + registeredRules.get(workspaceDir)?.push(ruleDetails); } return { name: pluginName, offset, ruleNames }; diff --git a/apps/oxlint/src-js/plugins/workspace.ts b/apps/oxlint/src-js/plugins/workspace.ts new file mode 100644 index 0000000000000..e8682895c6b9d --- /dev/null +++ b/apps/oxlint/src-js/plugins/workspace.ts @@ -0,0 +1,45 @@ +/* + * Isolated Workspaces for linting plugins. + * + * Every Workspace starts with a "workspace root" directory. This directory is + * used to isolate the plugin's dependencies from other plugins and the main + * application. + * + * Each workspace can be created, used, and then cleared to free up resources. + */ + +import { registeredPluginUrls, registeredRules } from "./load.js"; + +/** + * Set of workspace root directories. + */ +const workspaceRoots = new Set(); + +/** + * Create a new workspace. + */ +export const createWorkspace = async (rootDir: string): Promise => { + if (DEBUG) { + if (workspaceRoots.has(rootDir)) + throw new Error(`Workspace for rootDir "${rootDir}" already exists`); + } + + workspaceRoots.add(rootDir); + registeredPluginUrls.set(rootDir, new Set()); + registeredRules.set(rootDir, []); +}; + +/** + * Destroy a workspace. + */ +export const destroyWorkspace = (rootDir: string): undefined => { + if (DEBUG) { + if (!workspaceRoots.has(rootDir)) + throw new Error(`Workspace for rootDir "${rootDir}" does not exist`); + if (!registeredPluginUrls.has(rootDir)) throw new Error("Invalid workspaceDir"); + if (!registeredRules.has(rootDir)) throw new Error("Invalid workspaceDir"); + } + workspaceRoots.delete(rootDir); + registeredPluginUrls.delete(rootDir); + registeredRules.delete(rootDir); +}; diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 59e6bd5c9f24d..3cc948e5be052 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -15,18 +15,27 @@ use oxc_linter::{ use crate::{ generated::raw_transfer_constants::{BLOCK_ALIGN, BUFFER_SIZE}, - run::{JsLintFileCb, JsLoadPluginCb}, + run::{JsCreateWorkspaceCb, JsDestroyWorkspaceCb, JsLintFileCb, JsLoadPluginCb}, }; /// Wrap JS callbacks as normal Rust functions, and create [`ExternalLinter`]. pub fn create_external_linter( load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb, + create_workspace: JsCreateWorkspaceCb, + destroy_workspace: JsDestroyWorkspaceCb, ) -> ExternalLinter { let rust_load_plugin = wrap_load_plugin(load_plugin); let rust_lint_file = wrap_lint_file(lint_file); - - ExternalLinter::new(rust_load_plugin, rust_lint_file) + let rust_create_workspace = wrap_create_workspace(create_workspace); + let rust_destroy_workspace = wrap_destroy_workspace(destroy_workspace); + + ExternalLinter::new( + rust_load_plugin, + rust_lint_file, + rust_create_workspace, + rust_destroy_workspace, + ) } /// Wrap `loadPlugin` JS callback as a normal Rust function. @@ -36,12 +45,12 @@ pub fn create_external_linter( /// /// The returned function will panic if called outside of a Tokio runtime. fn wrap_load_plugin(cb: JsLoadPluginCb) -> ExternalLinterLoadPluginCb { - Box::new(move |plugin_url, package_name| { + Box::new(move |workspace_dir, plugin_url, package_name| { let cb = &cb; tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { let result = cb - .call_async(FnArgs::from((plugin_url, package_name))) + .call_async(FnArgs::from((workspace_dir, plugin_url, package_name))) .await? .into_future() .await?; @@ -59,6 +68,33 @@ pub enum LintFileReturnValue { Failure(String), } +/// Wrap `createWorkspace` JS callback as a normal Rust function. +/// +/// The JS-side function is async. The returned Rust function blocks the current thread +/// until the `Promise` returned by the JS function resolves. +/// +/// The returned function will panic if called outside of a Tokio runtime. +fn wrap_create_workspace(cb: JsCreateWorkspaceCb) -> oxc_linter::ExternalLinterCreateWorkspaceCb { + Box::new(move |workspace_dir| { + let cb = &cb; + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + cb.call_async(FnArgs::from((workspace_dir,))).await?.into_future().await?; + Ok(()) + }) + }) + }) +} + +/// Wrap `destroyWorkspace` JS callback as a normal Rust function. +fn wrap_destroy_workspace( + cb: JsDestroyWorkspaceCb, +) -> oxc_linter::ExternalLinterDestroyWorkspaceCb { + Box::new(move |root_dir: String| { + let _ = cb.call(FnArgs::from((root_dir,)), ThreadsafeFunctionCallMode::Blocking); + }) +} + /// Wrap `lintFile` JS callback as a normal Rust function. /// /// The returned function creates a `Uint8Array` referencing the memory of the given `Allocator`, @@ -70,7 +106,8 @@ pub enum LintFileReturnValue { /// completes execution. fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { Box::new( - move |file_path: String, + move |workspace_dir: String, + file_path: String, rule_ids: Vec, settings_json: String, allocator: &Allocator| { @@ -87,7 +124,14 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { // Send data to JS let status = cb.call_with_return_value( - FnArgs::from((file_path, buffer_id, buffer, rule_ids, settings_json)), + FnArgs::from(( + workspace_dir, + file_path, + buffer_id, + buffer, + rule_ids, + settings_json, + )), ThreadsafeFunctionCallMode::NonBlocking, move |result, _env| { let _ = match &result { diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 07e13aa61e1ee..1e7c9e05a50fe 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -65,6 +65,10 @@ impl CliRunner { let external_linter = self.external_linter.as_ref(); + if let Some(extern_linter) = external_linter { + let _ = (extern_linter.create_workspace)(self.cwd.to_string_lossy().to_string()); + } + let mut paths = paths; let provided_path_count = paths.len(); let now = Instant::now(); @@ -181,6 +185,7 @@ impl CliRunner { stdout, &handler, &filters, + &self.cwd, &paths, external_linter, &mut external_plugin_store, @@ -212,6 +217,7 @@ impl CliRunner { let config_builder = match ConfigStoreBuilder::from_oxlintrc( false, oxlintrc, + &self.cwd, external_linter, &mut external_plugin_store, ) { @@ -275,7 +281,8 @@ impl CliRunner { // the same functionality. let use_cross_module = config_builder.plugins().has_import() || nested_configs.values().any(|config| config.plugins().has_import()); - let mut options = LintServiceOptions::new(self.cwd).with_cross_module(use_cross_module); + let mut options = + LintServiceOptions::new(self.cwd.clone()).with_cross_module(use_cross_module); let lint_config = match config_builder.build(&external_plugin_store) { Ok(config) => config, @@ -335,9 +342,14 @@ impl CliRunner { .collect::>>(); let has_external_linter = external_linter.is_some(); - let linter = Linter::new(LintOptions::default(), config_store, external_linter) - .with_fix(fix_options.fix_kind()) - .with_report_unused_directives(report_unused_directives); + let linter = Linter::new( + self.cwd.to_string_lossy().to_string(), + LintOptions::default(), + config_store, + external_linter, + ) + .with_fix(fix_options.fix_kind()) + .with_report_unused_directives(report_unused_directives); let number_of_files = files_to_lint.len(); @@ -517,6 +529,7 @@ impl CliRunner { stdout: &mut dyn Write, handler: &GraphicalReportHandler, filters: &Vec, + cwd: &Path, paths: &Vec>, external_linter: Option<&ExternalLinter>, external_plugin_store: &mut ExternalPluginStore, @@ -569,6 +582,7 @@ impl CliRunner { let builder = match ConfigStoreBuilder::from_oxlintrc( false, oxlintrc, + cwd, external_linter, external_plugin_store, ) { diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index f77b974545128..47881d406f59c 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -20,11 +20,11 @@ use crate::{ #[napi] pub type JsLoadPluginCb = ThreadsafeFunction< // Arguments - FnArgs<(String, Option)>, // Absolute path of plugin file, optional package name + FnArgs<(String, String, Option)>, // workspace directory, absolute path of plugin file, optional package name // Return value Promise, // `PluginLoadResult`, serialized to JSON // Arguments (repeated) - FnArgs<(String, Option)>, + FnArgs<(String, String, Option)>, // Error status Status, // CalleeHandled @@ -36,6 +36,7 @@ pub type JsLoadPluginCb = ThreadsafeFunction< pub type JsLintFileCb = ThreadsafeFunction< // Arguments FnArgs<( + String, // Workspace directory String, // Absolute path of file to lint u32, // Buffer ID Option, // Buffer (optional) @@ -45,7 +46,37 @@ pub type JsLintFileCb = ThreadsafeFunction< // Return value String, // `Vec`, serialized to JSON // Arguments (repeated) - FnArgs<(String, u32, Option, Vec, String)>, + FnArgs<(String, String, u32, Option, Vec, String)>, + // Error status + Status, + // CalleeHandled + false, +>; + +/// JS callback to create a workspace. +#[napi] +pub type JsCreateWorkspaceCb = ThreadsafeFunction< + // Arguments + FnArgs<(String,)>, // Workspace directory + // Return value + Promise<()>, + // Arguments (repeated) + FnArgs<(String,)>, + // Error status + Status, + // CalleeHandled + false, +>; + +/// JS callback to destroy a workspace. +#[napi] +pub type JsDestroyWorkspaceCb = ThreadsafeFunction< + // Arguments + FnArgs<(String,)>, // Workspace directory + // Return value + (), + // Arguments (repeated) + FnArgs<(String,)>, // Error status Status, // CalleeHandled @@ -58,13 +89,22 @@ pub type JsLintFileCb = ThreadsafeFunction< /// 1. `args`: Command line arguments (process.argv.slice(2)) /// 2. `load_plugin`: Load a JS plugin from a file path. /// 3. `lint_file`: Lint a file. +/// 4. `create_workspace`: Create a new workspace. +/// 5. `destroy_workspace`: Destroy a workspace. /// /// Returns `true` if linting succeeded without errors, `false` otherwise. #[expect(clippy::allow_attributes)] #[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758 #[napi] -pub async fn lint(args: Vec, load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb) -> bool { - lint_impl(args, load_plugin, lint_file).await.report() == ExitCode::SUCCESS +pub async fn lint( + args: Vec, + load_plugin: JsLoadPluginCb, + lint_file: JsLintFileCb, + create_workspace: JsCreateWorkspaceCb, + destroy_workspace: JsDestroyWorkspaceCb, +) -> bool { + lint_impl(args, load_plugin, lint_file, create_workspace, destroy_workspace).await.report() + == ExitCode::SUCCESS } /// Run the linter. @@ -72,6 +112,8 @@ async fn lint_impl( args: Vec, load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb, + create_workspace: JsCreateWorkspaceCb, + destroy_workspace: JsDestroyWorkspaceCb, ) -> CliRunResult { // Convert String args to OsString for compatibility with bpaf let args: Vec = args.into_iter().map(std::ffi::OsString::from).collect(); @@ -104,10 +146,15 @@ async fn lint_impl( // JS plugins are only supported on 64-bit little-endian platforms at present #[cfg(all(target_pointer_width = "64", target_endian = "little"))] - let external_linter = Some(super::js_plugins::create_external_linter(load_plugin, lint_file)); + let external_linter = Some(super::js_plugins::create_external_linter( + load_plugin, + lint_file, + create_workspace, + destroy_workspace, + )); #[cfg(not(all(target_pointer_width = "64", target_endian = "little")))] let external_linter = { - let (_, _) = (load_plugin, lint_file); + let (_, _, _, _) = (load_plugin, lint_file, create_workspace, destroy_workspace); None }; diff --git a/crates/oxc_language_server/src/linter/isolated_lint_handler.rs b/crates/oxc_language_server/src/linter/isolated_lint_handler.rs index 0a1f91515ef6a..6f7b4a884a17b 100644 --- a/crates/oxc_language_server/src/linter/isolated_lint_handler.rs +++ b/crates/oxc_language_server/src/linter/isolated_lint_handler.rs @@ -65,13 +65,15 @@ impl RuntimeFileSystem for IsolatedLintHandlerFileSystem { impl IsolatedLintHandler { pub fn new( + cwd: &Path, lint_options: LintOptions, config_store: ConfigStore, options: &IsolatedLintHandlerOptions, ) -> Self { let config_store_clone = config_store.clone(); + let cwd = cwd.to_string_lossy().to_string(); - let linter = Linter::new(lint_options, config_store, None); + let linter = Linter::new(cwd.clone(), lint_options, config_store, None); let mut lint_service_options = LintServiceOptions::new(options.root_path.clone()) .with_cross_module(options.use_cross_module); @@ -90,7 +92,7 @@ impl IsolatedLintHandler { Ok(runner) => runner, Err(e) => { warn!("Failed to initialize type-aware linting: {e}"); - let linter = Linter::new(lint_options, config_store_clone, None); + let linter = Linter::new(cwd, lint_options, config_store_clone, None); LintRunnerBuilder::new(lint_service_options, linter) .with_type_aware(false) .with_fix_kind(options.fix_kind) diff --git a/crates/oxc_language_server/src/linter/server_linter.rs b/crates/oxc_language_server/src/linter/server_linter.rs index 6f0628bc3a02f..c32a0f375b710 100644 --- a/crates/oxc_language_server/src/linter/server_linter.rs +++ b/crates/oxc_language_server/src/linter/server_linter.rs @@ -77,9 +77,14 @@ impl ServerLinterBuilder { let base_patterns = oxlintrc.ignore_patterns.clone(); let mut external_plugin_store = ExternalPluginStore::new(false); - let config_builder = - ConfigStoreBuilder::from_oxlintrc(false, oxlintrc, None, &mut external_plugin_store) - .unwrap_or_default(); + let config_builder = ConfigStoreBuilder::from_oxlintrc( + false, + oxlintrc, + &root_path, + None, + &mut external_plugin_store, + ) + .unwrap_or_default(); // TODO(refactor): pull this into a shared function, because in oxlint we have the same functionality. let use_nested_config = options.use_nested_configs(); @@ -119,6 +124,7 @@ impl ServerLinterBuilder { ); let isolated_linter = IsolatedLintHandler::new( + &root_path, lint_options, config_store, &IsolatedLintHandlerOptions { @@ -242,6 +248,7 @@ impl ServerLinterBuilder { let Ok(config_store_builder) = ConfigStoreBuilder::from_oxlintrc( false, oxlintrc, + root_path, None, &mut external_plugin_store, ) else { diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 88200ebacbcfc..048e90603d0e6 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -97,6 +97,7 @@ impl ConfigStoreBuilder { pub fn from_oxlintrc( start_empty: bool, oxlintrc: Oxlintrc, + cwd: &Path, external_linter: Option<&ExternalLinter>, external_plugin_store: &mut ExternalPluginStore, ) -> Result { @@ -180,6 +181,7 @@ impl ConfigStoreBuilder { for (config_path, specifier) in &external_plugins { Self::load_external_plugin( + cwd, config_path, specifier, external_linter, @@ -508,6 +510,7 @@ impl ConfigStoreBuilder { } fn load_external_plugin( + cwd: &Path, resolve_dir: &Path, plugin_specifier: &str, external_linter: &ExternalLinter, @@ -544,11 +547,14 @@ impl ConfigStoreBuilder { // Note: `unwrap()` here is infallible as `plugin_path` is an absolute path. let plugin_url = Url::from_file_path(&plugin_path).unwrap().as_str().to_string(); - let result = (external_linter.load_plugin)(plugin_url, package_name).map_err(|e| { - ConfigBuilderError::PluginLoadFailed { - plugin_specifier: plugin_specifier.to_string(), - error: e.to_string(), - } + let result = (external_linter.load_plugin)( + cwd.to_string_lossy().to_string(), + plugin_url, + package_name, + ) + .map_err(|e| ConfigBuilderError::PluginLoadFailed { + plugin_specifier: plugin_specifier.to_string(), + error: e.to_string(), })?; match result { @@ -932,8 +938,14 @@ mod test { .unwrap(); let builder = { let mut external_plugin_store = ExternalPluginStore::default(); - ConfigStoreBuilder::from_oxlintrc(false, oxlintrc, None, &mut external_plugin_store) - .unwrap() + ConfigStoreBuilder::from_oxlintrc( + false, + oxlintrc, + Path::new("/root"), + None, + &mut external_plugin_store, + ) + .unwrap() }; for (rule, severity) in &builder.rules { let name = rule.name(); @@ -1111,6 +1123,7 @@ mod test { "fixtures/extends_config/extends_invalid_config.json", )) .unwrap(), + Path::new("/root"), None, &mut external_plugin_store, ) @@ -1257,6 +1270,7 @@ mod test { let builder = ConfigStoreBuilder::from_oxlintrc( false, // start_empty = false to get default rules current_oxlintrc, + Path::new("/root"), None, &mut external_plugin_store, ) @@ -1284,6 +1298,7 @@ mod test { ConfigStoreBuilder::from_oxlintrc( true, Oxlintrc::from_file(&PathBuf::from(path)).unwrap(), + Path::new("/root"), None, &mut external_plugin_store, ) @@ -1297,6 +1312,7 @@ mod test { ConfigStoreBuilder::from_oxlintrc( true, serde_json::from_str(s).unwrap(), + Path::new("/root"), None, &mut external_plugin_store, ) diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index d9f3a213444fe..1f4adaad53959 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -5,17 +5,22 @@ use serde::Deserialize; use oxc_allocator::Allocator; pub type ExternalLinterLoadPluginCb = Box< - dyn Fn(String, Option) -> Result> + dyn Fn(String, String, Option) -> Result> + Send + Sync, >; pub type ExternalLinterLintFileCb = Box< - dyn Fn(String, Vec, String, &Allocator) -> Result, String> + dyn Fn(String, String, Vec, String, &Allocator) -> Result, String> + Sync + Send, >; +pub type ExternalLinterCreateWorkspaceCb = + Box Result<(), Box> + Send + Sync>; + +pub type ExternalLinterDestroyWorkspaceCb = Box; + #[derive(Clone, Debug, Deserialize)] pub enum PluginLoadResult { #[serde(rename_all = "camelCase")] @@ -47,14 +52,19 @@ pub struct JsFix { pub struct ExternalLinter { pub(crate) load_plugin: ExternalLinterLoadPluginCb, pub(crate) lint_file: ExternalLinterLintFileCb, + pub create_workspace: ExternalLinterCreateWorkspaceCb, + #[expect(dead_code)] + destroy_workspace: ExternalLinterDestroyWorkspaceCb, } impl ExternalLinter { pub fn new( load_plugin: ExternalLinterLoadPluginCb, lint_file: ExternalLinterLintFileCb, + create_workspace: ExternalLinterCreateWorkspaceCb, + destroy_workspace: ExternalLinterDestroyWorkspaceCb, ) -> Self { - Self { load_plugin, lint_file } + Self { load_plugin, lint_file, create_workspace, destroy_workspace } } } diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index fe1900dd6068e..aa9e2c775da8e 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -60,8 +60,9 @@ pub use crate::{ }, context::{ContextSubHost, LintContext}, external_linter::{ - ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, JsFix, - LintFileResult, PluginLoadResult, + ExternalLinter, ExternalLinterCreateWorkspaceCb, ExternalLinterDestroyWorkspaceCb, + ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, JsFix, LintFileResult, + PluginLoadResult, }, external_plugin_store::{ExternalPluginStore, ExternalRuleId}, fixer::{Fix, FixKind, Message, PossibleFixes}, @@ -97,6 +98,7 @@ fn size_asserts() { #[derive(Debug)] #[expect(clippy::struct_field_names)] pub struct Linter { + cwd: String, options: LintOptions, config: ConfigStore, external_linter: Option, @@ -104,11 +106,12 @@ pub struct Linter { impl Linter { pub fn new( + cwd: String, options: LintOptions, config: ConfigStore, external_linter: Option, ) -> Self { - Self { options, config, external_linter } + Self { cwd, options, config, external_linter } } /// Set the kind of auto fixes to apply. @@ -462,6 +465,7 @@ impl Linter { }; let result = (external_linter.lint_file)( + self.cwd.clone(), path.to_string_lossy().into_owned(), external_rules.iter().map(|(rule_id, _)| rule_id.raw()).collect(), settings_json, diff --git a/crates/oxc_linter/src/tester.rs b/crates/oxc_linter/src/tester.rs index 759fc521c01ed..da381b83f44f4 100644 --- a/crates/oxc_linter/src/tester.rs +++ b/crates/oxc_linter/src/tester.rs @@ -485,6 +485,7 @@ impl Tester { let rule = self.find_rule().read_json(rule_config.unwrap_or_default()); let mut external_plugin_store = ExternalPluginStore::default(); let linter = Linter::new( + "/root".to_owned(), self.lint_options, ConfigStore::new( eslint_config @@ -493,6 +494,7 @@ impl Tester { ConfigStoreBuilder::from_oxlintrc( true, Oxlintrc::deserialize(v).unwrap(), + Path::new("/root"), None, &mut external_plugin_store, ) diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index d5e1e56b98467..25b49a8ada05a 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -385,6 +385,7 @@ impl Oxc { let config_builder = ConfigStoreBuilder::from_oxlintrc( false, oxlintrc, + Path::new("/root"), // Dummy path None, &mut ExternalPluginStore::default(), ) @@ -395,6 +396,7 @@ impl Oxc { }; let lint_config = lint_config.unwrap(); let linter_ret = Linter::new( + "/root".to_string(), // Dummy root path LintOptions::default(), ConfigStore::new(lint_config, FxHashMap::default(), external_plugin_store), None, diff --git a/tasks/benchmark/benches/linter.rs b/tasks/benchmark/benches/linter.rs index bcea7bdde3af1..54143e840786f 100644 --- a/tasks/benchmark/benches/linter.rs +++ b/tasks/benchmark/benches/linter.rs @@ -41,6 +41,7 @@ fn bench_linter(criterion: &mut Criterion) { let external_plugin_store = ExternalPluginStore::default(); let lint_config = ConfigStoreBuilder::all().build(&external_plugin_store).unwrap(); let linter = Linter::new( + String::new(), LintOptions::default(), ConfigStore::new(lint_config, FxHashMap::default(), external_plugin_store), None,