Skip to main content

opys_dev/
engine.rs

1//! Merge plugin contributions into a `Manifest`.
2//!
3//! Running the plugins is the caller's job — JS drives JS closures, a native
4//! builder drives Rust plugins. What both must agree on is how the resulting
5//! contributions fold together, and that lives here.
6
7use std::collections::HashMap;
8
9use opys_bundle::Blobs;
10use opys_core::{
11    deduplicate_artifacts, Artifact, CleanupRule, Launch, Manifest, Val, ValDefs, Valset,
12};
13use serde::Deserialize;
14
15use crate::contribution::{Contribution, LaunchFragment, PluginOutput};
16
17/// The author-supplied half of the manifest.
18///
19/// The launch line is written as data. A bare string that begins with `@` —
20/// `@forge.jvmArgs` — names a launch group a plugin exposes, and is replaced
21/// by it here; `\@file` is the literal `@file`, which `java` reads as an
22/// argument file. `command` and `workdir` take the same spelling, and there a
23/// reference has to come to exactly one string. These were functions over a
24/// plugin map once, which made a config code where it could be data and left
25/// a misspelt group to be found at launch.
26///
27/// Every field but `command` defaults: the author writes what they mean and
28/// omits the rest. `command` is the one thing a launch cannot do without, so
29/// its absence is an error rather than an empty string.
30#[derive(Debug, Clone, Default, Deserialize)]
31pub struct ManifestConfig {
32    /// Hand-written literal artifacts, appended after all plugin output.
33    #[serde(default)]
34    pub artifacts: Vec<Artifact>,
35    /// Override vars layered on top of the merged plugin vars — the sanctioned
36    /// silent override, so no collision warning is raised for it.
37    #[serde(default)]
38    pub vars: ValDefs,
39    /// A literal, or a reference to a group that is one string.
40    pub command: String,
41    /// A literal or a reference, as `command`. `None` means the manifest
42    /// default, `"."`.
43    #[serde(default)]
44    pub workdir: Option<String>,
45    #[serde(default)]
46    pub args: Vec<LaunchFragment>,
47    #[serde(default)]
48    pub envs: ValDefs,
49    /// Emitted only when non-empty, matching the wire format.
50    #[serde(default)]
51    pub cleanup: Vec<CleanupRule>,
52}
53
54/// The assembled manifest plus the collision warnings raised while merging.
55///
56/// Warnings are returned rather than logged through a callback: the engine
57/// stays a pure function, and the caller keeps ownership of its own log
58/// channel on whichever side of the boundary it lives.
59#[derive(Debug, Clone, Default)]
60pub struct Assembled {
61    pub manifest: Manifest,
62    /// Where each blob the manifest names is kept. Only those: a blob whose
63    /// artifact a later plugin replaced is dropped with it.
64    pub blobs: Blobs,
65    pub warnings: Vec<String>,
66}
67
68/// Why a set of contributions and a config could not be made into a manifest.
69#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
70pub enum AssembleError {
71    /// A reference goes by plugin name, so a name has to mean one plugin.
72    #[error("two plugins are named '{name}': rename one with `.as('…')`")]
73    DuplicatePlugin { name: String },
74    #[error("plugin '{plugin}': {reason}")]
75    Artifact { plugin: String, reason: String },
76    #[error("'{reference}' is not a reference: one is written `@plugin.group`")]
77    BadReference { reference: String },
78    #[error("'{reference}': there is no plugin named '{plugin}' (there are: {})", .plugins.join(", "))]
79    UnknownPlugin {
80        reference: String,
81        plugin: String,
82        plugins: Vec<String>,
83    },
84    #[error("'{reference}': '{plugin}' exposes no '{group}' (it has: {})", .groups.join(", "))]
85    UnknownGroup {
86        reference: String,
87        plugin: String,
88        group: String,
89        groups: Vec<String>,
90    },
91    #[error("`{field}` is one string, and '{reference}' is not: it is a list, or carries rules")]
92    NotOneString {
93        field: &'static str,
94        reference: String,
95    },
96}
97
98/// What a bare string in the launch line says.
99enum Written<'a> {
100    Literal(&'a str),
101    Reference { plugin: &'a str, group: &'a str },
102}
103
104/// Split at the last dot, so a plugin whose name has one can still be named.
105fn written(text: &str) -> Result<Written<'_>, AssembleError> {
106    if let Some(escaped) = text.strip_prefix('\\') {
107        if escaped.starts_with('@') {
108            return Ok(Written::Literal(escaped));
109        }
110    }
111    let Some(path) = text.strip_prefix('@') else {
112        return Ok(Written::Literal(text));
113    };
114    match path.rsplit_once('.') {
115        Some((plugin, group)) if !plugin.is_empty() && !group.is_empty() => {
116            Ok(Written::Reference { plugin, group })
117        }
118        _ => Err(AssembleError::BadReference {
119            reference: text.to_owned(),
120        }),
121    }
122}
123
124fn sorted(names: impl Iterator<Item = impl AsRef<str>>) -> Vec<String> {
125    let mut names: Vec<String> = names.map(|n| n.as_ref().to_owned()).collect();
126    names.sort_unstable();
127    names
128}
129
130/// The launch group `@plugin.group` names.
131fn lookup<'a>(
132    outputs: &'a [PluginOutput],
133    reference: &str,
134    plugin: &str,
135    group: &str,
136) -> Result<&'a LaunchFragment, AssembleError> {
137    let Some(output) = outputs.iter().find(|o| o.name == plugin) else {
138        return Err(AssembleError::UnknownPlugin {
139            reference: reference.to_owned(),
140            plugin: plugin.to_owned(),
141            plugins: sorted(outputs.iter().map(|o| &o.name)),
142        });
143    };
144    output
145        .contribution
146        .launch
147        .get(group)
148        .ok_or_else(|| AssembleError::UnknownGroup {
149            reference: reference.to_owned(),
150            plugin: plugin.to_owned(),
151            group: group.to_owned(),
152            groups: sorted(output.contribution.launch.keys()),
153        })
154}
155
156fn text_val(text: &str) -> Val {
157    Val {
158        rules: Vec::new(),
159        value: vec![text.to_owned()],
160    }
161}
162
163/// Flatten author-ordered launch fragments into a single `Valset`, each
164/// reference replaced by the group it names.
165fn flatten_args(
166    outputs: &[PluginOutput],
167    items: &[LaunchFragment],
168) -> Result<Valset, AssembleError> {
169    let mut out: Valset = Vec::new();
170    let mut push = |fragment: &LaunchFragment| match fragment {
171        LaunchFragment::Text(s) => out.push(text_val(s)),
172        LaunchFragment::Many(vs) => out.extend(vs.iter().cloned()),
173        LaunchFragment::One(v) => out.push(v.clone()),
174    };
175    for item in items {
176        match item {
177            LaunchFragment::Text(text) => match written(text)? {
178                Written::Literal(literal) => push(&LaunchFragment::Text(literal.to_owned())),
179                Written::Reference { plugin, group } => push(lookup(outputs, text, plugin, group)?),
180            },
181            // Only a bare string is read for a reference. A value written
182            // out in full is taken as it is, what it holds included.
183            other => push(other),
184        }
185    }
186    Ok(out)
187}
188
189/// A field that is one string on every machine: a literal, or a reference to
190/// a group that is exactly that.
191fn one_string(
192    outputs: &[PluginOutput],
193    field: &'static str,
194    text: &str,
195) -> Result<String, AssembleError> {
196    let (plugin, group) = match written(text)? {
197        Written::Literal(literal) => return Ok(literal.to_owned()),
198        Written::Reference { plugin, group } => (plugin, group),
199    };
200    match lookup(outputs, text, plugin, group)? {
201        LaunchFragment::Text(s) => Ok(s.clone()),
202        LaunchFragment::One(Val { rules, value }) if rules.is_empty() && value.len() == 1 => {
203            Ok(value[0].clone())
204        }
205        _ => Err(AssembleError::NotOneString {
206            field,
207            reference: text.to_owned(),
208        }),
209    }
210}
211
212/// Merge one `ValDefs` field across plugins in list order, last wins.
213///
214/// A key claimed by two different plugins is a warning, not an error: opys
215/// has always resolved it in favour of the later plugin, and the author can
216/// settle it deliberately through `ManifestConfig`.
217fn merge_owned(
218    outputs: &[PluginOutput],
219    pick: fn(&Contribution) -> &ValDefs,
220    kind: &str,
221    warnings: &mut Vec<String>,
222) -> ValDefs {
223    let mut merged = ValDefs::new();
224    let mut owner: HashMap<&str, &str> = HashMap::new();
225    for output in outputs {
226        for (key, value) in pick(&output.contribution) {
227            let name = output.name.as_str();
228            match owner.get(key.as_str()) {
229                Some(prev) if *prev != name => warnings.push(format!(
230                    "warning: {kind} '{key}' set by both '{prev}' and '{name}' — using '{name}'"
231                )),
232                _ => {}
233            }
234            merged.insert(key.clone(), value.clone());
235            owner.insert(key.as_str(), name);
236        }
237    }
238    merged
239}
240
241/// Fold plugin contributions and the author's manifest config into the final
242/// `Manifest`.
243pub fn assemble(
244    outputs: &[PluginOutput],
245    config: &ManifestConfig,
246) -> Result<Assembled, AssembleError> {
247    let mut warnings = Vec::new();
248
249    let mut names = std::collections::BTreeSet::new();
250    if let Some(twice) = outputs.iter().find(|o| !names.insert(o.name.as_str())) {
251        return Err(AssembleError::DuplicatePlugin {
252            name: twice.name.clone(),
253        });
254    }
255
256    // Artifacts: plugin output in list order, then the literal artifacts.
257    // One that is carried is read here and becomes a blob artifact, with its
258    // bytes set aside for whoever writes the bundle.
259    let mut artifacts: Vec<Artifact> = Vec::new();
260    let mut held = Blobs::new();
261    for output in outputs {
262        for built in &output.contribution.artifacts {
263            let (artifact, blob) =
264                built
265                    .clone()
266                    .resolve()
267                    .map_err(|error| AssembleError::Artifact {
268                        plugin: output.name.clone(),
269                        reason: error.to_string(),
270                    })?;
271            artifacts.push(artifact);
272            held.extend(blob);
273        }
274    }
275    artifacts.extend(config.artifacts.iter().cloned());
276    let artifacts = deduplicate_artifacts(artifacts);
277
278    let mut vars = merge_owned(outputs, |c| &c.vars, "var", &mut warnings);
279    for (key, value) in &config.vars {
280        vars.insert(key.clone(), value.clone());
281    }
282
283    let mut envs = merge_owned(outputs, |c| &c.envs, "env", &mut warnings);
284    for (key, value) in &config.envs {
285        envs.insert(key.clone(), value.clone());
286    }
287
288    // Blobs need no merge rule. An id is the hash of its bytes, so two plugins
289    // carrying the same id carry the same thing and either copy will do. One
290    // whose artifact a later plugin replaced is dropped with it.
291    let named: std::collections::BTreeSet<&str> =
292        artifacts.iter().filter_map(Artifact::blob_id).collect();
293    let blobs: Blobs = held
294        .into_iter()
295        .filter(|(id, _)| named.contains(id.as_str()))
296        .collect();
297
298    let launch = Launch {
299        command: one_string(outputs, "command", &config.command)?,
300        workdir: match &config.workdir {
301            Some(workdir) => one_string(outputs, "workdir", workdir)?,
302            None => ".".to_owned(),
303        },
304        args: flatten_args(outputs, &config.args)?,
305        envs,
306    };
307
308    Ok(Assembled {
309        manifest: Manifest {
310            vars,
311            launch: Some(launch),
312            artifacts,
313            cleanup: config.cleanup.clone(),
314        },
315        blobs,
316        warnings,
317    })
318}