1use rustc_hash::{FxHashMap, FxHashSet};
4use std::fmt;
5use std::path::{Path, PathBuf};
6use std::sync::{OnceLock, RwLock};
7
8use fallow_config::{
9 AutoImportRule, EntryPointRole, ExternalPluginDef, JsxImportSourceRule, PackageJson,
10 UsedClassMemberRule,
11};
12
13use crate::scripts;
14
15use super::{PathRule, Plugin, PluginResult, PluginUsedExportRule, ProvidedDependencyRule};
16
17pub(crate) mod builtin;
18mod helpers;
19mod tooling_evidence;
20
21pub(crate) use tooling_evidence::find_config_file;
22
23#[must_use]
28pub fn builtin_plugin_names() -> Vec<&'static str> {
29 builtin::create_builtin_plugins()
30 .iter()
31 .map(|plugin| plugin.name())
32 .collect()
33}
34
35#[must_use]
40pub fn builtin_config_patterns() -> Vec<&'static str> {
41 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
42 .iter()
43 .flat_map(|plugin| plugin.config_patterns().iter().copied())
44 .collect();
45 patterns.sort_unstable();
46 patterns.dedup();
47 patterns
48}
49
50#[must_use]
54pub fn builtin_config_patterns_of(plugin_names: &[String]) -> Vec<&'static str> {
55 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
56 .iter()
57 .filter(|plugin| plugin_names.iter().any(|name| name == plugin.name()))
58 .flat_map(|plugin| plugin.config_patterns().iter().copied())
59 .collect();
60 patterns.sort_unstable();
61 patterns.dedup();
62 patterns
63}
64
65#[must_use]
69pub fn builtin_always_used_patterns_of(plugin_names: &[String]) -> Vec<&'static str> {
70 let mut patterns: Vec<&'static str> = builtin::create_builtin_plugins()
71 .iter()
72 .filter(|plugin| plugin_names.iter().any(|name| name == plugin.name()))
73 .flat_map(|plugin| plugin.always_used().iter().copied())
74 .collect();
75 patterns.sort_unstable();
76 patterns.dedup();
77 patterns
78}
79
80pub use helpers::ConfigCandidateIndex;
81pub use helpers::is_external_plugin_active;
82use helpers::{
83 check_has_config_file, discover_config_files, prepare_config_pattern, process_config_result,
84 process_external_plugins, process_package_json_metadata, process_static_patterns,
85};
86
87fn must_parse_workspace_config_when_root_active(plugin_name: &str) -> bool {
88 matches!(
89 plugin_name,
90 "eslint" | "docusaurus" | "jest" | "storybook" | "tanstack-router" | "vitest"
91 )
92}
93
94fn compile_config_matchers<'a>(
95 active: &[&'a dyn Plugin],
96) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
97 active
98 .iter()
99 .filter(|plugin| !plugin.config_patterns().is_empty())
100 .map(|plugin| (*plugin, cached_plugin_config_matchers(*plugin)))
101 .collect()
102}
103
104fn compile_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
105 plugin
106 .config_patterns()
107 .iter()
108 .filter_map(|pattern| {
109 let prepared = prepare_config_pattern(pattern);
110 globset::Glob::new(&prepared)
111 .ok()
112 .map(|glob| glob.compile_matcher())
113 })
114 .collect()
115}
116
117struct CachedPluginConfigMatchers {
118 patterns: &'static [&'static str],
119 matchers: Vec<globset::GlobMatcher>,
120}
121
122#[derive(Default)]
123struct PluginConfigMatcherCache {
124 by_name: RwLock<FxHashMap<&'static str, Vec<CachedPluginConfigMatchers>>>,
125}
126
127impl PluginConfigMatcherCache {
128 fn get_or_compile(&self, plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
129 let patterns = plugin.config_patterns();
130 let cached = self
131 .by_name
132 .read()
133 .unwrap_or_else(std::sync::PoisonError::into_inner)
134 .get(plugin.name())
135 .and_then(|variants| {
136 variants
137 .iter()
138 .find(|entry| entry.patterns == patterns)
139 .map(|entry| entry.matchers.clone())
140 });
141 if let Some(matchers) = cached {
142 return matchers;
143 }
144
145 let matchers = compile_plugin_config_matchers(plugin);
146 {
147 let mut by_name = self
148 .by_name
149 .write()
150 .unwrap_or_else(std::sync::PoisonError::into_inner);
151 let variants = by_name.entry(plugin.name()).or_default();
152 if let Some(entry) = variants.iter().find(|entry| entry.patterns == patterns) {
153 return entry.matchers.clone();
154 }
155 variants.push(CachedPluginConfigMatchers {
156 patterns,
157 matchers: matchers.clone(),
158 });
159 drop(by_name);
160 }
161 matchers
162 }
163}
164
165fn cached_plugin_config_matchers(plugin: &dyn Plugin) -> Vec<globset::GlobMatcher> {
166 static MATCHERS: OnceLock<PluginConfigMatcherCache> = OnceLock::new();
167 MATCHERS
168 .get_or_init(PluginConfigMatcherCache::default)
169 .get_or_compile(plugin)
170}
171
172fn log_active_plugins(active: &[&dyn Plugin]) {
174 tracing::info!(
175 plugins = active
176 .iter()
177 .map(|p| p.name())
178 .collect::<Vec<_>>()
179 .join(", "),
180 "active plugins"
181 );
182}
183
184fn compute_relative_files(
188 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
189 active: &[&dyn Plugin],
190 discovered_files: &[PathBuf],
191 root: &Path,
192) -> Vec<(PathBuf, String)> {
193 use rayon::prelude::*;
194 let needs_relative_files =
195 !config_matchers.is_empty() || active.iter().any(|p| p.package_json_config_key().is_some());
196 if !needs_relative_files {
197 return Vec::new();
198 }
199 discovered_files
200 .par_iter()
201 .map(|f| {
202 let rel = f
203 .strip_prefix(root)
204 .unwrap_or(f)
205 .to_string_lossy()
206 .into_owned();
207 (f.clone(), rel)
208 })
209 .collect()
210}
211
212pub struct PluginRegistry {
214 plugins: Vec<Box<dyn Plugin>>,
215 external_plugins: Vec<ExternalPluginDef>,
216}
217
218pub(crate) struct WorkspacePluginRunInput<'a> {
220 pub(crate) pkg: &'a PackageJson,
221 pub(crate) root: &'a Path,
222 pub(crate) project_root: &'a Path,
223 pub(crate) precompiled_config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
224 pub(crate) relative_files: &'a [(PathBuf, String)],
225 pub(crate) skip_config_plugins: &'a FxHashSet<&'a str>,
226 pub(crate) production_mode: bool,
227 pub(crate) candidate_index: Option<&'a ConfigCandidateIndex>,
228}
229
230struct PluginRunContext<'a> {
231 all_deps: Vec<String>,
232 active: Vec<&'a dyn Plugin>,
233}
234
235struct PluginActivationInput<'a> {
237 pkg: &'a PackageJson,
238 root: &'a Path,
239 discovered_files: &'a [PathBuf],
240 all_deps: &'a [String],
241 script_packages: &'a FxHashSet<String>,
242 candidate_index: Option<&'a ConfigCandidateIndex>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct PluginRegexValidationError {
248 plugin_name: String,
249 config_path: Option<PathBuf>,
250 rule_kind: &'static str,
251 field: &'static str,
252 rule_pattern: String,
253 regex_pattern: String,
254 source: String,
255}
256
257impl PluginRegexValidationError {
258 fn new(input: PluginRegexValidationErrorInput<'_>) -> Self {
259 Self {
260 plugin_name: input.plugin_name.to_owned(),
261 config_path: input.config_path.map(Path::to_path_buf),
262 rule_kind: input.rule_kind,
263 field: input.field,
264 rule_pattern: input.rule_pattern.to_owned(),
265 regex_pattern: input.regex_pattern.to_owned(),
266 source: input.source.to_string(),
267 }
268 }
269}
270
271#[derive(Clone, Copy)]
272pub(crate) struct PluginRegexValidationErrorInput<'a> {
273 plugin_name: &'a str,
274 config_path: Option<&'a Path>,
275 rule_kind: &'static str,
276 field: &'static str,
277 rule_pattern: &'a str,
278 regex_pattern: &'a str,
279 source: &'a regex::Error,
280}
281
282impl fmt::Display for PluginRegexValidationError {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 let location = self
285 .config_path
286 .as_ref()
287 .map(|path| format!(" in {}", path.display()))
288 .unwrap_or_default();
289 write!(
290 f,
291 "plugin '{}'{}: invalid regex '{}' in {}.{} for path rule '{}': {}",
292 self.plugin_name,
293 location,
294 self.regex_pattern,
295 self.rule_kind,
296 self.field,
297 self.rule_pattern,
298 self.source
299 )
300 }
301}
302
303#[must_use]
304pub(crate) fn format_plugin_regex_errors(errors: &[PluginRegexValidationError]) -> String {
305 let joined = errors
306 .iter()
307 .map(ToString::to_string)
308 .collect::<Vec<_>>()
309 .join("\n - ");
310 format!(
311 "invalid plugin regex configuration:\n - {joined}\n\nRewrite the plugin config with Rust-compatible regex syntax, or remove unsupported constructs such as JavaScript lookahead and lookbehind."
312 )
313}
314
315#[derive(Debug, Clone, Default, PartialEq, Eq)]
323pub struct PluginToolingDependencies {
324 pub plugin: String,
326 pub dependencies: Vec<String>,
328 pub references: Vec<String>,
332 pub own_config: Option<PathBuf>,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub enum PluginToolingEvidence<'a> {
340 OwnConfig(&'a Path),
342 Reference(&'a str),
344}
345
346impl PluginToolingDependencies {
347 #[must_use]
351 pub fn evidence<'a>(
352 &'a self,
353 script_used_packages: &FxHashSet<String>,
354 ) -> Option<PluginToolingEvidence<'a>> {
355 if let Some(config) = &self.own_config {
356 return Some(PluginToolingEvidence::OwnConfig(config));
357 }
358 self.references
359 .iter()
360 .find(|name| script_used_packages.contains(name.as_str()))
361 .map(|name| PluginToolingEvidence::Reference(name))
362 }
363}
364
365#[derive(Debug, Clone, Default)]
367pub struct AggregatedPluginResult {
368 pub entry_patterns: Vec<(PathRule, String)>,
370 pub replaced_entry_plugins: FxHashSet<String>,
375 pub entry_point_roles: rustc_hash::FxHashMap<String, EntryPointRole>,
377 pub config_patterns: Vec<String>,
379 pub always_used: Vec<(String, String)>,
381 pub used_exports: Vec<PluginUsedExportRule>,
383 pub used_class_members: Vec<UsedClassMemberRule>,
387 pub framework_class_member_contracts: Vec<fallow_types::semantic::SemanticFrameworkContract>,
389 pub referenced_dependencies: Vec<String>,
391 pub package_referenced_dependencies: Vec<(PathBuf, String)>,
393 pub discovered_always_used: Vec<(String, String)>,
395 pub setup_files: Vec<(PathBuf, String)>,
397 pub tooling_dependencies: Vec<String>,
401 pub plugin_tooling: Vec<PluginToolingDependencies>,
405 pub script_used_packages: FxHashSet<String>,
407 pub dependency_binaries: crate::scripts::DependencyBinaries,
411 pub dev_dependency_names: FxHashSet<String>,
416 pub virtual_module_prefixes: Vec<String>,
419 pub virtual_package_suffixes: Vec<String>,
422 pub generated_import_patterns: Vec<String>,
425 pub generated_type_import_prefixes: Vec<String>,
428 pub path_aliases: Vec<(String, String)>,
431 pub auto_imports: Vec<AutoImportRule>,
435 pub active_plugins: Vec<String>,
437 pub fixture_patterns: Vec<(String, String)>,
439 pub scss_include_paths: Vec<PathBuf>,
444 pub static_dir_mappings: Vec<(PathBuf, String)>,
446 pub framework_static_dir_mappings: Vec<(PathBuf, String)>,
449 pub provided_dependencies: Vec<ProvidedDependencyRule>,
451 pub config_diagnostics: Vec<super::PluginConfigDiagnostic>,
455 pub federation_sources: Vec<super::FederationSource>,
458 pub jsx_import_sources: Vec<JsxImportSourceRule>,
462 pub jsx_package_credits: Vec<JsxImportSourceRule>,
467 pub tsconfig_jsx_dirs: Vec<PathBuf>,
471}
472
473fn extend_unique(target: &mut Vec<String>, incoming: Vec<String>) {
478 let mut seen: FxHashSet<String> = target.iter().cloned().collect();
479 for item in incoming {
480 if seen.insert(item.clone()) {
481 target.push(item);
482 }
483 }
484}
485
486fn prefix_if_needed(pat: &str, ws_prefix: &str) -> String {
490 if pat.starts_with(ws_prefix) || pat.starts_with('/') {
491 pat.to_string()
492 } else {
493 format!("{ws_prefix}/{pat}")
494 }
495}
496
497impl AggregatedPluginResult {
498 pub(crate) fn apply_workspace_prefix(&mut self, ws_prefix: &str) {
514 for (rule, _) in &mut self.entry_patterns {
515 *rule = rule.prefixed(ws_prefix);
516 }
517 for (pat, _) in &mut self.always_used {
518 *pat = prefix_if_needed(pat, ws_prefix);
519 }
520 for (pat, _) in &mut self.discovered_always_used {
521 *pat = prefix_if_needed(pat, ws_prefix);
522 }
523 for (pat, _) in &mut self.fixture_patterns {
524 *pat = prefix_if_needed(pat, ws_prefix);
525 }
526 for rule in &mut self.used_exports {
527 *rule = rule.prefixed(ws_prefix);
528 }
529 for rule in &mut self.provided_dependencies {
530 *rule = rule.prefixed(ws_prefix);
531 }
532 for source in &mut self.federation_sources {
533 *source = source.prefixed(ws_prefix);
534 }
535 for (_, replacement) in &mut self.path_aliases {
536 *replacement = format!("{ws_prefix}/{replacement}");
537 }
538 }
539
540 pub(crate) fn merge_into(&mut self, other: Self) {
554 let Self {
555 entry_patterns,
556 replaced_entry_plugins: _,
558 entry_point_roles,
559 config_patterns,
560 always_used,
561 used_exports,
562 used_class_members,
563 framework_class_member_contracts,
564 referenced_dependencies,
565 package_referenced_dependencies,
566 discovered_always_used,
567 setup_files,
568 tooling_dependencies,
569 plugin_tooling,
570 script_used_packages,
571 dependency_binaries: _,
572 dev_dependency_names,
573 virtual_module_prefixes,
574 virtual_package_suffixes,
575 generated_import_patterns,
576 generated_type_import_prefixes,
577 path_aliases,
578 auto_imports,
579 active_plugins,
580 fixture_patterns,
581 scss_include_paths,
582 static_dir_mappings,
583 framework_static_dir_mappings,
584 provided_dependencies,
585 config_diagnostics,
586 federation_sources,
587 jsx_import_sources,
588 jsx_package_credits,
589 tsconfig_jsx_dirs,
590 } = other;
591
592 self.entry_patterns.extend(entry_patterns);
593 for (plugin_name, role) in entry_point_roles {
594 self.entry_point_roles.entry(plugin_name).or_insert(role);
595 }
596 self.config_patterns.extend(config_patterns);
597 self.always_used.extend(always_used);
598 self.used_exports.extend(used_exports);
599 self.used_class_members.extend(used_class_members);
600 for contract in framework_class_member_contracts {
601 if !self.framework_class_member_contracts.contains(&contract) {
602 self.framework_class_member_contracts.push(contract);
603 }
604 }
605 self.referenced_dependencies.extend(referenced_dependencies);
606 self.package_referenced_dependencies
607 .extend(package_referenced_dependencies);
608 self.discovered_always_used.extend(discovered_always_used);
609 self.setup_files.extend(setup_files);
610 self.tooling_dependencies.extend(tooling_dependencies);
611 self.plugin_tooling.extend(plugin_tooling);
612 self.dev_dependency_names.extend(dev_dependency_names);
613 self.script_used_packages.extend(script_used_packages);
614 extend_unique(&mut self.virtual_module_prefixes, virtual_module_prefixes);
615 extend_unique(&mut self.virtual_package_suffixes, virtual_package_suffixes);
616 extend_unique(
617 &mut self.generated_import_patterns,
618 generated_import_patterns,
619 );
620 extend_unique(
621 &mut self.generated_type_import_prefixes,
622 generated_type_import_prefixes,
623 );
624 self.path_aliases.extend(path_aliases);
625 self.auto_imports.extend(auto_imports);
626 extend_unique(&mut self.active_plugins, active_plugins);
627 self.fixture_patterns.extend(fixture_patterns);
628 self.scss_include_paths.extend(scss_include_paths);
629 self.static_dir_mappings.extend(static_dir_mappings);
630 self.framework_static_dir_mappings
631 .extend(framework_static_dir_mappings);
632 self.provided_dependencies.extend(provided_dependencies);
633 self.federation_sources.extend(federation_sources);
634 for rule in jsx_import_sources {
635 if !self.jsx_import_sources.contains(&rule) {
636 self.jsx_import_sources.push(rule);
637 }
638 }
639 for rule in jsx_package_credits {
640 if !self.jsx_package_credits.contains(&rule) {
641 self.jsx_package_credits.push(rule);
642 }
643 }
644 for dir in tsconfig_jsx_dirs {
645 if !self.tsconfig_jsx_dirs.contains(&dir) {
646 self.tsconfig_jsx_dirs.push(dir);
647 }
648 }
649 for diagnostic in config_diagnostics {
650 if !self.config_diagnostics.contains(&diagnostic) {
651 self.config_diagnostics.push(diagnostic);
652 }
653 }
654 }
655}
656
657impl PluginRegistry {
658 #[must_use]
660 pub fn new(external: Vec<ExternalPluginDef>) -> Self {
661 Self {
662 plugins: builtin::create_builtin_plugins(),
663 external_plugins: external,
664 }
665 }
666
667 #[must_use]
672 pub fn discovery_hidden_dirs(&self, pkg: &PackageJson, root: &Path) -> Vec<String> {
673 let all_deps = pkg.all_dependency_names();
674 let mut seen = FxHashSet::default();
675 let mut dirs = Vec::new();
676
677 for plugin in &self.plugins {
678 if !plugin.is_enabled_with_deps(&all_deps, root) {
679 continue;
680 }
681 for dir in plugin.discovery_hidden_dirs() {
682 if seen.insert(*dir) {
683 dirs.push((*dir).to_string());
684 }
685 }
686 }
687
688 dirs
689 }
690
691 #[cfg(test)]
696 fn run(
697 &self,
698 pkg: &PackageJson,
699 root: &Path,
700 discovered_files: &[PathBuf],
701 ) -> AggregatedPluginResult {
702 self.try_run(pkg, root, discovered_files)
703 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
704 }
705
706 pub fn try_run(
708 &self,
709 pkg: &PackageJson,
710 root: &Path,
711 discovered_files: &[PathBuf],
712 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
713 self.try_run_with_search_roots(pkg, root, discovered_files, &[root], false, None)
714 }
715
716 #[expect(
719 clippy::too_many_arguments,
720 reason = "public PluginRegistry API; signature is part of the crate surface for embedders"
721 )]
722 pub(crate) fn try_run_with_search_roots(
723 &self,
724 pkg: &PackageJson,
725 root: &Path,
726 discovered_files: &[PathBuf],
727 config_search_roots: &[&Path],
728 production_mode: bool,
729 candidate_index: Option<&ConfigCandidateIndex>,
730 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
731 let _span = tracing::info_span!("run_plugins").entered();
732 let mut result = AggregatedPluginResult::default();
733 let mut regex_errors = Vec::new();
734
735 let PluginRunContext { all_deps, active } = self.prepare_plugin_run_context(
736 pkg,
737 root,
738 discovered_files,
739 production_mode,
740 candidate_index,
741 );
742
743 self.run_plugin_preflight(&active, &all_deps, root, discovered_files);
744
745 for plugin in &active {
746 process_static_patterns(*plugin, root, &mut result);
747 }
748 process_package_json_metadata(&active, pkg, root, &mut result, &mut regex_errors);
749
750 process_external_plugins(
751 &self.external_plugins,
752 &all_deps,
753 root,
754 discovered_files,
755 &mut result,
756 );
757 tooling_evidence::record_plugin_tooling(
758 &tooling_evidence::ToolingEvidenceInput {
759 active: &active,
760 external_plugins: &self.external_plugins,
761 all_deps: &all_deps,
762 roots: &[root],
763 extra_roots: if production_mode {
767 &[]
768 } else {
769 config_search_roots
770 },
771 candidate_index,
772 discovered_files,
773 },
774 &mut result,
775 );
776
777 let config_matchers = compile_config_matchers(&active);
778 let relative_files =
779 compute_relative_files(&config_matchers, &active, discovered_files, root);
780
781 resolve_plugin_config_files(&mut PluginConfigResolutionInput {
782 config_matchers: &config_matchers,
783 relative_files: &relative_files,
784 config_search_roots,
785 production_mode,
786 candidate_index,
787 root,
788 result: &mut result,
789 regex_errors: &mut regex_errors,
790 });
791
792 process_package_json_inline_configs(
793 &active,
794 &config_matchers,
795 &relative_files,
796 root,
797 &mut result,
798 &mut regex_errors,
799 );
800
801 if regex_errors.is_empty() {
802 Ok(result)
803 } else {
804 Err(regex_errors)
805 }
806 }
807
808 #[cfg(test)]
814 fn run_workspace_fast(&self, input: &WorkspacePluginRunInput<'_>) -> AggregatedPluginResult {
815 self.try_run_workspace_fast(input)
816 .unwrap_or_else(|errors| panic!("{}", format_plugin_regex_errors(&errors)))
817 }
818
819 pub(crate) fn try_run_workspace_fast(
825 &self,
826 input: &WorkspacePluginRunInput<'_>,
827 ) -> Result<AggregatedPluginResult, Vec<PluginRegexValidationError>> {
828 let _span = tracing::info_span!("run_plugins").entered();
829 let mut result = AggregatedPluginResult::default();
830 let mut regex_errors = Vec::new();
831
832 let all_deps = input.pkg.all_dependency_names();
833 let script_packages =
834 script_activation_packages(input.pkg, input.root, &all_deps, input.production_mode);
835 let workspace_files: Vec<PathBuf> = input
836 .relative_files
837 .iter()
838 .map(|(abs_path, _)| abs_path.clone())
839 .collect();
840
841 let active = self.collect_active_plugins(&PluginActivationInput {
842 pkg: input.pkg,
843 root: input.root,
844 discovered_files: &workspace_files,
845 all_deps: &all_deps,
846 script_packages: &script_packages,
847 candidate_index: input.candidate_index,
848 });
849
850 log_active_plugins(&active);
851
852 self.emit_silent_fail_diagnostics(&active, &all_deps, input.root, &workspace_files);
853
854 process_external_plugins(
855 &self.external_plugins,
856 &all_deps,
857 input.root,
858 &workspace_files,
859 &mut result,
860 );
861
862 if active.is_empty() && result.active_plugins.is_empty() {
863 return Ok(result);
864 }
865
866 process_workspace_active_plugins(&active, input, &mut result, &mut regex_errors);
867 let evidence_roots: &[&Path] = if input.root == input.project_root {
869 &[input.root]
870 } else {
871 &[input.root, input.project_root]
872 };
873 tooling_evidence::record_plugin_tooling(
874 &tooling_evidence::ToolingEvidenceInput {
875 active: &active,
876 external_plugins: &self.external_plugins,
877 all_deps: &all_deps,
878 roots: evidence_roots,
879 extra_roots: &[],
880 candidate_index: input.candidate_index,
881 discovered_files: &workspace_files,
882 },
883 &mut result,
884 );
885 resolve_workspace_plugin_configs(&active, input, &mut result, &mut regex_errors);
886
887 if regex_errors.is_empty() {
888 Ok(result)
889 } else {
890 Err(regex_errors)
891 }
892 }
893
894 #[must_use]
897 pub(crate) fn precompile_config_matchers(
898 &self,
899 ) -> Vec<(&dyn Plugin, Vec<globset::GlobMatcher>)> {
900 self.plugins
901 .iter()
902 .filter(|p| !p.config_patterns().is_empty())
903 .map(|p| (p.as_ref(), cached_plugin_config_matchers(p.as_ref())))
904 .collect()
905 }
906
907 pub(crate) fn resolve_script_config_files(
918 &self,
919 config_files: &[scripts::BinaryConfigFile],
920 package_root: &Path,
921 project_root: &Path,
922 ws_prefix: &str,
923 ) -> AggregatedPluginResult {
924 let mut aggregated = AggregatedPluginResult::default();
925 for config_file in config_files {
926 let Some(rel_path) =
927 scripts::normalize_script_entry_pattern(ws_prefix, &config_file.path)
928 else {
929 continue;
930 };
931 for plugin in &self.plugins {
932 let plugin = plugin.as_ref();
933 if !plugin
934 .script_config_binaries()
935 .contains(&config_file.binary.as_str())
936 || cached_plugin_config_matchers(plugin)
937 .iter()
938 .any(|m| m.is_match(rel_path.as_str()))
939 {
940 continue;
941 }
942 let abs_path = package_root.join(config_file.path.trim_start_matches("./"));
943 if !super::config_parser::is_inside_root(&abs_path, project_root) {
944 continue;
945 }
946 let Ok(source) = std::fs::read_to_string(&abs_path) else {
947 continue;
948 };
949 let mut file_result = AggregatedPluginResult::default();
950 file_result
951 .entry_point_roles
952 .insert(plugin.name().to_string(), plugin.entry_point_role());
953 let plugin_result = plugin.resolve_config_in_project(
954 &abs_path,
955 &source,
956 package_root,
957 project_root,
958 );
959 if let Err(errors) = process_config_result(
960 plugin.name(),
961 plugin_result,
962 &mut file_result,
963 Some(&abs_path),
964 ) {
965 for error in errors {
966 tracing::warn!("{error}");
967 }
968 continue;
969 }
970 if !ws_prefix.is_empty() {
971 file_result.apply_workspace_prefix(ws_prefix);
972 }
973 file_result.used_exports.push(PluginUsedExportRule::new(
975 plugin.name().to_string(),
976 super::UsedExportRule::new(rel_path.clone(), ["default"]),
977 ));
978 aggregated.merge_into(file_result);
979 }
980 }
981 aggregated
982 }
983}
984
985fn process_workspace_active_plugins(
986 active: &[&dyn Plugin],
987 input: &WorkspacePluginRunInput<'_>,
988 result: &mut AggregatedPluginResult,
989 regex_errors: &mut Vec<PluginRegexValidationError>,
990) {
991 for plugin in active {
992 process_static_patterns(*plugin, input.root, result);
993 }
994 process_package_json_metadata(active, input.pkg, input.root, result, regex_errors);
995}
996
997fn resolve_workspace_plugin_configs(
998 active: &[&dyn Plugin],
999 input: &WorkspacePluginRunInput<'_>,
1000 result: &mut AggregatedPluginResult,
1001 regex_errors: &mut Vec<PluginRegexValidationError>,
1002) {
1003 let workspace_matchers = select_workspace_matchers(
1004 input.precompiled_config_matchers,
1005 active,
1006 input.skip_config_plugins,
1007 );
1008
1009 let mut resolved_ws_plugins: FxHashSet<&str> = FxHashSet::default();
1010 for (plugin, matchers) in &workspace_matchers {
1011 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1012 plugin: *plugin,
1013 matchers,
1014 relative_files: input.relative_files,
1015 root: input.root,
1016 project_root: input.project_root,
1017 result,
1018 regex_errors,
1019 resolved_plugins: &mut resolved_ws_plugins,
1020 });
1021 }
1022
1023 load_workspace_filesystem_configs(&mut WorkspaceFsConfigInput {
1024 workspace_matchers: &workspace_matchers,
1025 resolved_ws_plugins: &resolved_ws_plugins,
1026 root: input.root,
1027 project_root: input.project_root,
1028 production_mode: input.production_mode,
1029 candidate_index: input.candidate_index,
1030 result,
1031 regex_errors,
1032 });
1033}
1034
1035impl Default for PluginRegistry {
1036 fn default() -> Self {
1037 Self::new(vec![])
1038 }
1039}
1040
1041impl PluginRegistry {
1042 fn prepare_plugin_run_context<'a>(
1043 &'a self,
1044 pkg: &PackageJson,
1045 root: &Path,
1046 discovered_files: &[PathBuf],
1047 production_mode: bool,
1048 candidate_index: Option<&ConfigCandidateIndex>,
1049 ) -> PluginRunContext<'a> {
1050 let all_deps = pkg.all_dependency_names();
1051 let script_packages = script_activation_packages(pkg, root, &all_deps, production_mode);
1052 let active = self.collect_active_plugins(&PluginActivationInput {
1053 pkg,
1054 root,
1055 discovered_files,
1056 all_deps: &all_deps,
1057 script_packages: &script_packages,
1058 candidate_index,
1059 });
1060
1061 PluginRunContext { all_deps, active }
1062 }
1063
1064 fn run_plugin_preflight(
1065 &self,
1066 active: &[&dyn Plugin],
1067 all_deps: &[String],
1068 root: &Path,
1069 discovered_files: &[PathBuf],
1070 ) {
1071 log_active_plugins(active);
1072 check_meta_framework_prerequisites(active, root);
1073 self.emit_silent_fail_diagnostics(active, all_deps, root, discovered_files);
1074 }
1075
1076 fn collect_active_plugins<'a>(
1079 &'a self,
1080 activation: &PluginActivationInput<'_>,
1081 ) -> Vec<&'a dyn Plugin> {
1082 self.plugins
1083 .iter()
1084 .filter(|p| {
1085 p.is_enabled_with_files(
1086 activation.all_deps,
1087 activation.root,
1088 activation.discovered_files,
1089 activation.candidate_index,
1090 ) || p.is_enabled_with_scripts(activation.script_packages, activation.root)
1091 || p.is_enabled_with_package_json(activation.pkg, activation.root)
1092 })
1093 .map(AsRef::as_ref)
1094 .collect()
1095 }
1096
1097 fn emit_silent_fail_diagnostics(
1106 &self,
1107 active: &[&dyn Plugin],
1108 all_deps: &[String],
1109 root: &Path,
1110 discovered_files: &[PathBuf],
1111 ) {
1112 let active_external: Vec<&ExternalPluginDef> = self
1113 .external_plugins
1114 .iter()
1115 .filter(|ext| is_external_plugin_active(ext, all_deps, root, discovered_files))
1116 .collect();
1117 let mut diagnostics = detect_pattern_collisions(active, &active_external);
1118 diagnostics.extend(detect_enabler_typos(&self.external_plugins, all_deps));
1119 emit_plugin_diagnostics(&diagnostics);
1120 }
1121}
1122
1123fn plugin_warn_dedupe() -> &'static std::sync::Mutex<FxHashSet<String>> {
1130 static WARNED: std::sync::OnceLock<std::sync::Mutex<FxHashSet<String>>> =
1131 std::sync::OnceLock::new();
1132 WARNED.get_or_init(|| std::sync::Mutex::new(FxHashSet::default()))
1133}
1134
1135struct PluginConfigResolutionInput<'a> {
1136 config_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1137 relative_files: &'a [(PathBuf, String)],
1138 config_search_roots: &'a [&'a Path],
1139 production_mode: bool,
1140 candidate_index: Option<&'a ConfigCandidateIndex>,
1141 root: &'a Path,
1142 result: &'a mut AggregatedPluginResult,
1143 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1144}
1145
1146fn select_workspace_matchers<'a>(
1149 precompiled_config_matchers: &[(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1150 active: &[&dyn Plugin],
1151 skip_config_plugins: &FxHashSet<&str>,
1152) -> Vec<(&'a dyn Plugin, Vec<globset::GlobMatcher>)> {
1153 let active_names: FxHashSet<&str> = active.iter().map(|p| p.name()).collect();
1154 precompiled_config_matchers
1155 .iter()
1156 .filter(|(p, _)| {
1157 active_names.contains(p.name())
1158 && (!skip_config_plugins.contains(p.name())
1159 || must_parse_workspace_config_when_root_active(p.name()))
1160 })
1161 .map(|(plugin, matchers)| (*plugin, matchers.clone()))
1162 .collect()
1163}
1164
1165struct WorkspaceFsConfigInput<'a> {
1166 workspace_matchers: &'a [(&'a dyn Plugin, Vec<globset::GlobMatcher>)],
1167 resolved_ws_plugins: &'a FxHashSet<&'a str>,
1168 root: &'a Path,
1169 project_root: &'a Path,
1170 production_mode: bool,
1171 candidate_index: Option<&'a ConfigCandidateIndex>,
1172 result: &'a mut AggregatedPluginResult,
1173 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1174}
1175
1176fn load_workspace_filesystem_configs(input: &mut WorkspaceFsConfigInput<'_>) {
1179 let search_roots: &[&Path] = if input.root == input.project_root {
1180 &[input.root]
1181 } else {
1182 &[input.root, input.project_root]
1183 };
1184 let ws_json_configs = discover_config_files(
1185 input.workspace_matchers,
1186 input.resolved_ws_plugins,
1187 search_roots,
1188 input.production_mode,
1189 input.candidate_index,
1190 );
1191 for (abs_path, plugin) in &ws_json_configs {
1192 let Ok(source) = std::fs::read_to_string(abs_path) else {
1193 continue;
1194 };
1195 let plugin_result =
1196 plugin.resolve_config_in_project(abs_path, &source, input.root, input.project_root);
1197 if plugin_result.is_empty() {
1198 continue;
1199 }
1200 let rel = abs_path
1201 .strip_prefix(input.project_root)
1202 .map(|p| p.to_string_lossy())
1203 .unwrap_or_default();
1204 tracing::debug!(
1205 plugin = plugin.name(),
1206 config = %rel,
1207 entries = plugin_result.entry_patterns.len(),
1208 deps = plugin_result.referenced_dependencies.len(),
1209 "resolved config (workspace filesystem fallback)"
1210 );
1211 if let Err(mut errors) =
1212 process_config_result(plugin.name(), plugin_result, input.result, Some(abs_path))
1213 {
1214 input.regex_errors.append(&mut errors);
1215 }
1216 }
1217}
1218
1219fn resolve_plugin_config_files(input: &mut PluginConfigResolutionInput<'_>) {
1220 if input.config_matchers.is_empty() {
1221 return;
1222 }
1223
1224 let mut resolved_plugins: FxHashSet<&str> = FxHashSet::default();
1225 for (plugin, matchers) in input.config_matchers {
1226 resolve_plugin_matching_files(&mut PluginMatchingFilesInput {
1227 plugin: *plugin,
1228 matchers,
1229 relative_files: input.relative_files,
1230 root: input.root,
1231 project_root: input.root,
1232 result: input.result,
1233 regex_errors: input.regex_errors,
1234 resolved_plugins: &mut resolved_plugins,
1235 });
1236 }
1237
1238 let json_configs = discover_config_files(
1239 input.config_matchers,
1240 &resolved_plugins,
1241 input.config_search_roots,
1242 input.production_mode,
1243 input.candidate_index,
1244 );
1245 for (abs_path, plugin) in &json_configs {
1246 resolve_plugin_filesystem_config(
1247 *plugin,
1248 abs_path,
1249 input.root,
1250 input.result,
1251 input.regex_errors,
1252 );
1253 }
1254}
1255
1256struct PluginMatchingFilesInput<'plugins, 'data, 'state> {
1257 plugin: &'plugins dyn Plugin,
1258 matchers: &'data [globset::GlobMatcher],
1259 relative_files: &'data [(PathBuf, String)],
1260 root: &'data Path,
1261 project_root: &'data Path,
1262 result: &'state mut AggregatedPluginResult,
1263 regex_errors: &'state mut Vec<PluginRegexValidationError>,
1264 resolved_plugins: &'state mut FxHashSet<&'plugins str>,
1265}
1266
1267fn resolve_plugin_matching_files(input: &mut PluginMatchingFilesInput<'_, '_, '_>) {
1268 use rayon::prelude::*;
1269
1270 let plugin_hits: Vec<&PathBuf> = input
1271 .relative_files
1272 .par_iter()
1273 .filter_map(|(abs_path, rel_path)| {
1274 input
1275 .matchers
1276 .iter()
1277 .any(|m| m.is_match(rel_path.as_str()))
1278 .then_some(abs_path)
1279 })
1280 .collect();
1281 for abs_path in plugin_hits {
1282 let Ok(source) = std::fs::read_to_string(abs_path) else {
1283 continue;
1284 };
1285 let plugin_result = input.plugin.resolve_config_in_project(
1286 abs_path,
1287 &source,
1288 input.root,
1289 input.project_root,
1290 );
1291 if plugin_result.is_empty() {
1292 continue;
1293 }
1294 input.resolved_plugins.insert(input.plugin.name());
1295 process_resolved_plugin_config(ResolvedPluginConfigInput {
1296 plugin: input.plugin,
1297 abs_path,
1298 plugin_result,
1299 result: input.result,
1300 regex_errors: input.regex_errors,
1301 message: "resolved config",
1302 config_display: abs_path.display(),
1303 });
1304 }
1305}
1306
1307fn resolve_plugin_filesystem_config(
1308 plugin: &dyn Plugin,
1309 abs_path: &Path,
1310 root: &Path,
1311 result: &mut AggregatedPluginResult,
1312 regex_errors: &mut Vec<PluginRegexValidationError>,
1313) {
1314 let Ok(source) = std::fs::read_to_string(abs_path) else {
1315 return;
1316 };
1317 let plugin_result = plugin.resolve_config(abs_path, &source, root);
1318 if plugin_result.is_empty() {
1319 return;
1320 }
1321 let rel = abs_path
1322 .strip_prefix(root)
1323 .map(|p| p.to_string_lossy())
1324 .unwrap_or_default();
1325 process_resolved_plugin_config(ResolvedPluginConfigInput {
1326 plugin,
1327 abs_path,
1328 plugin_result,
1329 result,
1330 regex_errors,
1331 message: "resolved config (filesystem fallback)",
1332 config_display: rel,
1333 });
1334}
1335
1336struct ResolvedPluginConfigInput<'a, D> {
1337 plugin: &'a dyn Plugin,
1338 abs_path: &'a Path,
1339 plugin_result: PluginResult,
1340 result: &'a mut AggregatedPluginResult,
1341 regex_errors: &'a mut Vec<PluginRegexValidationError>,
1342 message: &'static str,
1343 config_display: D,
1344}
1345
1346fn process_resolved_plugin_config(input: ResolvedPluginConfigInput<'_, impl std::fmt::Display>) {
1347 tracing::debug!(
1348 plugin = input.plugin.name(),
1349 config = %input.config_display,
1350 entries = input.plugin_result.entry_patterns.len(),
1351 deps = input.plugin_result.referenced_dependencies.len(),
1352 input.message
1353 );
1354 if let Err(mut errors) = process_config_result(
1355 input.plugin.name(),
1356 input.plugin_result,
1357 input.result,
1358 Some(input.abs_path),
1359 ) {
1360 input.regex_errors.append(&mut errors);
1361 }
1362}
1363
1364fn should_warn(key: String) -> bool {
1368 plugin_warn_dedupe()
1369 .lock()
1370 .map_or(true, |mut set| set.insert(key))
1371}
1372
1373#[derive(Debug, Clone, PartialEq, Eq)]
1380pub(crate) enum PluginDiagnostic {
1381 PatternCollision {
1383 pattern: String,
1384 owners: Vec<String>,
1385 },
1386 EnablerTypo {
1389 plugin: String,
1390 enabler: String,
1391 suggestion: String,
1392 },
1393}
1394
1395fn detect_pattern_collisions(
1421 builtin_active: &[&dyn Plugin],
1422 external_active: &[&ExternalPluginDef],
1423) -> Vec<PluginDiagnostic> {
1424 use rustc_hash::FxHashMap;
1425
1426 let mut pattern_owners: FxHashMap<String, (Vec<String>, FxHashSet<String>)> =
1427 FxHashMap::default();
1428
1429 let record = |pattern_owners: &mut FxHashMap<_, (Vec<String>, FxHashSet<String>)>,
1430 pattern: String,
1431 name: String| {
1432 let (list, seen) = pattern_owners.entry(pattern).or_default();
1433 if seen.insert(name.clone()) {
1434 list.push(name);
1435 }
1436 };
1437
1438 for plugin in builtin_active {
1439 for pat in plugin.config_patterns() {
1440 record(
1441 &mut pattern_owners,
1442 (*pat).to_string(),
1443 plugin.name().to_string(),
1444 );
1445 }
1446 }
1447 for ext in external_active {
1448 for pat in &ext.config_patterns {
1449 record(&mut pattern_owners, pat.clone(), ext.name.clone());
1450 }
1451 }
1452
1453 let builtin_names: FxHashSet<&str> = builtin_active.iter().map(|p| p.name()).collect();
1460
1461 let mut findings: Vec<PluginDiagnostic> = pattern_owners
1462 .into_iter()
1463 .filter_map(|(pattern, (owners, _seen))| {
1464 if owners.len() < 2 || owners.iter().all(|o| builtin_names.contains(o.as_str())) {
1465 None
1466 } else {
1467 Some(PluginDiagnostic::PatternCollision { pattern, owners })
1468 }
1469 })
1470 .collect();
1471 findings.sort_unstable_by(|a, b| match (a, b) {
1472 (
1473 PluginDiagnostic::PatternCollision { pattern: ap, .. },
1474 PluginDiagnostic::PatternCollision { pattern: bp, .. },
1475 ) => ap.cmp(bp),
1476 _ => std::cmp::Ordering::Equal,
1477 });
1478 findings
1479}
1480
1481fn detect_enabler_typos(
1496 external_plugins: &[ExternalPluginDef],
1497 all_deps: &[String],
1498) -> Vec<PluginDiagnostic> {
1499 let mut findings = Vec::new();
1500
1501 for ext in external_plugins {
1502 if ext.detection.is_some() || ext.enablers.is_empty() {
1503 continue;
1504 }
1505
1506 let any_match = ext.enablers.iter().any(|enabler| {
1507 if enabler.ends_with('/') {
1508 all_deps.iter().any(|d| d.starts_with(enabler))
1509 } else {
1510 all_deps.iter().any(|d| d == enabler)
1511 }
1512 });
1513 if any_match {
1514 continue;
1515 }
1516
1517 for enabler in &ext.enablers {
1518 let candidates = all_deps.iter().map(String::as_str);
1519 let Some(suggestion) = fallow_config::levenshtein::closest_match(enabler, candidates)
1520 else {
1521 continue;
1522 };
1523
1524 findings.push(PluginDiagnostic::EnablerTypo {
1525 plugin: ext.name.clone(),
1526 enabler: enabler.clone(),
1527 suggestion: suggestion.to_string(),
1528 });
1529 }
1530 }
1531
1532 findings
1533}
1534
1535fn emit_plugin_diagnostics(findings: &[PluginDiagnostic]) {
1538 for finding in findings {
1539 match finding {
1540 PluginDiagnostic::PatternCollision { pattern, owners } => {
1541 let key = format!("collision::{pattern}::{owners:?}");
1542 if !should_warn(key) {
1543 continue;
1544 }
1545 let winner = &owners[0];
1546 let others = owners[1..].join(", ");
1547 tracing::warn!(
1548 "plugin config_patterns collision: identical pattern \
1549 '{pattern}' is claimed by plugins [{joined}]; '{winner}' \
1550 runs first (registration order), others ({others}) \
1551 follow. Rename one of the patterns or remove the \
1552 duplicate plugin to make resolution explicit. A future \
1553 release may reject identical-pattern collisions.",
1554 joined = owners.join(", "),
1555 );
1556 }
1557 PluginDiagnostic::EnablerTypo {
1558 plugin,
1559 enabler,
1560 suggestion,
1561 } => {
1562 let key = format!("enabler::{plugin}::{enabler}");
1563 if !should_warn(key) {
1564 continue;
1565 }
1566 tracing::warn!(
1567 "plugin '{plugin}' enabler '{enabler}' does not match any \
1568 dependency in package.json; did you mean '{suggestion}'? \
1569 The plugin will not activate. A future release may reject \
1570 unmatched enablers.",
1571 );
1572 }
1573 }
1574 }
1575}
1576
1577fn process_package_json_inline_configs(
1582 active: &[&dyn Plugin],
1583 config_matchers: &[(&dyn Plugin, Vec<globset::GlobMatcher>)],
1584 relative_files: &[(PathBuf, String)],
1585 root: &Path,
1586 result: &mut AggregatedPluginResult,
1587 regex_errors: &mut Vec<PluginRegexValidationError>,
1588) {
1589 for plugin in active {
1590 let Some(key) = plugin.package_json_config_key() else {
1591 continue;
1592 };
1593 if check_has_config_file(*plugin, config_matchers, relative_files) {
1594 continue;
1595 }
1596 let pkg_path = root.join("package.json");
1597 let Ok(content) = std::fs::read_to_string(&pkg_path) else {
1598 continue;
1599 };
1600 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1601 continue;
1602 };
1603 let Some(config_value) = json.get(key) else {
1604 continue;
1605 };
1606 let config_json = serde_json::to_string(config_value).unwrap_or_default();
1607 let fake_path = root.join(format!("{key}.config.json"));
1608 let plugin_result = plugin.resolve_config(&fake_path, &config_json, root);
1609 if plugin_result.is_empty() {
1610 continue;
1611 }
1612 tracing::debug!(
1613 plugin = plugin.name(),
1614 key = key,
1615 "resolved inline package.json config"
1616 );
1617 if let Err(mut errors) =
1618 process_config_result(plugin.name(), plugin_result, result, Some(&pkg_path))
1619 {
1620 regex_errors.append(&mut errors);
1621 }
1622 }
1623}
1624
1625#[derive(Debug)]
1628struct MetaFrameworkWarning {
1629 dedupe_key: &'static str,
1630 message: &'static str,
1631}
1632
1633fn missing_meta_framework_prerequisites(
1643 active_plugins: &[&dyn Plugin],
1644 root: &Path,
1645) -> Vec<MetaFrameworkWarning> {
1646 active_plugins
1647 .iter()
1648 .filter_map(|plugin| match plugin.name() {
1649 "nuxt" if !root.join(".nuxt/tsconfig.json").exists() => Some(MetaFrameworkWarning {
1650 dedupe_key: "meta-prereq::nuxt",
1651 message: "Nuxt project missing .nuxt/tsconfig.json: run `nuxt prepare` \
1652 before fallow for accurate analysis",
1653 }),
1654 "astro" if !root.join(".astro").exists() => Some(MetaFrameworkWarning {
1655 dedupe_key: "meta-prereq::astro",
1656 message: "Astro project missing .astro/ types: run `astro sync` \
1657 before fallow for accurate analysis",
1658 }),
1659 _ => None,
1660 })
1661 .collect()
1662}
1663
1664fn check_meta_framework_prerequisites(active_plugins: &[&dyn Plugin], root: &Path) {
1674 for warning in missing_meta_framework_prerequisites(active_plugins, root) {
1675 if should_warn(warning.dedupe_key.to_owned()) {
1676 tracing::warn!("{}", warning.message);
1677 }
1678 }
1679}
1680
1681fn script_activation_packages(
1682 pkg: &PackageJson,
1683 root: &Path,
1684 all_deps: &[String],
1685 production_mode: bool,
1686) -> FxHashSet<String> {
1687 let Some(pkg_scripts) = pkg.scripts.as_ref() else {
1688 return FxHashSet::default();
1689 };
1690
1691 let scripts_to_analyze = if production_mode {
1692 scripts::filter_production_scripts(pkg_scripts)
1693 } else {
1694 pkg_scripts.clone()
1695 };
1696
1697 let mut nm_roots = Vec::new();
1698 if root.join("node_modules").is_dir() {
1699 nm_roots.push(root);
1700 }
1701 let bin_map = scripts::build_bin_to_package_map(&nm_roots, all_deps);
1702 let dep_set: FxHashSet<String> = all_deps.iter().cloned().collect();
1703 let catalog =
1704 scripts::ScriptCatalog::from_scripts_with_bodies(pkg_scripts, &scripts_to_analyze);
1705
1706 scripts::analyze_scripts_with_dependency_context(
1707 &scripts_to_analyze,
1708 root,
1709 &bin_map,
1710 &dep_set,
1711 &catalog,
1712 scripts::IgnoredCommandEntries::NONE,
1713 )
1714 .used_packages
1715}
1716
1717#[cfg(test)]
1718mod tests;