unplugin-oxlint
Version:
A universal bundler plugin for integrating the Oxlint linter into your project.
379 lines (378 loc) • 10.9 kB
JavaScript
import { createUnplugin } from "unplugin";
import { isAbsolute, join, relative, resolve } from "node:path";
import process from "node:process";
import { isPackageExists } from "local-pkg";
import { detectPackageManager } from "nypm";
import { colors } from "consola/utils";
import { defu } from "defu";
import { destr } from "destr";
import { ESLint } from "eslint";
import { execa } from "execa";
import fse from "fs-extra";
import { hash } from "ohash";
import chokidar from "chokidar";
import fg from "fast-glob";
//#region package.json
var version = "0.8.2";
//#endregion
//#region src/core/logger.ts
const defaultLogger = { log(value) {
return process.stdout.write(value);
} };
function createLogger(logger = defaultLogger) {
function printLintResults(records) {
logger.log("\r\n");
logger.log(colors.bgCyan(colors.black(` v${version} `)));
logger.log(" unplugin-oxlint ");
logger.log("\r\n");
Object.entries(records).forEach(([filename, results]) => {
if (results.length) {
logger.log("\r\n");
logger.log(colors.blue(colors.underline(join(process.cwd(), filename))));
logger.log("\r\n\r\n");
results.forEach(({ message, severity, ruleId, line, column }) => {
const suffix = colors.gray(`(${ruleId})`);
let prefix = `${[line, column].join(":")}`;
const prefixPlaceholder = 7 - prefix.length;
if (prefixPlaceholder > 0) prefix += Array.from({ length: prefixPlaceholder }).join(" ");
prefix = colors.gray(prefix);
if (severity === "error") logger.log(` ${prefix}${colors.red("✘")} ${colors.red(message)} ${suffix}\r\n`);
if (severity === "warning") logger.log(` ${prefix}${colors.yellow("⚠")} ${colors.yellow(message)} ${suffix}\r\n`);
});
}
});
logger.log("\r\n");
}
return { printLintResults };
}
//#endregion
//#region src/core/options.ts
const DEFAULT_OXLINT_OPTIONS = {
rootDir: ".",
glob: false,
deny: [],
allow: [],
fix: false,
params: "",
config: "",
noIgnore: false,
quiet: false,
denyWarnings: false,
watch: false,
includes: ".",
excludes: [/[\\/]node_modules[\\/]/, /[\\/]\.git[\\/]/]
};
function resolveOptions(options = {}) {
return defu(options, DEFAULT_OXLINT_OPTIONS);
}
//#endregion
//#region src/core/regexp.ts
const oxlintRE = /(\w+)\((\w+(?:-\w+)*)\):\s*([\s\S]*)/g;
//#endregion
//#region src/core/utils.ts
function until(value, truthyValue = true, ms = 500) {
return new Promise((resolve) => {
function c() {
if (value() === truthyValue) resolve();
else setTimeout(c, ms);
}
c();
});
}
function isString(value) {
return typeof value === "string";
}
function normalizeIgnores(excludes) {
return excludes.map((pattern) => {
if (isString(pattern)) return new RegExp(pattern);
return pattern;
});
}
function normalizeAbsolutePath(ids, defaults = []) {
return (() => {
if (Array.isArray(ids) ? !!ids.length : !!ids) return [ids];
return [defaults];
})().flat().map((path) => isAbsolute(path) ? path : join(process.cwd(), path));
}
function generateFileHash(id) {
const [path] = normalizeAbsolutePath(id, []);
return hash(fse.readFileSync(path));
}
async function createESLint(options = {}) {
return new ESLint(options);
}
//#endregion
//#region src/core/oxlint.ts
const agents = {
bun: ["bunx"],
npm: ["npx"],
yarn: ["yarn", "dlx"],
pnpm: ["pnpm", "dlx"]
};
async function runNpxCommand(command, args, ctx) {
const [agent, dlx] = agents[await ctx.getPackageManager() ?? "npm"];
return execa(agent, [
dlx,
command,
...args
].filter(Boolean), { reject: false });
}
function detectOxlintMessage(message) {
let ruleId = "";
let content = message;
let plugin = "";
const match = oxlintRE.exec(message);
if (match) {
plugin = match[1];
ruleId = match[2];
content = match[3];
}
oxlintRE.lastIndex = 0;
return {
ruleId,
content,
plugin
};
}
const RULE_ID_MATCHER = /\(([^)]+)\)/;
async function runOxlintCommand(ids, ctx) {
const options = ctx.options;
const contentRecord = {};
const paths = normalizeAbsolutePath(ids, options.rootDir);
const { stdout } = await runNpxCommand("oxlint", [
...options.deny.map((d) => ["-D", d]).flat(),
...options.allow.map((a) => ["-A", a]).flat(),
...options.config ? ["-c", options.config] : [],
...options.params.split(" "),
options.fix ? "--fix" : "",
options.noIgnore ? "--no-ignore" : "",
options.quiet ? "--quiet" : "",
options.denyWarnings ? "--deny-warnings" : "",
"--format",
"json",
...paths
], ctx);
function format(value) {
const index = value.indexOf("Finished");
if (index > -1) return value.slice(0, index).trim();
return value;
}
const results = [];
const outputs = destr(format(stdout));
const diagnostics = Array.isArray(outputs) ? outputs : outputs.diagnostics;
for await (const { filename } of diagnostics) {
if (contentRecord[filename]) continue;
contentRecord[filename] = await fse.readFile(filename, { encoding: "utf8" });
}
diagnostics.forEach(({ code, filename, severity, message, labels }) => {
const [{ span }] = labels;
const lines = contentRecord[filename].slice(0, span.offset).split("\n");
const line = lines.length;
const column = lines.at(-1).length + 1;
const { content } = detectOxlintMessage(message);
const result = {
filename,
linter: "oxlint",
severity,
message: content,
ruleId: code.match(RULE_ID_MATCHER)?.[1] ?? "",
line,
column
};
results.push(result);
ctx.insertLintResult(filename, result);
});
return results;
}
function resolveESLintOptions({ options }) {
return defu({}, {
fix: options.fix,
overrideConfigFile: options.config || void 0,
ignore: !options.noIgnore
});
}
async function runESLintCommand(ids, ctx) {
const options = ctx.options;
const paths = normalizeAbsolutePath(ids, options.rootDir || ".");
const outputs = await (await createESLint(resolveESLintOptions(ctx))).lintFiles(paths);
if (options.fix) await ESLint.outputFixes(outputs);
if (options.quiet) return [];
const results = [];
outputs.forEach(({ filePath: filename, messages }) => {
messages.forEach(({ message, severity, ruleId, line, column }) => {
const result = {
filename,
linter: "eslint",
severity: [
"off",
"warning",
"error"
][severity],
ruleId: ruleId ?? "",
message,
line,
column
};
results.push(result);
ctx.insertLintResult(filename, result);
});
});
return results;
}
async function runLintCommand(ids, ctx) {
ctx.setHoldingStatus(true);
if (isString(ids) ? !!ids : ids.length) [ids].flat().forEach((filename) => {
ctx.resetLintResults(filename);
});
const results = await Promise.all([ctx.isExist("oxlint") ? runOxlintCommand(ids, ctx) : void 0, ctx.isExist("eslint") ? runESLintCommand(ids, ctx) : void 0].filter(Boolean));
ctx.outputLintResults();
ctx.setHoldingStatus(false);
return results.flat();
}
//#endregion
//#region src/core/watcher.ts
let watcher;
function setupWatcher(paths, ctx) {
watcher = chokidar.watch(ctx.options.glob ? fg.sync(paths) : paths, {
ignored: (id) => normalizeIgnores(ctx.options.excludes).some((regex) => regex.test(id)),
persistent: true,
ignoreInitial: true
});
watcher.on("change", (id) => {
if (ctx.getHoldingStatus()) return;
const hash = generateFileHash(id);
if (hash === ctx.getFileHash(id)) return;
ctx.runLintCommand(id, ctx);
ctx.setFileHash(id, hash);
});
watcher.on("add", (id) => {
ctx.runLintCommand(id, ctx);
});
watcher.on("unlink", (id) => {
ctx.setFileHash(id, void 0);
ctx.resetLintResults(id);
ctx.outputLintResults();
});
process.on("exit", () => {
if (watcher) watcher.close?.();
});
return watcher;
}
//#endregion
//#region src/core/context.ts
function createOxlint(rawOptions = {}) {
let watcher;
const options = resolveOptions(rawOptions);
const ctx = createInternalContext(options);
const paths = [ctx.options.includes].flat().map((path) => join(process.cwd(), ctx.options.rootDir || ".", path));
async function runLintCommandWithContext(ids) {
return ctx.runLintCommand(ids, ctx);
}
function setup() {
if (options.watch) watcher = setupWatcher(paths, ctx);
return runLintCommandWithContext(paths);
}
return {
options,
setup,
watcher,
runLintCommand: runLintCommandWithContext,
getHoldingStatus: ctx.getHoldingStatus,
getFileHash: ctx.getFileHash,
setFileHash: ctx.setFileHash,
resetLintResults: ctx.resetLintResults,
outputLintResults: ctx.outputLintResults
};
}
function createInternalContext(options) {
let isHolding = false;
let hasESLint = null;
let hasOxlint = null;
let packageManagerName = options.packageManager;
const logger = createLogger();
const lintResultRecord = {};
const fileHashRecord = {};
async function getPackageManager() {
if (!packageManagerName) packageManagerName = (await detectPackageManager(process.cwd()))?.name ?? "npm";
return packageManagerName;
}
function getHoldingStatus() {
return isHolding;
}
function setHoldingStatus(value) {
isHolding = value;
}
function getFileHash(id) {
return fileHashRecord[id];
}
function setFileHash(id, hash) {
if (hash) {
fileHashRecord[id] = hash;
return;
}
delete fileHashRecord[id];
}
function insertLintResult(filename, result) {
filename = relative(process.cwd(), filename);
if (!lintResultRecord[filename]) lintResultRecord[filename] = [];
lintResultRecord[filename].push({
...result,
filename
});
}
function resetLintResults(filename) {
filename = relative(process.cwd(), filename);
lintResultRecord[filename] = [];
}
function outputLintResults() {
if (!Object.values(lintResultRecord ?? {}).filter((results) => !!results.length)?.length) return;
logger.printLintResults(lintResultRecord);
}
async function detectDependencies() {
if ([hasOxlint, hasESLint].includes(null)) {
const exists = await Promise.all([isPackageExists("oxlint", { paths: [resolve(options.rootDir)] }), isPackageExists("eslint", { paths: [resolve(options.rootDir)] })]);
hasOxlint = exists[0];
hasESLint = exists[1];
}
}
function isExist(dep) {
if (dep === "oxlint") return hasOxlint ?? false;
if (dep === "eslint") return hasESLint ?? false;
return false;
}
async function runLintCommand$1(ids, ctx) {
await detectDependencies();
return runLintCommand(ids, ctx);
}
return {
version,
options,
logger,
getPackageManager,
runLintCommand: runLintCommand$1,
getHoldingStatus,
setHoldingStatus,
getFileHash,
setFileHash,
insertLintResult,
resetLintResults,
outputLintResults,
detectDependencies,
isExist
};
}
//#endregion
//#region src/core/unplugin.ts
const unplugin = createUnplugin((rawOptions = {}) => {
const ctx = createOxlint(rawOptions);
ctx.setup();
return {
name: "oxlint",
async buildEnd() {
await until(ctx.getHoldingStatus, false);
if (!ctx.options.watch) await ctx.watcher?.close();
}
};
});
//#endregion
export { createOxlint as n, unplugin as t };