Skip to content

Commit 626733c

Browse files
committed
feat(linter): add vue/require-default-export rule
1 parent 0df1125 commit 626733c

File tree

5 files changed

+323
-0
lines changed

5 files changed

+323
-0
lines changed

crates/oxc_linter/src/context/host.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ impl<'a> ContextSubHost<'a> {
9696
pub fn disable_directives(&self) -> &DisableDirectives {
9797
&self.disable_directives
9898
}
99+
100+
/// Shared reference to the [`FrameworkOptions`]
101+
pub fn framework_options(&self) -> FrameworkOptions {
102+
self.framework_options
103+
}
99104
}
100105

101106
/// Stores shared information about a file being linted.

crates/oxc_linter/src/generated/rule_runner_impls.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2933,6 +2933,10 @@ impl RuleRunner for crate::rules::vue::prefer_import_from_vue::PreferImportFromV
29332933
const NODE_TYPES: Option<&AstTypesBitset> = None;
29342934
}
29352935

2936+
impl RuleRunner for crate::rules::vue::require_default_export::RequireDefaultExport {
2937+
const NODE_TYPES: Option<&AstTypesBitset> = None;
2938+
}
2939+
29362940
impl RuleRunner for crate::rules::vue::require_typed_ref::RequireTypedRef {
29372941
const NODE_TYPES: Option<&AstTypesBitset> =
29382942
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ pub(crate) mod vue {
649649
pub mod no_multiple_slot_args;
650650
pub mod no_required_prop_with_default;
651651
pub mod prefer_import_from_vue;
652+
pub mod require_default_export;
652653
pub mod require_typed_ref;
653654
pub mod valid_define_emits;
654655
pub mod valid_define_props;
@@ -1254,6 +1255,7 @@ oxc_macros::declare_all_lint_rules! {
12541255
vue::no_multiple_slot_args,
12551256
vue::no_required_prop_with_default,
12561257
vue::prefer_import_from_vue,
1258+
vue::require_default_export,
12571259
vue::require_typed_ref,
12581260
vue::valid_define_emits,
12591261
vue::valid_define_props,
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
use oxc_ast::{AstKind, ast::Expression};
2+
use oxc_diagnostics::OxcDiagnostic;
3+
use oxc_macros::declare_oxc_lint;
4+
use oxc_span::Span;
5+
6+
use crate::{
7+
context::{ContextHost, LintContext},
8+
frameworks::FrameworkOptions,
9+
rule::Rule,
10+
};
11+
12+
fn missing_default_export_diagnostic(span: Span) -> OxcDiagnostic {
13+
OxcDiagnostic::warn("Missing default export.").with_label(span)
14+
}
15+
16+
fn must_be_default_export_diagnostic(span: Span) -> OxcDiagnostic {
17+
OxcDiagnostic::warn("Component must be the default export.").with_label(span)
18+
}
19+
20+
#[derive(Debug, Default, Clone)]
21+
pub struct RequireDefaultExport;
22+
23+
// See <https://github.com/oxc-project/oxc/issues/6050> for documentation details.
24+
declare_oxc_lint!(
25+
/// ### What it does
26+
///
27+
/// Require components to be the default export
28+
///
29+
/// ### Why is this bad?
30+
///
31+
/// Explain why violating this rule is problematic.
32+
///
33+
/// ### Examples
34+
///
35+
/// Examples of **incorrect** code for this rule:
36+
/// ```js
37+
/// FIXME: Tests will fail if examples are missing or syntactically incorrect.
38+
/// ```
39+
///
40+
/// Examples of **correct** code for this rule:
41+
/// ```js
42+
/// FIXME: Tests will fail if examples are missing or syntactically incorrect.
43+
/// ```
44+
RequireDefaultExport,
45+
vue,
46+
suspicious,
47+
pending // TODO: describe fix capabilities. Remove if no fix can be done,
48+
// keep at 'pending' if you think one could be added but don't know how.
49+
// Options are 'fix', 'fix_dangerous', 'suggestion', and 'conditional_fix_suggestion'
50+
);
51+
52+
impl Rule for RequireDefaultExport {
53+
fn run_once(&self, ctx: &LintContext) {
54+
let has_define_component = ctx.nodes().iter().any(|node| {
55+
let AstKind::CallExpression(call_expr) = node.kind() else {
56+
return false;
57+
};
58+
59+
match call_expr.callee.get_inner_expression() {
60+
Expression::Identifier(identifier) => identifier.name == "defineComponent",
61+
Expression::StaticMemberExpression(member_expr) => {
62+
let Expression::Identifier(object_identifier) =
63+
member_expr.object.get_inner_expression()
64+
else {
65+
return false;
66+
};
67+
68+
object_identifier.name == "Vue" && member_expr.property.name == "component"
69+
}
70+
_ => false,
71+
}
72+
});
73+
74+
#[expect(clippy::cast_possible_truncation)]
75+
let span = Span::sized(
76+
ctx.source_text().len() as u32,
77+
9, // `</script>` length
78+
);
79+
80+
if has_define_component {
81+
ctx.diagnostic(must_be_default_export_diagnostic(span));
82+
} else {
83+
ctx.diagnostic(missing_default_export_diagnostic(span));
84+
}
85+
}
86+
87+
fn should_run(&self, ctx: &ContextHost) -> bool {
88+
// only on vue files
89+
if ctx.file_path().extension().is_none_or(|ext| ext != "vue") {
90+
return false;
91+
}
92+
93+
// only with `<script>`, not `<script setup>`
94+
if ctx.frameworks_options() == FrameworkOptions::VueSetup {
95+
return false;
96+
}
97+
98+
// only when no default export is present
99+
if ctx.module_record().export_default.is_some() {
100+
return false;
101+
}
102+
103+
// only when no `<script setup>` is present in the current file
104+
!ctx.other_file_hosts()
105+
.iter()
106+
.any(|host| host.framework_options() == FrameworkOptions::VueSetup)
107+
}
108+
}
109+
110+
#[test]
111+
fn test() {
112+
use crate::tester::Tester;
113+
use std::path::PathBuf;
114+
115+
let pass = vec![
116+
(
117+
"
118+
<template>Without script</template>
119+
",
120+
None,
121+
None,
122+
Some(PathBuf::from("test.vue")),
123+
),
124+
(
125+
"
126+
<script>
127+
import { ref } from 'vue';
128+
129+
export default {}
130+
</script>
131+
",
132+
None,
133+
None,
134+
Some(PathBuf::from("test.vue")),
135+
),
136+
(
137+
"
138+
<script setup>
139+
const foo = 'foo';
140+
</script>
141+
",
142+
None,
143+
None,
144+
Some(PathBuf::from("test.vue")),
145+
),
146+
(
147+
"
148+
<script>
149+
const component = {};
150+
151+
export default component;
152+
</script>
153+
",
154+
None,
155+
None,
156+
Some(PathBuf::from("test.vue")),
157+
),
158+
(
159+
"
160+
<script>
161+
import {defineComponent} from 'vue';
162+
163+
export default defineComponent({});
164+
</script>
165+
",
166+
None,
167+
None,
168+
Some(PathBuf::from("test.vue")),
169+
),
170+
(
171+
"
172+
const foo = 'foo';
173+
export const bar = 'bar';
174+
",
175+
None,
176+
None,
177+
Some(PathBuf::from("test.js")),
178+
),
179+
(
180+
"
181+
import {defineComponent} from 'vue';
182+
defineComponent({});
183+
",
184+
None,
185+
None,
186+
Some(PathBuf::from("test.js")),
187+
),
188+
];
189+
190+
let fail = vec![
191+
(
192+
"
193+
<script>
194+
const foo = 'foo';
195+
</script>
196+
",
197+
None,
198+
None,
199+
Some(PathBuf::from("test.vue")),
200+
),
201+
(
202+
"
203+
<script>
204+
export const foo = 'foo';
205+
</script>
206+
",
207+
None,
208+
None,
209+
Some(PathBuf::from("test.vue")),
210+
),
211+
(
212+
"
213+
<script>
214+
const foo = 'foo';
215+
216+
export { foo };
217+
</script>
218+
",
219+
None,
220+
None,
221+
Some(PathBuf::from("test.vue")),
222+
),
223+
(
224+
"
225+
<script>
226+
export const foo = 'foo';
227+
export const bar = 'bar';
228+
</script>
229+
",
230+
None,
231+
None,
232+
Some(PathBuf::from("test.vue")),
233+
),
234+
(
235+
"
236+
<script>
237+
import { defineComponent } from 'vue';
238+
239+
export const component = defineComponent({});
240+
</script>
241+
",
242+
None,
243+
None,
244+
Some(PathBuf::from("test.vue")),
245+
),
246+
(
247+
"
248+
<script>
249+
import Vue from 'vue';
250+
251+
const component = Vue.component('foo', {});
252+
</script>
253+
",
254+
None,
255+
None,
256+
Some(PathBuf::from("test.vue")),
257+
),
258+
];
259+
260+
Tester::new(RequireDefaultExport::NAME, RequireDefaultExport::PLUGIN, pass, fail)
261+
.test_and_snapshot();
262+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
source: crates/oxc_linter/src/tester.rs
3+
---
4+
eslint-plugin-vue(require-default-export): Missing default export.
5+
╭─[require_default_export.tsx:4:10]
6+
3const foo = 'foo';
7+
4</script>
8+
· ─────────
9+
5
10+
╰────
11+
12+
eslint-plugin-vue(require-default-export): Missing default export.
13+
╭─[require_default_export.tsx:4:10]
14+
3export const foo = 'foo';
15+
4</script>
16+
· ─────────
17+
5
18+
╰────
19+
20+
eslint-plugin-vue(require-default-export): Missing default export.
21+
╭─[require_default_export.tsx:6:10]
22+
5export { foo };
23+
6</script>
24+
· ─────────
25+
7
26+
╰────
27+
28+
eslint-plugin-vue(require-default-export): Missing default export.
29+
╭─[require_default_export.tsx:5:10]
30+
4export const bar = 'bar';
31+
5</script>
32+
· ─────────
33+
6
34+
╰────
35+
36+
eslint-plugin-vue(require-default-export): Component must be the default export.
37+
╭─[require_default_export.tsx:6:10]
38+
5export const component = defineComponent({});
39+
6</script>
40+
· ─────────
41+
7
42+
╰────
43+
44+
eslint-plugin-vue(require-default-export): Component must be the default export.
45+
╭─[require_default_export.tsx:6:10]
46+
5const component = Vue.component('foo', {});
47+
6</script>
48+
· ─────────
49+
7
50+
╰────

0 commit comments

Comments
 (0)