Skip to main content

vivacity_resolver/
path_repo.rs

1//! `Composer\Repository\PathRepository` (docs/reference/PathRepository.php):
2//! every directory matched by the `url` glob that holds a `composer.json`
3//! becomes a package with a `path` dist. The dist reference is
4//! `sha1(json . serialize(options))` (the raw file bytes and the repository
5//! options with `relative` auto-appended), replaced by the HEAD commit when
6//! the directory has a `.git` of its own and `reference` is `auto`, null
7//! with `reference: none`. The version comes, in this order, from
8//! `options.versions[name]`, the package's `version`, `COMPOSER_ROOT_VERSION`
9//! (when the directory and the project share the same HEAD), the git guess
10//! (a feature branch adds the branch AND its parent as two packages), else
11//! `dev-main`.
12
13use crate::loader;
14use crate::package::{Origin, Package};
15use crate::repository::RepoError;
16use serde_json::{Map, Value};
17use sha1::{Digest, Sha1};
18use std::path::Path;
19use vivacity_core::glob;
20use vivacity_core::root_version;
21
22/// A `path` repository: its packages are in the arena (`members`).
23pub struct PathRepository {
24    /// Arena indices, in `addPackage` order (an alias right before its base).
25    pub members: Vec<usize>,
26    /// The `url` as written in the configuration.
27    pub url_as_written: String,
28}
29
30impl PathRepository {
31    /// `getRepoName`.
32    pub fn repo_name(&self) -> String {
33        format!("path repo ({})", self.url_as_written)
34    }
35}
36
37/// `Filesystem::isAbsolutePath` as written in Composer: a leading `/`, a
38/// `:` as second character (any drive-letter form), or a leading `\\\\`.
39fn php_is_absolute_path(path: &str) -> bool {
40    path.starts_with('/') || path.as_bytes().get(1) == Some(&b':') || path.starts_with("\\\\")
41}
42
43/// `__construct` + `initialize`: `def` is the repository configuration,
44/// `project_dir` PHP's cwd, `origin` the repository's index in the set.
45pub fn open(
46    def: &Value,
47    project_dir: &Path,
48    origin: Origin,
49    arena: &mut Vec<Package>,
50) -> Result<PathRepository, RepoError> {
51    let url_as_written = match def.get("url") {
52        Some(Value::String(s)) => s.clone(),
53        _ => {
54            return Err(RepoError::data(
55                "You must specify the `url` configuration for the path repository",
56            ))
57        }
58    };
59    let url = glob::expand_path(&url_as_written);
60    let mut options: Map<String, Value> = match def.get("options") {
61        Some(Value::Object(m)) => m.clone(),
62        _ => Map::new(),
63    };
64    // `!isset($this->options['relative'])`: absent or null.
65    if options.get("relative").is_none_or(Value::is_null) {
66        options.insert("relative".into(), Value::Bool(!php_is_absolute_path(&url)));
67    }
68    let options = Value::Object(options);
69    let serialized_options = vivacity_core::phpserialize::serialize(&options)
70        .map_err(|e| RepoError::data(format!("path repository options: {e}")))?;
71
72    let url_matches: Vec<String> = glob::glob_dirs(&url, project_dir)
73        .into_iter()
74        .map(|m| {
75            // `str_replace(DIRECTORY_SEPARATOR, '/', …)`: a no-op on Unix.
76            let m = if cfg!(windows) {
77                m.replace('\\', "/")
78            } else {
79                m
80            };
81            m.trim_end_matches('/').to_owned()
82        })
83        .collect();
84    if url_matches.is_empty() {
85        let has_magic = |s: &str| s.contains(['*', '{', '}']);
86        if has_magic(&url) {
87            let mut walk = url.clone();
88            while has_magic(&walk) {
89                walk = glob::php_dirname(&walk);
90            }
91            if project_dir.join(&walk).is_dir() {
92                return Ok(PathRepository {
93                    members: Vec::new(),
94                    url_as_written,
95                });
96            }
97        }
98        return Err(RepoError::data(format!(
99            "The `url` supplied for the path ({url}) repository does not exist"
100        )));
101    }
102
103    // `$this->options['reference'] ?? 'auto'`, compared with `===`: a
104    // non-string value matches neither `none` nor `config`/`auto` (no
105    // reference at all).
106    let reference_mode = match options.get("reference") {
107        None | Some(Value::Null) => "auto".to_owned(),
108        Some(Value::String(s)) => s.clone(),
109        Some(_) => String::new(),
110    };
111    let mut members: Vec<usize> = Vec::new();
112    for matched in url_matches {
113        let dir = project_dir.join(&matched);
114        // `$path = realpath($url) . '/'`: the messages name the real path.
115        let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
116        let composer_file = dir.join("composer.json");
117        if !composer_file.exists() {
118            continue;
119        }
120        let json = std::fs::read(&composer_file)
121            .map_err(|e| RepoError::data(format!("{}: {e}", composer_file.display())))?;
122        let mut package: Map<String, Value> = match serde_json::from_slice::<Value>(&json) {
123            Ok(Value::Object(m)) => m,
124            Ok(_) => {
125                return Err(RepoError::data(format!(
126                    "\"{}\" does not contain valid JSON",
127                    composer_file.display()
128                )))
129            }
130            Err(e) => {
131                return Err(RepoError::data(format!(
132                    "\"{}\" does not contain valid JSON\n{e}",
133                    composer_file.display()
134                )))
135            }
136        };
137        let mut dist = Map::new();
138        dist.insert("type".into(), Value::String("path".into()));
139        dist.insert("url".into(), Value::String(matched.clone()));
140        match reference_mode.as_str() {
141            "none" => {
142                dist.insert("reference".into(), Value::Null);
143            }
144            "config" | "auto" => {
145                let mut h = Sha1::new();
146                h.update(&json);
147                h.update(serialized_options.as_bytes());
148                dist.insert(
149                    "reference".into(),
150                    Value::String(format!("{:x}", h.finalize())),
151                );
152            }
153            _ => {}
154        }
155        package.insert("dist".into(), Value::Object(dist));
156
157        // `array_intersect_key($this->options, ['symlink' => true, 'relative' => true])`
158        let mut transport = Map::new();
159        for (k, v) in options.as_object().into_iter().flatten() {
160            if k == "symlink" || k == "relative" {
161                transport.insert(k.clone(), v.clone());
162            }
163        }
164        package.insert("transport-options".into(), Value::Object(transport));
165
166        let name = package
167            .get("name")
168            .and_then(Value::as_str)
169            .map(str::to_owned);
170        if let Some(name) = &name {
171            if let Some(v) = options
172                .get("versions")
173                .and_then(|v| v.get(name))
174                .filter(|v| !v.is_null())
175            {
176                package.insert("version".into(), v.clone());
177            }
178        }
179        let has_version = |p: &Map<String, Value>| p.get("version").is_some_and(|v| !v.is_null());
180
181        if !has_version(&package) {
182            // `($rootVersion = Platform::getEnv('COMPOSER_ROOT_VERSION'))`:
183            // empty and `0` are falsy (`root_version_from_env` says so).
184            if let Some(env_version) = root_version::root_version_from_env() {
185                let head = |d: &Path| root_version::git(d, &["rev-parse", "HEAD"]);
186                if let (Some(a), Some(b)) = (head(&dir), head(project_dir)) {
187                    if a == b {
188                        package.insert("version".into(), Value::String(env_version));
189                    }
190                }
191            }
192        }
193
194        if reference_mode == "auto" && dir.join(".git").is_dir() {
195            if let Some(out) = root_version::git(
196                &dir,
197                &[
198                    "rev-list",
199                    "-n1",
200                    "--format=%H",
201                    "HEAD",
202                    "--no-show-signature",
203                ],
204            ) {
205                // `parseRevListOutput`: the `commit <sha>` header dropped.
206                let sha = out
207                    .lines()
208                    .filter(|l| !l.starts_with("commit "))
209                    .collect::<Vec<_>>()
210                    .join("\n");
211                if let Some(d) = package.get_mut("dist").and_then(Value::as_object_mut) {
212                    d.insert("reference".into(), Value::String(sha.trim().to_owned()));
213                }
214            }
215        }
216
217        let mut feature: Option<String> = None;
218        if !has_version(&package) {
219            match root_version::guess_version(&Value::Object(package.clone()), &dir) {
220                Some(g) if !g.pretty_version.is_empty() => {
221                    feature = g.feature_pretty_version.filter(|f| !f.is_empty());
222                    package.insert("version".into(), Value::String(g.pretty_version));
223                }
224                _ => {
225                    package.insert("version".into(), Value::String("dev-main".into()));
226                }
227            }
228        }
229        let add = |config: Value, arena: &mut Vec<Package>, members: &mut Vec<usize>| {
230            let ids = loader::load_packages(&[config], origin, arena, false).map_err(|e| {
231                RepoError::data(format!(
232                    "Failed loading the package in {}: {}",
233                    composer_file.display(),
234                    e.0
235                ))
236            })?;
237            members.extend(ids);
238            Ok::<(), RepoError>(())
239        };
240        if let Some(feature_version) = feature {
241            let mut feature_package = package.clone();
242            feature_package.insert("version".into(), Value::String(feature_version));
243            add(Value::Object(feature_package), arena, &mut members)?;
244        }
245        add(Value::Object(package), arena, &mut members)?;
246    }
247    Ok(PathRepository {
248        members,
249        url_as_written,
250    })
251}