Skip to content

Commit 4a1ca4a

Browse files
committed
fix(linter/export): detect duplicate explicit exports (#22798)
fixes #22377
1 parent a769b33 commit 4a1ca4a

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

crates/oxc_linter/src/rules/import/export.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ use oxc_macros::declare_oxc_lint;
77
use oxc_span::Span;
88
use oxc_str::CompactStr;
99

10-
use crate::{ModuleRecord, context::LintContext, rule::Rule};
10+
use crate::{
11+
ModuleRecord,
12+
context::LintContext,
13+
module_record::{ExportEntry, ExportExportName},
14+
rule::Rule,
15+
};
1116

1217
fn no_named_export(module_name: &str, span: Span) -> OxcDiagnostic {
1318
OxcDiagnostic::warn(format!("No named exports found in module '{module_name}'"))
@@ -56,6 +61,8 @@ impl Rule for Export {
5661
let module_record = ctx.module_record();
5762
let named_export = &module_record.exported_bindings;
5863

64+
diagnose_duplicate_named_exports(ctx, module_record);
65+
5966
let mut all_export_names = FxHashMap::default();
6067
let mut visited = FxHashSet::default();
6168

@@ -104,6 +111,64 @@ impl Rule for Export {
104111
}
105112
}
106113

114+
struct ExportNameSpans {
115+
spans: Vec<Span>,
116+
has_named_specifier: bool,
117+
}
118+
119+
fn diagnose_duplicate_named_exports(ctx: &LintContext<'_>, module_record: &ModuleRecord) {
120+
let mut export_names: FxHashMap<(CompactStr, bool), ExportNameSpans> = FxHashMap::default();
121+
122+
module_record
123+
.local_export_entries
124+
.iter()
125+
.chain(&module_record.indirect_export_entries)
126+
.for_each(|export_entry| {
127+
let Some((name, span)) = export_name(export_entry) else {
128+
return;
129+
};
130+
131+
let entry = export_names
132+
.entry((CompactStr::from(name), export_entry.is_type))
133+
.or_insert(ExportNameSpans { spans: Vec::new(), has_named_specifier: false });
134+
entry.spans.push(span);
135+
entry.has_named_specifier |= is_named_export_specifier(ctx, export_entry);
136+
});
137+
138+
for ((name, _), entry) in export_names {
139+
if entry.spans.len() <= 1 || !entry.has_named_specifier {
140+
continue;
141+
}
142+
143+
let labels = entry.spans.into_iter().map(LabeledSpan::underline).collect::<Vec<_>>();
144+
ctx.diagnostic(
145+
OxcDiagnostic::warn(format!("Multiple exports of name '{name}'."))
146+
.with_help(
147+
"Rename or remove the duplicate export so each name is exported only once.",
148+
)
149+
.with_labels(labels),
150+
);
151+
}
152+
}
153+
154+
fn export_name(export_entry: &ExportEntry) -> Option<(&str, Span)> {
155+
match &export_entry.export_name {
156+
ExportExportName::Name(name) => Some((name.name(), name.span)),
157+
ExportExportName::Default(span) => Some(("default", *span)),
158+
ExportExportName::Null => None,
159+
}
160+
}
161+
162+
fn is_named_export_specifier(ctx: &LintContext<'_>, export_entry: &ExportEntry) -> bool {
163+
// Resolved indirect exports use the import statement span, which can appear after the export.
164+
if export_entry.statement_span.start > export_entry.span.start {
165+
return true;
166+
}
167+
168+
ctx.find_next_token_within(export_entry.statement_span.start, export_entry.span.start, "{")
169+
.is_some()
170+
}
171+
107172
fn walk_exported_recursive(
108173
module_record: &ModuleRecord,
109174
result: &mut FxHashSet<CompactStr>,
@@ -265,6 +330,7 @@ fn test() {
265330
export {Bar as default};
266331
"#),
267332
"export type * from './export-props.js'",
333+
"export const foo = 1;\nimport { foo } from 'mod';",
268334
];
269335
let fail = vec![
270336
(r#"let foo; export { foo }; export * from "./export-all""#),
@@ -337,6 +403,9 @@ fn test() {
337403
// export const Foo = 'bar';
338404
// export namespace Foo { }
339405
// "),
406+
("export const foo = function () {};\nfunction bar() {}\nexport { bar as foo };"),
407+
("export const foo = 1;\nexport /* comment */ { foo };"),
408+
("export const value = 1;\nexport { value };"),
340409
];
341410

342411
Tester::new(Export::NAME, Export::PLUGIN, pass, fail)

crates/oxc_linter/src/snapshots/import_export.snap

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,31 @@ source: crates/oxc_linter/src/tester.rs
9999
· ╰── It can not be redeclared here
100100
4export namespace Foo { }
101101
╰────
102+
103+
import(export): Multiple exports of name 'foo'.
104+
╭─[index.ts:1:14]
105+
1export const foo = function () {};
106+
· ───
107+
2function bar() {}
108+
3export { bar as foo };
109+
· ───
110+
╰────
111+
help: Rename or remove the duplicate export so each name is exported only once.
112+
113+
import(export): Multiple exports of name 'foo'.
114+
╭─[index.ts:1:14]
115+
1export const foo = 1;
116+
· ───
117+
2export /* comment */ { foo };
118+
· ───
119+
╰────
120+
help: Rename or remove the duplicate export so each name is exported only once.
121+
122+
import(export): Multiple exports of name 'value'.
123+
╭─[index.ts:1:14]
124+
1export const value = 1;
125+
· ─────
126+
2export { value };
127+
· ─────
128+
╰────
129+
help: Rename or remove the duplicate export so each name is exported only once.

0 commit comments

Comments
 (0)