Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/oxlint/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ export type JsDestroyWorkspaceCb =

/** JS callback to lint a file. */
export type JsLintFileCb =
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>, arg4: Array<number>, arg5: string, arg6: string) => string | null)
((arg0: string, arg1: number, arg2: Uint8Array | undefined | null, arg3: Array<number>, arg4: Array<number>, arg5: string, arg6: string, arg7?: string | undefined | null) => string | null)

/** JS callback to load JavaScript config files. */
export type JsLoadJsConfigsCb =
((arg: Array<string>) => Promise<string>)

/** JS callback to load a JS plugin. */
export type JsLoadPluginCb =
((arg0: string, arg1: string | undefined | null, arg2: boolean) => Promise<string>)
((arg0: string, arg1: string | undefined | null, arg2: boolean, arg3?: string | undefined | null) => Promise<string>)

/** JS callback to setup configs. */
export type JsSetupRuleConfigsCb =
Expand Down
19 changes: 16 additions & 3 deletions apps/oxlint/src-js/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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);
}

/**
Expand Down Expand Up @@ -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(
Expand All @@ -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,
);
}

/**
Expand Down
14 changes: 10 additions & 4 deletions apps/oxlint/src-js/package/rule_tester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ------------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand All @@ -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]}`;
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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}`);
Expand Down
36 changes: 26 additions & 10 deletions apps/oxlint/src-js/plugins/lint.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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";
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,
Expand Down Expand Up @@ -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(
Expand All @@ -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;

Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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
Expand Down
33 changes: 12 additions & 21 deletions apps/oxlint/src-js/plugins/load.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string>();

// Rule objects for loaded rules.
// Indexed by `ruleId`, which is passed to `lintFile`.
export const registeredRules: Map<WorkspaceIdentifier, RuleDetails[]> = new Map();
Expand All @@ -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<string> {
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) });
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
26 changes: 20 additions & 6 deletions apps/oxlint/src-js/plugins/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,6 +52,9 @@ export const DEFAULT_OPTIONS: Readonly<Options> = Object.freeze([]);
// First element is irrelevant - never accessed - because 0 index is a sentinel meaning default options.
export const allOptions: Map<WorkspaceIdentifier, Readonly<Options>[]> = new Map();

// Mapping from workspace URIs to CWD paths
export const cwds: Map<WorkspaceIdentifier, string> = new Map();

// Index into `allOptions` for default options
export const DEFAULT_OPTIONS_ID = 0;

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -391,4 +404,5 @@ export function setupOptionsForWorkspace(workspace: WorkspaceIdentifier) {
*/
export function removeOptionsInWorkspace(workspace: WorkspaceIdentifier) {
allOptions.delete(workspace);
cwds.delete(workspace);
}
Loading
Loading