Skip to main content

sz_rust_addons_loader/
autoload.rs

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