use skif_core::HashSet;
use std::path::{Path, PathBuf};
use indexmap::IndexSet;
use serde_json::Value;
use skif_config::Config;
use skif_core::FileSystem;
use skif_workspace::{PackageId, Workspace};
use crate::analysis::workspace_key;
use crate::discover::Candidate;
use crate::entries::{Disk, names_no_file};
use crate::resolve::{is_read_file, join_normalized};
use crate::setup::{Setup, WorkspaceKeys};
use crate::specifier::package_name;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum HintKind {
EntryRedundant,
ProjectRedundant,
TopLevel,
ExtensionUnregistered,
ExtensionExcluded,
PackageEntry,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ConfigHint {
pub kind: HintKind,
pub key: String,
pub identifier: String,
pub package: Option<PackageId>,
}
const COMPONENT_EXTENSIONS: [&str; 3] = ["vue", "svelte", "astro"];
pub(crate) struct HintInputs<'a> {
pub workspace: &'a Workspace,
pub config: &'a Config,
pub setup: &'a Setup,
pub candidates: &'a [Candidate],
pub entries: &'a HashSet<PathBuf>,
pub provided: &'a HashSet<PathBuf>,
pub disk: &'a dyn FileSystem,
}
pub(crate) fn config_hints(inputs: &HintInputs<'_>) -> Vec<ConfigHint> {
let mut hints: IndexSet<ConfigHint> = top_level_hints(inputs.workspace, inputs.config).collect();
let keys = &inputs.setup.workspace_keys;
for (id, package) in inputs.workspace.iter() {
if let Some(package_setup) = inputs.setup.package(id) {
let scope = Scope {
inputs,
keys,
id,
package,
package_setup,
};
scope.redundant(&mut hints);
if !inputs.config.js.use_tsconfig_files {
scope.extensions(&mut hints);
}
}
}
hints.extend(package_entries(inputs));
hints.into_iter().collect()
}
fn top_level_hints<'a>(workspace: &Workspace, config: &'a Config) -> impl Iterator<Item = ConfigHint> + 'a {
let several = workspace.iter().count() > 1;
[("entry", &config.js.entry), ("project", &config.js.project)]
.into_iter()
.filter(move |_| several)
.filter_map(|(name, patterns)| {
let first = patterns.first()?;
let more = if patterns.len() > 1 { ", …" } else { "" };
Some(ConfigHint {
kind: HintKind::TopLevel,
key: format!("js.{name}"),
identifier: format!("[{first}{more}]"),
package: None,
})
})
}
struct Scope<'s, 'a> {
inputs: &'s HintInputs<'a>,
keys: &'s WorkspaceKeys,
id: PackageId,
package: &'s skif_workspace::Package,
package_setup: &'s crate::setup::PackageSetup,
}
impl Scope<'_, '_> {
fn key(&self, name: &str, pattern: Option<&String>) -> String {
let js = &self.inputs.config.js;
let top_level = if name == "entry" { &js.entry } else { &js.project };
let from_top_level = pattern.map_or_else(
|| self.package_setup.user_patterns.project == js.project,
|pattern| top_level.contains(pattern),
);
if self.package.is_root && from_top_level {
format!("js.{name}")
} else {
workspace_key(self.inputs.config, self.keys, self.package, name)
}
}
fn hint(&self, kind: HintKind, name: &str, pattern: Option<&String>, identifier: String) -> ConfigHint {
ConfigHint {
kind,
key: self.key(name, pattern),
identifier,
package: None,
}
}
fn redundant(&self, hints: &mut IndexSet<ConfigHint>) {
let user = &self.package_setup.user_patterns;
let dir = &self.package_setup.dir;
for pattern in &user.entry {
if literal_path(dir, pattern).is_some_and(|path| self.inputs.provided.contains(&path)) {
hints.insert(self.hint(HintKind::EntryRedundant, "entry", Some(pattern), pattern.clone()));
}
}
if self.inputs.config.js.use_tsconfig_files {
return;
}
for pattern in &user.project {
if literal_path(dir, pattern).is_some_and(|path| self.inputs.entries.contains(&path)) {
hints.insert(self.hint(HintKind::ProjectRedundant, "project", Some(pattern), pattern.clone()));
}
}
}
fn extensions(&self, hints: &mut IndexSet<ConfigHint>) {
let mut named: Vec<String> = Vec::new();
for pattern in self
.package_setup
.user_patterns
.project
.iter()
.filter(|pattern| !pattern.starts_with('!'))
{
for extension in pattern_extensions(pattern) {
let file = format!("x.{extension}");
if !is_read_file(Path::new(&file)) && !self.inputs.setup.compilers.handles(Path::new(&file)) {
let identifier = format!(".{extension}");
hints.insert(self.hint(HintKind::ExtensionUnregistered, "project", Some(pattern), identifier));
}
named.push(extension.to_ascii_lowercase());
}
}
if named.is_empty() {
return;
}
for extension in COMPONENT_EXTENSIONS
.iter()
.filter(|extension| !named.iter().any(|n| n == *extension))
{
let suffix = format!(".{extension}");
let has_files =
self.inputs.candidates.iter().any(|candidate| {
candidate.owner == self.id && candidate.rel.to_ascii_lowercase().ends_with(&suffix)
});
if has_files {
hints.insert(self.hint(HintKind::ExtensionExcluded, "project", None, suffix));
}
}
}
}
fn literal_path(dir: &Path, pattern: &str) -> Option<PathBuf> {
let body = pattern.trim();
let body = body.strip_suffix('!').unwrap_or(body);
if body.is_empty() || body.starts_with('!') || body.starts_with("../") || body.contains(['*', '?', '[', '{']) {
return None;
}
Some(join_normalized(dir, body.trim_start_matches("./")))
}
fn pattern_extensions(pattern: &str) -> Vec<String> {
let body = pattern.strip_suffix('!').unwrap_or(pattern);
let segment = body.rsplit('/').next().unwrap_or(body);
let simple = |text: &str| !text.is_empty() && text.chars().all(|c| c.is_ascii_alphanumeric());
let collect = |inner: &str, delimiter: char| -> Vec<String> {
inner
.split(delimiter)
.map(str::trim)
.filter(|part| simple(part))
.map(str::to_owned)
.collect()
};
if let Some(open) = segment.strip_suffix('}') {
return open
.rfind(".{")
.map(|start| &open[start + 2..])
.filter(|inner| !inner.contains(['{', '}']))
.map_or_default(|inner| collect(inner, ','));
}
if let Some(open) = segment.strip_suffix(')') {
let Some(start) = open.rfind('(') else {
return Vec::new();
};
let before = &open[..start];
let before = before.strip_suffix(['@', '+', '*', '?']).unwrap_or(before);
if !before.ends_with('.') || open[start + 1..].contains(['(', ')']) {
return Vec::new();
}
return collect(&open[start + 1..], '|');
}
segment
.rsplit_once('.')
.map(|(_, extension)| extension)
.filter(|extension| simple(extension))
.map_or_default(|extension| vec![extension.to_owned()])
}
fn published_paths(manifest: &Value) -> Vec<(&'static str, String)> {
let mut paths: Vec<(&'static str, String)> = Vec::new();
let mut push = |field: &'static str, path: &str| {
let entry = (field, path.to_owned());
if !path.trim().is_empty() && !paths.contains(&entry) {
paths.push(entry);
}
};
for field in ["main", "module", "browser"] {
if let Some(Value::String(path)) = manifest.get(field) {
push(field, path);
}
}
match manifest.get("bin") {
Some(Value::String(path)) => push("bin", path),
Some(Value::Object(bins)) => bins
.values()
.filter_map(Value::as_str)
.for_each(|path| push("bin", path)),
_ => {}
}
for field in ["types", "typings"] {
if let Some(Value::String(path)) = manifest.get(field) {
push(field, path);
}
}
if let Some(exports) = manifest.get("exports") {
let mut leaves = Vec::new();
export_leaves(exports, &mut leaves);
for leaf in leaves.into_iter().filter(|leaf| leaf != "./*") {
push("exports", &widen_stars(&leaf));
}
}
paths
}
fn export_leaves(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(text) => out.push(text.clone()),
Value::Object(map) => map.values().for_each(|item| export_leaves(item, out)),
_ => {}
}
}
fn widen_stars(path: &str) -> String {
let path = path
.strip_suffix("/*")
.map_or_else(|| path.to_owned(), |head| format!("{head}/**"));
let path = path.replacen("/*.", "/**/*.", 1);
path.replacen("/*/", "/**/", 1)
}
fn package_entries(inputs: &HintInputs<'_>) -> Vec<ConfigHint> {
let disk = Disk {
fs: inputs.disk,
gitignore: !inputs.config.js.no_gitignore,
};
let mut hints = Vec::new();
for (id, package) in inputs.workspace.iter() {
let Some(package_setup) = inputs.setup.package(id) else {
continue;
};
for (field, path) in published_paths(package.manifest.value()) {
if path.starts_with('!') {
continue;
}
if !path.starts_with(['.', '/'])
&& package_name(&path).is_some_and(|name| package.dependencies.iter().any(|dep| *dep.name == *name))
{
continue;
}
if names_no_file(
disk,
&inputs.workspace.root,
package_setup,
package.manifest.value(),
&path,
) {
hints.push(ConfigHint {
kind: HintKind::PackageEntry,
key: field.to_owned(),
identifier: path,
package: Some(id),
});
}
}
}
hints
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extensions_come_from_the_last_segment() {
assert_eq!(pattern_extensions("src/**/*.ts"), ["ts"]);
assert_eq!(pattern_extensions("**/*.{ts,json}!"), ["ts", "json"]);
assert_eq!(pattern_extensions("docs/*.@(md|mdx)"), ["md", "mdx"]);
assert_eq!(pattern_extensions("lib/**"), Vec::<String>::new());
assert_eq!(pattern_extensions("src/{a,b}/x"), Vec::<String>::new());
assert_eq!(pattern_extensions("index.html"), ["html"]);
}
#[test]
fn literal_paths_are_plain_paths() {
let dir = Path::new("/ws/a");
assert_eq!(
literal_path(dir, "./src/index.ts!"),
Some(PathBuf::from("/ws/a/src/index.ts"))
);
assert_eq!(literal_path(dir, "src/*.ts"), None);
assert_eq!(literal_path(dir, "!src/a.ts"), None);
assert_eq!(literal_path(dir, "../b/x.ts"), None);
}
#[test]
fn export_stars_cross_directories() {
assert_eq!(widen_stars("./dist/*"), "./dist/**");
assert_eq!(widen_stars("./dist/*.js"), "./dist/**/*.js");
assert_eq!(widen_stars("./dist/*/index.js"), "./dist/**/index.js");
}
}