feat(linter/tree-shaking): support options (#3504)

This commit is contained in:
Wang Wenzhe 2024-06-03 11:28:48 +08:00 committed by GitHub
parent 568c9c54c4
commit 6b39654c80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 353 additions and 66 deletions

View file

@ -15,46 +15,20 @@ use oxc_ast::{
},
AstKind,
};
use oxc_semantic::{AstNode, SymbolId};
use oxc_semantic::{AstNode, AstNodeId};
use oxc_span::{GetSpan, Span};
use oxc_syntax::operator::{LogicalOperator, UnaryOperator};
use rustc_hash::FxHashSet;
use std::{cell::Cell, cell::RefCell};
use crate::{
ast_util::{get_declaration_of_variable, get_symbol_id_of_variable},
utils::{
calculate_binary_operation, calculate_logical_operation, calculate_unary_operation,
get_write_expr, has_comment_about_side_effect_check, has_pure_notation, is_pure_function,
no_effects, FunctionName, Value,
get_write_expr, has_comment_about_side_effect_check, has_pure_notation,
is_function_side_effect_free, is_local_variable_a_whitelisted_module, is_pure_function,
no_effects, FunctionName, NodeListenerOptions, Value,
},
LintContext,
};
pub struct NodeListenerOptions<'a, 'b> {
checked_mutated_nodes: RefCell<FxHashSet<SymbolId>>,
ctx: &'b LintContext<'a>,
has_valid_this: Cell<bool>,
called_with_new: Cell<bool>,
}
impl<'a, 'b> NodeListenerOptions<'a, 'b> {
fn insert_mutated_node(&self, symbol_id: SymbolId) -> bool {
self.checked_mutated_nodes.borrow_mut().insert(symbol_id)
}
}
impl<'a, 'b> NodeListenerOptions<'a, 'b> {
pub fn new(ctx: &'b LintContext<'a>) -> Self {
Self {
checked_mutated_nodes: RefCell::new(FxHashSet::default()),
ctx,
has_valid_this: Cell::new(false),
called_with_new: Cell::new(false),
}
}
}
pub trait ListenerMap {
fn report_effects(&self, _options: &NodeListenerOptions) {}
fn report_effects_when_assigned(&self, _options: &NodeListenerOptions) {}
@ -276,21 +250,28 @@ impl<'a> ListenerMap for AstNode<'a> {
class.report_effects_when_called(options);
}
AstKind::ImportDefaultSpecifier(specifier) => {
if !has_comment_about_side_effect_check(specifier.span, options.ctx) {
options.ctx.diagnostic(super::call_import(specifier.span));
}
report_on_imported_call(
specifier.local.span,
&specifier.local.name,
self.id(),
options,
);
}
AstKind::ImportSpecifier(specifier) => {
let span = specifier.local.span;
if !has_comment_about_side_effect_check(span, options.ctx) {
options.ctx.diagnostic(super::call_import(span));
}
report_on_imported_call(
specifier.local.span,
&specifier.local.name,
self.id(),
options,
);
}
AstKind::ImportNamespaceSpecifier(specifier) => {
let span = specifier.local.span;
if !has_comment_about_side_effect_check(span, options.ctx) {
options.ctx.diagnostic(super::call_import(span));
}
report_on_imported_call(
specifier.local.span,
&specifier.local.name,
self.id(),
options,
);
}
_ => {}
}
@ -324,6 +305,24 @@ impl<'a> ListenerMap for AstNode<'a> {
}
}
fn report_on_imported_call(
span: Span,
name: &str,
node_id: AstNodeId,
options: &NodeListenerOptions,
) {
if has_comment_about_side_effect_check(span, options.ctx) {
return;
}
let Some(AstKind::ImportDeclaration(decl)) = options.ctx.nodes().parent_kind(node_id) else {
return;
};
if is_function_side_effect_free(name, &decl.source.value, options) {
return;
}
options.ctx.diagnostic(super::call_import(span));
}
impl<'a> ListenerMap for Declaration<'a> {
fn report_effects(&self, options: &NodeListenerOptions) {
match self {
@ -1053,11 +1052,7 @@ impl<'a> ListenerMap for CallExpression<'a> {
let ctx = options.ctx;
if let Expression::Identifier(ident) = &self.callee {
if let Some(node) = get_declaration_of_variable(ident, ctx) {
let Some(parent) = ctx.nodes().parent_kind(node.id()) else {
return;
};
// TODO: `isLocalVariableAWhitelistedModule`
if matches!(parent, AstKind::ImportDeclaration(_)) {
if is_local_variable_a_whitelisted_module(node, ident.name.as_str(), options) {
return;
}
options.ctx.diagnostic(super::call_return_value(self.span));
@ -1120,7 +1115,7 @@ impl<'a> ListenerMap for IdentifierReference<'a> {
}
fn report_effects_when_called(&self, options: &NodeListenerOptions) {
if is_pure_function(&FunctionName::Identifier(self), options.ctx) {
if is_pure_function(&FunctionName::Identifier(self), options) {
return;
}
@ -1249,31 +1244,43 @@ impl<'a> ListenerMap for StaticMemberExpression<'a> {
fn report_effects_when_called(&self, options: &NodeListenerOptions) {
self.report_effects(options);
let mut node = &self.object;
let mut root_member_expr = &self.object;
loop {
match node {
match root_member_expr {
Expression::ComputedMemberExpression(expr) => {
node = &expr.object;
root_member_expr = &expr.object;
}
Expression::StaticMemberExpression(expr) => node = &expr.object,
Expression::PrivateInExpression(expr) => node = &expr.right,
Expression::StaticMemberExpression(expr) => root_member_expr = &expr.object,
Expression::PrivateInExpression(expr) => root_member_expr = &expr.right,
_ => {
break;
}
}
}
let Expression::Identifier(ident) = node else {
options.ctx.diagnostic(super::call_member(node.span()));
let Expression::Identifier(ident) = root_member_expr else {
options.ctx.diagnostic(super::call_member(root_member_expr.span()));
return;
};
if get_declaration_of_variable(ident, options.ctx)
.is_some_and(|_| !has_pure_notation(self.span, options.ctx))
|| !is_pure_function(&FunctionName::StaticMemberExpr(self), options.ctx)
{
options.ctx.diagnostic(super::call_member(self.span));
let Some(node) = get_declaration_of_variable(ident, options.ctx) else {
// If the variable is not declared, it is a global variable.
// `ext.x()`
if !is_pure_function(&FunctionName::StaticMemberExpr(self), options) {
options.ctx.diagnostic(super::call_member(self.span));
}
return;
};
if is_local_variable_a_whitelisted_module(node, &ident.name, options) {
return;
};
if has_pure_notation(self.span, options.ctx) {
return;
}
options.ctx.diagnostic(super::call_member(self.span));
}
fn report_effects_when_assigned(&self, options: &NodeListenerOptions) {
self.report_effects(options);

View file

@ -2,10 +2,15 @@ use oxc_ast::AstKind;
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use serde_json::Value;
use crate::{context::LintContext, rule::Rule};
use crate::{
context::LintContext,
rule::Rule,
utils::{ModuleFunctions, NodeListenerOptions, WhitelistModule},
};
use self::listener_map::{ListenerMap, NodeListenerOptions};
use self::listener_map::ListenerMap;
mod listener_map;
@ -75,7 +80,21 @@ fn throw(span0: Span) -> OxcDiagnostic {
/// <https://github.com/lukastaegert/eslint-plugin-tree-shaking/blob/master/src/rules/no-side-effects-in-initialization.ts>
#[derive(Debug, Default, Clone)]
pub struct NoSideEffectsInInitialization;
pub struct NoSideEffectsInInitialization(Box<NoSideEffectsInInitiallizationOptions>);
impl std::ops::Deref for NoSideEffectsInInitialization {
type Target = NoSideEffectsInInitiallizationOptions;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Default, Clone)]
pub struct NoSideEffectsInInitiallizationOptions {
functions: Vec<String>,
modules: Vec<WhitelistModule>,
}
declare_oxc_lint!(
/// ### What it does
@ -94,17 +113,118 @@ declare_oxc_lint!(
/// const x = { [globalFunction()]: "myString" }; // Cannot determine side-effects of calling global function
/// export default 42;
/// ```
///
/// ### Options
///
/// ```json
/// {
/// "rules": {
/// "tree-shaking/no-side-effects-in-initialization": [
/// 2,
/// {
/// "noSideEffectsWhenCalled": [
/// // If you want to mark a function call as side-effect free
/// { "function": "Object.freeze" },
/// {
/// "module": "react",
/// "functions": ["createContext", "createRef"]
/// },
/// {
/// "module": "zod",
/// "functions": ["array", "string", "nativeEnum", "number", "object", "optional"]
/// },
/// {
/// "module": "my/local/module",
/// "functions": ["foo", "bar", "baz"]
/// },
/// // If you want to whitelist all functions of a module
/// {
/// "module": "lodash",
/// "functions": "*"
/// }
/// ]
/// }
/// ]
/// }
/// }
/// ```
///
/// ### Magic Comments
///
/// Besides the configuration, you can also use magic comments to mark a function call as side effect free.
///
/// By default, imported functions are assumed to have side-effects, unless they are marked with a magic comment:
///
/// ```js
/// import { /* tree-shaking no-side-effects-when-called */ x } from "./some-file";
/// x();
/// ```
///
/// `@__PURE__` is also supported:
///
/// ```js
/// import {x} from "./some-file";
/// /*@__PURE__*/ x();
/// ```
NoSideEffectsInInitialization,
nursery
);
impl Rule for NoSideEffectsInInitialization {
fn from_configuration(value: serde_json::Value) -> Self {
let mut functions = vec![];
let mut modules = vec![];
if let Value::Array(arr) = value {
for obj in arr {
let Value::Object(obj) = obj else {
continue;
};
// { "function": "Object.freeze" }
if let Some(name) = obj.get("function").and_then(Value::as_str) {
functions.push(name.to_string());
continue;
}
// { "module": "react", "functions": ["createContext", "createRef"] }
// { "module": "react", "functions": "*" }
if let Some(name) = obj.get("module").and_then(Value::as_str) {
let functions = match obj.get("functions") {
Some(Value::Array(arr)) => {
let val = arr
.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect::<Vec<_>>();
Some(ModuleFunctions::Specific(val))
}
Some(Value::String(str)) => {
if str == "*" {
Some(ModuleFunctions::All)
} else {
None
}
}
_ => None,
};
if let Some(functions) = functions {
modules.push(WhitelistModule { name: name.to_string(), functions });
}
}
}
}
Self(Box::new(NoSideEffectsInInitiallizationOptions { functions, modules }))
}
fn run_once(&self, ctx: &LintContext) {
let Some(root) = ctx.nodes().root_node() else {
return;
};
let AstKind::Program(program) = root.kind() else { unreachable!() };
let node_listener_options = NodeListenerOptions::new(ctx);
let node_listener_options = NodeListenerOptions::new(ctx)
.with_whitelist_functions(&self.functions)
.with_whitelist_modules(&self.modules);
program.report_effects(&node_listener_options);
}
}
@ -670,5 +790,46 @@ fn test() {
"function* x(){yield ext()}; x()",
];
// test options
let pass_with_options = vec![
(
"Object.freeze({})",
Some(serde_json::json!([
{ "function": "Object.freeze" },
])),
),
(
"import {createContext, createRef} from 'react'; createContext(); createRef();",
Some(serde_json::json!([
{ "module": "react", "functions": ["createContext", "createRef"] },
])),
),
(
"import _ from 'lodash'; _.cloneDeep({});",
Some(serde_json::json!([
{ "module": "lodash", "functions": "*" },
])),
),
(
"import * as React from 'react'; React.createRef();",
Some(serde_json::json!([
{ "module": "react", "functions": "*" },
])),
),
];
let fail_with_options = vec![
("Object.freeze({})", None),
("import {createContext, createRef} from 'react'; createContext(); createRef();", None),
("import _ from 'lodash'; _.cloneDeep({});", None),
("import * as React from 'react'; React.createRef();", None),
];
let pass =
pass.into_iter().map(|case| (case, None)).chain(pass_with_options).collect::<Vec<_>>();
let fail =
fail.into_iter().map(|case| (case, None)).chain(fail_with_options).collect::<Vec<_>>();
Tester::new(NoSideEffectsInInitialization::NAME, pass, fail).test_and_snapshot();
}

View file

@ -1303,3 +1303,33 @@ expression: no_side_effects_in_initialization
1 │ function* x(){yield ext()}; x()
· ───
╰────
⚠ eslint-plugin-tree-shaking(no-side-effects-in-initialization): Cannot determine side-effects of calling member function
╭─[no_side_effects_in_initialization.tsx:1:1]
1 │ Object.freeze({})
· ─────────────
╰────
⚠ eslint-plugin-tree-shaking(no-side-effects-in-initialization): Cannot determine side-effects of calling imported function
╭─[no_side_effects_in_initialization.tsx:1:9]
1 │ import {createContext, createRef} from 'react'; createContext(); createRef();
· ─────────────
╰────
⚠ eslint-plugin-tree-shaking(no-side-effects-in-initialization): Cannot determine side-effects of calling imported function
╭─[no_side_effects_in_initialization.tsx:1:24]
1 │ import {createContext, createRef} from 'react'; createContext(); createRef();
· ─────────
╰────
⚠ eslint-plugin-tree-shaking(no-side-effects-in-initialization): Cannot determine side-effects of calling member function
╭─[no_side_effects_in_initialization.tsx:1:25]
1 │ import _ from 'lodash'; _.cloneDeep({});
· ───────────
╰────
⚠ eslint-plugin-tree-shaking(no-side-effects-in-initialization): Cannot determine side-effects of calling member function
╭─[no_side_effects_in_initialization.tsx:1:33]
1 │ import * as React from 'react'; React.createRef();
· ───────────────
╰────

View file

@ -1,9 +1,11 @@
use std::cell::{Cell, RefCell};
use lazy_static::lazy_static;
use oxc_ast::{
ast::{Expression, IdentifierReference, StaticMemberExpression},
AstKind, CommentKind,
};
use oxc_semantic::AstNodeId;
use oxc_semantic::{AstNode, AstNodeId, SymbolId};
use oxc_span::{CompactStr, GetSpan, Span};
use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator};
use rustc_hash::FxHashSet;
@ -12,6 +14,50 @@ use crate::LintContext;
mod pure_functions;
pub struct NodeListenerOptions<'a, 'b> {
pub checked_mutated_nodes: RefCell<FxHashSet<SymbolId>>,
pub ctx: &'b LintContext<'a>,
pub has_valid_this: Cell<bool>,
pub called_with_new: Cell<bool>,
pub whitelist_modules: Option<&'b Vec<WhitelistModule>>,
pub whitelist_functions: Option<&'b Vec<String>>,
}
impl<'a, 'b> NodeListenerOptions<'a, 'b> {
pub fn new(ctx: &'b LintContext<'a>) -> Self {
Self {
checked_mutated_nodes: RefCell::new(FxHashSet::default()),
ctx,
has_valid_this: Cell::new(false),
called_with_new: Cell::new(false),
whitelist_modules: None,
whitelist_functions: None,
}
}
pub fn with_whitelist_modules(self, whitelist_modules: &'b Vec<WhitelistModule>) -> Self {
Self { whitelist_modules: Some(whitelist_modules), ..self }
}
pub fn with_whitelist_functions(self, whitelist_functions: &'b Vec<String>) -> Self {
Self { whitelist_functions: Some(whitelist_functions), ..self }
}
pub fn insert_mutated_node(&self, symbol_id: SymbolId) -> bool {
self.checked_mutated_nodes.borrow_mut().insert(symbol_id)
}
}
#[derive(Debug, Default, Clone)]
pub struct WhitelistModule {
pub name: String,
pub functions: ModuleFunctions,
}
#[derive(Debug, Clone, Default)]
pub enum ModuleFunctions {
#[default]
All,
Specific(Vec<String>),
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Value {
Boolean(bool),
@ -127,11 +173,16 @@ impl GetSpan for FunctionName<'_> {
}
}
pub fn is_pure_function(function_name: &FunctionName, ctx: &LintContext) -> bool {
if has_pure_notation(function_name.span(), ctx) {
pub fn is_pure_function(function_name: &FunctionName, options: &NodeListenerOptions) -> bool {
if has_pure_notation(function_name.span(), options.ctx) {
return true;
}
let name = flatten_member_expr_if_possible(function_name);
if options.whitelist_functions.is_some_and(|whitelist| whitelist.contains(&name.to_string())) {
return true;
}
PURE_FUNCTIONS_SET.contains(name.as_str())
}
@ -251,6 +302,44 @@ pub fn get_leading_tree_shaking_comment<'a>(span: Span, ctx: &LintContext<'a>) -
Some(comment_text)
}
pub fn is_local_variable_a_whitelisted_module(
node: &AstNode,
name: &str,
options: &NodeListenerOptions,
) -> bool {
let Some(AstKind::ImportDeclaration(parent)) = options.ctx.nodes().parent_kind(node.id())
else {
return false;
};
let module_name = parent.source.value.as_str();
is_function_side_effect_free(name, module_name, options)
}
pub fn is_function_side_effect_free(
name: &str,
module_name: &str,
options: &NodeListenerOptions,
) -> bool {
let Some(whitelist_modules) = options.whitelist_modules else {
return false;
};
for module in whitelist_modules {
let is_module_match =
module.name == module_name || module.name == "#local" && module_name.starts_with('.');
if is_module_match {
match &module.functions {
ModuleFunctions::All => return true,
ModuleFunctions::Specific(functions) => {
return functions.contains(&name.to_string())
}
}
}
}
false
}
/// Port from <https://github.com/lukastaegert/eslint-plugin-tree-shaking/blob/463fa1f0bef7caa2b231a38b9c3557051f506c92/src/rules/no-side-effects-in-initialization.ts#L136-L161>
/// <https://tc39.es/ecma262/#sec-evaluatestringornumericbinaryexpression>
#[allow(clippy::cast_possible_truncation)]