Skip to main content

backhopper_core/jigsaw/
plugin_set.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::PathBuf;
7use std::str::{self, FromStr};
8use std::sync::LazyLock;
9
10use regex::Regex;
11use thiserror::Error;
12
13use serde::{Deserialize, Serialize};
14
15use crate::model::app_manifest::ReadManifest;
16use crate::model::names::{ApplicationName, ModuleName, RelativePath, vocabulary};
17
18vocabulary!(
19    /// An app another app in the plugin set requires is bundled; the rest
20    /// are plugins. The split is for the report: the analysis treats both
21    /// the same.
22    #[derive(PartialOrd, Ord, Hash)]
23    pub enum AppRole: "app role" {
24        Plugin => "plugin",
25        Bundled => "bundled",
26    }
27);
28
29/// A file of an app, addressed by the app and the path from the app root.
30#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct PluginFile {
32    pub app: ApplicationName,
33    pub path: RelativePath,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SourceFile {
38    pub path: RelativePath,
39    pub bytes: Vec<u8>,
40}
41
42#[derive(Debug, Clone)]
43pub struct PluginAppSources {
44    pub root: PathBuf,
45    pub manifest: ReadManifest,
46    pub manifest_path: RelativePath,
47    pub files: Vec<SourceFile>,
48}
49
50/// What the directory walk found, as plain data.
51#[derive(Debug, Clone, Default)]
52pub struct PluginSources {
53    pub apps: Vec<PluginAppSources>,
54    pub host_provided: BTreeSet<ApplicationName>,
55}
56
57#[derive(Debug, Clone)]
58pub struct PluginApp {
59    root: PathBuf,
60    manifest: ReadManifest,
61    manifest_path: RelativePath,
62    files: Vec<SourceFile>,
63    role: AppRole,
64}
65
66impl PluginApp {
67    #[must_use]
68    pub fn root(&self) -> &PathBuf {
69        &self.root
70    }
71
72    #[must_use]
73    pub fn manifest(&self) -> &ReadManifest {
74        &self.manifest
75    }
76
77    #[must_use]
78    pub fn manifest_path(&self) -> &RelativePath {
79        &self.manifest_path
80    }
81
82    #[must_use]
83    pub fn files(&self) -> &[SourceFile] {
84        &self.files
85    }
86
87    #[must_use]
88    pub fn role(&self) -> AppRole {
89        self.role
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Error)]
94pub enum PluginSetError {
95    #[error("app {app} is found twice: {} and {}", first.display(), second.display())]
96    DuplicateApp {
97        app: ApplicationName,
98        first: PathBuf,
99        second: PathBuf,
100    },
101    #[error("module {module} is defined twice: {}/{} and {}/{}", first.app, first.path, second.app, second.path)]
102    DuplicateModule {
103        module: ModuleName,
104        first: PluginFile,
105        second: PluginFile,
106    },
107}
108
109/// The plugins and every library they bundle, checked as one unit.
110#[derive(Debug, Clone)]
111pub struct PluginSet {
112    apps: BTreeMap<ApplicationName, PluginApp>,
113    host_provided: BTreeSet<ApplicationName>,
114}
115
116static MODULE_ATTRIBUTE: LazyLock<Regex> = LazyLock::new(|| {
117    Regex::new(r"(?m)^\s*-\s*module\s*\(\s*([A-Za-z0-9_@.']+)\s*\)").expect("regex")
118});
119
120/// The module an `.erl` file declares with `-module(...)`.
121#[must_use]
122pub fn declared_module(file: &SourceFile) -> Option<ModuleName> {
123    if !file.path.as_str().ends_with(".erl") {
124        return None;
125    }
126    let text = str::from_utf8(&file.bytes).ok()?;
127    let name = MODULE_ATTRIBUTE.captures(text)?.get(1)?.as_str();
128    ModuleName::from_str(name).ok()
129}
130
131impl PluginSet {
132    pub fn new(sources: PluginSources) -> Result<Self, PluginSetError> {
133        let mut apps: BTreeMap<ApplicationName, PluginApp> = BTreeMap::new();
134        let mut modules: BTreeMap<ModuleName, PluginFile> = BTreeMap::new();
135        for app in sources.apps {
136            let name = app.manifest.name().clone();
137            if let Some(first) = apps.get(&name) {
138                return Err(PluginSetError::DuplicateApp {
139                    app: name,
140                    first: first.root.clone(),
141                    second: app.root,
142                });
143            }
144            for file in &app.files {
145                let Some(module) = declared_module(file) else {
146                    continue;
147                };
148                let here = PluginFile {
149                    app: name.clone(),
150                    path: file.path.clone(),
151                };
152                if let Some(first) = modules.get(&module) {
153                    return Err(PluginSetError::DuplicateModule {
154                        module,
155                        first: first.clone(),
156                        second: here,
157                    });
158                }
159                modules.insert(module, here);
160            }
161            apps.insert(
162                name,
163                PluginApp {
164                    root: app.root,
165                    manifest: app.manifest,
166                    manifest_path: app.manifest_path,
167                    files: app.files,
168                    role: AppRole::Plugin,
169                },
170            );
171        }
172        let required: BTreeSet<ApplicationName> = apps
173            .values()
174            .flat_map(|app| app.manifest.requires().iter().cloned())
175            .collect();
176        for (name, app) in &mut apps {
177            if required.contains(name) {
178                app.role = AppRole::Bundled;
179            }
180        }
181        Ok(Self {
182            apps,
183            host_provided: sources.host_provided,
184        })
185    }
186
187    #[must_use]
188    pub fn apps(&self) -> &BTreeMap<ApplicationName, PluginApp> {
189        &self.apps
190    }
191
192    #[must_use]
193    pub fn app(&self, name: &ApplicationName) -> Option<&PluginApp> {
194        self.apps.get(name)
195    }
196
197    #[must_use]
198    pub fn contains_app(&self, name: &ApplicationName) -> bool {
199        self.apps.contains_key(name)
200    }
201
202    #[must_use]
203    pub fn host_provided(&self) -> &BTreeSet<ApplicationName> {
204        &self.host_provided
205    }
206
207    /// Every source file with the app it belongs to.
208    pub fn files(&self) -> impl Iterator<Item = (PluginFile, &SourceFile)> {
209        self.apps.iter().flat_map(|(app, plugin)| {
210            plugin.files.iter().map(move |file| {
211                (
212                    PluginFile {
213                        app: app.clone(),
214                        path: file.path.clone(),
215                    },
216                    file,
217                )
218            })
219        })
220    }
221}