use std::path::Path;
use indexmap::IndexMap;
use skif_config::{JsWorkspace, Override, PluginConfig, PluginOptions};
use skif_js_plugins::{PluginContext, PluginOutput, resolve_plugins_in};
use crate::config_check::relative_tsconfig;
use crate::config_modules::ConfigModules;
use crate::input::{
BuildDir, ConfigDependency, CustomElementLibrary, IncompleteCause, IncompleteEntries, IncompleteScope,
InputRequest, InputSource, PathAlias, RunBinary, ScopedEntryRegex, WorkspaceInputs,
};
use crate::setup::WorkspaceKeys;
use crate::specifier::types_package;
#[derive(Debug, Clone)]
pub struct PluginSource {
plugins: IndexMap<String, PluginConfig>,
workspaces: IndexMap<String, JsWorkspace>,
workspace_keys: WorkspaceKeys,
scoped: Vec<Override>,
root_tsconfig: Option<String>,
}
impl PluginSource {
#[must_use]
pub fn new(plugins: IndexMap<String, PluginConfig>) -> Self {
Self {
plugins,
workspaces: IndexMap::new(),
workspace_keys: WorkspaceKeys::default(),
scoped: Vec::new(),
root_tsconfig: None,
}
}
#[must_use]
pub fn with_root_tsconfig(mut self, path: Option<&str>) -> Self {
self.root_tsconfig = path.and_then(relative_tsconfig);
self
}
#[must_use]
pub fn with_workspaces(mut self, workspaces: &IndexMap<String, JsWorkspace>) -> Self {
self.workspaces = workspaces
.iter()
.filter(|(_, workspace)| !workspace.plugins.is_empty())
.map(|(key, workspace)| (key.clone(), workspace.clone()))
.collect();
self.workspace_keys = WorkspaceKeys::new(&self.workspaces);
self
}
#[must_use]
pub fn with_overrides(mut self, overrides: &[Override]) -> Self {
self.scoped = overrides
.iter()
.filter(|scoped| !scoped.js.plugins.is_empty())
.cloned()
.collect();
self
}
fn plugins_for(&self, name: &str, dir: &Path, key: &str, is_root: bool) -> IndexMap<String, PluginConfig> {
let mut plugins = self.plugins.clone();
if let Some(workspace) = self.workspace_keys.table(&self.workspaces, key) {
for (plugin, incoming) in &workspace.plugins {
let merged = merge(plugins.get(plugin), incoming);
plugins.insert(plugin.clone(), merged);
}
}
if is_root && let Some(path) = &self.root_tsconfig {
let current = plugins.get(TYPESCRIPT);
if let Some(read) = with_config_file(current, path) {
plugins.insert(TYPESCRIPT.to_owned(), read);
}
}
for scoped in self
.scoped
.iter()
.filter(|scoped| scoped.applies_to(Some((name, dir)), None))
{
for (plugin, incoming) in &scoped.js.plugins {
let merged = merge(plugins.get(plugin), incoming);
plugins.insert(plugin.clone(), merged);
}
}
plugins
}
}
const TYPESCRIPT: &str = "typescript";
fn with_config_file(current: Option<&PluginConfig>, path: &str) -> Option<PluginConfig> {
let mut options = match current {
Some(PluginConfig::Enabled(false)) => return None,
Some(PluginConfig::Options(options)) if options.enabled == Some(false) => return None,
Some(PluginConfig::Options(options)) => options.clone(),
Some(PluginConfig::Enabled(true)) => PluginOptions::new().with_enabled(true),
_ => PluginOptions::new(),
};
if options.config.is_empty() {
options.config = skif_js_plugins::registry::plugin(TYPESCRIPT)
.map_or_default(|plugin| plugin.spec().config.iter().map(|glob| (*glob).to_owned()).collect());
}
if options.config.iter().any(|glob| glob == path) {
return None;
}
options.config.push(path.to_owned());
Some(PluginConfig::Options(options))
}
fn merge(inherited: Option<&PluginConfig>, incoming: &PluginConfig) -> PluginConfig {
let inherited_enabled = match inherited {
Some(PluginConfig::Enabled(enabled)) => Some(*enabled),
Some(PluginConfig::Options(options)) => options.enabled,
_ => None,
};
match (incoming, inherited) {
(PluginConfig::Enabled(enabled), Some(PluginConfig::Options(options))) => {
PluginConfig::Options(options.clone().with_enabled(*enabled))
}
(PluginConfig::Options(options), _) if options.enabled.is_none() => {
let mut merged = options.clone();
merged.enabled = inherited_enabled;
PluginConfig::Options(merged)
}
_ => incoming.clone(),
}
}
fn scripts(request: &InputRequest<'_>) -> IndexMap<String, String> {
request
.package
.manifest
.value()
.get("scripts")
.and_then(serde_json::Value::as_object)
.map_or_default(|scripts| {
scripts
.iter()
.filter_map(|(name, command)| Some((name.clone(), command.as_str()?.to_owned())))
.collect()
})
}
fn names(output: &[skif_js_plugins::DepRef], optional: bool) -> impl Iterator<Item = String> + '_ {
output
.iter()
.filter(move |dependency| dependency.optional == optional && (optional || dependency.dir.is_none()))
.flat_map(|dependency| {
let types = types_package(&dependency.name).filter(|_| dependency.type_only);
std::iter::once(dependency.name.clone()).chain(types)
})
}
fn incomplete_scope(reasons: &[skif_js_plugins::IncompleteEntries]) -> IncompleteScope {
reasons
.iter()
.map(|reason| match &reason.scope {
skif_js_plugins::IncompleteScope::Within(globs) => IncompleteScope::Within(globs.clone()),
_ => IncompleteScope::Package,
})
.reduce(IncompleteScope::union)
.unwrap_or_default()
}
fn inputs(output: PluginOutput) -> WorkspaceInputs {
let incomplete_scope = incomplete_scope(&output.incomplete_reasons);
let entry = output
.entries
.iter()
.filter(|glob| !output.production_entries.contains(glob))
.cloned()
.collect();
let build_source = output
.build_sources
.iter()
.filter(|glob| !output.production_build_sources.contains(glob))
.cloned()
.collect();
let mut ignore_binaries = output.provided_binaries;
ignore_binaries.sort();
ignore_binaries.dedup();
WorkspaceInputs {
entry,
production_entry: output.production_entries,
build_source,
production_build_source: output.production_build_sources,
entry_regex: output.entry_regexes,
scoped_entry_regex: output
.scoped_entry_regexes
.into_iter()
.map(|scoped| ScopedEntryRegex {
scope: scoped.scope,
regex: scoped.regex,
})
.collect(),
project: output.project,
ignore: output.ignore,
ignore_dependencies: names(&output.referenced_dependencies, true).collect(),
ignore_binaries,
virtual_modules: output.virtual_modules,
dependencies: names(&output.referenced_dependencies, false).collect(),
relocated_dependencies: output
.referenced_dependencies
.iter()
.filter(|dependency| !dependency.optional)
.filter_map(|dependency| Some((dependency.dir.clone()?, dependency.name.clone())))
.collect(),
config_dependencies: output
.referenced_dependencies
.iter()
.filter(|dependency| dependency.named_by_config)
.map(|dependency| ConfigDependency {
name: dependency.name.clone(),
file: dependency.from.clone(),
span: dependency.span,
type_only: dependency.type_only,
reader_may_list: dependency.reader_may_list,
reader_lists: dependency.reader_lists,
})
.collect(),
binaries: output.referenced_binaries,
run_binaries: output
.run_binaries
.into_iter()
.map(|run| {
let binary = RunBinary::new(run.name, run.from, run.dir, run.filtered);
if run.judged { binary } else { binary.credit_only() }
})
.collect(),
config_files: output.config_files,
aliases: output
.aliases
.into_iter()
.map(|alias| PathAlias {
find: alias.find,
target: alias.target,
})
.collect(),
build_dirs: output
.build_dirs
.into_iter()
.map(|dir| BuildDir {
out_dir: dir.out_dir,
root_dir: dir.root_dir,
})
.collect(),
conditions: output.conditions,
module_dirs: output.module_dirs,
extensions: output.extensions,
custom_elements: output
.custom_elements
.into_iter()
.map(|library| CustomElementLibrary {
modules: library.modules.iter().map(|module| (*module).to_owned()).collect(),
decorator: library.decorator.to_owned(),
base_class: library.base_class.map(str::to_owned),
})
.collect(),
entries_incomplete: output.entries_incomplete,
incomplete_reasons: output
.incomplete_reasons
.into_iter()
.map(|reason| {
let cause = match reason.cause {
skif_js_plugins::IncompleteCause::Unparsable => IncompleteCause::UnparsableConfig,
skif_js_plugins::IncompleteCause::UnknownValue => IncompleteCause::UnknownConfig,
skif_js_plugins::IncompleteCause::UnknownSpread => IncompleteCause::UnknownSpread,
skif_js_plugins::IncompleteCause::ComputedEntries => IncompleteCause::ComputedEntries,
_ => IncompleteCause::Unspecified,
};
IncompleteEntries::new(cause)
.with_plugin(reason.plugin)
.with_file(reason.file)
})
.collect(),
incomplete_scope,
}
}
fn inherited_dependencies(request: &InputRequest<'_>) -> Vec<String> {
let dir = &request.package.dir;
let mut names: Vec<String> = request
.workspace
.iter()
.filter(|(_, other)| other.dir != *dir && (other.is_root || dir.starts_with(&other.dir)))
.flat_map(|(_, other)| other.dependencies.iter().map(|entry| entry.name.to_string()))
.chain(request.ancestor_dependencies.iter().cloned())
.collect();
names.sort();
names.dedup();
names
}
fn dir_below(dir: &str, package_dir: &str) -> Option<String> {
if dir == package_dir {
return Some(String::new());
}
if package_dir.is_empty() {
return Some(dir.to_owned());
}
dir.strip_prefix(package_dir)?.strip_prefix('/').map(str::to_owned)
}
impl InputSource for PluginSource {
fn inputs(&self, request: &InputRequest<'_>) -> WorkspaceInputs {
let package = request.package;
let dependencies: Vec<String> = package
.dependencies
.iter()
.map(|entry| entry.name.to_string())
.collect();
let inherited = inherited_dependencies(request);
let scripts = scripts(request);
let dir = request.workspace.root.join(Path::new(&package.dir));
let plugins = self.plugins_for(
package.display_name(),
if package.is_root { &request.workspace.root } else { &dir },
request.workspace_key,
package.is_root,
);
let modules = ConfigModules::new(request.fs, request.workspace);
let context = PluginContext {
dir: &dir,
is_root: package.is_root,
package_name: package.name.as_deref(),
dependencies: &dependencies,
inherited_dependencies: &inherited,
scripts: &scripts,
fs: request.fs,
plugins: &plugins,
modules: Some(&modules),
nx: None,
globs: request.listing.globs(),
sources: None,
};
let package_dir = package.dir.to_string_lossy().replace('\\', "/");
let projects = || -> Vec<(String, bool)> {
request
.listing
.nx_projects(request.fs, request.workspace)
.iter()
.filter_map(|(project, owner)| {
dir_below(project, &package_dir).map(|relative| (relative, *owner == package_dir))
})
.collect()
};
resolve_plugins_in(&context, &package_dir, projects).into_iter().fold(
WorkspaceInputs::default(),
|mut all, (_, output)| {
all.extend(inputs(output));
all
},
)
}
}
#[cfg(test)]
mod tests {
use skif_config::{PluginConfig, PluginOptions};
use super::with_config_file;
#[test]
fn the_root_tsconfig_is_added_to_the_typescript_config_files() {
let Some(PluginConfig::Options(options)) = with_config_file(None, "config/tsconfig.app.json") else {
panic!("expected options");
};
assert_eq!(options.enabled, None);
assert!(options.config.iter().any(|glob| glob == "tsconfig.json"));
assert_eq!(
options.config.last().map(String::as_str),
Some("config/tsconfig.app.json")
);
let custom = PluginConfig::Options(PluginOptions::new().with_config(["base.json"]));
let Some(PluginConfig::Options(options)) = with_config_file(Some(&custom), "app.json") else {
panic!("expected options");
};
assert_eq!(options.config, ["base.json", "app.json"]);
assert_eq!(with_config_file(Some(&PluginConfig::Enabled(false)), "app.json"), None);
let reads = PluginConfig::Options(PluginOptions::new().with_config(["app.json"]));
assert_eq!(with_config_file(Some(&reads), "app.json"), None);
}
}