diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index 89431afecbb9c..37e193a6a0309 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -28,7 +28,6 @@ pub struct ContextSubHost<'a> { /// `eslint-disable` or `eslint-disable-next-line`. pub(super) disable_directives: Rc>, // Specific framework options, for example, whether the context is inside ` + /// ``` + /// + /// ```vue + /// + /// ``` + /// + /// ```vue + /// + /// ``` + /// + /// ```vue + /// + /// + /// ``` + /// + /// Examples of **correct** code for this rule: + /// + /// ```vue + /// + /// ``` + /// + /// ```vue + /// + /// ``` + /// + /// ```vue + /// + /// ``` + /// + /// ```vue + /// + /// + /// ``` + ValidDefineEmits, + vue, + correctness, + pending // TODO: removing empty `defineEmits` and merging multiple `defineEmits` calls +); + +impl Rule for ValidDefineEmits { + fn run_once(&self, ctx: &LintContext) { + let mut found: Option = None; + + let has_other_script_emits = has_default_emits_exports(&ctx.other_file_hosts()); + for node in ctx.nodes() { + let AstKind::CallExpression(call_expr) = node.kind() else { + continue; + }; + + // only check call Expression which is `defineEmits` + if call_expr + .callee + .get_identifier_reference() + .is_none_or(|reference| reference.name != "defineEmits") + { + continue; + } + + if let Some(other_span) = found { + ctx.diagnostic(called_multiple_times(call_expr.span, other_span)); + continue; + } + found = Some(call_expr.span); + + handle_call_expression(call_expr, ctx, has_other_script_emits); + } + } + + fn should_run(&self, ctx: &crate::context::ContextHost) -> bool { + ctx.frameworks_options() == FrameworkOptions::VueSetup + } +} + +fn handle_call_expression( + call_expr: &CallExpression, + ctx: &LintContext, + has_other_script_emits: bool, +) { + let has_type_args = call_expr.type_arguments.is_some(); + + if has_type_args && has_other_script_emits { + ctx.diagnostic(define_in_both(call_expr.span)); + return; + } + + // `defineEmits` has type arguments and js arguments. Vue Compiler allows only one of them. + if has_type_args && !call_expr.arguments.is_empty() { + ctx.diagnostic(has_type_and_arguments_diagnostic(call_expr.span)); + return; // Skip if there are type arguments + } + + if has_type_args { + // If there are type arguments, we don't need to check the arguments. + return; + } + + let Some(expression) = call_expr.arguments.first().and_then(|first| first.as_expression()) + else { + // `defineEmits();` is valid when `export default { emits: [] }` is defined + if !has_other_script_emits { + ctx.diagnostic(events_not_defined(call_expr.span)); + } + return; + }; + + if has_other_script_emits { + ctx.diagnostic(define_in_both(call_expr.span)); + return; + } + + match expression { + Expression::ArrayExpression(_) | Expression::ObjectExpression(_) => {} + Expression::Identifier(identifier) => { + if !is_non_local_reference(identifier, ctx) { + ctx.diagnostic(referencing_locally(call_expr.span)); + } + } + _ => { + ctx.diagnostic(referencing_locally(call_expr.span)); + } + } +} + +pub fn is_non_local_reference(identifier: &IdentifierReference, ctx: &LintContext<'_>) -> bool { + if let Some(symbol_id) = ctx.semantic().scoping().get_root_binding(&identifier.name) { + return matches!( + ctx.semantic().symbol_declaration(symbol_id).kind(), + AstKind::ImportSpecifier(_) + ); + } + + // variables outside the current ` + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + r#" + + "#, + None, + None, + Some(PathBuf::from("test.vue")), + ), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }, + ( + " + + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + r#" + + "#, + None, + None, + Some(PathBuf::from("test.vue")), + ), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }, + ( + r#" + + "#, + None, + None, + Some(PathBuf::from("test.vue")), + ), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }, + ( + " + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ]; + + let fail = vec![ + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + r#" + + "#, + None, + None, + Some(PathBuf::from("test.vue")), + ), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }, + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ( + " + + ", + None, + None, + Some(PathBuf::from("test.vue")), + ), + ]; + + Tester::new(ValidDefineEmits::NAME, ValidDefineEmits::PLUGIN, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/vue_valid_define_emits.snap b/crates/oxc_linter/src/snapshots/vue_valid_define_emits.snap new file mode 100644 index 0000000000000..95f3404596e30 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/vue_valid_define_emits.snap @@ -0,0 +1,61 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ vue(valid-define-emits): `defineEmits` is referencing locally declared variables. + ╭─[valid_define_emits.tsx:5:12] + 4 │ const def = { notify: null } + 5 │ defineEmits(def) + · ──────────────── + 6 │ + ╰──── + help: inline the variable or import it from another module. + + ⚠ vue(valid-define-emits): `defineEmits` has both a type-only emit and an argument. + ╭─[valid_define_emits.tsx:4:12] + 3 │ /* ✗ BAD */ + 4 │ defineEmits<(e: 'notify')=>void>({ submit: null }) + · ────────────────────────────────────────────────── + 5 │ + ╰──── + help: remove the argument for better type inference. + + ⚠ vue(valid-define-emits): `defineEmits` has been called multiple times. + ╭─[valid_define_emits.tsx:4:12] + 3 │ /* ✗ BAD */ + 4 │ defineEmits({ notify: null }) + · ──────────────┬────────────── + · ╰── `defineEmits` is called here too + 5 │ defineEmits({ submit: null }) + · ──────────────┬────────────── + · ╰── `defineEmits` is called here + 6 │ + ╰──── + help: combine all events into a single `defineEmits` call. + + ⚠ vue(valid-define-emits): Custom events are defined in both `defineEmits` and `export default {}`. + ╭─[valid_define_emits.tsx:9:18] + 8 │ /* ✗ BAD */ + 9 │ defineEmits({ submit: null }) + · ───────────────────────────── + 10 │ + ╰──── + help: Remove `export default`. + + ⚠ vue(valid-define-emits): Custom events are defined in both `defineEmits` and `export default {}`. + ╭─[valid_define_emits.tsx:6:21] + 5 │ + ╰──── + help: Remove `export default`. + + ⚠ vue(valid-define-emits): Custom events are not defined. + ╭─[valid_define_emits.tsx:4:12] + 3 │ /* ✗ BAD */ + 4 │ defineEmits() + · ───────────── + 5 │ + ╰──── + help: Define at least one event in `defineEmits`.