Skip to main content

mir_analyzer/
composer.rs

1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4// ---------------------------------------------------------------------------
5// Error
6// ---------------------------------------------------------------------------
7
8#[derive(Debug, Error)]
9pub enum ComposerError {
10    #[error("composer I/O error: {0}")]
11    Io(#[from] std::io::Error),
12    #[error("composer JSON error: {0}")]
13    Json(#[from] serde_json::Error),
14    #[error("composer.json has no autoload section")]
15    MissingAutoload,
16}
17
18// ---------------------------------------------------------------------------
19// Psr4Map
20// ---------------------------------------------------------------------------
21
22/// PSR-4 namespace → directory mapping, built from `composer.json`.
23///
24/// `project_entries` covers `autoload.psr-4` and `autoload-dev.psr-4`.
25/// `vendor_entries`  covers `vendor/composer/installed.json` packages.
26///
27/// Both lists are sorted longest-prefix-first for correct prefix matching.
28#[derive(Clone)]
29pub struct Psr4Map {
30    project_entries: Vec<(String, PathBuf)>,
31    vendor_entries: Vec<(String, PathBuf)>,
32    #[allow(dead_code)] // used by issue #50 (lazy FQCN resolution)
33    root: PathBuf,
34}
35
36fn ensure_trailing_backslash(prefix: &str) -> String {
37    if prefix.ends_with('\\') {
38        prefix.to_string()
39    } else {
40        format!("{}\\", prefix)
41    }
42}
43
44fn parse_vendor_entries(root: &Path) -> Vec<(String, PathBuf)> {
45    let installed_path = root.join("vendor/composer/installed.json");
46    let content = match std::fs::read_to_string(&installed_path) {
47        Ok(c) => c,
48        Err(_) => return Vec::new(),
49    };
50    let value: serde_json::Value = match serde_json::from_str(&content) {
51        Ok(v) => v,
52        Err(_) => return Vec::new(),
53    };
54
55    let packages = if let Some(arr) = value.get("packages").and_then(|v| v.as_array()) {
56        arr.clone()
57    } else if let Some(arr) = value.as_array() {
58        arr.clone()
59    } else {
60        return Vec::new();
61    };
62
63    let vendor_dir = root.join("vendor");
64    let mut entries: Vec<(String, PathBuf)> = Vec::new();
65
66    for pkg in &packages {
67        if let Some(map) = pkg.pointer("/autoload/psr-4").and_then(|v| v.as_object()) {
68            let pkg_name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("");
69            let pkg_dir = vendor_dir.join(pkg_name);
70            for (prefix, dir) in map {
71                if let Some(d) = dir.as_str() {
72                    entries.push((ensure_trailing_backslash(prefix), pkg_dir.join(d)));
73                } else if let Some(arr) = dir.as_array() {
74                    for item in arr {
75                        if let Some(d) = item.as_str() {
76                            entries.push((ensure_trailing_backslash(prefix), pkg_dir.join(d)));
77                        }
78                    }
79                }
80            }
81        }
82    }
83
84    entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
85    entries
86}
87
88impl Psr4Map {
89    pub fn from_composer(root: &Path) -> Result<Self, ComposerError> {
90        let composer_path = root.join("composer.json");
91        let content = std::fs::read_to_string(&composer_path)?;
92        let value: serde_json::Value = serde_json::from_str(&content)?;
93
94        let has_autoload = value.get("autoload").is_some() || value.get("autoload-dev").is_some();
95        if !has_autoload {
96            return Err(ComposerError::MissingAutoload);
97        }
98
99        let mut project_entries: Vec<(String, PathBuf)> = Vec::new();
100
101        if let Some(map) = value.pointer("/autoload/psr-4").and_then(|v| v.as_object()) {
102            for (prefix, dir) in map {
103                if let Some(d) = dir.as_str() {
104                    project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
105                } else if let Some(arr) = dir.as_array() {
106                    for item in arr {
107                        if let Some(d) = item.as_str() {
108                            project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
109                        }
110                    }
111                }
112            }
113        }
114        if let Some(map) = value
115            .pointer("/autoload-dev/psr-4")
116            .and_then(|v| v.as_object())
117        {
118            for (prefix, dir) in map {
119                if let Some(d) = dir.as_str() {
120                    project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
121                } else if let Some(arr) = dir.as_array() {
122                    for item in arr {
123                        if let Some(d) = item.as_str() {
124                            project_entries.push((ensure_trailing_backslash(prefix), root.join(d)));
125                        }
126                    }
127                }
128            }
129        }
130
131        project_entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
132
133        let vendor_entries = parse_vendor_entries(root);
134
135        Ok(Psr4Map {
136            project_entries,
137            vendor_entries,
138            root: root.to_path_buf(),
139        })
140    }
141
142    pub fn project_files(&self) -> Vec<PathBuf> {
143        let mut out = Vec::new();
144        for (_, dir) in &self.project_entries {
145            crate::project::collect_php_files(dir, &mut out);
146        }
147        out
148    }
149
150    pub fn vendor_files(&self) -> Vec<PathBuf> {
151        let mut out = Vec::new();
152        for (_, dir) in &self.vendor_entries {
153            crate::project::collect_php_files(dir, &mut out);
154        }
155        out
156    }
157
158    /// Resolve a fully-qualified class name to a file path using longest-prefix-first matching.
159    /// Returns `None` if no prefix matches or the mapped file does not exist on disk.
160    pub fn resolve(&self, fqcn: &str) -> Option<PathBuf> {
161        for (prefix, dir) in self
162            .project_entries
163            .iter()
164            .chain(self.vendor_entries.iter())
165        {
166            if fqcn.starts_with(prefix.as_str()) {
167                let relative = &fqcn[prefix.len()..];
168                let file_path = dir.join(relative.replace('\\', "/")).with_extension("php");
169                if file_path.exists() {
170                    return Some(file_path);
171                }
172            }
173        }
174        None
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::fs;
182
183    fn make_temp_project(name: &str) -> PathBuf {
184        let dir = std::env::temp_dir().join(format!("mir_psr4_{}", name));
185        fs::create_dir_all(&dir).unwrap();
186        dir
187    }
188
189    #[test]
190    fn parse_project_entries() {
191        let root = make_temp_project("parse_project_entries");
192        fs::write(
193            root.join("composer.json"),
194            r#"{
195                "autoload": {
196                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
197                },
198                "autoload-dev": {
199                    "psr-4": { "Tests\\": "tests/" }
200                }
201            }"#,
202        )
203        .unwrap();
204
205        let map = Psr4Map::from_composer(&root).unwrap();
206
207        let prefixes: Vec<&str> = map
208            .project_entries
209            .iter()
210            .map(|(p, _)| p.as_str())
211            .collect();
212        assert!(prefixes.contains(&"App\\Models\\"), "missing App\\Models\\");
213        assert!(prefixes.contains(&"App\\"), "missing App\\");
214        assert!(prefixes.contains(&"Tests\\"), "missing Tests\\");
215    }
216
217    #[test]
218    fn longest_prefix_first() {
219        let root = make_temp_project("longest_prefix_first");
220        fs::write(
221            root.join("composer.json"),
222            r#"{
223                "autoload": {
224                    "psr-4": { "App\\": "src/", "App\\Models\\": "src/models/" }
225                }
226            }"#,
227        )
228        .unwrap();
229
230        let map = Psr4Map::from_composer(&root).unwrap();
231
232        assert_eq!(map.project_entries[0].0, "App\\Models\\");
233    }
234
235    #[test]
236    fn missing_autoload_section_is_error() {
237        let root = make_temp_project("missing_autoload");
238        fs::write(root.join("composer.json"), r#"{ "name": "my/pkg" }"#).unwrap();
239
240        let result = Psr4Map::from_composer(&root);
241        assert!(
242            matches!(result, Err(ComposerError::MissingAutoload)),
243            "expected MissingAutoload error"
244        );
245    }
246
247    #[test]
248    fn composer_v2_installed() {
249        let root = make_temp_project("composer_v2");
250        fs::write(
251            root.join("composer.json"),
252            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
253        )
254        .unwrap();
255
256        let vendor_dir = root.join("vendor/composer");
257        fs::create_dir_all(&vendor_dir).unwrap();
258        fs::write(
259            vendor_dir.join("installed.json"),
260            r#"{
261                "packages": [
262                    {
263                        "name": "vendor/pkg",
264                        "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
265                    }
266                ]
267            }"#,
268        )
269        .unwrap();
270        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
271
272        let map = Psr4Map::from_composer(&root).unwrap();
273        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
274        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
275    }
276
277    #[test]
278    fn composer_v1_installed() {
279        let root = make_temp_project("composer_v1");
280        fs::write(
281            root.join("composer.json"),
282            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
283        )
284        .unwrap();
285
286        let vendor_dir = root.join("vendor/composer");
287        fs::create_dir_all(&vendor_dir).unwrap();
288        fs::write(
289            vendor_dir.join("installed.json"),
290            r#"[
291                {
292                    "name": "vendor/pkg",
293                    "autoload": { "psr-4": { "Vendor\\Pkg\\": "src/" } }
294                }
295            ]"#,
296        )
297        .unwrap();
298        fs::create_dir_all(root.join("vendor/vendor/pkg/src")).unwrap();
299
300        let map = Psr4Map::from_composer(&root).unwrap();
301        let prefixes: Vec<&str> = map.vendor_entries.iter().map(|(p, _)| p.as_str()).collect();
302        assert!(prefixes.contains(&"Vendor\\Pkg\\"), "missing Vendor\\Pkg\\");
303    }
304
305    #[test]
306    fn missing_installed_json() {
307        let root = make_temp_project("missing_installed");
308        fs::write(
309            root.join("composer.json"),
310            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
311        )
312        .unwrap();
313        let map = Psr4Map::from_composer(&root).unwrap();
314        assert!(map.vendor_entries.is_empty());
315    }
316
317    #[test]
318    fn project_files_returns_php_files() {
319        let root = make_temp_project("project_files");
320        let src = root.join("src");
321        fs::create_dir_all(&src).unwrap();
322        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
323        fs::write(src.join("README.md"), "not php").unwrap();
324        fs::write(
325            root.join("composer.json"),
326            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
327        )
328        .unwrap();
329
330        let map = Psr4Map::from_composer(&root).unwrap();
331        let files = map.project_files();
332        assert_eq!(files.len(), 1);
333        assert!(files[0].ends_with("Foo.php"));
334    }
335
336    #[test]
337    fn resolve_existing_file() {
338        let root = make_temp_project("resolve_existing");
339        let models = root.join("src/models");
340        fs::create_dir_all(&models).unwrap();
341        fs::write(models.join("User.php"), "<?php class User {}").unwrap();
342        fs::write(
343            root.join("composer.json"),
344            r#"{"autoload":{"psr-4":{"App\\Models\\":"src/models/","App\\":"src/"}}}"#,
345        )
346        .unwrap();
347
348        let map = Psr4Map::from_composer(&root).unwrap();
349        let result = map.resolve("App\\Models\\User");
350        assert!(result.is_some(), "expected a resolved path");
351        assert!(result.unwrap().ends_with("User.php"));
352    }
353
354    #[test]
355    fn resolve_missing_file() {
356        let root = make_temp_project("resolve_missing");
357        fs::create_dir_all(root.join("src")).unwrap();
358        fs::write(
359            root.join("composer.json"),
360            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
361        )
362        .unwrap();
363
364        let map = Psr4Map::from_composer(&root).unwrap();
365        let result = map.resolve("App\\Models\\User");
366        assert!(result.is_none());
367    }
368
369    #[test]
370    fn boundary_check() {
371        let root = make_temp_project("boundary_check");
372        fs::create_dir_all(root.join("src")).unwrap();
373        fs::write(
374            root.join("composer.json"),
375            r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
376        )
377        .unwrap();
378
379        let map = Psr4Map::from_composer(&root).unwrap();
380        // "App\" must NOT match "Application\Foo"
381        let result = map.resolve("Application\\Foo");
382        assert!(
383            result.is_none(),
384            "App\\ prefix must not match Application\\Foo"
385        );
386    }
387
388    #[test]
389    fn array_valued_psr4_dirs() {
390        let root = make_temp_project("array_dirs");
391        let src = root.join("src");
392        let lib = root.join("lib");
393        fs::create_dir_all(&src).unwrap();
394        fs::create_dir_all(&lib).unwrap();
395        fs::write(src.join("Foo.php"), "<?php class Foo {}").unwrap();
396        fs::write(lib.join("Bar.php"), "<?php class Bar {}").unwrap();
397        fs::write(
398            root.join("composer.json"),
399            r#"{"autoload":{"psr-4":{"App\\":["src/","lib/"]}}}"#,
400        )
401        .unwrap();
402
403        let map = Psr4Map::from_composer(&root).unwrap();
404        // Both dirs should be in project_entries
405        assert_eq!(
406            map.project_entries.len(),
407            2,
408            "expected 2 entries for array-valued dir"
409        );
410        let files = map.project_files();
411        assert_eq!(files.len(), 2, "expected Foo.php and Bar.php");
412    }
413}