1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8
9use crate::config::UsedClassMemberRule;
10
11pub const PLUGIN_EXTENSIONS: &[&str] = &["toml", "json", "jsonc"];
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
17#[serde(rename_all = "camelCase")]
18pub enum EntryPointRole {
19 Runtime,
21 Test,
23 #[default]
25 Support,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum AutoImportKind {
32 Named,
34 Default,
36 DefaultComponent,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct AutoImportRule {
52 pub name: String,
56 pub source: PathBuf,
58 pub kind: AutoImportKind,
60 pub scope: Vec<PathBuf>,
65}
66
67impl AutoImportRule {
68 #[must_use]
70 pub const fn new(name: String, source: PathBuf, kind: AutoImportKind) -> Self {
71 Self {
72 name,
73 source,
74 kind,
75 scope: Vec::new(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct JsxImportSourceRule {
90 pub source: String,
93 pub config_dir: PathBuf,
97 pub include: Vec<String>,
100 pub exclude: Vec<String>,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(tag = "type", rename_all = "camelCase")]
111pub enum PluginDetection {
112 Dependency {
114 package: String,
116 },
117 FileExists {
119 pattern: String,
124 },
125 All {
127 conditions: Vec<Self>,
129 },
130 Any {
132 conditions: Vec<Self>,
134 },
135}
136
137#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
164#[serde(rename_all = "camelCase")]
165pub struct ExternalPluginDef {
166 #[serde(rename = "$schema", default, skip_serializing)]
168 #[schemars(skip)]
169 pub schema: Option<String>,
170
171 pub name: String,
173
174 #[serde(default)]
177 pub detection: Option<PluginDetection>,
178
179 #[serde(default)]
183 pub enablers: Vec<String>,
184
185 #[serde(default)]
187 pub entry_points: Vec<String>,
188
189 #[serde(default = "default_external_entry_point_role")]
194 pub entry_point_role: EntryPointRole,
195
196 #[serde(default)]
203 pub manifest_entries: Vec<ManifestEntryRule>,
204
205 #[serde(default)]
207 pub config_patterns: Vec<String>,
208
209 #[serde(default)]
215 pub config_default_export_opt_out: Vec<String>,
216
217 #[serde(default)]
219 pub always_used: Vec<String>,
220
221 #[serde(default)]
224 pub tooling_dependencies: Vec<String>,
225
226 #[serde(default)]
228 pub used_exports: Vec<ExternalUsedExport>,
229
230 #[serde(default)]
235 pub used_class_members: Vec<UsedClassMemberRule>,
236}
237
238#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
240pub struct ExternalUsedExport {
241 pub pattern: String,
244 pub exports: Vec<String>,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Default)]
252#[serde(rename_all = "lowercase")]
253pub enum ManifestFormat {
254 #[default]
256 Jsonc,
257 Json,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
267pub struct ManifestFieldPath {
268 raw: String,
269 segments: Vec<ManifestFieldSegment>,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
274pub enum ManifestFieldSegment {
275 Key(String),
277 Each,
279}
280
281impl ManifestFieldPath {
282 #[must_use]
284 pub fn as_str(&self) -> &str {
285 &self.raw
286 }
287
288 #[must_use]
290 pub fn segments(&self) -> &[ManifestFieldSegment] {
291 &self.segments
292 }
293
294 fn parse(raw: String) -> Result<Self, String> {
295 if raw.is_empty() {
296 return Err("manifest field path must not be empty".to_string());
297 }
298
299 let mut segments = Vec::new();
300 for component in raw.split('.') {
301 if component.is_empty() {
302 return Err(format!(
303 "manifest field path '{raw}' contains an empty segment"
304 ));
305 }
306
307 let key_end = component.find('[').unwrap_or(component.len());
308 let key = &component[..key_end];
309 if key.is_empty() || key.contains(['{', '}', ']']) {
310 return Err(format!(
311 "manifest field path '{raw}' contains unsupported bracket syntax"
312 ));
313 }
314 segments.push(ManifestFieldSegment::Key(key.to_string()));
315
316 let mut suffix = &component[key_end..];
317 while !suffix.is_empty() {
318 let Some(rest) = suffix.strip_prefix("[*]") else {
319 return Err(format!(
320 "manifest field path '{raw}' only supports exact '[*]' array traversal"
321 ));
322 };
323 segments.push(ManifestFieldSegment::Each);
324 suffix = rest;
325 }
326 }
327
328 Ok(Self { raw, segments })
329 }
330}
331
332impl FromStr for ManifestFieldPath {
333 type Err = String;
334
335 fn from_str(raw: &str) -> Result<Self, Self::Err> {
336 Self::parse(raw.to_string())
337 }
338}
339
340impl fmt::Display for ManifestFieldPath {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 f.write_str(&self.raw)
343 }
344}
345
346impl Serialize for ManifestFieldPath {
347 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
348 where
349 S: Serializer,
350 {
351 serializer.serialize_str(&self.raw)
352 }
353}
354
355impl<'de> Deserialize<'de> for ManifestFieldPath {
356 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
357 where
358 D: Deserializer<'de>,
359 {
360 let raw = String::deserialize(deserializer)?;
361 Self::parse(raw).map_err(D::Error::custom)
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum ManifestPathPart {
368 Literal(String),
370 Field(ManifestFieldPath),
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct ManifestPathTemplate {
377 raw: String,
378 parts: Vec<ManifestPathPart>,
379}
380
381impl ManifestPathTemplate {
382 #[must_use]
384 pub fn as_str(&self) -> &str {
385 &self.raw
386 }
387
388 #[must_use]
390 pub fn parts(&self) -> &[ManifestPathPart] {
391 &self.parts
392 }
393
394 #[must_use]
396 pub fn validation_pattern(&self) -> String {
397 let mut pattern = String::with_capacity(self.raw.len());
398 for part in &self.parts {
399 match part {
400 ManifestPathPart::Literal(literal) => pattern.push_str(literal),
401 ManifestPathPart::Field(_) => pattern.push_str("fallowinterp"),
402 }
403 }
404 pattern
405 }
406
407 fn parse(raw: String) -> Result<Self, String> {
408 let mut parts = Vec::new();
409 let mut rest = raw.as_str();
410
411 while let Some(start) = rest.find("${") {
412 if start > 0 {
413 parts.push(ManifestPathPart::Literal(rest[..start].to_string()));
414 }
415
416 let after = &rest[start + 2..];
417 let Some(end) = after.find('}') else {
418 return Err(format!(
419 "manifest entry path '{raw}' contains an unterminated interpolation"
420 ));
421 };
422 let field = after[..end].parse::<ManifestFieldPath>()?;
423 parts.push(ManifestPathPart::Field(field));
424 rest = &after[end + 1..];
425 }
426
427 if !rest.is_empty() {
428 parts.push(ManifestPathPart::Literal(rest.to_string()));
429 }
430 if parts.is_empty() {
431 parts.push(ManifestPathPart::Literal(String::new()));
432 }
433
434 Ok(Self { raw, parts })
435 }
436}
437
438impl FromStr for ManifestPathTemplate {
439 type Err = String;
440
441 fn from_str(raw: &str) -> Result<Self, Self::Err> {
442 Self::parse(raw.to_string())
443 }
444}
445
446impl fmt::Display for ManifestPathTemplate {
447 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
448 f.write_str(&self.raw)
449 }
450}
451
452impl Serialize for ManifestPathTemplate {
453 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
454 where
455 S: Serializer,
456 {
457 serializer.serialize_str(&self.raw)
458 }
459}
460
461impl<'de> Deserialize<'de> for ManifestPathTemplate {
462 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463 where
464 D: Deserializer<'de>,
465 {
466 let raw = String::deserialize(deserializer)?;
467 Self::parse(raw).map_err(D::Error::custom)
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
476#[serde(untagged)]
477pub enum ManifestCondition {
478 Exists(ManifestExistsPredicate),
480 Equals(serde_json::Value),
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
486#[serde(deny_unknown_fields)]
487pub struct ManifestExistsPredicate {
488 pub exists: bool,
490}
491
492#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
511#[serde(rename_all = "camelCase")]
512pub struct ManifestEntryRule {
513 pub manifests: String,
516
517 #[serde(default)]
519 pub format: ManifestFormat,
520
521 #[serde(default)]
527 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
528 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
529
530 pub entries: Vec<ManifestSeedRule>,
532}
533
534#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
537#[serde(rename_all = "camelCase")]
538pub struct ManifestSeedRule {
539 #[schemars(with = "String")]
547 pub path: ManifestPathTemplate,
548
549 #[serde(default)]
552 #[schemars(with = "BTreeMap<String, ManifestCondition>")]
553 pub when: BTreeMap<ManifestFieldPath, ManifestCondition>,
554}
555
556fn default_external_entry_point_role() -> EntryPointRole {
557 EntryPointRole::Support
558}
559
560impl ExternalPluginDef {
561 #[must_use]
563 pub fn json_schema() -> serde_json::Value {
564 serde_json::to_value(schemars::schema_for!(ExternalPluginDef)).unwrap_or_default()
565 }
566
567 pub fn validate_user_globs(
581 &self,
582 ) -> Result<(), Vec<crate::config::glob_validation::GlobValidationError>> {
583 use crate::config::glob_validation::{compile_user_glob, validate_user_globs};
584
585 let mut errors = Vec::new();
586 validate_user_globs(&self.entry_points, "framework[].entryPoints", &mut errors);
587 validate_user_globs(&self.always_used, "framework[].alwaysUsed", &mut errors);
588 validate_user_globs(
589 &self.config_patterns,
590 "framework[].configPatterns",
591 &mut errors,
592 );
593 for used in &self.used_exports {
594 if let Err(e) = compile_user_glob(&used.pattern, "framework[].usedExports[].pattern") {
595 errors.push(e);
596 }
597 }
598 for rule in &self.manifest_entries {
599 if let Err(e) =
600 compile_user_glob(&rule.manifests, "framework[].manifestEntries[].manifests")
601 {
602 errors.push(e);
603 }
604 for seed in &rule.entries {
605 let probe = seed.path.validation_pattern();
608 if let Err(e) =
609 compile_user_glob(&probe, "framework[].manifestEntries[].entries[].path")
610 {
611 errors.push(e);
612 }
613 }
614 }
615 if let Some(detection) = &self.detection {
616 validate_detection_user_globs(detection, "framework[].detection", &mut errors);
617 }
618 if errors.is_empty() {
619 Ok(())
620 } else {
621 Err(errors)
622 }
623 }
624}
625
626fn validate_detection_user_globs(
629 detection: &PluginDetection,
630 field: &'static str,
631 errors: &mut Vec<crate::config::glob_validation::GlobValidationError>,
632) {
633 match detection {
634 PluginDetection::Dependency { .. } => {}
635 PluginDetection::FileExists { pattern } => {
636 if let Err(e) = crate::config::glob_validation::compile_user_glob(pattern, field) {
637 errors.push(e);
638 }
639 }
640 PluginDetection::All { conditions } | PluginDetection::Any { conditions } => {
641 for condition in conditions {
642 validate_detection_user_globs(condition, field, errors);
643 }
644 }
645 }
646}
647
648pub fn discover_and_validate_external_plugins(
663 root: &Path,
664 config_plugin_paths: &[String],
665) -> Result<Vec<ExternalPluginDef>, Vec<crate::config::glob_validation::GlobValidationError>> {
666 let plugins = discover_external_plugins(root, config_plugin_paths);
667 let mut errors = Vec::new();
668 for plugin in &plugins {
669 if let Err(mut plugin_errors) = plugin.validate_user_globs() {
670 errors.append(&mut plugin_errors);
671 }
672 }
673 if errors.is_empty() {
674 Ok(plugins)
675 } else {
676 Err(errors)
677 }
678}
679
680enum PluginFormat {
682 Toml,
683 Json,
684 Jsonc,
685}
686
687impl PluginFormat {
688 fn from_path(path: &Path) -> Option<Self> {
689 match path.extension().and_then(|e| e.to_str()) {
690 Some("toml") => Some(Self::Toml),
691 Some("json") => Some(Self::Json),
692 Some("jsonc") => Some(Self::Jsonc),
693 _ => None,
694 }
695 }
696}
697
698const DEFAULT_PLUGINS_DIR_NAME: &str = "plugins";
701
702pub const ROOT_PLUGIN_FILE_PREFIX: &str = "fallow-plugin-";
705
706fn is_plugin_file(path: &Path) -> bool {
707 path.extension()
708 .and_then(|e| e.to_str())
709 .is_some_and(|ext| PLUGIN_EXTENSIONS.contains(&ext))
710}
711
712fn parse_plugin(content: &str, format: &PluginFormat, path: &Path) -> Option<ExternalPluginDef> {
714 match format {
715 PluginFormat::Toml => match toml::from_str::<ExternalPluginDef>(content) {
716 Ok(plugin) => Some(plugin),
717 Err(e) => {
718 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
719 None
720 }
721 },
722 PluginFormat::Json => match serde_json::from_str::<ExternalPluginDef>(content) {
723 Ok(plugin) => Some(plugin),
724 Err(e) => {
725 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
726 None
727 }
728 },
729 PluginFormat::Jsonc => match crate::jsonc::parse_to_value::<ExternalPluginDef>(content) {
730 Ok(plugin) => Some(plugin),
731 Err(e) => {
732 tracing::warn!("failed to parse external plugin {}: {e}", path.display());
733 None
734 }
735 },
736 }
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
741#[non_exhaustive]
742pub enum ConfiguredPluginDiagnosticKind {
743 Missing,
745 OutsideProjectRoot,
747 UnsupportedFormat,
749 Unreadable,
751 InvalidDefinition,
753 NoPluginFiles,
755}
756
757#[derive(Debug, Clone, PartialEq, Eq)]
759#[non_exhaustive]
760pub struct ConfiguredPluginDiagnostic {
761 pub kind: ConfiguredPluginDiagnosticKind,
763 pub path: PathBuf,
765}
766
767#[must_use]
774pub fn diagnose_configured_external_plugins(
775 root: &Path,
776 config_plugin_paths: &[String],
777) -> Vec<ConfiguredPluginDiagnostic> {
778 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
779 let mut diagnostics = Vec::new();
780
781 for path_str in config_plugin_paths {
782 let path = root.join(path_str);
783 let metadata = match std::fs::metadata(&path) {
784 Ok(metadata) => metadata,
785 Err(error) => {
786 diagnostics.push(ConfiguredPluginDiagnostic {
787 kind: if error.kind() == std::io::ErrorKind::NotFound {
788 ConfiguredPluginDiagnosticKind::Missing
789 } else {
790 ConfiguredPluginDiagnosticKind::Unreadable
791 },
792 path,
793 });
794 continue;
795 }
796 };
797
798 if !is_within_root(&path, &canonical_root) {
799 diagnostics.push(ConfiguredPluginDiagnostic {
800 kind: ConfiguredPluginDiagnosticKind::OutsideProjectRoot,
801 path,
802 });
803 continue;
804 }
805
806 if metadata.is_dir() {
807 diagnose_configured_plugin_dir(&path, &canonical_root, &mut diagnostics);
808 } else if metadata.is_file() {
809 diagnose_configured_plugin_file(&path, &canonical_root, &mut diagnostics);
810 } else {
811 diagnostics.push(ConfiguredPluginDiagnostic {
812 kind: ConfiguredPluginDiagnosticKind::UnsupportedFormat,
813 path,
814 });
815 }
816 }
817
818 diagnostics
819}
820
821fn diagnose_configured_plugin_dir(
822 dir: &Path,
823 canonical_root: &Path,
824 diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
825) {
826 let Ok(entries) = std::fs::read_dir(dir) else {
827 diagnostics.push(ConfiguredPluginDiagnostic {
828 kind: ConfiguredPluginDiagnosticKind::Unreadable,
829 path: dir.to_path_buf(),
830 });
831 return;
832 };
833 let mut plugin_files = Vec::new();
834 let mut entry_failed = false;
835 for entry in entries {
836 match entry {
837 Ok(entry) => {
838 let path = entry.path();
839 if path.is_file() && is_plugin_file(&path) {
840 plugin_files.push(path);
841 }
842 }
843 Err(_) => {
844 entry_failed = true;
845 diagnostics.push(ConfiguredPluginDiagnostic {
846 kind: ConfiguredPluginDiagnosticKind::Unreadable,
847 path: dir.to_path_buf(),
848 });
849 }
850 }
851 }
852 if plugin_files.is_empty() && !entry_failed {
853 diagnostics.push(ConfiguredPluginDiagnostic {
854 kind: ConfiguredPluginDiagnosticKind::NoPluginFiles,
855 path: dir.to_path_buf(),
856 });
857 return;
858 }
859 plugin_files.sort();
860 for path in plugin_files {
861 diagnose_configured_plugin_file(&path, canonical_root, diagnostics);
862 }
863}
864
865fn diagnose_configured_plugin_file(
866 path: &Path,
867 canonical_root: &Path,
868 diagnostics: &mut Vec<ConfiguredPluginDiagnostic>,
869) {
870 let kind = if !is_within_root(path, canonical_root) {
871 Some(ConfiguredPluginDiagnosticKind::OutsideProjectRoot)
872 } else if let Some(format) = PluginFormat::from_path(path) {
873 match std::fs::read_to_string(path) {
874 Ok(content) => match parse_plugin(&content, &format, path) {
875 Some(plugin) if !plugin.name.is_empty() => None,
876 Some(_) | None => Some(ConfiguredPluginDiagnosticKind::InvalidDefinition),
877 },
878 Err(_) => Some(ConfiguredPluginDiagnosticKind::Unreadable),
879 }
880 } else {
881 Some(ConfiguredPluginDiagnosticKind::UnsupportedFormat)
882 };
883 if let Some(kind) = kind {
884 diagnostics.push(ConfiguredPluginDiagnostic {
885 kind,
886 path: path.to_path_buf(),
887 });
888 }
889}
890
891pub fn discover_external_plugins(
898 root: &Path,
899 config_plugin_paths: &[String],
900) -> Vec<ExternalPluginDef> {
901 let mut plugins = Vec::new();
902 let mut seen_names = rustc_hash::FxHashSet::default();
903
904 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
905
906 load_configured_plugin_paths(
907 root,
908 config_plugin_paths,
909 &canonical_root,
910 &mut plugins,
911 &mut seen_names,
912 );
913 load_default_plugins_dir(root, &canonical_root, &mut plugins, &mut seen_names);
914 load_root_plugin_files(root, &canonical_root, &mut plugins, &mut seen_names);
915
916 plugins
917}
918
919fn load_configured_plugin_paths(
920 root: &Path,
921 config_plugin_paths: &[String],
922 canonical_root: &Path,
923 plugins: &mut Vec<ExternalPluginDef>,
924 seen_names: &mut rustc_hash::FxHashSet<String>,
925) {
926 for path_str in config_plugin_paths {
927 let path = root.join(path_str);
928 if !is_within_root(&path, canonical_root) {
929 tracing::warn!("plugin path '{path_str}' resolves outside project root, skipping");
930 continue;
931 }
932 if path.is_dir() {
933 load_plugins_from_dir(&path, canonical_root, plugins, seen_names);
934 } else if path.is_file() {
935 load_plugin_file(&path, canonical_root, plugins, seen_names);
936 }
937 }
938}
939
940fn load_default_plugins_dir(
941 root: &Path,
942 canonical_root: &Path,
943 plugins: &mut Vec<ExternalPluginDef>,
944 seen_names: &mut rustc_hash::FxHashSet<String>,
945) {
946 let plugins_dir = root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME);
947 if plugins_dir.is_dir() && is_within_root(&plugins_dir, canonical_root) {
948 load_plugins_from_dir(&plugins_dir, canonical_root, plugins, seen_names);
949 }
950}
951
952fn load_root_plugin_files(
953 root: &Path,
954 canonical_root: &Path,
955 plugins: &mut Vec<ExternalPluginDef>,
956 seen_names: &mut rustc_hash::FxHashSet<String>,
957) {
958 for path in root_plugin_files(root) {
959 load_plugin_file(&path, canonical_root, plugins, seen_names);
960 }
961}
962
963fn root_plugin_files(root: &Path) -> Vec<PathBuf> {
965 let Ok(entries) = std::fs::read_dir(root) else {
966 return Vec::new();
967 };
968 let mut plugin_files: Vec<PathBuf> = entries
969 .filter_map(Result::ok)
970 .map(|e| e.path())
971 .filter(|p| {
972 p.is_file()
973 && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
974 n.starts_with(ROOT_PLUGIN_FILE_PREFIX) && is_plugin_file(Path::new(n))
975 })
976 })
977 .collect();
978 plugin_files.sort();
979 plugin_files
980}
981
982fn plugin_files_in_dir(dir: &Path) -> Vec<PathBuf> {
984 let Ok(entries) = std::fs::read_dir(dir) else {
985 return Vec::new();
986 };
987 let mut plugin_files: Vec<PathBuf> = entries
988 .filter_map(Result::ok)
989 .map(|e| e.path())
990 .filter(|p| p.is_file() && is_plugin_file(p))
991 .collect();
992 plugin_files.sort();
993 plugin_files
994}
995
996#[must_use]
1000pub fn is_default_external_plugin_file(path: &Path) -> bool {
1001 if !is_plugin_file(path) {
1002 return false;
1003 }
1004 let name_matches = path
1005 .file_name()
1006 .and_then(|name| name.to_str())
1007 .is_some_and(|name| name.starts_with(ROOT_PLUGIN_FILE_PREFIX));
1008 let parent = path.parent();
1009 let in_plugins_dir = parent
1010 .and_then(Path::file_name)
1011 .is_some_and(|name| name == DEFAULT_PLUGINS_DIR_NAME)
1012 && parent
1013 .and_then(Path::parent)
1014 .and_then(Path::file_name)
1015 .is_some_and(|name| name == ".fallow");
1016 name_matches || in_plugins_dir
1017}
1018
1019#[must_use]
1025pub fn external_plugin_source_files(root: &Path, config_plugin_paths: &[String]) -> Vec<PathBuf> {
1026 let mut files = Vec::new();
1027 for path_str in config_plugin_paths {
1028 let path = root.join(path_str);
1029 if path.is_dir() {
1030 files.extend(plugin_files_in_dir(&path));
1031 } else {
1032 files.push(path);
1033 }
1034 }
1035 files.extend(plugin_files_in_dir(
1036 &root.join(".fallow").join(DEFAULT_PLUGINS_DIR_NAME),
1037 ));
1038 files.extend(root_plugin_files(root));
1039 files
1040}
1041
1042#[expect(
1044 clippy::redundant_pub_crate,
1045 reason = "this module is glob re-exported from lib.rs, so `pub` would leak this helper into the public API; pub(crate) is the minimal widening for the rule-pack loader"
1046)]
1047pub(crate) fn is_within_root(path: &Path, canonical_root: &Path) -> bool {
1048 let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1049 canonical.starts_with(canonical_root)
1050}
1051
1052fn load_plugins_from_dir(
1053 dir: &Path,
1054 canonical_root: &Path,
1055 plugins: &mut Vec<ExternalPluginDef>,
1056 seen: &mut rustc_hash::FxHashSet<String>,
1057) {
1058 for path in plugin_files_in_dir(dir) {
1059 load_plugin_file(&path, canonical_root, plugins, seen);
1060 }
1061}
1062
1063fn load_plugin_file(
1064 path: &Path,
1065 canonical_root: &Path,
1066 plugins: &mut Vec<ExternalPluginDef>,
1067 seen: &mut rustc_hash::FxHashSet<String>,
1068) {
1069 if !is_within_root(path, canonical_root) {
1070 tracing::warn!(
1071 "plugin file '{}' resolves outside project root (symlink?), skipping",
1072 path.display()
1073 );
1074 return;
1075 }
1076
1077 let Some(format) = PluginFormat::from_path(path) else {
1078 tracing::warn!(
1079 "unsupported plugin file extension for {}, expected .toml, .json, or .jsonc",
1080 path.display()
1081 );
1082 return;
1083 };
1084
1085 let Some(content) = read_plugin_file(path) else {
1086 return;
1087 };
1088
1089 if let Some(plugin) = parse_plugin(&content, &format, path) {
1090 push_plugin_if_unique(plugin, path, plugins, seen);
1091 }
1092}
1093
1094fn read_plugin_file(path: &Path) -> Option<String> {
1095 match std::fs::read_to_string(path) {
1096 Ok(content) => Some(content),
1097 Err(e) => {
1098 tracing::warn!(
1099 "failed to read external plugin file {}: {e}",
1100 path.display()
1101 );
1102 None
1103 }
1104 }
1105}
1106
1107fn push_plugin_if_unique(
1108 plugin: ExternalPluginDef,
1109 path: &Path,
1110 plugins: &mut Vec<ExternalPluginDef>,
1111 seen: &mut rustc_hash::FxHashSet<String>,
1112) {
1113 if plugin.name.is_empty() {
1114 tracing::warn!(
1115 "external plugin in {} has an empty name, skipping",
1116 path.display()
1117 );
1118 return;
1119 }
1120
1121 if seen.insert(plugin.name.clone()) {
1122 plugins.push(plugin);
1123 } else {
1124 tracing::warn!(
1125 "duplicate external plugin '{}' in {}, skipping",
1126 plugin.name,
1127 path.display()
1128 );
1129 }
1130}
1131
1132#[cfg(test)]
1133mod tests;