Skip to main content

sz_rust_addons_loader/
autoload.rs

1//! 插件自动加载(Phase 10.1)
2//!
3//! ## PHP 对齐
4//!
5//! 对齐 PHP `helper.php` 中的 `spl_autoload_register` 回调:
6//!
7//! ```php
8//! // vendor/zzstudio/think-addons/src/helper.php
9//! spl_autoload_register(function ($class) {
10//!     $class = ltrim($class, '\\');
11//!     $dir = app()->getRootPath();
12//!     $namespace = 'addons';
13//!     if (strpos($class, $namespace) === 0) {
14//!         $class = substr($class, strlen($namespace));
15//!         $path = '';
16//!         if (($pos = strripos($class, '\\')) !== false) {
17//!             $path = str_replace('\\', '/', substr($class, 0, $pos)) . '/';
18//!             $class = substr($class, $pos + 1);
19//!         }
20//!         $path .= str_replace('_', '/', $class) . '.php';
21//!         $dir .= $namespace . $path;
22//!         if (file_exists($dir)) {
23//!             include $dir;
24//!             return true;
25//!         }
26//!         return false;
27//!     }
28//!     return false;
29//! });
30//! ```
31//!
32//! ## 类名解析规则(对齐 PHP `get_addons_class`)
33//!
34//! | PHP 类名 | 文件路径 |
35//! |---------|---------|
36//! | `addons\operate\Plugin` | `addons/operate/Plugin.php` |
37//! | `addons\operate\controller\Order` | `addons/operate/controller/Order.php` |
38//! | `addons\operate\controller\admin\Order` | `addons/operate/controller/admin/Order.php` |
39//! | `addons\operate\controller\admin_Order` | `addons/operate/controller/admin/Order.php`(下划线转分隔符) |
40//!
41//! ## 多级控制器点号分隔(对齐 PHP `get_addons_class` 中 `.` 处理)
42//!
43//! ```php
44//! // helper.php
45//! if (strpos($class, '.') !== false) {
46//!     $array = explode('.', $class);
47//!     $class = array_pop($array);
48//!     $class = Str::studly($class);
49//!     $class = implode('\\', $array) . '\\' . $class;
50//! }
51//! ```
52
53use std::path::{Path, PathBuf};
54
55use crate::error::{AddonLoaderError, AddonLoaderResult};
56
57/// 插件自动加载器(对齐 PHP `spl_autoload_register` 回调)
58///
59/// ## 设计
60///
61/// - 持有插件根目录(对齐 PHP `app()->getRootPath() . 'addons/'`)
62/// - 提供 `resolve(class)` 方法:类名 → 文件路径(不实际加载文件)
63/// - 支持 PSR-0 风格的下划线转目录分隔符
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct AddonAutoload {
66    /// 插件根目录(对齐 PHP `{rootPath}/addons/`)
67    addons_path: PathBuf,
68}
69
70impl AddonAutoload {
71    /// 创建自动加载器
72    ///
73    /// - `addons_path`:插件根目录,对齐 PHP `getAddonsPath()` 返回的 `{rootPath}/addons/`
74    pub fn new(addons_path: impl Into<PathBuf>) -> Self {
75        Self {
76            addons_path: addons_path.into(),
77        }
78    }
79
80    /// 获取插件根目录
81    pub fn addons_path(&self) -> &Path {
82        &self.addons_path
83    }
84
85    /// 解析类名到文件路径(对齐 PHP `spl_autoload_register` 回调)
86    ///
87    /// ## 规则
88    ///
89    /// 1. 类名必须以 `addons\` 开头(对齐 PHP `strpos($class, $namespace) === 0`)
90    /// 2. 命名空间分隔符 `\` 转换为目录分隔符 `/`(对齐 `str_replace('\\', '/', ...)`)
91    /// 3. 类名中的下划线 `_` 转换为目录分隔符(对齐 `str_replace('_', '/', $class)`)
92    /// 4. 拼接 `.php` 后缀
93    /// 5. 检查文件是否存在
94    ///
95    /// ## 参数
96    ///
97    /// - `class`:完整类名(如 `addons\operate\Plugin`)
98    ///
99    /// ## 返回
100    ///
101    /// - `Ok(Some(path))`:类映射到文件且文件存在
102    /// - `Ok(None)`:类名不属于 `addons\` 命名空间,或文件不存在(让其他 autoloader 处理)
103    /// - `Err(_)`:路径解析失败
104    pub fn resolve(&self, class: &str) -> AddonLoaderResult<Option<PathBuf>> {
105        let class = class.trim_start_matches('\\');
106
107        // 对齐 PHP `strpos($class, $namespace) === 0`
108        if !class.starts_with("addons\\") {
109            return Ok(None);
110        }
111
112        // 对齐 PHP `substr($class, strlen($namespace))`
113        // 注意:PHP 中 namespace = 'addons'(不带 \),substr 后得到 '\operate\Plugin'
114        // Rust 侧我们直接 strip "addons\" 前缀
115        let stripped = &class["addons\\".len()..];
116
117        // 对齐 PHP `strripos($class, '\\')` 找到最后一个命名空间分隔符
118        let (path_part, class_part) = if let Some(pos) = stripped.rfind('\\') {
119            // 有命名空间前缀:path = 命名空间部分(\ → /),class = 末段
120            let path = stripped[..pos].replace('\\', "/");
121            let class_name = &stripped[pos + 1..];
122            (format!("{}/", path), class_name.to_string())
123        } else {
124            // 无命名空间前缀
125            (String::new(), stripped.to_string())
126        };
127
128        // 对齐 PHP `str_replace('_', '/', $class) . '.php'`
129        // 下划线转目录分隔符(PSR-0 风格)
130        let class_file = format!("{}.php", class_part.replace('_', "/"));
131
132        // 对齐 PHP `$dir .= $namespace . $path`
133        let file_path = self
134            .addons_path
135            .join(format!("{}{}", path_part, class_file));
136
137        if file_path.exists() {
138            Ok(Some(file_path))
139        } else {
140            Ok(None)
141        }
142    }
143
144    /// 解析控制器类名(对齐 PHP `get_addons_class($name, 'controller', $class)`)
145    ///
146    /// ## 多级控制器点号分隔
147    ///
148    /// 对齐 PHP `get_addons_class` 中 `.` 处理:
149    ///
150    /// ```php
151    /// if (strpos($class, '.') !== false) {
152    ///     $array = explode('.', $class);
153    ///     $class = array_pop($array);
154    ///     $class = Str::studly($class);
155    ///     $class = implode('\\', $array) . '\\' . $class;
156    /// }
157    /// ```
158    ///
159    /// ## 示例
160    ///
161    /// - `resolve_controller("operate", "Order")` → `addons/operate/controller/Order.php`
162    /// - `resolve_controller("operate", "admin.Order")` → `addons/operate/controller/admin/Order.php`
163    /// - `resolve_controller("operate", "admin.sub.Order")` → `addons/operate/controller/admin/sub/Order.php`
164    pub fn resolve_controller(
165        &self,
166        addon: &str,
167        controller: &str,
168    ) -> AddonLoaderResult<Option<PathBuf>> {
169        let controller_class = parse_dotted_controller(controller);
170        let full_class = format!("addons\\{}\\controller\\{}", addon, controller_class);
171        self.resolve(&full_class)
172    }
173
174    /// 解析插件入口类(对齐 PHP `get_addons_class($name)` 默认 type='hook')
175    ///
176    /// ## 示例
177    ///
178    /// - `resolve_plugin("operate")` → `addons/operate/Plugin.php`
179    pub fn resolve_plugin(&self, addon: &str) -> AddonLoaderResult<Option<PathBuf>> {
180        let full_class = format!("addons\\{}\\Plugin", addon);
181        self.resolve(&full_class)
182    }
183
184    /// 强制解析类名(对齐 PHP `get_addons_class` 返回字符串而非 bool)
185    ///
186    /// 与 `resolve` 的区别:不检查文件是否存在,直接返回路径
187    pub fn resolve_strict(&self, class: &str) -> AddonLoaderResult<PathBuf> {
188        let class = class.trim_start_matches('\\');
189
190        if !class.starts_with("addons\\") {
191            return Err(AddonLoaderError::AutoloadMiss {
192                class: class.to_string(),
193            });
194        }
195
196        let stripped = &class["addons\\".len()..];
197        let (path_part, class_part) = if let Some(pos) = stripped.rfind('\\') {
198            let path = stripped[..pos].replace('\\', "/");
199            let class_name = &stripped[pos + 1..];
200            (format!("{}/", path), class_name.to_string())
201        } else {
202            (String::new(), stripped.to_string())
203        };
204
205        let class_file = format!("{}.php", class_part.replace('_', "/"));
206        let file_path = self
207            .addons_path
208            .join(format!("{}{}", path_part, class_file));
209        Ok(file_path)
210    }
211}
212
213/// 解析多级控制器点号分隔(对齐 PHP `get_addons_class` 中 `.` 处理)
214///
215/// ## PHP 对齐
216///
217/// ```php
218/// if (strpos($class, '.') !== false) {
219///     $array = explode('.', $class);
220///     $class = array_pop($array);
221///     $class = Str::studly($class);
222///     $class = implode('\\', $array) . '\\' . $class;
223/// }
224/// ```
225///
226/// ## 示例
227///
228/// - `Order` → `Order`
229/// - `admin.Order` → `admin\Order`
230/// - `admin.sub.Order` → `admin\sub\Order`
231fn parse_dotted_controller(controller: &str) -> String {
232    if !controller.contains('.') {
233        return controller.to_string();
234    }
235
236    let mut parts: Vec<&str> = controller.split('.').collect();
237    if parts.len() == 1 {
238        return controller.to_string();
239    }
240
241    // 末段转大驼峰(对齐 PHP `Str::studly`)
242    let last = parts.pop().unwrap();
243    let last_studly = studly_case(last);
244
245    // 前段保持原样(PHP 不转换),用 \ 拼回
246    parts.push(&last_studly);
247    parts.join("\\")
248}
249
250/// 下划线转大驼峰(对齐 PHP `Str::studly`)
251///
252/// ## 示例
253///
254/// - `order` → `Order`
255/// - `user_order` → `UserOrder`
256/// - `Order` → `Order`
257fn studly_case(s: &str) -> String {
258    s.split('_')
259        .map(|part| {
260            let mut chars = part.chars();
261            match chars.next() {
262                None => String::new(),
263                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
264            }
265        })
266        .collect()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::fs;
273
274    /// 创建临时插件目录结构
275    fn make_test_addons_dir() -> tempfile::TempDir {
276        let tmp = tempfile::tempdir().expect("create tempdir");
277        let addons_path = tmp.path().join("addons");
278
279        // operate 插件
280        let operate_dir = addons_path.join("operate");
281        fs::create_dir_all(&operate_dir).expect("create operate dir");
282        fs::write(operate_dir.join("Plugin.php"), "<?php // stub").expect("write Plugin.php");
283
284        // operate/controller 目录
285        let controller_dir = operate_dir.join("controller");
286        fs::create_dir_all(&controller_dir).expect("create controller dir");
287        fs::write(controller_dir.join("Order.php"), "<?php // stub").expect("write Order.php");
288
289        // operate/controller/admin 多级目录
290        let admin_dir = controller_dir.join("admin");
291        fs::create_dir_all(&admin_dir).expect("create admin dir");
292        fs::write(admin_dir.join("Order.php"), "<?php // stub").expect("write admin/Order.php");
293
294        // operate/model 目录
295        let model_dir = operate_dir.join("model");
296        fs::create_dir_all(&model_dir).expect("create model dir");
297        fs::write(model_dir.join("Customer.php"), "<?php // stub").expect("write Customer.php");
298
299        // 下划线命名测试:admin_Order 类映射到 admin/Order.php
300        // 注意:PSR-0 下划线转换在类名末段生效,所以 admin\Order 类的文件是 admin/Order.php
301        // 而 admin_Order 类(无命名空间分隔)的文件也是 admin/Order.php
302
303        tmp
304    }
305
306    #[test]
307    fn test_new_autoload() {
308        let loader = AddonAutoload::new("/addons");
309        assert_eq!(loader.addons_path(), Path::new("/addons"));
310    }
311
312    #[test]
313    fn test_resolve_plugin_class() {
314        let tmp = make_test_addons_dir();
315        let addons_path = tmp.path().join("addons");
316        let loader = AddonAutoload::new(&addons_path);
317
318        let result = loader.resolve("addons\\operate\\Plugin").unwrap();
319        assert!(result.is_some());
320        let path = result.unwrap();
321        assert_eq!(path, addons_path.join("operate").join("Plugin.php"));
322    }
323
324    #[test]
325    fn test_resolve_controller_class() {
326        let tmp = make_test_addons_dir();
327        let addons_path = tmp.path().join("addons");
328        let loader = AddonAutoload::new(&addons_path);
329
330        let result = loader
331            .resolve("addons\\operate\\controller\\Order")
332            .unwrap();
333        assert!(result.is_some());
334        let path = result.unwrap();
335        assert_eq!(
336            path,
337            addons_path
338                .join("operate")
339                .join("controller")
340                .join("Order.php")
341        );
342    }
343
344    #[test]
345    fn test_resolve_multilevel_controller_class() {
346        let tmp = make_test_addons_dir();
347        let addons_path = tmp.path().join("addons");
348        let loader = AddonAutoload::new(&addons_path);
349
350        let result = loader
351            .resolve("addons\\operate\\controller\\admin\\Order")
352            .unwrap();
353        assert!(result.is_some());
354        let path = result.unwrap();
355        assert_eq!(
356            path,
357            addons_path
358                .join("operate")
359                .join("controller")
360                .join("admin")
361                .join("Order.php")
362        );
363    }
364
365    #[test]
366    fn test_resolve_model_class() {
367        let tmp = make_test_addons_dir();
368        let addons_path = tmp.path().join("addons");
369        let loader = AddonAutoload::new(&addons_path);
370
371        let result = loader.resolve("addons\\operate\\model\\Customer").unwrap();
372        assert!(result.is_some());
373        let path = result.unwrap();
374        assert_eq!(
375            path,
376            addons_path
377                .join("operate")
378                .join("model")
379                .join("Customer.php")
380        );
381    }
382
383    #[test]
384    fn test_resolve_non_addons_namespace_returns_none() {
385        let tmp = make_test_addons_dir();
386        let addons_path = tmp.path().join("addons");
387        let loader = AddonAutoload::new(&addons_path);
388
389        let result = loader.resolve("app\\controller\\Home").unwrap();
390        assert!(result.is_none());
391    }
392
393    #[test]
394    fn test_resolve_nonexistent_file_returns_none() {
395        let tmp = make_test_addons_dir();
396        let addons_path = tmp.path().join("addons");
397        let loader = AddonAutoload::new(&addons_path);
398
399        let result = loader.resolve("addons\\operate\\NonExistent").unwrap();
400        assert!(result.is_none());
401    }
402
403    #[test]
404    fn test_resolve_leading_backslash_stripped() {
405        let tmp = make_test_addons_dir();
406        let addons_path = tmp.path().join("addons");
407        let loader = AddonAutoload::new(&addons_path);
408
409        let result = loader.resolve("\\addons\\operate\\Plugin").unwrap();
410        assert!(result.is_some());
411    }
412
413    #[test]
414    fn test_resolve_controller_helper() {
415        let tmp = make_test_addons_dir();
416        let addons_path = tmp.path().join("addons");
417        let loader = AddonAutoload::new(&addons_path);
418
419        let result = loader.resolve_controller("operate", "Order").unwrap();
420        assert!(result.is_some());
421        let path = result.unwrap();
422        assert_eq!(
423            path,
424            addons_path
425                .join("operate")
426                .join("controller")
427                .join("Order.php")
428        );
429    }
430
431    #[test]
432    fn test_resolve_controller_multilevel_dotted() {
433        let tmp = make_test_addons_dir();
434        let addons_path = tmp.path().join("addons");
435        let loader = AddonAutoload::new(&addons_path);
436
437        let result = loader.resolve_controller("operate", "admin.Order").unwrap();
438        assert!(result.is_some());
439        let path = result.unwrap();
440        assert_eq!(
441            path,
442            addons_path
443                .join("operate")
444                .join("controller")
445                .join("admin")
446                .join("Order.php")
447        );
448    }
449
450    #[test]
451    fn test_resolve_controller_three_levels_dotted() {
452        let tmp = make_test_addons_dir();
453        let addons_path = tmp.path().join("addons");
454        // 创建 admin/sub/Order.php
455        let sub_dir = addons_path
456            .join("operate")
457            .join("controller")
458            .join("admin")
459            .join("sub");
460        fs::create_dir_all(&sub_dir).expect("create sub dir");
461        fs::write(sub_dir.join("Order.php"), "<?php // stub").expect("write sub/Order.php");
462
463        let loader = AddonAutoload::new(&addons_path);
464        let result = loader
465            .resolve_controller("operate", "admin.sub.Order")
466            .unwrap();
467        assert!(result.is_some());
468        let path = result.unwrap();
469        assert!(path.to_string_lossy().contains("admin"));
470        assert!(path.to_string_lossy().contains("sub"));
471        assert!(path.to_string_lossy().ends_with("Order.php"));
472    }
473
474    #[test]
475    fn test_resolve_plugin_helper() {
476        let tmp = make_test_addons_dir();
477        let addons_path = tmp.path().join("addons");
478        let loader = AddonAutoload::new(&addons_path);
479
480        let result = loader.resolve_plugin("operate").unwrap();
481        assert!(result.is_some());
482        let path = result.unwrap();
483        assert_eq!(path, addons_path.join("operate").join("Plugin.php"));
484    }
485
486    #[test]
487    fn test_resolve_strict_addons_class() {
488        let loader = AddonAutoload::new("/addons");
489        let path = loader.resolve_strict("addons\\operate\\Plugin").unwrap();
490        assert_eq!(path, PathBuf::from("/addons/operate/Plugin.php"));
491    }
492
493    #[test]
494    fn test_resolve_strict_controller_class() {
495        let loader = AddonAutoload::new("/addons");
496        let path = loader
497            .resolve_strict("addons\\operate\\controller\\admin\\Order")
498            .unwrap();
499        assert_eq!(
500            path,
501            PathBuf::from("/addons/operate/controller/admin/Order.php")
502        );
503    }
504
505    #[test]
506    fn test_resolve_strict_non_addons_returns_error() {
507        let loader = AddonAutoload::new("/addons");
508        let result = loader.resolve_strict("app\\Home");
509        assert!(result.is_err());
510        match result.unwrap_err() {
511            AddonLoaderError::AutoloadMiss { class } => {
512                assert_eq!(class, "app\\Home");
513            }
514            other => panic!("expected AutoloadMiss, got {:?}", other),
515        }
516    }
517
518    #[test]
519    fn test_parse_dotted_controller_simple() {
520        assert_eq!(parse_dotted_controller("Order"), "Order");
521    }
522
523    #[test]
524    fn test_parse_dotted_controller_two_levels() {
525        assert_eq!(parse_dotted_controller("admin.Order"), "admin\\Order");
526    }
527
528    #[test]
529    fn test_parse_dotted_controller_three_levels() {
530        assert_eq!(
531            parse_dotted_controller("admin.sub.Order"),
532            "admin\\sub\\Order"
533        );
534    }
535
536    #[test]
537    fn test_parse_dotted_controller_studly_conversion() {
538        // 末段应该转大驼峰(对齐 PHP Str::studly)
539        assert_eq!(
540            parse_dotted_controller("admin.user_order"),
541            "admin\\UserOrder"
542        );
543    }
544
545    #[test]
546    fn test_parse_dotted_controller_no_dot_passthrough() {
547        assert_eq!(parse_dotted_controller("user_order"), "user_order");
548        // 注意:不带点号时不做 studly 转换(PHP 原始行为)
549    }
550
551    #[test]
552    fn test_studly_case_basic() {
553        assert_eq!(studly_case("order"), "Order");
554    }
555
556    #[test]
557    fn test_studly_case_with_underscore() {
558        assert_eq!(studly_case("user_order"), "UserOrder");
559    }
560
561    #[test]
562    fn test_studly_case_already_studly() {
563        assert_eq!(studly_case("Order"), "Order");
564    }
565
566    #[test]
567    fn test_studly_case_empty() {
568        assert_eq!(studly_case(""), "");
569    }
570
571    #[test]
572    fn test_studly_case_multiple_underscores() {
573        assert_eq!(studly_case("a_b_c"), "ABC");
574    }
575
576    #[test]
577    fn test_clone_eq() {
578        let l1 = AddonAutoload::new("/addons");
579        let l2 = l1.clone();
580        assert_eq!(l1, l2);
581    }
582
583    #[test]
584    fn test_resolve_with_trailing_backslash_in_class() {
585        // PHP 行为:class 末尾不会有 \,但测试健壮性
586        let loader = AddonAutoload::new("/addons");
587        let result = loader.resolve_strict("addons\\operate\\Plugin\\").unwrap();
588        // 末尾 \ 会被 rfind 处理,path_part = "operate/Plugin/",class_part = ""
589        assert!(result.to_string_lossy().ends_with(".php"));
590    }
591}