diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 93921c1f0a557..92ff6e75b3501 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -1,5 +1,9 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** JS callback to clear loaded plugins. */ +export type JsClearLoadedPluginCb = + (() => void) + /** JS callback to lint a file. */ export type JsLintFileCb = ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: string) => string) @@ -15,7 +19,8 @@ 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. `clear_loaded_plugin`: Clear loaded plugin state. * * 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, clearLoadedPlugin: JsClearLoadedPluginCb): Promise diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 34450e0f6c0c9..febae3623a518 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -6,6 +6,7 @@ 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 clearLoadedPlugin: typeof clearLoadedPluginWrapper | null = null; /** * Load a plugin. @@ -21,7 +22,7 @@ function loadPluginWrapper(path: string, packageName: string | null): Promise { - ({ loadPlugin, lintFile } = mod); + ({ loadPlugin, lintFile, clearLoadedPlugin } = mod); return loadPlugin(path, packageName); }); } @@ -54,11 +55,23 @@ function lintFileWrapper( return lintFile(filePath, bufferId, buffer, ruleIds, settingsJSON); } +/** + * Clear loaded plugin state. + * + * Delegates to `clearLoadedPlugin`, which was lazy-loaded by `loadPluginWrapper`. + * If plugins haven't been loaded yet, this is a no-op. + */ +function clearLoadedPluginWrapper(): void { + if (clearLoadedPlugin !== null) { + clearLoadedPlugin(); + } +} + // 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`, and `clearLoadedPlugin` as callbacks, and CLI arguments +const success = await lint(args, loadPluginWrapper, lintFileWrapper, clearLoadedPluginWrapper); // 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/index.ts b/apps/oxlint/src-js/index.ts index 5f3817c88eea3..266bfe196332c 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -286,3 +286,19 @@ function createContextAndVisitor(rule: CreateOnceRule): { return { context, visitor, beforeHook }; } + +/** + * Clear all loaded plugins and rules. + * + * This function clears the internal state of registered plugins and rules, + * allowing plugins to be reloaded from scratch. This is useful for the + * language server when restarting or reloading configuration. + * + * Note: This function is lazy-loaded and will only be available after + * the first plugin has been loaded. It will not have any effect if no + * plugins have been loaded yet. + */ +export async function clearLoadedPlugin(): Promise { + const mod = await import("./plugins/index.js"); + mod.clearLoadedPlugin(); +} diff --git a/apps/oxlint/src-js/plugins/index.ts b/apps/oxlint/src-js/plugins/index.ts index 8ad106c118384..0bdcdd22ee3f8 100644 --- a/apps/oxlint/src-js/plugins/index.ts +++ b/apps/oxlint/src-js/plugins/index.ts @@ -1,4 +1,4 @@ import { lintFile } from "./lint.js"; -import { loadPlugin } from "./load.js"; +import { clearLoadedPlugin, loadPlugin } from "./load.js"; -export { lintFile, loadPlugin }; +export { clearLoadedPlugin, lintFile, loadPlugin }; diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index 6d522d4ae1d3c..506e223b91093 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -277,3 +277,15 @@ function conformHookFn(hookFn: H | null | undefined, hookName: string): H | n throw new TypeError(`\`${hookName}\` hook must be a function if provided`); return hookFn; } + +/** + * Clear all loaded plugins and rules. + * + * This function clears the internal state of registered plugins and rules, + * allowing plugins to be reloaded from scratch. This is useful for the + * language server when restarting or reloading configuration. + */ +export function clearLoadedPlugin(): void { + registeredPluginPaths.clear(); + registeredRules.length = 0; +} diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 670552ee3ad81..13555a4db8cc6 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -15,18 +15,20 @@ use oxc_linter::{ use crate::{ generated::raw_transfer_constants::{BLOCK_ALIGN, BUFFER_SIZE}, - run::{JsLintFileCb, JsLoadPluginCb}, + run::{JsClearLoadedPluginCb, JsLintFileCb, JsLoadPluginCb}, }; /// Wrap JS callbacks as normal Rust functions, and create [`ExternalLinter`]. pub fn create_external_linter( load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb, + clear_loaded_plugin: JsClearLoadedPluginCb, ) -> ExternalLinter { let rust_load_plugin = wrap_load_plugin(load_plugin); let rust_lint_file = wrap_lint_file(lint_file); + let rust_clear_loaded_plugin = wrap_clear_loaded_plugin(clear_loaded_plugin); - ExternalLinter::new(rust_load_plugin, rust_lint_file) + ExternalLinter::new(rust_load_plugin, rust_lint_file, rust_clear_loaded_plugin) } /// Wrap `loadPlugin` JS callback as a normal Rust function. @@ -59,6 +61,18 @@ pub enum LintFileReturnValue { Failure(String), } +/// Wrap `clearLoadedPlugin` JS callback as a normal Rust function. +/// +/// This function is called when the `ExternalLinter` is dropped to clear loaded plugin state. +fn wrap_clear_loaded_plugin( + cb: JsClearLoadedPluginCb, +) -> oxc_linter::ExternalLinterClearLoadedPluginCb { + Box::new(move || { + // Call the JavaScript callback to clear loaded plugin state + let _ = cb.call((), ThreadsafeFunctionCallMode::Blocking); + }) +} + /// Wrap `lintFile` JS callback as a normal Rust function. /// /// The returned function creates a `Uint8Array` referencing the memory of the given `Allocator`, diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index f77b974545128..187f6f027b31c 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -52,19 +52,40 @@ pub type JsLintFileCb = ThreadsafeFunction< false, >; +/// JS callback to clear loaded plugins. +#[napi] +pub type JsClearLoadedPluginCb = ThreadsafeFunction< + // Arguments + (), + // Return value + (), + // Arguments (repeated) + (), + // Error status + Status, + // CalleeHandled + false, +>; + /// NAPI entry point. /// /// JS side passes in: /// 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. `clear_loaded_plugin`: Clear loaded plugin state. /// /// 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, + clear_loaded_plugin: JsClearLoadedPluginCb, +) -> bool { + lint_impl(args, load_plugin, lint_file, clear_loaded_plugin).await.report() == ExitCode::SUCCESS } /// Run the linter. @@ -72,6 +93,7 @@ async fn lint_impl( args: Vec, load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb, + clear_loaded_plugin: JsClearLoadedPluginCb, ) -> CliRunResult { // Convert String args to OsString for compatibility with bpaf let args: Vec = args.into_iter().map(std::ffi::OsString::from).collect(); @@ -104,10 +126,14 @@ 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, + clear_loaded_plugin, + )); #[cfg(not(all(target_pointer_width = "64", target_endian = "little")))] let external_linter = { - let (_, _) = (load_plugin, lint_file); + let (_, _, _) = (load_plugin, lint_file, clear_loaded_plugin); None }; diff --git a/apps/oxlint/test/clear-loaded-plugin.test.ts b/apps/oxlint/test/clear-loaded-plugin.test.ts new file mode 100644 index 0000000000000..e9651e4faeb98 --- /dev/null +++ b/apps/oxlint/test/clear-loaded-plugin.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { clearLoadedPlugin } from "../dist/index.js"; + +/** + * Test the clearLoadedPlugin function + */ +describe("clearLoadedPlugin", () => { + it("should be exported from the main module", () => { + expect(clearLoadedPlugin).toBeDefined(); + expect(typeof clearLoadedPlugin).toBe("function"); + }); + + it("should execute without errors", async () => { + // Call the clear function - it should not throw + await expect(clearLoadedPlugin()).resolves.toBeUndefined(); + }); + + it("should work when called multiple times", async () => { + // Call it multiple times to ensure it doesn't error + await expect(clearLoadedPlugin()).resolves.toBeUndefined(); + await expect(clearLoadedPlugin()).resolves.toBeUndefined(); + await expect(clearLoadedPlugin()).resolves.toBeUndefined(); + }); +}); diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index d9f3a213444fe..7042ca7dd8b27 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -16,6 +16,8 @@ pub type ExternalLinterLintFileCb = Box< + Send, >; +pub type ExternalLinterClearLoadedPluginCb = Box; + #[derive(Clone, Debug, Deserialize)] pub enum PluginLoadResult { #[serde(rename_all = "camelCase")] @@ -47,14 +49,23 @@ pub struct JsFix { pub struct ExternalLinter { pub(crate) load_plugin: ExternalLinterLoadPluginCb, pub(crate) lint_file: ExternalLinterLintFileCb, + clear_loaded_plugin: ExternalLinterClearLoadedPluginCb, } impl ExternalLinter { pub fn new( load_plugin: ExternalLinterLoadPluginCb, lint_file: ExternalLinterLintFileCb, + clear_loaded_plugin: ExternalLinterClearLoadedPluginCb, ) -> Self { - Self { load_plugin, lint_file } + Self { load_plugin, lint_file, clear_loaded_plugin } + } +} + +impl Drop for ExternalLinter { + fn drop(&mut self) { + // Clear loaded plugin state when the linter is destroyed + (self.clear_loaded_plugin)(); } } diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index 3bd5efe74b653..0fd1feba063c1 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -60,8 +60,8 @@ pub use crate::{ }, context::{ContextSubHost, LintContext}, external_linter::{ - ExternalLinter, ExternalLinterLintFileCb, ExternalLinterLoadPluginCb, JsFix, - LintFileResult, PluginLoadResult, + ExternalLinter, ExternalLinterClearLoadedPluginCb, ExternalLinterLintFileCb, + ExternalLinterLoadPluginCb, JsFix, LintFileResult, PluginLoadResult, }, external_plugin_store::{ExternalPluginStore, ExternalRuleId}, fixer::{Fix, FixKind, Message, PossibleFixes},