1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[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#[derive(Clone)]
29pub struct Psr4Map {
30 project_entries: Vec<(String, PathBuf)>,
31 vendor_entries: Vec<(String, PathBuf)>,
32 #[allow(dead_code)] 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 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 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 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}