Skip to main content

sz_rust_mvc_facade/view/
layout.rs

1//! 模板布局 — 对齐 PHP `think\Template` 的布局(layout)机制
2//!
3//! 实现对齐 PHP `Template::compiler()` 和 `Template::parseLayout()`
4//! 的布局功能。
5//!
6//! ## PHP 对齐说明
7//!
8//! ### 配置模式(对齐 `compiler()` 中的 `layout_on` 检查)
9//! - `layout_on=true` + `layout_name='layout'` + `layout_item='{__CONTENT__}'`
10//! - 编译时自动读取布局模板,将 `{__CONTENT__}` 替换为当前模板内容
11//! - `{__NOLAYOUT__}` 标签可单独禁用布局
12//!
13//! ### 标签模式(对齐 `parseLayout()`)
14//! - `{layout name="layout" replace="{__CONTENT__}"}`
15//! - `parseLayout()` 解析标签并替换
16//! - 如果 `layout_on=true` 且 `layout_name` == 标签 `name`,则跳过(已由 `compiler()` 处理)
17//!
18//! ### PHP 解析顺序
19//! 1. `compiler()` — 配置模式布局(在 `parse()` 之前)
20//! 2. `parse()`:
21//!    - `parseLiteral` — 暂存 literal
22//!    - `parseExtend` — 继承
23//!    - `parseLayout` — 标签模式布局
24//!    - `parseInclude` — 包含
25//!    - ...
26//!
27//! ### PHP `str_replace` 行为
28//! - `str_replace($search, $replace, $subject)` — 单次遍历,非递归
29//! - 如果 `$replace` 包含 `$search`,不会再次替换
30//! - Rust `String::replace` 行为一致
31//!
32//! ## PHP 源码参考
33//! - `think-template\src\Template.php` — `compiler()` (389-432), `parseLayout()` (529-551),
34//!   `layout()` (316-336), `getRegex('layout')` (1306-1319), `parseAttr()` (816-833),
35//!   `parseTemplateFile()` (1238-1259)
36
37use std::path::PathBuf;
38
39use regex::Regex;
40
41use super::{ViewConfig, ViewError};
42
43// ============================================================================
44// 公共入口
45// ============================================================================
46
47/// 应用布局(对齐 PHP `compiler()` + `parseLayout()`)
48///
49/// PHP 流程:
50/// 1. `compiler()` 检查 `layout_on`,如果开启则读取布局模板并替换 `{__CONTENT__}`
51/// 2. `parseLayout()` 检查 `{layout}` 标签,如果存在且未被 `compiler()` 处理则应用
52///
53/// Rust 合并为单函数,按 PHP 顺序执行:
54/// 1. 应用配置布局(`layout_on=true`),处理 `{__NOLAYOUT__}`
55/// 2. 解析 `{layout}` 标签(对齐 `parseLayout()`)
56///
57/// # 参数
58/// - `content` — 模板内容
59/// - `config` — 视图配置
60///
61/// # 返回
62/// 应用布局后的内容
63///
64/// # 错误
65/// - 如果 `layout_on=true` 且布局文件不存在,返回 `ViewError::TemplateNotFound`
66/// - 如果 `{layout}` 标签指定的布局文件不存在,返回 `ViewError::TemplateNotFound`
67pub fn apply_layout(content: &str, config: &ViewConfig) -> Result<String, ViewError> {
68    let mut result = content.to_string();
69
70    // 1. 应用配置布局(对齐 PHP `compiler()` 中的 `layout_on` 检查)
71    if config.layout_on {
72        if result.contains("{__NOLAYOUT__}") {
73            // 单独禁用布局(对齐 PHP `compiler()` 中的 `{__NOLAYOUT__}` 检查)
74            result = result.replace("{__NOLAYOUT__}", "");
75        } else {
76            // 读取布局模板并替换
77            let layout_file = resolve_template_path(&config.layout_name, config);
78            if !layout_file.is_file() {
79                return Err(ViewError::TemplateNotFound(format!(
80                    "布局模板: {} (解析路径: {})",
81                    config.layout_name,
82                    layout_file.display()
83                )));
84            }
85            let layout_content = std::fs::read_to_string(&layout_file)?;
86            // 对齐 PHP: str_replace(layout_item, content, layout_content)
87            // 搜索 layout_item,替换为 result,目标为 layout_content
88            result = layout_content.replace(&config.layout_item, &result);
89        }
90    } else {
91        // 对齐 PHP `compiler()` else 分支:移除 {__NOLAYOUT__}
92        result = result.replace("{__NOLAYOUT__}", "");
93    }
94
95    // 2. 解析 {layout} 标签(对齐 PHP `parseLayout()`)
96    result = parse_layout_tag(&result, config)?;
97
98    Ok(result)
99}
100
101// ============================================================================
102// 内部实现
103// ============================================================================
104
105/// 解析 `{layout name="..." replace="..."}` 标签(对齐 PHP `parseLayout()`)
106///
107/// PHP `parseLayout()` 逻辑:
108/// 1. 用 `preg_match`(单匹配)查找 `{layout}` 标签
109/// 2. 如果找到:
110///    - 移除 `{layout}` 标签
111///    - 解析属性(`name` 必填,`replace` 可选)
112///    - 如果 `layout_on=false` 或 `layout_name != tag.name`:
113///      - 读取布局模板文件
114///      - 替换 `replace`(或默认 `layout_item`)为内容
115/// 3. 如果未找到:移除 `{__NOLAYOUT__}`(对齐 PHP else 分支)
116///
117/// # PHP 正则限制
118/// PHP `getRegex('layout')` 要求 `name=` 存在且值非空。
119/// Rust `regex` 不支持 lookahead `(?!name=)`,采用两步法:
120/// 1. 先匹配 `{layout ...}` 任意标签
121/// 2. 再解析 `name` 属性,若缺失或为空则视为无匹配
122fn parse_layout_tag(content: &str, config: &ViewConfig) -> Result<String, ViewError> {
123    let begin = regex::escape(&config.taglib_begin);
124    let end = regex::escape(&config.taglib_end);
125
126    // 匹配 {layout ...} 标签(对齐 PHP getRegex('layout'),简化为不使用 lookahead)
127    // 对齐 PHP: 标签内容允许任意非 end 字符(包括 begin 字符 {)
128    // PHP regex: (?>(?:(?!end).)*) — 仅排除 end,不排除 begin
129    // 例:{layout name="x" replace="{__BODY__}"} 会匹配到第一个 }(即 {__BODY__} 的 })
130    let pattern = format!(r"{}layout\b\s+([^}}]+){}", begin, end);
131    let re = Regex::new(&pattern).map_err(|e| ViewError::SyntaxError(e.to_string()))?;
132
133    if let Some(caps) = re.captures(content) {
134        let full_match = caps.get(0).expect("正则捕获组 0 必定存在");
135        let tag_name = parse_attr(full_match.as_str(), "name");
136
137        // 如果 name 属性缺失或为空,视为无 {layout} 标签
138        // (对齐 PHP regex 要求 name 值至少 1 字符:[\$\w\-\/\.\:@,\\]+)
139        let tag_name = match tag_name {
140            Some(name) if !name.is_empty() => name,
141            _ => {
142                // 无有效 {layout} 标签,移除 {__NOLAYOUT__}(对齐 PHP else 分支)
143                return Ok(content.replace("{__NOLAYOUT__}", ""));
144            }
145        };
146
147        // 移除 {layout} 标签(对齐 PHP str_replace($matches[0], '', $content))
148        let result = content.replace(full_match.as_str(), "");
149
150        // 如果配置布局已开启且名称匹配,则跳过
151        // 对齐 PHP: if (!$this->config['layout_on'] || $this->config['layout_name'] != $array['name'])
152        if config.layout_on && config.layout_name == tag_name {
153            return Ok(result);
154        }
155
156        // 解析 replace 属性
157        // 对齐 PHP: isset($array['replace']) ? $array['replace'] : $this->config['layout_item']
158        let replace = parse_attr(full_match.as_str(), "replace")
159            .unwrap_or_else(|| config.layout_item.clone());
160
161        // 读取布局模板文件(对齐 PHP parseTemplateFile)
162        let layout_file = resolve_template_path(&tag_name, config);
163        if !layout_file.is_file() {
164            return Err(ViewError::TemplateNotFound(format!(
165                "布局模板: {} (解析路径: {})",
166                tag_name,
167                layout_file.display()
168            )));
169        }
170        let layout_content = std::fs::read_to_string(&layout_file)?;
171
172        // 替换布局主体内容
173        // 对齐 PHP: str_replace($replace, $content, file_get_contents($layoutFile))
174        // 搜索 replace,替换为 result,目标为 layout_content
175        Ok(layout_content.replace(&replace, &result))
176    } else {
177        // 无 {layout} 标签,移除 {__NOLAYOUT__}(对齐 PHP parseLayout() else 分支)
178        Ok(content.replace("{__NOLAYOUT__}", ""))
179    }
180}
181
182/// 解析标签属性(对齐 PHP `parseAttr`)
183///
184/// PHP 正则: `/\s+(?>(?P<name>[\w-]+)\s*)=(?>\s*)([\"\'])(?P<value>(?:(?!\2).)*)\2/is`
185///
186/// PHP `parseAttr` 返回所有属性的 map,此处简化为返回指定属性名的值。
187///
188/// # 限制
189/// Rust `regex` 不支持 backreference `\1`,因此 `[^"']*` 同时排除两种引号。
190/// PHP 仅排除匹配的引号(允许 value 中包含另一种引号)。
191/// 在实际模板中,属性值几乎不包含引号,此限制可忽略。
192fn parse_attr(tag: &str, attr_name: &str) -> Option<String> {
193    // 匹配 attr_name="value" 或 attr_name='value'
194    // 对齐 PHP parseAttr 的 \s+(name)\s*=\s*(["'])value\2
195    let pattern = format!(r#"{}\s*=\s*["']([^"']*)["']"#, regex::escape(attr_name));
196    let re = Regex::new(&pattern).ok()?;
197    re.captures(tag).map(|c| c[1].to_string())
198}
199
200/// 解析模板文件路径(对齐 PHP `parseTemplateFile`)
201///
202/// PHP 规则:
203/// 1. 如果无扩展名:
204///    - 首字符 `/` → 去掉 `/`,替换 `/`/`:` 为 `view_depr`
205///    - 否则 → 替换 `/`/`:` 为 `view_depr`
206///    - 拼接 `view_path` + normalized + `.suffix`(字符串拼接,非 with_extension)
207/// 2. 如果有扩展名 → 直接使用
208fn resolve_template_path(name: &str, config: &ViewConfig) -> PathBuf {
209    // 检查是否已有扩展名(对齐 PHP pathinfo PATHINFO_EXTENSION)
210    if std::path::Path::new(name).extension().is_some() {
211        return PathBuf::from(name);
212    }
213
214    // 去掉首字符 `/`(对齐 PHP substr($template, 1))
215    let name = name.strip_prefix('/').unwrap_or(name);
216
217    // 替换分隔符(对齐 PHP str_replace(['/', ':'], view_depr, $template))
218    let normalized = name.replace(['/', ':'], &config.view_depr);
219
220    // 拼接 view_path + normalized + .suffix(对齐 PHP 字符串拼接)
221    // 不使用 PathBuf::with_extension,因为 with_extension 会替换最后一个扩展名
222    // 例:view_depr='.' 时 normalized='admin.layout',with_extension('html') 会得到 'admin.html'
223    // PHP 行为:view_path + 'admin.layout' + '.html' = 'view_path/admin.layout.html'
224    let suffix = config.view_suffix.trim_start_matches('.');
225    let file_name = format!("{}.{}", normalized, suffix);
226    config.view_path.join(file_name)
227}
228
229// ============================================================================
230// 测试
231// ============================================================================
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::path::PathBuf;
237    use std::sync::atomic::{AtomicU64, Ordering};
238
239    /// 全局计数器,保证并发测试下临时目录名唯一
240    ///
241    /// 修复 flaky test:原实现仅用纳秒时间戳生成目录名,
242    /// 多线程并发测试时可能因纳秒级冲突导致目录被另一测试的 cleanup_dir 误删。
243    static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
244
245    // =========================================================================
246    // 辅助函数
247    // =========================================================================
248
249    /// 创建临时目录(并发安全)
250    ///
251    /// 使用 `纳秒时间戳 + 全局递增计数器` 保证目录名唯一,
252    /// 避免多线程测试下的目录名冲突。
253    fn make_temp_dir() -> PathBuf {
254        let id = TEMP_DIR_COUNTER.fetch_add(1, Ordering::SeqCst);
255        let nanos = std::time::SystemTime::now()
256            .duration_since(std::time::UNIX_EPOCH)
257            .unwrap_or_default()
258            .as_nanos();
259        let dir = std::env::temp_dir().join(format!("sz_rust_layout_test_{}_{}", nanos, id));
260        std::fs::create_dir_all(&dir).unwrap();
261        dir
262    }
263
264    /// 写入模板文件
265    fn write_template(dir: &std::path::Path, name: &str, content: &str) {
266        let path = dir.join(format!("{}.html", name));
267        std::fs::write(&path, content).unwrap();
268    }
269
270    /// 清理临时目录
271    fn cleanup_dir(dir: &std::path::Path) {
272        let _ = std::fs::remove_dir_all(dir);
273    }
274
275    /// 创建默认配置(layout_on=false)
276    fn make_config(view_path: PathBuf) -> ViewConfig {
277        ViewConfig {
278            view_path,
279            ..Default::default()
280        }
281    }
282
283    /// 创建 layout_on=true 配置
284    fn make_config_layout_on(view_path: PathBuf) -> ViewConfig {
285        ViewConfig {
286            view_path,
287            layout_on: true,
288            ..Default::default()
289        }
290    }
291
292    // =========================================================================
293    // 组 1:配置模式布局(对齐 PHP compiler() layout_on=true)
294    // =========================================================================
295
296    #[test]
297    fn test_config_layout_on_basic() {
298        // 对齐 PHP: layout_on=true,内容替换 {__CONTENT__}
299        let dir = make_temp_dir();
300        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
301        let config = make_config_layout_on(dir.clone());
302
303        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
304        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
305
306        cleanup_dir(&dir);
307    }
308
309    #[test]
310    fn test_config_layout_on_with_nolayout() {
311        // 对齐 PHP: layout_on=true + {__NOLAYOUT__} → 跳过布局
312        let dir = make_temp_dir();
313        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
314        let config = make_config_layout_on(dir.clone());
315
316        let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
317        assert_eq!(result, "<h1>Hello</h1>");
318
319        cleanup_dir(&dir);
320    }
321
322    #[test]
323    fn test_config_layout_on_custom_name() {
324        // 对齐 PHP: layout_name="custom"
325        let dir = make_temp_dir();
326        write_template(&dir, "custom", "<div class=\"wrapper\">{__CONTENT__}</div>");
327        let config = ViewConfig {
328            view_path: dir.clone(),
329            layout_on: true,
330            layout_name: "custom".to_string(),
331            ..Default::default()
332        };
333
334        let result = apply_layout("<p>Content</p>", &config).unwrap();
335        assert_eq!(result, "<div class=\"wrapper\"><p>Content</p></div>");
336
337        cleanup_dir(&dir);
338    }
339
340    #[test]
341    fn test_config_layout_on_custom_item() {
342        // 对齐 PHP: layout_item="{__BODY__}"
343        let dir = make_temp_dir();
344        write_template(&dir, "layout", "<html><body>{__BODY__}</body></html>");
345        let config = ViewConfig {
346            view_path: dir.clone(),
347            layout_on: true,
348            layout_item: "{__BODY__}".to_string(),
349            ..Default::default()
350        };
351
352        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
353        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
354
355        cleanup_dir(&dir);
356    }
357
358    #[test]
359    fn test_config_layout_on_file_not_found() {
360        // 对齐 PHP: parseTemplateFile 抛出异常
361        let dir = make_temp_dir();
362        let config = make_config_layout_on(dir.clone());
363
364        let result = apply_layout("<h1>Hello</h1>", &config);
365        assert!(matches!(result, Err(ViewError::TemplateNotFound(_))));
366
367        cleanup_dir(&dir);
368    }
369
370    // =========================================================================
371    // 组 2:配置关闭布局(对齐 PHP compiler() layout_on=false)
372    // =========================================================================
373
374    #[test]
375    fn test_config_layout_off_basic() {
376        // 对齐 PHP: layout_on=false → 不应用布局
377        let dir = make_temp_dir();
378        let config = make_config(dir.clone());
379
380        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
381        assert_eq!(result, "<h1>Hello</h1>");
382
383        cleanup_dir(&dir);
384    }
385
386    #[test]
387    fn test_config_layout_off_with_nolayout() {
388        // 对齐 PHP: layout_on=false → 移除 {__NOLAYOUT__}
389        let dir = make_temp_dir();
390        let config = make_config(dir.clone());
391
392        let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
393        assert_eq!(result, "<h1>Hello</h1>");
394
395        cleanup_dir(&dir);
396    }
397
398    // =========================================================================
399    // 组 3:标签模式布局(对齐 PHP parseLayout())
400    // =========================================================================
401
402    #[test]
403    fn test_tag_layout_basic() {
404        // 对齐 PHP: {layout name="custom" /}
405        let dir = make_temp_dir();
406        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
407        let config = make_config(dir.clone());
408
409        let result = apply_layout(r#"{layout name="custom" /}<h1>Hello</h1>"#, &config).unwrap();
410        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
411
412        cleanup_dir(&dir);
413    }
414
415    #[test]
416    fn test_tag_layout_without_self_closing() {
417        // 对齐 PHP: {layout name="custom"}(无 / 自闭合)
418        let dir = make_temp_dir();
419        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
420        let config = make_config(dir.clone());
421
422        let result = apply_layout(r#"{layout name="custom"}<h1>Hello</h1>"#, &config).unwrap();
423        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
424
425        cleanup_dir(&dir);
426    }
427
428    #[test]
429    fn test_tag_layout_custom_replace() {
430        // 对齐 PHP: {layout name="custom" replace="BODY"}
431        // 注:replace 值不能含 }(PHP regex 以 } 作为标签结束符,会提前终止匹配)
432        // PHP 行为:{layout name="x" replace="{__BODY__}"} 会匹配到第一个 },
433        // 导致 replace 值被截断为 {__BODY__(缺少 }),无法匹配布局中的 {__BODY__}
434        let dir = make_temp_dir();
435        write_template(&dir, "custom", "<html><body>BODY</body></html>");
436        let config = make_config(dir.clone());
437
438        let result = apply_layout(
439            r#"{layout name="custom" replace="BODY"}<h1>Hello</h1>"#,
440            &config,
441        )
442        .unwrap();
443        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
444
445        cleanup_dir(&dir);
446    }
447
448    #[test]
449    fn test_tag_layout_replace_with_brace_php_bug() {
450        // R5 PHP/Rust 行为对比:复刻 PHP 源码 bug
451        // PHP regex 以 } 作为标签结束符,replace 值含 } 会提前终止匹配
452        // 输入:{layout name="custom" replace="{__BODY__}"}<h1>Hello</h1>
453        // PHP 匹配:{layout name="custom" replace="{__BODY__}(到第一个 })
454        // PHP parseAttr: replace="{__BODY__ 缺少闭合引号 ",不匹配 → replace 属性解析失败
455        // PHP 回退到 layout_item ({__CONTENT__}),但布局中无 {__CONTENT__},不替换
456        // 结果:布局原样输出,内容被丢弃
457        let dir = make_temp_dir();
458        write_template(&dir, "custom", "<html><body>{__BODY__}</body></html>");
459        let config = make_config(dir.clone());
460
461        let result = apply_layout(
462            r#"{layout name="custom" replace="{__BODY__}"}<h1>Hello</h1>"#,
463            &config,
464        )
465        .unwrap();
466        // 对齐 PHP: 标签匹配到第一个 },replace 属性解析失败(无闭合引号)
467        // 回退到 layout_item ({__CONTENT__}),布局中无此标记,原样输出
468        assert_eq!(result, "<html><body>{__BODY__}</body></html>");
469
470        cleanup_dir(&dir);
471    }
472
473    #[test]
474    fn test_tag_layout_file_not_found() {
475        // 对齐 PHP: parseTemplateFile 抛出异常
476        let dir = make_temp_dir();
477        let config = make_config(dir.clone());
478
479        let result = apply_layout(r#"{layout name="nonexistent" /}<h1>Hello</h1>"#, &config);
480        assert!(matches!(result, Err(ViewError::TemplateNotFound(_))));
481
482        cleanup_dir(&dir);
483    }
484
485    #[test]
486    fn test_tag_layout_missing_name_attr() {
487        // 对齐 PHP: regex 要求 name= 存在,否则不匹配
488        let dir = make_temp_dir();
489        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
490        let config = make_config(dir.clone());
491
492        // 无 name 属性 → 视为无 {layout} 标签
493        let result =
494            apply_layout(r#"{layout replace="{__CONTENT__}"}<h1>Hello</h1>"#, &config).unwrap();
495        // {layout} 标签未被移除(因为不匹配),内容原样输出
496        assert_eq!(result, r#"{layout replace="{__CONTENT__}"}<h1>Hello</h1>"#);
497
498        cleanup_dir(&dir);
499    }
500
501    #[test]
502    fn test_tag_layout_empty_name() {
503        // 对齐 PHP: regex 要求 name 值至少 1 字符
504        let dir = make_temp_dir();
505        let config = make_config(dir.clone());
506
507        let result = apply_layout(r#"{layout name="" /}<h1>Hello</h1>"#, &config).unwrap();
508        // name 为空 → 视为无 {layout} 标签
509        assert_eq!(result, r#"{layout name="" /}<h1>Hello</h1>"#);
510
511        cleanup_dir(&dir);
512    }
513
514    // =========================================================================
515    // 组 4:配置 + 标签交互(对齐 PHP compiler() + parseLayout())
516    // =========================================================================
517
518    #[test]
519    fn test_config_on_tag_same_name_skip() {
520        // 对齐 PHP: layout_on=true + {layout name="layout"} → 标签跳过
521        // compiler() 已应用配置布局,parseLayout() 检查 layout_name==tag.name 则跳过
522        let dir = make_temp_dir();
523        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
524        let config = make_config_layout_on(dir.clone());
525
526        let result = apply_layout(r#"{layout name="layout" /}<h1>Hello</h1>"#, &config).unwrap();
527        // 配置布局应用,{layout} 标签被移除,标签布局跳过
528        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
529
530        cleanup_dir(&dir);
531    }
532
533    #[test]
534    fn test_config_on_tag_different_name_double_layout() {
535        // 对齐 PHP: layout_on=true + {layout name="other"} → 双重布局
536        // compiler() 应用配置布局,parseLayout() 再应用标签布局
537        let dir = make_temp_dir();
538        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
539        write_template(&dir, "other", "<wrapper>{__CONTENT__}</wrapper>");
540        let config = make_config_layout_on(dir.clone());
541
542        let result = apply_layout(r#"{layout name="other" /}<h1>Hello</h1>"#, &config).unwrap();
543        // 先应用配置布局:layout → <html><body>{layout name="other" /}<h1>Hello</h1></body></html>
544        // 再应用标签布局:other → <wrapper><html><body><h1>Hello</h1></body></html></wrapper>
545        assert_eq!(
546            result,
547            "<wrapper><html><body><h1>Hello</h1></body></html></wrapper>"
548        );
549
550        cleanup_dir(&dir);
551    }
552
553    #[test]
554    fn test_config_off_tag_layout_applied() {
555        // 对齐 PHP: layout_on=false + {layout name="custom"} → 标签布局应用
556        let dir = make_temp_dir();
557        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
558        let config = make_config(dir.clone());
559
560        let result = apply_layout(r#"{layout name="custom" /}<h1>Hello</h1>"#, &config).unwrap();
561        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
562
563        cleanup_dir(&dir);
564    }
565
566    // =========================================================================
567    // 组 5:{__NOLAYOUT__} 标签处理
568    // =========================================================================
569
570    #[test]
571    fn test_nolayout_with_config_on() {
572        // 对齐 PHP: layout_on=true + {__NOLAYOUT__} → 跳过配置布局
573        let dir = make_temp_dir();
574        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
575        let config = make_config_layout_on(dir.clone());
576
577        let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
578        assert_eq!(result, "<h1>Hello</h1>");
579
580        cleanup_dir(&dir);
581    }
582
583    #[test]
584    fn test_nolayout_with_config_off() {
585        // 对齐 PHP: layout_on=false + {__NOLAYOUT__} → 移除 {__NOLAYOUT__}
586        let dir = make_temp_dir();
587        let config = make_config(dir.clone());
588
589        let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
590        assert_eq!(result, "<h1>Hello</h1>");
591
592        cleanup_dir(&dir);
593    }
594
595    #[test]
596    fn test_nolayout_in_layout_file() {
597        // 对齐 PHP: 布局文件中的 {__NOLAYOUT__} 被 parseLayout() else 分支移除
598        let dir = make_temp_dir();
599        write_template(
600            &dir,
601            "layout",
602            "{__NOLAYOUT__}<html><body>{__CONTENT__}</body></html>",
603        );
604        let config = make_config_layout_on(dir.clone());
605
606        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
607        // compiler() 应用布局后,{__NOLAYOUT__} 来自布局文件
608        // parseLayout() else 分支移除 {__NOLAYOUT__}
609        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
610
611        cleanup_dir(&dir);
612    }
613
614    // =========================================================================
615    // 组 6:str_replace 行为对齐
616    // =========================================================================
617
618    #[test]
619    fn test_str_replace_non_recursive() {
620        // 对齐 PHP str_replace 非递归行为
621        // content 包含 {__CONTENT__},但替换后不再被处理
622        let dir = make_temp_dir();
623        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
624        let config = make_config_layout_on(dir.clone());
625
626        // content 中的 {__CONTENT__} 不应被再次替换
627        let result = apply_layout("{__CONTENT__}<h1>Hello</h1>", &config).unwrap();
628        // layout_content.replace("{__CONTENT__}", "{__CONTENT__}<h1>Hello</h1>")
629        // = "<html><body>{__CONTENT__}<h1>Hello</h1></body></html>"
630        // {__CONTENT__} 在替换值中不被再次替换(非递归)
631        assert_eq!(
632            result,
633            "<html><body>{__CONTENT__}<h1>Hello</h1></body></html>"
634        );
635
636        cleanup_dir(&dir);
637    }
638
639    #[test]
640    fn test_multiple_content_placeholders() {
641        // 对齐 PHP str_replace 替换所有出现
642        let dir = make_temp_dir();
643        write_template(
644            &dir,
645            "layout",
646            "<header>{__CONTENT__}</header><main>{__CONTENT__}</main>",
647        );
648        let config = make_config_layout_on(dir.clone());
649
650        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
651        assert_eq!(
652            result,
653            "<header><h1>Hello</h1></header><main><h1>Hello</h1></main>"
654        );
655
656        cleanup_dir(&dir);
657    }
658
659    // =========================================================================
660    // 组 7:模板路径解析(对齐 PHP parseTemplateFile)
661    // =========================================================================
662
663    #[test]
664    fn test_resolve_path_simple() {
665        let config = make_config(PathBuf::from("/view"));
666        let path = resolve_template_path("layout", &config);
667        assert_eq!(path, PathBuf::from("/view/layout.html"));
668    }
669
670    #[test]
671    fn test_resolve_path_with_slash_prefix() {
672        // 对齐 PHP: 首字符 / → 去掉 /
673        let config = make_config(PathBuf::from("/view"));
674        let path = resolve_template_path("/layout", &config);
675        assert_eq!(path, PathBuf::from("/view/layout.html"));
676    }
677
678    #[test]
679    fn test_resolve_path_nested() {
680        let config = make_config(PathBuf::from("/view"));
681        let path = resolve_template_path("admin/layout", &config);
682        assert_eq!(path, PathBuf::from("/view/admin/layout.html"));
683    }
684
685    #[test]
686    fn test_resolve_path_with_colon() {
687        // 对齐 PHP: str_replace(['/', ':'], view_depr, $template)
688        let config = make_config(PathBuf::from("/view"));
689        let path = resolve_template_path("admin:layout", &config);
690        assert_eq!(path, PathBuf::from("/view/admin/layout.html"));
691    }
692
693    #[test]
694    fn test_resolve_path_with_extension() {
695        // 对齐 PHP: 有扩展名 → 直接使用
696        let config = make_config(PathBuf::from("/view"));
697        let path = resolve_template_path("layout.tpl", &config);
698        assert_eq!(path, PathBuf::from("layout.tpl"));
699    }
700
701    #[test]
702    fn test_resolve_path_custom_view_depr() {
703        // 对齐 PHP: view_depr 自定义分隔符
704        let config = ViewConfig {
705            view_path: PathBuf::from("/view"),
706            view_depr: ".".to_string(),
707            ..Default::default()
708        };
709        let path = resolve_template_path("admin/layout", &config);
710        assert_eq!(path, PathBuf::from("/view/admin.layout.html"));
711    }
712
713    // =========================================================================
714    // 组 8:属性解析(对齐 PHP parseAttr)
715    // =========================================================================
716
717    #[test]
718    fn test_parse_attr_double_quote() {
719        let val = parse_attr(r#"name="custom""#, "name");
720        assert_eq!(val, Some("custom".to_string()));
721    }
722
723    #[test]
724    fn test_parse_attr_single_quote() {
725        let val = parse_attr(r#"name='custom'"#, "name");
726        assert_eq!(val, Some("custom".to_string()));
727    }
728
729    #[test]
730    fn test_parse_attr_missing() {
731        let val = parse_attr(r#"other="value""#, "name");
732        assert_eq!(val, None);
733    }
734
735    #[test]
736    fn test_parse_attr_with_spaces() {
737        let val = parse_attr(r#"name  =  "custom""#, "name");
738        assert_eq!(val, Some("custom".to_string()));
739    }
740
741    #[test]
742    fn test_parse_attr_multiple_attrs() {
743        let tag = r#"{layout name="custom" replace="{__BODY__}"}"#;
744        assert_eq!(parse_attr(tag, "name"), Some("custom".to_string()));
745        assert_eq!(parse_attr(tag, "replace"), Some("{__BODY__}".to_string()));
746    }
747
748    // =========================================================================
749    // 组 9:边界情况
750    // =========================================================================
751
752    #[test]
753    fn test_empty_content() {
754        let dir = make_temp_dir();
755        let config = make_config(dir.clone());
756
757        let result = apply_layout("", &config).unwrap();
758        assert_eq!(result, "");
759
760        cleanup_dir(&dir);
761    }
762
763    #[test]
764    fn test_empty_content_with_layout_on() {
765        let dir = make_temp_dir();
766        write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
767        let config = make_config_layout_on(dir.clone());
768
769        let result = apply_layout("", &config).unwrap();
770        assert_eq!(result, "<html><body></body></html>");
771
772        cleanup_dir(&dir);
773    }
774
775    #[test]
776    fn test_no_layout_tag_no_nolayout() {
777        // 无 {layout} 标签,无 {__NOLAYOUT__} → 原样返回
778        let dir = make_temp_dir();
779        let config = make_config(dir.clone());
780
781        let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
782        assert_eq!(result, "<h1>Hello</h1>");
783
784        cleanup_dir(&dir);
785    }
786
787    #[test]
788    fn test_layout_tag_not_first() {
789        // {layout} 标签不在内容开头
790        let dir = make_temp_dir();
791        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
792        let config = make_config(dir.clone());
793
794        let result = apply_layout(r#"Hello {layout name="custom" /}World"#, &config).unwrap();
795        // {layout} 标签被移除,剩余内容 "Hello World" 被放入布局
796        assert_eq!(result, "<html><body>Hello World</body></html>");
797
798        cleanup_dir(&dir);
799    }
800
801    // =========================================================================
802    // 组 10:自定义 taglib_begin/end
803    // =========================================================================
804
805    #[test]
806    fn test_custom_taglib_delimiters() {
807        // 对齐 PHP: 自定义 taglib_begin/taglib_end
808        let dir = make_temp_dir();
809        write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
810        let config = ViewConfig {
811            view_path: dir.clone(),
812            taglib_begin: "<".to_string(),
813            taglib_end: "/>".to_string(),
814            ..Default::default()
815        };
816
817        let result = apply_layout(r#"<layout name="custom" /><h1>Hello</h1>"#, &config).unwrap();
818        assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
819
820        cleanup_dir(&dir);
821    }
822
823    // =========================================================================
824    // 组 11:集成场景(布局 + 变量插值)
825    // =========================================================================
826
827    #[test]
828    fn test_layout_preserves_variable_tags() {
829        // 布局应用在变量解析之前,{$var} 应保留
830        let dir = make_temp_dir();
831        write_template(
832            &dir,
833            "layout",
834            "<html><body>{__CONTENT__}</body><title>{$title}</title></html>",
835        );
836        let config = make_config_layout_on(dir.clone());
837
838        let result = apply_layout("<h1>{$name}</h1>", &config).unwrap();
839        // 布局中的 {$title} 和内容中的 {$name} 都应保留
840        assert_eq!(
841            result,
842            "<html><body><h1>{$name}</h1></body><title>{$title}</title></html>"
843        );
844
845        cleanup_dir(&dir);
846    }
847}