Skip to main content

vivacity_core/
runtime_stub.rs

1//! Emulation of the `symfony/runtime` plugin (plan r2): the plugin only
2//! generates `vendor/autoload_runtime.php` at autoload dump time. The template
3//! below is the observed output of the real plugin (symfony-demo fixture,
4//! default options); the drift test (ignored by default, slow) regenerates
5//! the reference through a real `composer install` with plugins.
6//!
7//! If the root composer.json customises `extra.runtime`, we are outside the
8//! default emulation, so the scope detector routes to the fallback.
9
10use crate::error::{Error, Result};
11use serde_json::Value;
12use std::path::{Path, PathBuf};
13
14pub const AUTOLOAD_RUNTIME_TEMPLATE: &str = r#"<?php
15
16// autoload_runtime.php @generated by Symfony Runtime
17
18if (true === (require_once __DIR__.'/autoload.php') || empty($_SERVER['SCRIPT_FILENAME'])) {
19    return;
20}
21
22$app = require $_SERVER['SCRIPT_FILENAME'];
23
24if (!is_object($app)) {
25    throw new TypeError(sprintf('Invalid return value: callable object expected, "%s" returned from "%s".', get_debug_type($app), $_SERVER['SCRIPT_FILENAME']));
26}
27
28if (is_string($_SERVER['APP_RUNTIME_OPTIONS'] ??= $_ENV['APP_RUNTIME_OPTIONS'] ?? [])) {
29    $_SERVER['APP_RUNTIME_OPTIONS'] = json_decode($_SERVER['APP_RUNTIME_OPTIONS'], true, 512, JSON_THROW_ON_ERROR);
30}
31$_SERVER['APP_RUNTIME'] ??= $_ENV['APP_RUNTIME'] ?? %runtime_class%;
32$runtime = new $_SERVER['APP_RUNTIME']($_SERVER['APP_RUNTIME_OPTIONS'] += %runtime_options%);
33
34[$app, $args] = $runtime
35    ->getResolver($app)
36    ->resolve();
37
38$app = $app(...$args);
39
40exit(
41    $runtime
42        ->getRunner($app)
43        ->run()
44);
45"#;
46
47/// `ComposerPlugin::updateAutoloadFile`: the template shipped by the
48/// installed `symfony/runtime` (`Internal/autoload_runtime.template`, or
49/// `extra.runtime.autoload_template`), with `%project_dir%`,
50/// `%runtime_class%` and `%runtime_options%` substituted from
51/// `extra.runtime`. `extra.runtime: false` writes nothing. The embedded
52/// template above is the fallback when the package ships none.
53pub fn write_stub(vendor_dir: &Path, project_dir: &Path, root_manifest: &Value) -> Result<()> {
54    let extra = root_manifest.get("extra").and_then(|e| e.get("runtime"));
55    if extra == Some(&Value::Bool(false)) {
56        return Ok(());
57    }
58    let mut options: serde_json::Map<String, Value> = match extra {
59        Some(Value::Object(m)) => m.clone(),
60        _ => serde_json::Map::new(),
61    };
62    let template = match options.get("autoload_template").and_then(Value::as_str) {
63        Some(t) => {
64            let path = if crate::pathutil::is_absolute_path(t) {
65                PathBuf::from(t)
66            } else {
67                project_dir.join(t)
68            };
69            std::fs::read_to_string(&path).map_err(|_| {
70                Error::Unsupported(format!(
71                    "File \"{t}\" defined under \"extra.runtime.autoload_template\" in your composer.json file not found."
72                ))
73            })?
74        }
75        None => {
76            let shipped = vendor_dir.join("symfony/runtime/Internal/autoload_runtime.template");
77            std::fs::read_to_string(&shipped)
78                .unwrap_or_else(|_| AUTOLOAD_RUNTIME_TEMPLATE.to_owned())
79        }
80    };
81    // `makePathRelative(realpath($projectDir.'/'.$extra['project_dir']), $vendorDir)`,
82    // then the `../` prefixes become a `dirname(__DIR__, n)`.
83    let vendor_real =
84        std::fs::canonicalize(vendor_dir).unwrap_or_else(|_| vendor_dir.to_path_buf());
85    let sub = options
86        .get("project_dir")
87        .and_then(Value::as_str)
88        .unwrap_or("");
89    let target =
90        std::fs::canonicalize(project_dir.join(sub)).unwrap_or_else(|_| project_dir.join(sub));
91    let mut relative = make_path_relative(&target, &vendor_real);
92    let mut nesting = 0;
93    while let Some(rest) = relative.strip_prefix("../") {
94        nesting += 1;
95        relative = rest.to_owned();
96    }
97    let project_code = if nesting == 0 {
98        format!("__DIR__.{}", php_var_export_str(&format!("/{relative}")))
99    } else if relative.is_empty() {
100        format!("dirname(__DIR__, {nesting})")
101    } else {
102        format!(
103            "dirname(__DIR__, {nesting}).{}",
104            php_var_export_str(&format!("/{relative}"))
105        )
106    };
107    let class = options
108        .get("class")
109        .and_then(Value::as_str)
110        .unwrap_or("Symfony\\Component\\Runtime\\SymfonyRuntime")
111        .to_owned();
112    for k in ["class", "autoload_template", "project_dir"] {
113        options.remove(k);
114    }
115    // `'['.substr(var_export($extra, true), 7, -1)."  'project_dir' => {$projectDir},\n]"`
116    let exported = php_var_export(&Value::Object(options), 0);
117    let inner = &exported[7..exported.len() - 1];
118    let runtime_options = format!("[{inner}  'project_dir' => {project_code},\n]");
119    let code = template
120        .replace("%project_dir%", &project_code)
121        .replace("%runtime_class%", &php_var_export_str(&class))
122        .replace("%runtime_options%", &runtime_options);
123    let path = vendor_dir.join("autoload_runtime.php");
124    if std::fs::read_to_string(&path).ok().as_deref() == Some(code.as_str()) {
125        return Ok(());
126    }
127    let tmp = vendor_dir.join(".autoload_runtime.php.vivacity-tmp");
128    std::fs::write(&tmp, code).map_err(Error::io(&tmp))?;
129    std::fs::rename(&tmp, &path).map_err(Error::io(&path))?;
130    Ok(())
131}
132
133/// Symfony `Filesystem::makePathRelative($endPath, $startPath)` for two
134/// absolute paths: `../` per segment left in `start`, the rest of `end`,
135/// a trailing `/`; `./` for equal paths.
136fn make_path_relative(end: &Path, start: &Path) -> String {
137    let e: Vec<String> = end
138        .components()
139        .skip(1)
140        .map(|c| c.as_os_str().to_string_lossy().into_owned())
141        .collect();
142    let st: Vec<String> = start
143        .components()
144        .skip(1)
145        .map(|c| c.as_os_str().to_string_lossy().into_owned())
146        .collect();
147    let common = e.iter().zip(st.iter()).take_while(|(a, b)| a == b).count();
148    let mut out = "../".repeat(st.len() - common);
149    let rest = e[common..].join("/");
150    if !rest.is_empty() {
151        out.push_str(&rest);
152        out.push('/');
153    }
154    if out.is_empty() {
155        "./".to_owned()
156    } else {
157        out
158    }
159}
160
161/// `var_export($string, true)`.
162fn php_var_export_str(s: &str) -> String {
163    format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
164}
165
166/// `var_export($value, true)` for a JSON-decoded value (`json_decode(…,
167/// true)`: objects are arrays), with PHP's two-space nesting.
168pub fn php_var_export(v: &Value, indent: usize) -> String {
169    let pad = "  ".repeat(indent);
170    match v {
171        Value::Null => "NULL".to_owned(),
172        Value::Bool(b) => b.to_string(),
173        Value::Number(n) => {
174            if let Some(i) = n.as_i64() {
175                i.to_string()
176            } else {
177                let f = n.as_f64().unwrap_or(0.0);
178                let s = crate::phpjson::php_double(f).unwrap_or_default();
179                if s.contains('.') || s.contains('E') || s.contains('e') {
180                    s
181                } else {
182                    format!("{s}.0")
183                }
184            }
185        }
186        Value::String(s) => php_var_export_str(s),
187        Value::Array(items) => {
188            let mut out = String::from("array (\n");
189            for (i, item) in items.iter().enumerate() {
190                out.push_str(&format!(
191                    "{pad}  {i} => {}",
192                    php_var_export_nested(item, indent + 1)
193                ));
194            }
195            out.push_str(&format!("{pad})"));
196            out
197        }
198        Value::Object(map) => {
199            let mut out = String::from("array (\n");
200            for (k, item) in map {
201                let key = match k.parse::<i64>() {
202                    Ok(i) if i.to_string() == *k => i.to_string(),
203                    _ => php_var_export_str(k),
204                };
205                out.push_str(&format!(
206                    "{pad}  {key} => {}",
207                    php_var_export_nested(item, indent + 1)
208                ));
209            }
210            out.push_str(&format!("{pad})"));
211            out
212        }
213    }
214}
215
216/// A value after `=>`: scalars inline, arrays on their own line (PHP
217/// prints `=> \n  array (`).
218fn php_var_export_nested(v: &Value, indent: usize) -> String {
219    match v {
220        Value::Array(_) | Value::Object(_) => {
221            format!("\n{}{},\n", "  ".repeat(indent), php_var_export(v, indent))
222        }
223        _ => format!("{},\n", php_var_export(v, indent)),
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    #[test]
233    fn var_export_like_php() {
234        // php -r 'var_export(json_decode(…, true));'
235        assert_eq!(php_var_export(&json!({}), 0), "array (\n)");
236        assert_eq!(
237            php_var_export(&json!({"debug": true, "env": "prod", "n": 3, "list": ["a"], "sub": {"k": null}}), 0),
238            "array (\n  'debug' => true,\n  'env' => 'prod',\n  'n' => 3,\n  'list' => \n  array (\n    0 => 'a',\n  ),\n  'sub' => \n  array (\n    'k' => NULL,\n  ),\n)"
239        );
240    }
241
242    #[test]
243    fn default_stub_matches_the_observed_output() {
244        let tmp = tempfile::tempdir().expect("tmp");
245        let vendor = tmp.path().join("vendor");
246        std::fs::create_dir_all(&vendor).expect("mkdir");
247        write_stub(&vendor, tmp.path(), &json!({})).expect("stub");
248        let code = std::fs::read_to_string(vendor.join("autoload_runtime.php")).expect("read");
249        assert!(code.contains("$_SERVER['APP_RUNTIME'] ??= $_ENV['APP_RUNTIME'] ?? 'Symfony\\\\Component\\\\Runtime\\\\SymfonyRuntime';"));
250        assert!(code.contains(
251            "$_SERVER['APP_RUNTIME_OPTIONS'] += [\n  'project_dir' => dirname(__DIR__, 1),\n]);"
252        ));
253        // Options and a sub-directory.
254        write_stub(&vendor, tmp.path(), &json!({"extra": {"runtime": {"class": "App\\Runtime", "dotenv_path": ".env.local", "project_dir": "."}}})).expect("stub");
255        let code = std::fs::read_to_string(vendor.join("autoload_runtime.php")).expect("read");
256        assert!(code.contains("?? 'App\\\\Runtime';"), "{code}");
257        assert!(code.contains("+= [\n  'dotenv_path' => '.env.local',\n  'project_dir' => dirname(__DIR__, 1),\n]);"), "{code}");
258    }
259}