Skip to main content

bougie_scripts/
lib.rs

1//! Opt-in execution of **root** `composer.json` scripts.
2//!
3//! Composer only ever runs `scripts` from the root package (never from
4//! dependencies), so they're the project author's own commands — not a
5//! supply-chain hazard. bougie keeps execution opt-in / off by default; this
6//! crate is the engine that runs them when the user turns it on.
7//!
8//! The crate is intentionally FS/PHP-agnostic: it [`parse`](Scripts::parse)s
9//! and classifies the `scripts` table into [`Entry`] values and
10//! [`dispatch`]es a named event, given a host-injected [`ScriptContext`]
11//! (resolved PHP binary, env, callback registry). Everything that needs to
12//! know about bougie's path/PHP/service-env machinery lives in the caller —
13//! mirroring how `bougie-installers` isolates declarative-plugin logic.
14//!
15//! **Scope:** the non-internal entry forms (`@php`, `@composer`, `@putenv`,
16//! `@<alias>`, plain shell). PHP-callback entries (`Class::method`) reach into
17//! Composer internals in-process; bougie does not host them. They are
18//! warn-and-skipped, except for a small allowlist the host registers natively
19//! (e.g. Laravel's `clearCompiled`).
20
21mod context;
22mod dispatch;
23
24pub use context::{CallbackHandler, CallbackRegistry, ScriptContext};
25pub use dispatch::{dispatch, EntryOutcome};
26
27/// A single listener entry within a `scripts.<event>` list, classified by
28/// Composer's entry grammar.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Entry {
31    /// A plain shell command, run via `/bin/sh -e -c` (Unix) / `cmd /C`.
32    Shell(String),
33    /// `@php <args>` — run with the project's resolved PHP binary.
34    Php(String),
35    /// `@composer <args>` — re-invoke Composer. bougie isn't Composer; the
36    /// common subcommands are mapped, the rest warn-skipped.
37    Composer(String),
38    /// `@putenv KEY=VAL` — set an env var for *subsequent* entries in this
39    /// dispatch (scoped to the dispatch, not the process).
40    PutEnv { key: String, val: String },
41    /// `@<name>` — a script alias; dispatch recurses into `scripts.<name>`.
42    Alias(String),
43    /// `Vendor\Class::method` — a PHP callback invoked in-process by Composer.
44    /// Not hosted by bougie except via the native callback registry.
45    Callback { class: String, method: String },
46}
47
48/// A parsed root `scripts` table: ordered event/alias name → ordered entries.
49///
50/// Order is preserved (Composer runs entries in declaration order, and alias
51/// recursion depends on it), so the backing store is an ordered `Vec` rather
52/// than a map.
53#[derive(Debug, Clone, Default)]
54pub struct Scripts(Vec<(String, Vec<Entry>)>);
55
56impl Scripts {
57    /// Parse the root `composer.json`'s `scripts` object into classified,
58    /// order-preserving entries.
59    ///
60    /// Reads the raw value directly rather than going through
61    /// `bougie-config`'s normaliser, which drops callback/mixed arrays — we
62    /// need every entry classified in order, callbacks included. A single
63    /// string `scripts.<event>` is treated as a one-element list, matching
64    /// Composer.
65    #[must_use]
66    pub fn parse(root_composer_json: &serde_json::Value) -> Self {
67        let mut out: Vec<(String, Vec<Entry>)> = Vec::new();
68        let Some(obj) = root_composer_json.get("scripts").and_then(serde_json::Value::as_object)
69        else {
70            return Self(out);
71        };
72        for (event, value) in obj {
73            let entries = match value {
74                serde_json::Value::String(s) => vec![classify(s)],
75                serde_json::Value::Array(a) => a
76                    .iter()
77                    .filter_map(serde_json::Value::as_str)
78                    .map(classify)
79                    .collect(),
80                // A non-string/array entry (object, number, …) isn't a valid
81                // listener; skip the whole event rather than guess.
82                _ => continue,
83            };
84            out.push((event.clone(), entries));
85        }
86        Self(out)
87    }
88
89    /// Look up the entries for an event or alias name.
90    #[must_use]
91    pub fn get(&self, name: &str) -> Option<&[Entry]> {
92        self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_slice())
93    }
94
95    /// Whether the table has no events.
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.0.is_empty()
99    }
100}
101
102/// Classify one raw `scripts` string into an [`Entry`].
103fn classify(raw: &str) -> Entry {
104    let trimmed = raw.trim_start();
105    // `@`-prefixed forms. Match the longest specific prefix first so `@php` /
106    // `@composer` / `@putenv` don't fall through to the generic `@alias` arm.
107    if let Some(rest) = trimmed.strip_prefix("@php")
108        && (rest.is_empty() || rest.starts_with(char::is_whitespace))
109    {
110        return Entry::Php(rest.trim_start().to_string());
111    }
112    if let Some(rest) = trimmed.strip_prefix("@composer")
113        && (rest.is_empty() || rest.starts_with(char::is_whitespace))
114    {
115        return Entry::Composer(rest.trim_start().to_string());
116    }
117    if let Some(rest) = trimmed.strip_prefix("@putenv")
118        && rest.starts_with(char::is_whitespace)
119    {
120        let assignment = rest.trim_start();
121        let (key, val) = assignment.split_once('=').unwrap_or((assignment, ""));
122        return Entry::PutEnv { key: key.trim().to_string(), val: val.to_string() };
123    }
124    if let Some(name) = trimmed.strip_prefix('@')
125        && !name.is_empty()
126        && !name.contains(char::is_whitespace)
127    {
128        // A bare `@name` alias: a single token, no embedded whitespace.
129        return Entry::Alias(name.to_string());
130    }
131    if let Some((class, method)) = as_callback(trimmed) {
132        return Entry::Callback { class, method };
133    }
134    // Preserve the original (untrimmed) string for shell fidelity.
135    Entry::Shell(raw.to_string())
136}
137
138/// Recognise a `Vendor\Class::method` PHP-callback entry: a single token
139/// (no whitespace) of the shape `<class>::<method>`, where the class may
140/// contain namespace separators. Anything with whitespace is a shell command.
141fn as_callback(s: &str) -> Option<(String, String)> {
142    if s.contains(char::is_whitespace) {
143        return None;
144    }
145    let (class, method) = s.split_once("::")?;
146    if class.is_empty() || method.is_empty() || method.contains("::") {
147        return None;
148    }
149    let class_ok = class.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '\\');
150    let method_ok = method.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
151    (class_ok && method_ok).then(|| (class.to_string(), method.to_string()))
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn classifies_each_entry_form() {
161        assert_eq!(classify("@php artisan migrate"), Entry::Php("artisan migrate".into()));
162        assert_eq!(classify("@php"), Entry::Php(String::new()));
163        assert_eq!(classify("@composer dump-autoload"), Entry::Composer("dump-autoload".into()));
164        assert_eq!(
165            classify("@putenv APP_ENV=testing"),
166            Entry::PutEnv { key: "APP_ENV".into(), val: "testing".into() }
167        );
168        assert_eq!(classify("@build"), Entry::Alias("build".into()));
169        assert_eq!(
170            classify("Illuminate\\Foundation\\ComposerScripts::postAutoloadDump"),
171            Entry::Callback {
172                class: "Illuminate\\Foundation\\ComposerScripts".into(),
173                method: "postAutoloadDump".into(),
174            }
175        );
176        assert_eq!(classify("phpunit --colors"), Entry::Shell("phpunit --colors".into()));
177    }
178
179    #[test]
180    fn php_prefix_does_not_swallow_longer_token() {
181        // `@phpstan` is an alias, not a `@php` invocation of `stan`.
182        assert_eq!(classify("@phpstan"), Entry::Alias("phpstan".into()));
183    }
184
185    #[test]
186    fn callback_requires_no_whitespace() {
187        // A shell command that merely contains `::` is not a callback.
188        assert_eq!(classify("echo a::b c"), Entry::Shell("echo a::b c".into()));
189    }
190
191    #[test]
192    fn parse_preserves_order_and_single_string_form() {
193        let scripts = Scripts::parse(&json!({
194            "scripts": {
195                "post-install-cmd": ["@php artisan migrate", "phpunit"],
196                "test": "phpunit"
197            }
198        }));
199        assert_eq!(
200            scripts.get("post-install-cmd"),
201            Some(&[Entry::Php("artisan migrate".into()), Entry::Shell("phpunit".into())][..])
202        );
203        assert_eq!(scripts.get("test"), Some(&[Entry::Shell("phpunit".into())][..]));
204        assert!(scripts.get("missing").is_none());
205    }
206
207    #[test]
208    fn parse_empty_when_no_scripts() {
209        assert!(Scripts::parse(&json!({})).is_empty());
210        assert!(Scripts::parse(&json!({"scripts": 5})).is_empty());
211    }
212}