use skif_core::{Category, Fix, FixSafety, Fixability, RuleMeta, Severity, diagnostic};
use skif_core::{HashMap, HashSet};
use skif_js_syntax::Export;
use skif_lint::Emitter;
use super::JsRule;
use super::support::{delete_range, module_location};
use crate::analysis::JsAnalysis;
use crate::graph::Module;
pub static META: RuleMeta = RuleMeta::new(
"unused-exports",
Category::Js,
Some(Severity::Warning),
Fixability::Unsafe,
"An exported value is not imported by any other module.",
);
pub struct UnusedExports;
impl JsRule for UnusedExports {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn check(&self, analysis: &JsAnalysis<'_>, out: &mut Emitter<'_>) {
report(&META, Scope::Imports, false, analysis, out);
}
}
fn fixes(module: &Module, unused: &[&Export]) -> HashMap<*const Export, (u32, u32)> {
let wanted: HashSet<*const Export> = unused
.iter()
.filter(|export| export.removal.is_some())
.map(|export| std::ptr::from_ref::<Export>(export))
.collect();
let edits = module
.info
.removal_edits(|export| wanted.contains(&std::ptr::from_ref::<Export>(export)));
let mut claimed = vec![false; edits.len()];
let mut fixes = HashMap::default();
for export in unused {
let Some(removal) = export.removal else { continue };
let overlapping = edits
.iter()
.position(|(start, end)| *start < removal.end && removal.start < *end);
if let Some(position) = overlapping
&& !claimed[position]
{
claimed[position] = true;
fixes.insert(std::ptr::from_ref::<Export>(export), edits[position]);
}
}
fixes
}
fn removal_fix(module: &Module, export: &Export, range: (u32, u32)) -> Fix {
let whole_statement = export.removal.is_some_and(|removal| removal.statement == range);
let title = if export.removal.is_some_and(|removal| removal.default_expression) && whole_statement {
"Remove the `export default` statement with its expression".to_owned()
} else if whole_statement {
"Remove the export statement".to_owned()
} else {
format!("Remove the unused export `{}`", export.name)
};
Fix::new(title, FixSafety::Unsafe, vec![delete_range(module, range.0, range.1)])
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Scope {
Imports,
Namespaces,
}
pub(super) fn report(
meta: &'static RuleMeta,
scope: Scope,
want_type: bool,
analysis: &JsAnalysis<'_>,
out: &mut Emitter<'_>,
) {
for module in analysis.graph.modules() {
if analysis.exports.is_namespace_only(module.id) != (scope == Scope::Namespaces) {
continue;
}
let unused = analysis.unused_exports(module);
if unused.iter().all(|export| export.is_type != want_type) {
continue;
}
let fixes = fixes(module, &unused);
let package = analysis.workspace.package(module.owner);
let unused_values: HashSet<&str> = if want_type {
unused
.iter()
.filter(|export| !export.is_type)
.map(|export| export.name.as_str())
.collect()
} else {
HashSet::default()
};
for export in unused.iter().copied().filter(|export| export.is_type == want_type) {
if unused_values.contains(export.name.as_str()) {
continue;
}
let noun = if want_type { "type" } else { "export" };
let message = match scope {
Scope::Imports => format!("{noun} `{}` is never imported by another module", export.name),
Scope::Namespaces => format!("{noun} `{}` is never read through a namespace import", export.name),
};
let mut builder = diagnostic(meta, message, module_location(module, Some(export.span)));
builder = if export.referenced_in_file {
builder.help("it is only used in this file; remove the `export` keyword")
} else {
builder.help("remove it, or tag it `@public` when it is part of a public API")
};
if let Some(package) = package {
builder = builder.package(package.display_name());
}
if let Some(range) = fixes.get(&std::ptr::from_ref::<Export>(export)) {
builder = builder.fix(removal_fix(module, export, *range));
}
out.emit(builder.build());
}
}
}