diff --git a/apps/oxlint/src-js/bindings.d.ts b/apps/oxlint/src-js/bindings.d.ts index 3bdce220b02bd..b6b81bdfc52ef 100644 --- a/apps/oxlint/src-js/bindings.d.ts +++ b/apps/oxlint/src-js/bindings.d.ts @@ -44,7 +44,7 @@ export type JsDestroyWorkspaceCb = /** JS callback to lint a file. */ export type JsLintFileCb = - ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: Array, arg5: string, arg6: string) => string | null) + ((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array, arg4: Array, arg5: string, arg6: string, arg7?: string | undefined | null) => string | null) /** JS callback to load JavaScript config files. */ export type JsLoadJsConfigsCb = @@ -52,7 +52,7 @@ export type JsLoadJsConfigsCb = /** JS callback to load a JS plugin. */ export type JsLoadPluginCb = - ((arg0: string, arg1: string | undefined | null, arg2: boolean) => Promise) + ((arg0: string, arg1: string | undefined | null, arg2: boolean, arg3?: string | undefined | null) => Promise) /** JS callback to setup configs. */ export type JsSetupRuleConfigsCb = diff --git a/apps/oxlint/src-js/cli.ts b/apps/oxlint/src-js/cli.ts index 2e1d6ebba5021..1cb6c95cdc6c5 100644 --- a/apps/oxlint/src-js/cli.ts +++ b/apps/oxlint/src-js/cli.ts @@ -20,23 +20,25 @@ let loadJsConfigs: typeof import("./js_config.ts").loadJsConfigs | null = null; * @param path - Absolute path of plugin file * @param pluginName - Plugin name (either alias or package name) * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) + * @param workspaceUri - Workspace URI (`null` in CLI mode, `string` in LSP mode) * @returns Plugin details or error serialized to JSON string */ function loadPluginWrapper( path: string, pluginName: string | null, pluginNameIsAlias: boolean, + workspaceUri: 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.ts").then((mod) => { ({ loadPlugin, lintFile, setupRuleConfigs } = mod); - return loadPlugin(path, pluginName, pluginNameIsAlias); + return loadPlugin(path, pluginName, pluginNameIsAlias, workspaceUri); }); } debugAssertIsNonNull(loadPlugin); - return loadPlugin(path, pluginName, pluginNameIsAlias); + return loadPlugin(path, pluginName, pluginNameIsAlias, workspaceUri); } /** @@ -64,6 +66,7 @@ function setupRuleConfigsWrapper(optionsJSON: string): string | null { * @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds` * @param settingsJSON - Settings for file, as JSON * @param globalsJSON - Globals for file, as JSON + * @param workspaceUri - Workspace URI (`null` in CLI mode, `string` in LSP mode) * @returns Diagnostics or error serialized to JSON string */ function lintFileWrapper( @@ -74,11 +77,21 @@ function lintFileWrapper( optionsIds: number[], settingsJSON: string, globalsJSON: string, + workspaceUri: string | null, ): string | null { // `lintFileWrapper` is never called without `loadPluginWrapper` being called first, // so `lintFile` must be defined here debugAssertIsNonNull(lintFile); - return lintFile(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON, globalsJSON); + return lintFile( + filePath, + bufferId, + buffer, + ruleIds, + optionsIds, + settingsJSON, + globalsJSON, + workspaceUri, + ); } /** diff --git a/apps/oxlint/src-js/package/rule_tester.ts b/apps/oxlint/src-js/package/rule_tester.ts index 06d3fbe3120c4..62f83cfe0ec7f 100644 --- a/apps/oxlint/src-js/package/rule_tester.ts +++ b/apps/oxlint/src-js/package/rule_tester.ts @@ -344,6 +344,11 @@ interface Diagnostic { // Default path (without extension) for test cases if not provided const DEFAULT_FILENAME_BASE = "file"; +// Dummy workspace URI. +// This just needs to be unique, and we only have a single workspace in existence at any time. +// It can be anything, so just use empty string. +const WORKSPACE_URI = ""; + // ------------------------------------------------------------------------------ // `RuleTester` class // ------------------------------------------------------------------------------ @@ -999,11 +1004,11 @@ function lint(test: TestCase, plugin: Plugin): Diagnostic[] { path = pathJoin(cwd, filename); } - createWorkspace(cwd); + createWorkspace(WORKSPACE_URI); try { // Register plugin. This adds rule to `registeredRules` array. - registerPlugin(path, plugin, null, false); + registerPlugin(plugin, null, false, null); // Set up options const optionsId = setupOptions(test, cwd); @@ -1021,7 +1026,7 @@ function lint(test: TestCase, plugin: Plugin): Diagnostic[] { // Lint file. // Buffer is stored already, at index 0. No need to pass it. - lintFileImpl(path, 0, null, [0], [optionsId], settingsJSON, globalsJSON); + lintFileImpl(path, 0, null, [0], [optionsId], settingsJSON, globalsJSON, null); // Return diagnostics const ruleId = `${plugin.meta!.name!}/${Object.keys(plugin.rules)[0]}`; @@ -1061,7 +1066,7 @@ function lint(test: TestCase, plugin: Plugin): Diagnostic[] { }); } finally { // Reset state - destroyWorkspace(cwd); + destroyWorkspace(WORKSPACE_URI); // Even if there hasn't been an error, do a full reset of state just to be sure. // This includes emptying `diagnostics`. @@ -1237,6 +1242,7 @@ function setupOptions(test: TestCase, cwd: string): number { options: allOptions, ruleIds: allRuleIds, cwd, + workspaceUri: null, }); } catch (err) { throw new Error(`Failed to serialize options: ${err}`); diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index e00fe9721a3f1..55a7053f7f72d 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -1,7 +1,7 @@ import { walkProgramWithCfg, resetCfgWalk } from "./cfg.ts"; import { setupFileContext, resetFileContext } from "./context.ts"; import { registeredRules } from "./load.ts"; -import { allOptions, DEFAULT_OPTIONS_ID } from "./options.ts"; +import { allOptions, cwds, DEFAULT_OPTIONS_ID } from "./options.ts"; import { diagnostics } from "./report.ts"; import { setSettingsForFile, resetSettings } from "./settings.ts"; import { ast, initAst, resetSourceAndAst, setupSourceForFile } from "./source_code.ts"; @@ -9,7 +9,7 @@ import { HAS_BOM_FLAG_POS } from "../generated/constants.ts"; import { typeAssertIs, debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts"; import { getErrorMessage } from "../utils/utils.ts"; import { setGlobalsForFile, resetGlobals } from "./globals.ts"; -import { getResponsibleWorkspace } from "../workspace/index.ts"; +import { getCliWorkspace } from "../workspace/index.ts"; import { addVisitorToCompiled, compiledVisitor, @@ -51,6 +51,7 @@ const afterHooks: AfterHook[] = []; * @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds` * @param settingsJSON - Settings for this file, as JSON string * @param globalsJSON - Globals for this file, as JSON string + * @param workspaceUri - Workspace URI (`null` in CLI, string in LSP) * @returns Diagnostics or error serialized to JSON string */ export function lintFile( @@ -61,9 +62,19 @@ export function lintFile( optionsIds: number[], settingsJSON: string, globalsJSON: string, + workspaceUri: string | null, ): string | null { try { - lintFileImpl(filePath, bufferId, buffer, ruleIds, optionsIds, settingsJSON, globalsJSON); + lintFileImpl( + filePath, + bufferId, + buffer, + ruleIds, + optionsIds, + settingsJSON, + globalsJSON, + workspaceUri, + ); let ret: string | null = null; @@ -97,6 +108,7 @@ export function lintFile( * @param optionsIds - IDs of options to use for rules on this file, in same order as `ruleIds` * @param settingsJSON - Settings for this file, as JSON string * @param globalsJSON - Globals for this file, as JSON string + * @param workspaceUri - Workspace URI (`null` in CLI, string in LSP) * @throws {Error} If any parameters are invalid * @throws {*} If any rule throws */ @@ -108,6 +120,7 @@ export function lintFileImpl( optionsIds: number[], settingsJSON: string, globalsJSON: string, + workspaceUri: string | null, ) { // If new buffer, add it to `buffers` array. Otherwise, get existing buffer from array. // Do this before checks below, to make sure buffer doesn't get garbage collected when not expected @@ -141,16 +154,20 @@ export function lintFileImpl( "`ruleIds` and `optionsIds` should be same length", ); - // Get workspace containing file - const workspace = getResponsibleWorkspace(filePath); - debugAssertIsNonNull(workspace, "No workspace responsible for file being linted"); + // Get workspace containing file. + // In CLI (`workspaceUri` is `null`), use the single workspace (the CWD passed to `setupRuleConfigs`). + // In LSP, use the provided workspace URI. + if (workspaceUri === null) workspaceUri = getCliWorkspace(); + + const cwd = cwds.get(workspaceUri); + debugAssertIsNonNull(cwd, `No CWD registered for workspace "${workspaceUri}"`); // Pass file path and CWD to context module, so `Context`s know what file is being linted - setupFileContext(filePath, workspace); + setupFileContext(filePath, cwd); // Load all relevant workspace configurations - const rules = registeredRules.get(workspace)!; - const workspaceOptions = allOptions.get(workspace); + const rules = registeredRules.get(workspaceUri)!; + const workspaceOptions = allOptions.get(workspaceUri); debugAssertIsNonNull(rules, "No rules registered for workspace"); debugAssertIsNonNull(workspaceOptions, "No options registered for workspace"); @@ -182,7 +199,6 @@ export function lintFileImpl( // Set `options` for rule const optionsId = optionsIds[i]; - debugAssertIsNonNull(workspaceOptions); debugAssert(optionsId < workspaceOptions.length, "Options ID out of bounds"); // If the rule has no user-provided options, use the plugin-provided default diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index a68ab2fefabc3..d6646c59f37cf 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -1,4 +1,4 @@ -import { getResponsibleWorkspace, isWorkspaceResponsible } from "../workspace/index.ts"; +import { getCliWorkspace } from "../workspace/index.ts"; import { createContext } from "./context.ts"; import { deepFreezeJsonArray } from "./json.ts"; import { compileSchema, DEFAULT_OPTIONS } from "./options.ts"; @@ -81,9 +81,6 @@ interface CreateOnceRuleDetails extends RuleDetailsBase { readonly afterHook: AfterHook | null; } -// Absolute paths of plugins which have been loaded -export const registeredPluginUrls = new Set(); - // Rule objects for loaded rules. // Indexed by `ruleId`, which is passed to `lintFile`. export const registeredRules: Map = new Map(); @@ -109,21 +106,18 @@ interface PluginDetails { * @param url - Absolute path of plugin file as a `file://...` URL * @param pluginName - Plugin name (either alias or package name) * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) + * @param workspaceUri - Workspace URI (`null` in CLI, string in LSP) * @returns Plugin details or error serialized to JSON string */ export async function loadPlugin( url: string, pluginName: string | null, pluginNameIsAlias: boolean, + workspaceUri: string | null, ): Promise { try { - if (DEBUG) { - if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered"); - registeredPluginUrls.add(url); - } - const plugin = (await import(url)).default as Plugin; - const res = registerPlugin(url, plugin, pluginName, pluginNameIsAlias); + const res = registerPlugin(plugin, pluginName, pluginNameIsAlias, workspaceUri); return JSON.stringify({ Success: res }); } catch (err) { return JSON.stringify({ Failure: getErrorMessage(err) }); @@ -133,29 +127,31 @@ export async function loadPlugin( /** * Register a plugin. * - * @param url - Plugin URL * @param plugin - Plugin * @param pluginName - Plugin name (either alias or package name) * @param pluginNameIsAlias - `true` if plugin name is an alias (takes priority over name that plugin defines itself) + * @param workspaceUri - Workspace URI (`null` in CLI, string in LSP) * @returns - Plugin details * @throws {Error} If `plugin.meta.name` is `null` / `undefined` and `packageName` not provided * @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 */ export function registerPlugin( - url: string, plugin: Plugin, pluginName: string | null, pluginNameIsAlias: boolean, + workspaceUri: string | null, ): PluginDetails { // TODO: Use a validation library to assert the shape of the plugin, and of rules pluginName = getPluginName(plugin, pluginName, pluginNameIsAlias); - const workspace = getResponsibleWorkspace(url.replace(/^file:\/\//, "")); - debugAssertIsNonNull(workspace, "Plugin url must belong to a workspace"); - debugAssert(registeredRules.has(workspace), "Workspace must have registered rules array"); - const registeredRulesForWorkspace = registeredRules.get(workspace)!; + // In CLI mode (`workspaceUri` is `null`), use the CWD from `setupRuleConfigs` (stored as CLI_WORKSPACE). + // In LSP mode, use the provided workspace URI. + if (workspaceUri === null) workspaceUri = getCliWorkspace(); + + const registeredRulesForWorkspace = registeredRules.get(workspaceUri); + debugAssertIsNonNull(registeredRulesForWorkspace, "Workspace must have registered rules array"); const offset = registeredRulesForWorkspace.length ?? 0; const { rules } = plugin; @@ -449,10 +445,5 @@ export function setupPluginSystemForWorkspace(workspace: WorkspaceIdentifier) { * Remove all plugins and rules associated with a workspace. */ export function removePluginsInWorkspace(workspace: WorkspaceIdentifier) { - for (const url of registeredPluginUrls) { - if (isWorkspaceResponsible(workspace, url.replace(/^file:\/\//, ""))) { - registeredPluginUrls.delete(url); - } - } registeredRules.delete(workspace); } diff --git a/apps/oxlint/src-js/plugins/options.ts b/apps/oxlint/src-js/plugins/options.ts index da643f1db0e31..881a35fd77652 100644 --- a/apps/oxlint/src-js/plugins/options.ts +++ b/apps/oxlint/src-js/plugins/options.ts @@ -8,8 +8,8 @@ import ajvPackageJson from "ajv/package.json" with { type: "json" }; import metaSchema from "ajv/lib/refs/json-schema-draft-04.json" with { type: "json" }; import { registeredRules } from "./load.ts"; import { deepCloneJsonValue, deepFreezeJsonArray } from "./json.ts"; -import { debugAssert } from "../utils/asserts.ts"; -import { getWorkspace, WorkspaceIdentifier } from "../workspace/index.ts"; +import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts"; +import { getCliWorkspace, WorkspaceIdentifier } from "../workspace/index.ts"; import type { JSONSchema4 } from "json-schema"; import type { Writable } from "type-fest"; @@ -52,6 +52,9 @@ export const DEFAULT_OPTIONS: Readonly = Object.freeze([]); // First element is irrelevant - never accessed - because 0 index is a sentinel meaning default options. export const allOptions: Map[]> = new Map(); +// Mapping from workspace URIs to CWD paths +export const cwds: Map = new Map(); + // Index into `allOptions` for default options export const DEFAULT_OPTIONS_ID = 0; @@ -182,14 +185,14 @@ function wrapSchemaValidator(validate: Ajv.ValidateFunction): SchemaValidator { export function setOptions(optionsJson: string): void { const details = JSON.parse(optionsJson); + let { workspaceUri } = details; + if (workspaceUri === null) workspaceUri = getCliWorkspace(); + const { ruleIds, cwd, options } = details; - allOptions.set(cwd, options); // Validate if (DEBUG) { assert(typeof cwd === "string", `cwd must be a string, got ${typeof cwd}`); - assert(getWorkspace(cwd) !== null, `cwd "${cwd}" is not a workspace`); - assert(registeredRules.has(cwd), `No registered rules for workspace cwd "${cwd}"`); assert(Array.isArray(options), `options must be an array, got ${typeof options}`); assert(Array.isArray(ruleIds), `ruleIds must be an array, got ${typeof ruleIds}`); assert.strictEqual( @@ -210,11 +213,21 @@ export function setOptions(optionsJson: string): void { } } + allOptions.set(workspaceUri, options); + + debugAssert(!cwds.has(workspaceUri), "Workspace must not already have registered CWD"); + cwds.set(workspaceUri, cwd); + // Process each options array. // For each options array, merge with default options and apply schema defaults for the corresponding rule. // Skip the first, as index 0 is a sentinel value meaning default options. First element is never accessed. // `processOptions` also deep-freezes the options. - const registeredWorkspaceRules = registeredRules.get(cwd)!; + const registeredWorkspaceRules = registeredRules.get(workspaceUri); + debugAssertIsNonNull( + registeredWorkspaceRules, + `No registered rules for workspace "${workspaceUri}"`, + ); + for (let i = 1, len = options.length; i < len; i++) { options[i] = processOptions( // `allOptions`' type is `Readonly`, but the array is mutable at present @@ -391,4 +404,5 @@ export function setupOptionsForWorkspace(workspace: WorkspaceIdentifier) { */ export function removeOptionsInWorkspace(workspace: WorkspaceIdentifier) { allOptions.delete(workspace); + cwds.delete(workspace); } diff --git a/apps/oxlint/src-js/workspace/index.ts b/apps/oxlint/src-js/workspace/index.ts index 74e7b7c303d08..051ea61c6e3b7 100644 --- a/apps/oxlint/src-js/workspace/index.ts +++ b/apps/oxlint/src-js/workspace/index.ts @@ -13,19 +13,19 @@ import { removeOptionsInWorkspace, setupOptionsForWorkspace } from "../plugins/o import { debugAssert } from "../utils/asserts"; /** - * Type representing a workspace identifier. - * Currently, this is just a string representing the workspace root directory. + * Type representing a workspace ID. + * Currently, this is just a string representing the workspace root directory as `file://` URL. */ export type WorkspaceIdentifier = string; /** * Type representing a workspace. - * Currently it only contains the identifier. + * Currently it only contains the workspace root directory as `file://` URL. */ export type Workspace = WorkspaceIdentifier; /** - * Set of workspace root directories. + * Set of workspace IDs. */ const workspaces = new Set(); @@ -52,31 +52,13 @@ export function destroyWorkspace(workspace: WorkspaceIdentifier): undefined { } /** - * Get a workspace by its identifier. + * Gets the CLI workspace ID. + * In CLI mode, there is exactly one workspace (the CWD), so this returns that workspace ID. */ -export function getWorkspace(workspace: WorkspaceIdentifier): Workspace | null { - return workspaces.has(workspace) ? workspace : null; -} - -/** - * Checks if a filePath is responsible for a workspace. - */ -export const isWorkspaceResponsible = (workspace: WorkspaceIdentifier, url: string): boolean => { - return getResponsibleWorkspace(url) === workspace; -}; - -/** - * Gets the workspace responsible for a given filePath. - * Returns `null` if no workspace is responsible. - * - * This function is kept in sync with Rust's `WorkspaceWorker::is_responsible_for_file` (`oxc_language_server` crate). - * Changing this function requires a corresponding change in Rust implementation. - */ -export const getResponsibleWorkspace = (filePath: string): Workspace | null => { - return ( - [...workspaces.keys()] - .filter((ws) => filePath.startsWith(ws)) - // Get the longest matching workspace path - .sort((a, b) => b.length - a.length)[0] ?? null +export function getCliWorkspace(): Workspace { + debugAssert( + workspaces.size === 1, + "getCliWorkspace should only be used in CLI mode with 1 workspace", ); -}; + return workspaces.values().next().value; +} diff --git a/apps/oxlint/src/config_loader.rs b/apps/oxlint/src/config_loader.rs index 76178079877df..0eadfb261987f 100644 --- a/apps/oxlint/src/config_loader.rs +++ b/apps/oxlint/src/config_loader.rs @@ -161,6 +161,7 @@ pub struct ConfigLoader<'a> { external_linter: Option<&'a ExternalLinter>, external_plugin_store: &'a mut ExternalPluginStore, filters: &'a [LintFilter], + workspace_uri: Option<&'a str>, } impl<'a> ConfigLoader<'a> { @@ -170,12 +171,14 @@ impl<'a> ConfigLoader<'a> { /// * `external_linter` - Optional external linter for plugin support /// * `external_plugin_store` - Store for external plugins /// * `filters` - Lint filters to apply to configs + /// * `workspace_uri` - Workspace URI - only `Some` in LSP, `None` in CLI pub fn new( external_linter: Option<&'a ExternalLinter>, external_plugin_store: &'a mut ExternalPluginStore, filters: &'a [LintFilter], + workspace_uri: Option<&'a str>, ) -> Self { - Self { external_linter, external_plugin_store, filters } + Self { external_linter, external_plugin_store, filters, workspace_uri } } /// Load a single config from a file path @@ -191,6 +194,7 @@ impl<'a> ConfigLoader<'a> { oxlintrc, self.external_linter, self.external_plugin_store, + self.workspace_uri, ) .map_err(|e| ConfigLoadError::Build { path: path.to_path_buf(), error: e.to_string() })?; diff --git a/apps/oxlint/src/js_plugins/external_linter.rs b/apps/oxlint/src/js_plugins/external_linter.rs index 591ba6adf1e0f..0bad019884af3 100644 --- a/apps/oxlint/src/js_plugins/external_linter.rs +++ b/apps/oxlint/src/js_plugins/external_linter.rs @@ -51,11 +51,11 @@ pub fn create_external_linter( /// /// The returned function will panic if called outside of a Tokio runtime. fn wrap_create_workspace(cb: JsCreateWorkspaceCb) -> oxc_linter::ExternalLinterCreateWorkspaceCb { - Arc::new(Box::new(move |workspace_dir| { + Arc::new(Box::new(move |workspace_uri| { let cb = &cb; let res = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - cb.call_async(FnArgs::from((workspace_dir,))).await?.into_future().await + cb.call_async(FnArgs::from((workspace_uri,))).await?.into_future().await }) }); @@ -72,8 +72,8 @@ fn wrap_create_workspace(cb: JsCreateWorkspaceCb) -> oxc_linter::ExternalLinterC fn wrap_destroy_workspace( cb: JsDestroyWorkspaceCb, ) -> oxc_linter::ExternalLinterDestroyWorkspaceCb { - Arc::new(Box::new(move |root_dir: String| { - let _ = cb.call(FnArgs::from((root_dir,)), ThreadsafeFunctionCallMode::Blocking); + Arc::new(Box::new(move |workspace_uri: String| { + let _ = cb.call(FnArgs::from((workspace_uri,)), ThreadsafeFunctionCallMode::Blocking); })) } @@ -91,14 +91,19 @@ pub enum LoadPluginReturnValue { /// /// The returned function will panic if called outside of a Tokio runtime. fn wrap_load_plugin(cb: JsLoadPluginCb) -> ExternalLinterLoadPluginCb { - Arc::new(Box::new(move |plugin_url, plugin_name, plugin_name_is_alias| { + Arc::new(Box::new(move |plugin_url, plugin_name, plugin_name_is_alias, workspace_uri| { let cb = &cb; let res = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - cb.call_async(FnArgs::from((plugin_url, plugin_name, plugin_name_is_alias))) - .await? - .into_future() - .await + cb.call_async(FnArgs::from(( + plugin_url, + plugin_name, + plugin_name_is_alias, + workspace_uri, + ))) + .await? + .into_future() + .await }) }); @@ -185,6 +190,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { options_ids: Vec, settings_json: String, globals_json: String, + workspace_uri: Option, allocator: &Allocator| { let (tx, rx) = channel(); @@ -206,6 +212,7 @@ fn wrap_lint_file(cb: JsLintFileCb) -> ExternalLinterLintFileCb { options_ids, settings_json, globals_json, + workspace_uri, )), ThreadsafeFunctionCallMode::NonBlocking, move |result, _env| { diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 7e041aa207c96..571be18da095b 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -192,7 +192,10 @@ impl CliRunner { // Setup JS workspace before loading any configs (config parsing can load JS plugins). if let Some(external_linter) = &external_linter { - let res = (external_linter.create_workspace)(self.cwd.to_string_lossy().into_owned()); + // Workspace URI doesn't need to be a valid URI, it just needs to be unique. + // In CLI we only have a single workspace in existence at any time, so it can be anything. + // So just use empty string. + let res = (external_linter.create_workspace)(String::new()); if let Err(err) = res { print_and_flush_stdout(stdout, &format!("Failed to setup JS workspace:\n{err}\n")); @@ -205,6 +208,7 @@ impl CliRunner { oxlintrc.clone(), external_linter, &mut external_plugin_store, + None, ) { Ok(builder) => builder, Err(e) => { @@ -294,9 +298,11 @@ impl CliRunner { // Send JS plugins config to JS side if let Some(external_linter) = &external_linter { - let res = config_store - .external_plugin_store() - .setup_rule_configs(self.cwd.to_string_lossy().into_owned(), external_linter); + let res = config_store.external_plugin_store().setup_rule_configs( + self.cwd.to_string_lossy().into_owned(), + None, + external_linter, + ); if let Err(err) = res { print_and_flush_stdout( stdout, @@ -465,7 +471,7 @@ impl CliRunner { let discovered_configs = discover_configs_in_ancestors(&config_paths); // Load all discovered configs - let mut loader = ConfigLoader::new(external_linter, external_plugin_store, filters); + let mut loader = ConfigLoader::new(external_linter, external_plugin_store, filters, None); let (configs, errors) = loader.load_many(discovered_configs); // Fail on first error (CLI requires all configs to be valid) diff --git a/apps/oxlint/src/lsp/server_linter.rs b/apps/oxlint/src/lsp/server_linter.rs index 5b77daa7b7277..7da9b9d752438 100644 --- a/apps/oxlint/src/lsp/server_linter.rs +++ b/apps/oxlint/src/lsp/server_linter.rs @@ -73,7 +73,7 @@ impl ServerLinterBuilder { // Setup JS workspace. This must be done before loading any configs if let Some(external_linter) = &self.external_linter { - let res = (external_linter.create_workspace)(root_path.to_string_lossy().into_owned()); + let res = (external_linter.create_workspace)(root_uri.as_str().to_string()); if let Err(err) = res { error!("Failed to setup JS workspace:\n{err}\n"); @@ -89,6 +89,7 @@ impl ServerLinterBuilder { &mut external_plugin_store, &mut nested_ignore_patterns, &mut extended_paths, + Some(root_uri.as_str()), ) } else { FxHashMap::default() @@ -120,6 +121,7 @@ impl ServerLinterBuilder { oxlintrc, self.external_linter.as_ref(), &mut external_plugin_store, + Some(root_uri.as_str()), ) .unwrap_or_default(); @@ -154,15 +156,18 @@ impl ServerLinterBuilder { // Send JS plugins config to JS side if let Some(external_linter) = &external_linter { - let res = config_store - .external_plugin_store() - .setup_rule_configs(root_path.to_string_lossy().into_owned(), external_linter); + let res = config_store.external_plugin_store().setup_rule_configs( + root_path.to_string_lossy().into_owned(), + Some(root_uri.as_str()), + external_linter, + ); if let Err(err) = res { error!("Failed to setup JS plugins config:\n{err}\n"); } } - let linter = Linter::new(lint_options, config_store, external_linter.cloned()); + let linter = Linter::new(lint_options, config_store, external_linter.cloned()) + .with_workspace_uri(Some(root_uri.as_str())); let mut lint_service_options = LintServiceOptions::new(root_path.clone()).with_cross_module(use_cross_module); @@ -183,7 +188,8 @@ impl ServerLinterBuilder { Err(e) => { warn!("Failed to initialize type-aware linting: {e}"); let linter = - Linter::new(lint_options, config_store_clone, external_linter.cloned()); + Linter::new(lint_options, config_store_clone, external_linter.cloned()) + .with_workspace_uri(Some(root_uri.as_str())); LintRunnerBuilder::new(lint_service_options, linter) .with_type_aware(false) .with_fix_kind(fix_kind) @@ -295,8 +301,7 @@ impl ToolBuilder for ServerLinterBuilder { fn shutdown(&self, root_uri: &Uri) { // Destroy JS workspace if let Some(external_linter) = &self.external_linter { - let root_path = root_uri.to_file_path().unwrap(); - (external_linter.destroy_workspace)(root_path.to_string_lossy().into_owned()); + (external_linter.destroy_workspace)(root_uri.as_str().to_string()); } } } @@ -310,10 +315,12 @@ impl ServerLinterBuilder { external_plugin_store: &mut ExternalPluginStore, nested_ignore_patterns: &mut Vec<(Vec, PathBuf)>, extended_paths: &mut FxHashSet, + workspace_uri: Option<&str>, ) -> FxHashMap { let config_paths = discover_configs_in_tree(root_path); - let mut loader = ConfigLoader::new(external_linter, external_plugin_store, &[]); + let mut loader = + ConfigLoader::new(external_linter, external_plugin_store, &[], workspace_uri); let (configs, errors) = loader.load_many(config_paths); for error in errors { @@ -1144,6 +1151,7 @@ mod test { &mut external_plugin_store, &mut nested_ignore_patterns, &mut extended_paths, + None, ); let mut configs_dirs = configs.keys().collect::>(); // sorting the key because for consistent tests results diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index 64ce0749475a4..575c7491bfe5a 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -20,7 +20,7 @@ use crate::{ #[napi] pub type JsCreateWorkspaceCb = ThreadsafeFunction< // Arguments - FnArgs<(String,)>, // Workspace directory + FnArgs<(String,)>, // Workspace URI // Return value Promise<()>, // Arguments (repeated) @@ -35,7 +35,7 @@ pub type JsCreateWorkspaceCb = ThreadsafeFunction< #[napi] pub type JsDestroyWorkspaceCb = ThreadsafeFunction< // Arguments - FnArgs<(String,)>, // Workspace directory + FnArgs<(String,)>, // Workspace URI // Return value (), // Arguments (repeated) @@ -58,11 +58,14 @@ pub type JsLoadPluginCb = ThreadsafeFunction< Option, // `true` if plugin name is an alias (takes priority over name that plugin defines itself) bool, + // Workspace URI (e.g. `file:///path/to/workspace`). + // `None` in CLI mode (single workspace), `Some` in LSP mode. + Option, )>, // Return value Promise, // `PluginLoadResult`, serialized to JSON // Arguments (repeated) - FnArgs<(String, Option, bool)>, + FnArgs<(String, Option, bool, Option)>, // Error status Status, // CalleeHandled @@ -81,11 +84,12 @@ pub type JsLintFileCb = ThreadsafeFunction< Vec, // Array of options IDs String, // Settings for the file, as JSON string String, // Globals for the file, as JSON string + Option, // Workspace URI (`None` in CLI mode, `Some` in LSP mode) )>, // Return value Option, // `Vec`, serialized to JSON, or `None` if no diagnostics // Arguments (repeated) - FnArgs<(String, u32, Option, Vec, Vec, String, String)>, + FnArgs<(String, u32, Option, Vec, Vec, String, String, Option)>, // Error status Status, // CalleeHandled diff --git a/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/.oxlintrc.json b/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/.oxlintrc.json new file mode 100644 index 0000000000000..c28b89c89753e --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/.oxlintrc.json @@ -0,0 +1,7 @@ +{ + "jsPlugins": ["../../plugin.ts"], + "categories": { "correctness": "off" }, + "rules": { + "basic-custom-plugin/no-debugger": "error" + } +} diff --git a/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/files/index.js b/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/files/index.js new file mode 100644 index 0000000000000..eab74692130a6 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_outside_cwd/files/subdirectory/files/index.js @@ -0,0 +1 @@ +debugger; diff --git a/apps/oxlint/test/fixtures/plugin_outside_cwd/options.json b/apps/oxlint/test/fixtures/plugin_outside_cwd/options.json new file mode 100644 index 0000000000000..42d255991615a --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_outside_cwd/options.json @@ -0,0 +1,3 @@ +{ + "cwd": "files/subdirectory" +} diff --git a/apps/oxlint/test/fixtures/plugin_outside_cwd/output.snap.md b/apps/oxlint/test/fixtures/plugin_outside_cwd/output.snap.md new file mode 100644 index 0000000000000..2fd6c5ce83c96 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_outside_cwd/output.snap.md @@ -0,0 +1,20 @@ +# Exit code +1 + +# stdout +``` + x basic-custom-plugin(no-debugger): Unexpected Debugger Statement + ,-[files/index.js:1:1] + 1 | debugger; + : ^^^^^^^^^ + `---- + +Found 0 warnings and 1 error. +Finished in Xms on 1 file with 1 rules using X threads. +``` + +# stderr +``` +WARNING: JS plugins are experimental and not subject to semver. +Breaking changes are possible while JS plugins support is under development. +``` diff --git a/apps/oxlint/test/fixtures/plugin_outside_cwd/plugin.ts b/apps/oxlint/test/fixtures/plugin_outside_cwd/plugin.ts new file mode 100644 index 0000000000000..6e6a1547ff2d0 --- /dev/null +++ b/apps/oxlint/test/fixtures/plugin_outside_cwd/plugin.ts @@ -0,0 +1,26 @@ +import type { Plugin } from "#oxlint/plugin"; + +// This test checks that a plugin which is outside the current working directory can be loaded. +// `cwd` is set to `files/subdirectory` in `options.json`, and the plugin is outside that directory. + +const plugin: Plugin = { + meta: { + name: "basic-custom-plugin", + }, + rules: { + "no-debugger": { + create(context) { + return { + DebuggerStatement(debuggerStatement) { + context.report({ + message: "Unexpected Debugger Statement", + node: debuggerStatement, + }); + }, + }; + }, + }, + }, +}; + +export default plugin; diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs index 658d94d48ac6f..c7fad0cc424ba 100644 --- a/crates/oxc_linter/src/config/config_builder.rs +++ b/crates/oxc_linter/src/config/config_builder.rs @@ -103,6 +103,7 @@ impl ConfigStoreBuilder { oxlintrc: Oxlintrc, external_linter: Option<&ExternalLinter>, external_plugin_store: &mut ExternalPluginStore, + workspace_uri: Option<&str>, ) -> Result { // TODO: this can be cached to avoid re-computing the same oxlintrc fn resolve_oxlintrc_config( @@ -189,6 +190,7 @@ impl ConfigStoreBuilder { external_linter, &resolver, external_plugin_store, + workspace_uri, )?; } } @@ -524,6 +526,7 @@ impl ConfigStoreBuilder { external_linter: &ExternalLinter, resolver: &Resolver, external_plugin_store: &mut ExternalPluginStore, + workspace_uri: Option<&str>, ) -> Result<(), ConfigBuilderError> { // Print warning on 1st attempt to load a plugin #[expect(clippy::print_stderr)] @@ -586,11 +589,16 @@ impl ConfigStoreBuilder { // Note: `unwrap()` here is infallible as `plugin_path` is an absolute path. let plugin_url = String::from(Url::from_file_path(&plugin_path).unwrap()); - let result = (external_linter.load_plugin)(plugin_url, plugin_name, alias.is_some()) - .map_err(|error| ConfigBuilderError::PluginLoadFailed { - plugin_specifier: plugin_specifier.to_string(), - error, - })?; + let result = (external_linter.load_plugin)( + plugin_url, + plugin_name, + alias.is_some(), + workspace_uri.map(String::from), + ) + .map_err(|error| ConfigBuilderError::PluginLoadFailed { + plugin_specifier: plugin_specifier.to_string(), + error, + })?; let plugin_name = result.name; if LintPlugins::try_from(plugin_name.as_str()).is_err() { @@ -1000,8 +1008,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, + None, + &mut external_plugin_store, + None, + ) + .unwrap() }; for (rule, severity) in &builder.rules { let name = rule.name(); @@ -1181,6 +1195,7 @@ mod test { .unwrap(), None, &mut external_plugin_store, + None, ) }; let err = invalid_config.unwrap_err(); @@ -1327,6 +1342,7 @@ mod test { current_oxlintrc, None, &mut external_plugin_store, + None, ) .unwrap(); @@ -1354,6 +1370,7 @@ mod test { Oxlintrc::from_file(&PathBuf::from(path)).unwrap(), None, &mut external_plugin_store, + None, ) .unwrap() .build(&mut external_plugin_store) @@ -1367,6 +1384,7 @@ mod test { serde_json::from_str(s).unwrap(), None, &mut external_plugin_store, + None, ) .unwrap() .build(&mut external_plugin_store) diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index bbaba8fab9976..ba9f62b42be0c 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -24,6 +24,9 @@ pub type ExternalLinterLoadPluginCb = Arc< Option, // `true` if plugin name is an alias (takes priority over name that plugin defines itself) bool, + // Workspace URI (e.g. `file:///path/to/workspace`). + // `None` in CLI mode (single workspace), `Some` in LSP mode. + Option, ) -> Result + Send + Sync, @@ -46,6 +49,9 @@ pub type ExternalLinterLintFileCb = Arc< String, // Globals JSON String, + // Workspace URI (e.g. `file:///path/to/workspace`). + // `None` in CLI mode (single workspace), `Some` in LSP mode. + Option, // Allocator &Allocator, ) -> Result, String> diff --git a/crates/oxc_linter/src/external_plugin_store.rs b/crates/oxc_linter/src/external_plugin_store.rs index 07a430a6e4e97..bc8be4fca2a34 100644 --- a/crates/oxc_linter/src/external_plugin_store.rs +++ b/crates/oxc_linter/src/external_plugin_store.rs @@ -166,9 +166,10 @@ impl ExternalPluginStore { pub fn setup_rule_configs( &self, cwd: String, + workspace_uri: Option<&str>, external_linter: &ExternalLinter, ) -> Result<(), String> { - let json = serde_json::to_string(&ConfigSer::new(cwd, self)); + let json = serde_json::to_string(&ConfigSer::new(cwd, workspace_uri, self)); match json { Ok(options_json) => (external_linter.setup_rule_configs)(options_json), Err(err) => Err(format!("Failed to serialize external plugin options: {err}")), @@ -203,14 +204,20 @@ impl ExternalPluginStore { #[serde(rename_all = "camelCase")] struct ConfigSer<'s> { cwd: String, + workspace_uri: Option<&'s str>, rule_ids: ConfigSerRuleIds<'s>, options: ConfigSerOptions<'s>, } impl<'s> ConfigSer<'s> { - fn new(cwd: String, external_plugin_store: &'s ExternalPluginStore) -> Self { + fn new( + cwd: String, + workspace_uri: Option<&'s str>, + external_plugin_store: &'s ExternalPluginStore, + ) -> Self { Self { cwd, + workspace_uri, rule_ids: ConfigSerRuleIds(external_plugin_store), options: ConfigSerOptions(external_plugin_store), } diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index 320554389f4f6..2687781636bbc 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -11,6 +11,7 @@ use std::{ path::Path, ptr::{self, NonNull}, rc::Rc, + string::ToString, }; use oxc_allocator::{Allocator, AllocatorPool, CloneIn}; @@ -112,6 +113,7 @@ pub struct Linter { options: LintOptions, config: ConfigStore, external_linter: Option, + workspace_uri: Option>, } impl Linter { @@ -120,7 +122,13 @@ impl Linter { config: ConfigStore, external_linter: Option, ) -> Self { - Self { options, config, external_linter } + Self { options, config, external_linter, workspace_uri: None } + } + + #[must_use] + pub fn with_workspace_uri(mut self, workspace_uri: Option<&str>) -> Self { + self.workspace_uri = workspace_uri.map(Box::from); + self } /// Set the kind of auto fixes to apply. @@ -607,6 +615,7 @@ impl Linter { external_rules.iter().map(|(_, options_id, _)| options_id.raw()).collect(), settings_json, globals_json, + self.workspace_uri.as_ref().map(ToString::to_string), allocator, ); match result { diff --git a/crates/oxc_linter/src/tester.rs b/crates/oxc_linter/src/tester.rs index bb844399ebe42..dff431722042f 100644 --- a/crates/oxc_linter/src/tester.rs +++ b/crates/oxc_linter/src/tester.rs @@ -566,6 +566,7 @@ impl Tester { Oxlintrc::deserialize(v).unwrap(), None, &mut external_plugin_store, + None, ) .unwrap() }) diff --git a/napi/playground/src/lib.rs b/napi/playground/src/lib.rs index cfe373b86fdbc..6f2010b718c4d 100644 --- a/napi/playground/src/lib.rs +++ b/napi/playground/src/lib.rs @@ -399,6 +399,7 @@ impl Oxc { oxlintrc, None, &mut external_plugin_store, + None, ) .unwrap_or_default(); config_builder.build(&mut external_plugin_store)