feat(lint): add JavaScript plugin support (#27203)

This commit adds an unstable lint plugin API.

Plugins are specified in the `deno.json` file under
`lint.plugins` option like so:

```
{
  "lint": {
    "plugins": [
      "./plugins/my-plugin.ts",
      "jsr:@deno/lint-plugin1",
      "npm:@deno/lint-plugin2"
    ]
  }
}
```

The API is considered unstable and might be subject
to changes in the future.

Plugin API was modelled after ESLint API for the 
most part, but there are no guarantees for compatibility.
The AST format exposed to plugins is closely modelled
after the AST that `typescript-eslint` uses.

Lint plugins use the visitor pattern and can add
diagnostics like so:

```
export default {
  name: "lint-plugin",
  rules: {
    "plugin-rule": {
      create(context) {
        return {
          Identifier(node) {
            if (node.name === "a") {
              context.report({
                node,
                message: "should be b",
                fix(fixer) {
                  return fixer.replaceText(node, "_b");
                },
              });
            }
          },
        };
      },
    },
  },
} satisfies Deno.lint.Plugin;
```

Besides reporting errors (diagnostics) plugins can provide
automatic fixes that use text replacement to apply changes.

---------

Co-authored-by: Marvin Hagemeister <marvin@deno.com>
Co-authored-by: David Sherret <dsherret@gmail.com>
This commit is contained in:
Bartek Iwańczuk 2025-02-05 16:59:24 +01:00 committed by GitHub
parent 8a07d38a53
commit f08ca6414b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 4219 additions and 2494 deletions

View file

@ -10,9 +10,14 @@ import {
import { core, internals } from "ext:core/mod.js";
const {
op_lint_get_source,
op_lint_report,
op_lint_create_serialized_ast,
op_is_cancelled,
} = core.ops;
let doReport = op_lint_report;
// Keep these in sync with Rust
const AST_IDX_INVALID = 0;
const AST_GROUP_TYPE = 1;
@ -72,29 +77,133 @@ const PropFlags = {
/** @typedef {import("./40_lint_types.d.ts").VisitorFn} VisitorFn */
/** @typedef {import("./40_lint_types.d.ts").CompiledVisitor} CompiledVisitor */
/** @typedef {import("./40_lint_types.d.ts").LintState} LintState */
/** @typedef {import("./40_lint_types.d.ts").RuleContext} RuleContext */
/** @typedef {import("./40_lint_types.d.ts").NodeFacade} NodeFacade */
/** @typedef {import("./40_lint_types.d.ts").LintPlugin} LintPlugin */
/** @typedef {import("./40_lint_types.d.ts").TransformFn} TransformFn */
/** @typedef {import("./40_lint_types.d.ts").MatchContext} MatchContext */
/** @typedef {import("./40_lint_types.d.ts").Node} Node */
/** @type {LintState} */
const state = {
plugins: [],
installedPlugins: new Set(),
ignoredRules: new Set(),
};
function resetState() {
state.plugins = [];
state.installedPlugins.clear();
state.ignoredRules.clear();
}
/**
* This implementation calls into Rust to check if Tokio's cancellation token
* has already been canceled.
*/
class CancellationToken {
isCancellationRequested() {
return op_is_cancelled();
}
}
/** @implements {Deno.lint.Fixer} */
class Fixer {
/**
* @param {Deno.lint.Node} node
* @param {string} text
*/
insertTextAfter(node, text) {
return {
range: /** @type {[number, number]} */ ([node.range[1], node.range[1]]),
text,
};
}
/**
* @param {Deno.lint.Node["range"]} range
* @param {string} text
*/
insertTextAfterRange(range, text) {
return {
range: /** @type {[number, number]} */ ([range[1], range[1]]),
text,
};
}
/**
* @param {Deno.lint.Node} node
* @param {string} text
*/
insertTextBefore(node, text) {
return {
range: /** @type {[number, number]} */ ([node.range[0], node.range[0]]),
text,
};
}
/**
* @param {Deno.lint.Node["range"]} range
* @param {string} text
*/
insertTextBeforeRange(range, text) {
return {
range: /** @type {[number, number]} */ ([range[0], range[0]]),
text,
};
}
/**
* @param {Deno.lint.Node} node
*/
remove(node) {
return {
range: node.range,
text: "",
};
}
/**
* @param {Deno.lint.Node["range"]} range
*/
removeRange(range) {
return {
range,
text: "",
};
}
/**
* @param {Deno.lint.Node} node
* @param {string} text
*/
replaceText(node, text) {
return {
range: node.range,
text,
};
}
/**
* @param {Deno.lint.Node["range"]} range
* @param {string} text
*/
replaceTextRange(range, text) {
return {
range,
text,
};
}
}
/**
* Every rule gets their own instance of this class. This is the main
* API lint rules interact with.
* @implements {RuleContext}
* @implements {Deno.lint.RuleContext}
*/
export class Context {
id;
fileName;
#source = null;
/**
* @param {string} id
* @param {string} fileName
@ -103,18 +212,85 @@ export class Context {
this.id = id;
this.fileName = fileName;
}
source() {
if (this.#source === null) {
this.#source = op_lint_get_source();
}
return /** @type {*} */ (this.#source);
}
/**
* @param {Deno.lint.ReportData} data
*/
report(data) {
const range = data.node ? data.node.range : data.range ? data.range : null;
if (range == null) {
throw new Error(
"Either `node` or `range` must be provided when reporting an error",
);
}
const start = range[0];
const end = range[1];
let fix;
if (typeof data.fix === "function") {
const fixer = new Fixer();
fix = data.fix(fixer);
}
doReport(
this.id,
data.message,
data.hint,
start,
end,
fix,
);
}
}
/**
* @param {LintPlugin} plugin
* @param {Deno.lint.Plugin[]} plugins
* @param {string[]} exclude
*/
export function installPlugin(plugin) {
export function installPlugins(plugins, exclude) {
if (Array.isArray(exclude)) {
for (let i = 0; i < exclude.length; i++) {
state.ignoredRules.add(exclude[i]);
}
}
return plugins.map((plugin) => installPlugin(plugin));
}
/**
* @param {Deno.lint.Plugin} plugin
*/
function installPlugin(plugin) {
if (typeof plugin !== "object") {
throw new Error("Linter plugin must be an object");
}
if (typeof plugin.name !== "string") {
throw new Error("Linter plugin name must be a string");
}
if (!/^[a-z-]+$/.test(plugin.name)) {
throw new Error(
"Linter plugin name must only contain lowercase letters (a-z) or hyphens (-).",
);
}
if (plugin.name.startsWith("-") || plugin.name.endsWith("-")) {
throw new Error(
"Linter plugin name must start and end with a lowercase letter.",
);
}
if (plugin.name.includes("--")) {
throw new Error(
"Linter plugin name must not have consequtive hyphens.",
);
}
if (typeof plugin.rules !== "object") {
throw new Error("Linter plugin rules must be an object");
}
@ -123,6 +299,11 @@ export function installPlugin(plugin) {
}
state.plugins.push(plugin);
state.installedPlugins.add(plugin.name);
return {
name: plugin.name,
ruleNames: Object.keys(plugin.rules),
};
}
/**
@ -285,7 +466,7 @@ function readType(buf, idx) {
/**
* @param {AstContext} ctx
* @param {number} idx
* @returns {Node["range"]}
* @returns {Deno.lint.Node["range"]}
*/
function readSpan(ctx, idx) {
let offset = ctx.spansOffset + (idx * SPAN_SIZE);
@ -765,6 +946,12 @@ export function runPluginsForFile(fileName, serializedAst) {
for (const name of Object.keys(plugin.rules)) {
const rule = plugin.rules[name];
const id = `${plugin.name}/${name}`;
// Check if this rule is excluded
if (state.ignoredRules.has(id)) {
continue;
}
const ctx = new Context(id, fileName);
const visitor = rule.create(ctx);
@ -852,10 +1039,11 @@ export function runPluginsForFile(fileName, serializedAst) {
visitors.push({ info, matcher });
}
const token = new CancellationToken();
// Traverse ast with all visitors at the same time to avoid traversing
// multiple times.
try {
traverse(ctx, visitors, ctx.rootOffset);
traverse(ctx, visitors, ctx.rootOffset, token);
} finally {
ctx.nodes.clear();
@ -870,9 +1058,11 @@ export function runPluginsForFile(fileName, serializedAst) {
* @param {AstContext} ctx
* @param {CompiledVisitor[]} visitors
* @param {number} idx
* @param {CancellationToken} cancellationToken
*/
function traverse(ctx, visitors, idx) {
function traverse(ctx, visitors, idx, cancellationToken) {
if (idx === AST_IDX_INVALID) return;
if (cancellationToken.isCancellationRequested()) return;
const { buf } = ctx;
const nodeType = readType(ctx.buf, idx);
@ -905,12 +1095,12 @@ function traverse(ctx, visitors, idx) {
try {
const childIdx = readChild(buf, idx);
if (childIdx > AST_IDX_INVALID) {
traverse(ctx, visitors, childIdx);
traverse(ctx, visitors, childIdx, cancellationToken);
}
const nextIdx = readNext(buf, idx);
if (nextIdx > AST_IDX_INVALID) {
traverse(ctx, visitors, nextIdx);
traverse(ctx, visitors, nextIdx, cancellationToken);
}
} finally {
if (exits !== null) {
@ -1064,8 +1254,12 @@ function _dump(ctx) {
}
}
// TODO(bartlomieju): this is temporary, until we get plugins plumbed through
// the CLI linter
// These are captured by Rust and called when plugins need to be loaded
// or run.
internals.installPlugins = installPlugins;
internals.runPluginsForFile = runPluginsForFile;
internals.resetState = resetState;
/**
* @param {LintPlugin} plugin
* @param {string} fileName
@ -1074,16 +1268,25 @@ function _dump(ctx) {
function runLintPlugin(plugin, fileName, sourceText) {
installPlugin(plugin);
const diagnostics = [];
doReport = (id, message, hint, start, end, fix) => {
diagnostics.push({
id,
message,
hint,
range: [start, end],
fix,
});
};
try {
const serializedAst = op_lint_create_serialized_ast(fileName, sourceText);
runPluginsForFile(fileName, serializedAst);
} finally {
// During testing we don't want to keep plugins around
state.installedPlugins.clear();
resetState();
}
doReport = op_lint_report;
return diagnostics;
}
// TODO(bartlomieju): this is temporary, until we get plugins plumbed through
// the CLI linter
internals.runLintPlugin = runLintPlugin;
Deno.lint.runPlugin = runLintPlugin;