Skip to content

Commit 57ed9a0

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

File tree

5 files changed

+330
-0
lines changed

5 files changed

+330
-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: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
declare_oxc_lint!(
24+
/// ### What it does
25+
///
26+
/// Require components to be the default export.
27+
///
28+
/// ### Why is this bad?
29+
///
30+
/// Using SFCs (Single File Components) without a default export is
31+
/// not supported in Vue 3. Components should be exported as the default export.
32+
///
33+
/// ### Examples
34+
///
35+
/// Examples of **incorrect** code for this rule:
36+
/// ```vue
37+
/// <script>
38+
/// const foo = 'foo';
39+
/// </script>
40+
/// ```
41+
///
42+
/// Examples of **correct** code for this rule:
43+
/// ```vue
44+
/// <script>
45+
/// export default {
46+
/// data() {
47+
/// return {
48+
/// foo: 'foo'
49+
/// };
50+
/// }
51+
/// };
52+
/// </script>
53+
/// ```
54+
RequireDefaultExport,
55+
vue,
56+
suspicious,
57+
);
58+
59+
impl Rule for RequireDefaultExport {
60+
fn run_once(&self, ctx: &LintContext) {
61+
let has_define_component = ctx.nodes().iter().any(|node| {
62+
let AstKind::CallExpression(call_expr) = node.kind() else {
63+
return false;
64+
};
65+
66+
match call_expr.callee.get_inner_expression() {
67+
Expression::Identifier(identifier) => identifier.name == "defineComponent",
68+
Expression::StaticMemberExpression(member_expr) => {
69+
let Expression::Identifier(object_identifier) =
70+
member_expr.object.get_inner_expression()
71+
else {
72+
return false;
73+
};
74+
75+
object_identifier.name == "Vue" && member_expr.property.name == "component"
76+
}
77+
_ => false,
78+
}
79+
});
80+
81+
#[expect(clippy::cast_possible_truncation)]
82+
let span = Span::sized(
83+
ctx.source_text().len() as u32,
84+
9, // `</script>` length
85+
);
86+
87+
if has_define_component {
88+
ctx.diagnostic(must_be_default_export_diagnostic(span));
89+
} else {
90+
ctx.diagnostic(missing_default_export_diagnostic(span));
91+
}
92+
}
93+
94+
fn should_run(&self, ctx: &ContextHost) -> bool {
95+
// only on vue files
96+
if ctx.file_path().extension().is_none_or(|ext| ext != "vue") {
97+
return false;
98+
}
99+
100+
// only with `<script>`, not `<script setup>`
101+
if ctx.frameworks_options() == FrameworkOptions::VueSetup {
102+
return false;
103+
}
104+
105+
// only when no default export is present
106+
if ctx.module_record().export_default.is_some() {
107+
return false;
108+
}
109+
110+
// only when no `<script setup>` is present in the current file
111+
!ctx.other_file_hosts()
112+
.iter()
113+
.any(|host| host.framework_options() == FrameworkOptions::VueSetup)
114+
}
115+
}
116+
117+
#[test]
118+
fn test() {
119+
use crate::tester::Tester;
120+
use std::path::PathBuf;
121+
122+
let pass = vec![
123+
(
124+
"
125+
<template>Without script</template>
126+
",
127+
None,
128+
None,
129+
Some(PathBuf::from("test.vue")),
130+
),
131+
(
132+
"
133+
<script>
134+
import { ref } from 'vue';
135+
136+
export default {}
137+
</script>
138+
",
139+
None,
140+
None,
141+
Some(PathBuf::from("test.vue")),
142+
),
143+
(
144+
"
145+
<script setup>
146+
const foo = 'foo';
147+
</script>
148+
",
149+
None,
150+
None,
151+
Some(PathBuf::from("test.vue")),
152+
),
153+
(
154+
"
155+
<script>
156+
const component = {};
157+
158+
export default component;
159+
</script>
160+
",
161+
None,
162+
None,
163+
Some(PathBuf::from("test.vue")),
164+
),
165+
(
166+
"
167+
<script>
168+
import {defineComponent} from 'vue';
169+
170+
export default defineComponent({});
171+
</script>
172+
",
173+
None,
174+
None,
175+
Some(PathBuf::from("test.vue")),
176+
),
177+
(
178+
"
179+
const foo = 'foo';
180+
export const bar = 'bar';
181+
",
182+
None,
183+
None,
184+
Some(PathBuf::from("test.js")),
185+
),
186+
(
187+
"
188+
import {defineComponent} from 'vue';
189+
defineComponent({});
190+
",
191+
None,
192+
None,
193+
Some(PathBuf::from("test.js")),
194+
),
195+
];
196+
197+
let fail = vec![
198+
(
199+
"
200+
<script>
201+
const foo = 'foo';
202+
</script>
203+
",
204+
None,
205+
None,
206+
Some(PathBuf::from("test.vue")),
207+
),
208+
(
209+
"
210+
<script>
211+
export const foo = 'foo';
212+
</script>
213+
",
214+
None,
215+
None,
216+
Some(PathBuf::from("test.vue")),
217+
),
218+
(
219+
"
220+
<script>
221+
const foo = 'foo';
222+
223+
export { foo };
224+
</script>
225+
",
226+
None,
227+
None,
228+
Some(PathBuf::from("test.vue")),
229+
),
230+
(
231+
"
232+
<script>
233+
export const foo = 'foo';
234+
export const bar = 'bar';
235+
</script>
236+
",
237+
None,
238+
None,
239+
Some(PathBuf::from("test.vue")),
240+
),
241+
(
242+
"
243+
<script>
244+
import { defineComponent } from 'vue';
245+
246+
export const component = defineComponent({});
247+
</script>
248+
",
249+
None,
250+
None,
251+
Some(PathBuf::from("test.vue")),
252+
),
253+
(
254+
"
255+
<script>
256+
import Vue from 'vue';
257+
258+
const component = Vue.component('foo', {});
259+
</script>
260+
",
261+
None,
262+
None,
263+
Some(PathBuf::from("test.vue")),
264+
),
265+
];
266+
267+
Tester::new(RequireDefaultExport::NAME, RequireDefaultExport::PLUGIN, pass, fail)
268+
.test_and_snapshot();
269+
}
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)