Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/oxc_linter/src/context/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ impl<'a> ContextSubHost<'a> {
pub fn disable_directives(&self) -> &DisableDirectives {
&self.disable_directives
}

/// Shared reference to the [`FrameworkOptions`]
pub fn framework_options(&self) -> FrameworkOptions {
self.framework_options
}
}

/// Stores shared information about a file being linted.
Expand Down
4 changes: 4 additions & 0 deletions crates/oxc_linter/src/generated/rule_runner_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2937,6 +2937,10 @@ impl RuleRunner for crate::rules::vue::prefer_import_from_vue::PreferImportFromV
const NODE_TYPES: Option<&AstTypesBitset> = None;
}

impl RuleRunner for crate::rules::vue::require_default_export::RequireDefaultExport {
const NODE_TYPES: Option<&AstTypesBitset> = None;
}

impl RuleRunner for crate::rules::vue::require_typed_ref::RequireTypedRef {
const NODE_TYPES: Option<&AstTypesBitset> =
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ pub(crate) mod vue {
pub mod no_multiple_slot_args;
pub mod no_required_prop_with_default;
pub mod prefer_import_from_vue;
pub mod require_default_export;
pub mod require_typed_ref;
pub mod valid_define_emits;
pub mod valid_define_props;
Expand Down Expand Up @@ -1256,6 +1257,7 @@ oxc_macros::declare_all_lint_rules! {
vue::no_multiple_slot_args,
vue::no_required_prop_with_default,
vue::prefer_import_from_vue,
vue::require_default_export,
vue::require_typed_ref,
vue::valid_define_emits,
vue::valid_define_props,
Expand Down
269 changes: 269 additions & 0 deletions crates/oxc_linter/src/rules/vue/require_default_export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
use oxc_ast::{AstKind, ast::Expression};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;

use crate::{
context::{ContextHost, LintContext},
frameworks::FrameworkOptions,
rule::Rule,
};

fn missing_default_export_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Missing default export.").with_label(span)
}

fn must_be_default_export_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Component must be the default export.").with_label(span)
}

#[derive(Debug, Default, Clone)]
pub struct RequireDefaultExport;

declare_oxc_lint!(
/// ### What it does
///
/// Require components to be the default export.
///
/// ### Why is this bad?
///
/// Using SFCs (Single File Components) without a default export is
/// not supported in Vue 3. Components should be exported as the default export.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```vue
/// <script>
/// const foo = 'foo';
/// </script>
/// ```
///
/// Examples of **correct** code for this rule:
/// ```vue
/// <script>
/// export default {
/// data() {
/// return {
/// foo: 'foo'
/// };
/// }
/// };
/// </script>
/// ```
RequireDefaultExport,
vue,
suspicious,
);

impl Rule for RequireDefaultExport {
fn run_once(&self, ctx: &LintContext) {
let has_define_component = ctx.nodes().iter().any(|node| {
let AstKind::CallExpression(call_expr) = node.kind() else {
return false;
};

match call_expr.callee.get_inner_expression() {
Expression::Identifier(identifier) => identifier.name == "defineComponent",
Expression::StaticMemberExpression(member_expr) => {
let Expression::Identifier(object_identifier) =
member_expr.object.get_inner_expression()
else {
return false;
};

object_identifier.name == "Vue" && member_expr.property.name == "component"
}
_ => false,
}
});

#[expect(clippy::cast_possible_truncation)]
let span = Span::sized(
ctx.source_text().len() as u32,
9, // `</script>` length
);

if has_define_component {
ctx.diagnostic(must_be_default_export_diagnostic(span));
} else {
ctx.diagnostic(missing_default_export_diagnostic(span));
}
}

fn should_run(&self, ctx: &ContextHost) -> bool {
// only on vue files
if ctx.file_path().extension().is_none_or(|ext| ext != "vue") {
return false;
}

// only with `<script>`, not `<script setup>`
if ctx.frameworks_options() == FrameworkOptions::VueSetup {
return false;
}

// only when no default export is present
if ctx.module_record().export_default.is_some() {
return false;
}

// only when no `<script setup>` is present in the current file
!ctx.other_file_hosts()
.iter()
.any(|host| host.framework_options() == FrameworkOptions::VueSetup)
}
}

#[test]
fn test() {
use crate::tester::Tester;
use std::path::PathBuf;

let pass = vec![
(
"
<template>Without script</template>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
import { ref } from 'vue';

export default {}
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script setup>
const foo = 'foo';
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
const component = {};

export default component;
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
import {defineComponent} from 'vue';

export default defineComponent({});
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
const foo = 'foo';
export const bar = 'bar';
",
None,
None,
Some(PathBuf::from("test.js")),
),
(
"
import {defineComponent} from 'vue';
defineComponent({});
",
None,
None,
Some(PathBuf::from("test.js")),
),
];

let fail = vec![
(
"
<script>
const foo = 'foo';
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
export const foo = 'foo';
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
const foo = 'foo';

export { foo };
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
export const foo = 'foo';
export const bar = 'bar';
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
import { defineComponent } from 'vue';

export const component = defineComponent({});
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
import Vue from 'vue';

const component = Vue.component('foo', {});
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
];

Tester::new(RequireDefaultExport::NAME, RequireDefaultExport::PLUGIN, pass, fail)
.test_and_snapshot();
}
50 changes: 50 additions & 0 deletions crates/oxc_linter/src/snapshots/vue_require_default_export.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint-plugin-vue(require-default-export): Missing default export.
╭─[require_default_export.tsx:4:10]
3 │ const foo = 'foo';
4 │ </script>
· ─────────
5 │
╰────

⚠ eslint-plugin-vue(require-default-export): Missing default export.
╭─[require_default_export.tsx:4:10]
3 │ export const foo = 'foo';
4 │ </script>
· ─────────
5 │
╰────

⚠ eslint-plugin-vue(require-default-export): Missing default export.
╭─[require_default_export.tsx:6:10]
5 │ export { foo };
6 │ </script>
· ─────────
7 │
╰────

⚠ eslint-plugin-vue(require-default-export): Missing default export.
╭─[require_default_export.tsx:5:10]
4 │ export const bar = 'bar';
5 │ </script>
· ─────────
6 │
╰────

⚠ eslint-plugin-vue(require-default-export): Component must be the default export.
╭─[require_default_export.tsx:6:10]
5 │ export const component = defineComponent({});
6 │ </script>
· ─────────
7 │
╰────

⚠ eslint-plugin-vue(require-default-export): Component must be the default export.
╭─[require_default_export.tsx:6:10]
5 │ const component = Vue.component('foo', {});
6 │ </script>
· ─────────
7 │
╰────
Loading