thymeleaf 0.1.0-beta.0

A framework-neutral Thymeleaf-compatible dynamic template engine for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
use std::sync::Arc;
use thiserror::Error;

use crate::util::Utf16String;

/// Token 值执行 Java `toString()` 时的可空、借用或新建结果。
///
/// 该适配保留 `String#toString()` 返回原实例,以及自定义对象返回共享字符串的
/// 引用身份;Java 允许覆写 `toString()` 后返回 null。
/// 对应 Java 语义:`Token` 的 Rust 侧类型 `TokenStringResult`。
pub enum TokenStringResult<'a> {
    /// Java null。
    Null,
    /// 借用的既有 Java 字符串。
    Borrowed(&'a Utf16String),
    /// 新建 Java 字符串。
    Owned(Utf16String),
}

/// 可被 `Token` 保存并按 Java 规则转换为字符串的值。
/// 对应 Java 语义:`Token` 的 Rust 侧类型 `TokenValue`。
pub trait TokenValue {
    /// 执行 Java `Object#toString()` 等价操作。
    ///
    /// # 返回
    /// 可空、借用或新建的 UTF-16 字符串。
    ///
    /// # 错误
    /// 自定义 `toString()` 抛出的运行时异常必须保留类别和消息。
    fn token_to_string(&self) -> Result<TokenStringResult<'_>, TokenError>;
}

impl TokenValue for Utf16String {
    fn token_to_string(&self) -> Result<TokenStringResult<'_>, TokenError> {
        Ok(TokenStringResult::Borrowed(self))
    }
}

impl<T: TokenValue> TokenValue for Arc<T> {
    fn token_to_string(&self) -> Result<TokenStringResult<'_>, TokenError> {
        self.as_ref().token_to_string()
    }
}

/// `Token` 操作中可观察的 Java 异常。
#[derive(Debug, Error, Eq, PartialEq)]
/// 对应 Java 语义:`Token` 的 Rust 侧类型 `TokenError`。
pub enum TokenError {
    /// 对 null String 或 null token 值调用实例方法。
    #[error("")]
    NullPointer,
    /// `String#charAt(int)` 的索引越界。
    #[error("String index out of range: {position}")]
    StringIndexOutOfBounds {
        /// Java 参数 `pos`。
        position: i32,
    },
    /// Token 值的自定义 `toString()` 抛出运行时异常。
    #[error("{message}")]
    Runtime {
        /// 原 Java 异常类名。
        exception_class_name: String,
        /// 原 Java detail message;null 用空字符串表达。
        message: String,
    },
}

impl TokenError {
    /// 返回对应 Java 异常类名。
    ///
    /// # 返回
    /// NullPointer、StringIndexOutOfBounds 或保存的运行时异常类名。
    #[must_use]
    pub fn class_name(&self) -> &str {
        match self {
            Self::NullPointer => "java.lang.NullPointerException",
            Self::StringIndexOutOfBounds { .. } => "java.lang.StringIndexOutOfBoundsException",
            Self::Runtime {
                exception_class_name,
                ..
            } => exception_class_name,
        }
    }

    /// 创建自定义 token 值 `toString()` 抛出的运行时错误。
    ///
    /// # 参数
    /// - `exception_class_name`:Java 异常类名;
    /// - `message`:可观察 detail message。
    ///
    /// # 返回
    /// 保留类别和消息的错误。
    #[must_use]
    /// 对应 Java 语义:`Token` 的 `runtime` 行为(Rust 侧辅助/私有路径)。
    pub fn runtime(exception_class_name: impl Into<String>, message: impl Into<String>) -> Self {
        Self::Runtime {
            exception_class_name: exception_class_name.into(),
            message: message.into(),
        }
    }
}

/// Thymeleaf 标准表达式 token 的公共基对象。
///
/// Rust 以泛型组合代替 Java `Token extends SimpleExpression` 的字段继承;值仍然
/// 只保存一次,并向 Boolean/Number/Generic/Null/NoOp token 子对象提供共同实现。
/// 公开构造器对应 Java protected 构造能力,供外部自定义 token 组合使用。
///
/// 对应 Java: `org.thymeleaf.standard.expression.Token`。
pub struct Token<T: TokenValue> {
    value: Option<T>,
}

impl<T: TokenValue> Token<T> {
    /// 创建保存指定可空对象的 token。对应 Java: `Token#Token(Object)`。
    ///
    /// # 参数
    /// - `value`:token 值;`None` 对应 Java null。
    ///
    /// # 返回
    /// 保存该值的新 token 基对象。
    #[must_use]
    pub const fn new(value: Option<T>) -> Self {
        Self { value }
    }

    /// 返回 token 的原始值。对应 Java: `Token#getValue()`。
    ///
    /// # 返回
    /// 原值借用;构造时为 Java null 则返回 `None`。
    #[must_use]
    pub const fn get_value(&self) -> Option<&T> {
        self.value.as_ref()
    }

    /// 返回 token 的字符串表示。
    ///
    /// 对应 Java: `Token#getStringRepresentation()`,同时也是
    /// `Token#toString()` 的完整委托目标。
    ///
    /// # 返回
    /// 值的 `toString()` 所产生的可空、借用或新建字符串。
    ///
    /// # 错误
    /// token 值为 null 时返回 NullPointer 类别;自定义值异常原样传播。
    pub fn get_string_representation(&self) -> Result<TokenStringResult<'_>, TokenError> {
        self.value
            .as_ref()
            .ok_or(TokenError::NullPointer)?
            .token_to_string()
    }

    /// 返回 Java `toString()` 的结果。
    ///
    /// 对应 Java: `Token#toString()`;上游实现完整委托
    /// `getStringRepresentation()`,因此这里保留相同的可空结果、引用身份和错误。
    ///
    /// # 返回
    /// 值的 `toString()` 所产生的可空、借用或新建字符串。
    ///
    /// # 错误
    /// token 值为 null 时返回 NullPointer 类别;自定义值异常原样传播。
    pub fn to_string(&self) -> Result<TokenStringResult<'_>, TokenError> {
        self.get_string_representation()
    }

    /// 判断指定 UTF-16 位置是否属于标准表达式 token。
    ///
    /// 对应 Java: `Token#isTokenChar(String,int)`。
    ///
    /// # 参数
    /// - `context`:完整 Java 字符串上下文,`None` 对应 null;
    /// - `pos`:按 UTF-16 code unit 计数的 Java int 索引。
    ///
    /// # 返回
    /// ASCII、规定的国际字符区间及符合上下文条件的连字符返回 `true`。
    ///
    /// # 错误
    /// context 为 null 或 pos 越界时,保留 Java 异常类别和校验顺序。
    pub fn is_token_char(context: Option<&Utf16String>, pos: i32) -> Result<bool, TokenError> {
        let context = context.ok_or(TokenError::NullPointer)?;
        let position = position_in(context, pos)?;
        Ok(is_token_char_at(context.as_utf16(), position))
    }
}

/// 在表达式解析诊断中把所有 token 字符替换成 `#` 的追踪器。
///
/// 这是 `Token` 的紧耦合 Java 静态内部类,按对象组织规则与主对象同文件。
///
/// 对应 Java:
/// `org.thymeleaf.standard.expression.Token.TokenParsingTracer`。
pub struct TokenParsingTracer {
    _private: (),
}

impl TokenParsingTracer {
    /// token 字符的追踪替代码元 `#`。
    pub const TOKEN_SUBSTITUTE: u16 = 0x0023;

    /// 生成与输入等长的 UTF-16 token 追踪文本。
    ///
    /// 对应 Java: `Token.TokenParsingTracer#trace(String)`。
    ///
    /// # 参数
    /// - `input`:待追踪 Java 字符串;`None` 对应 null。
    ///
    /// # 返回
    /// 每个 token code unit 替换为 `#`,其他 code unit 原样保留。
    ///
    /// # 错误
    /// 输入为 null 时返回 Java NullPointer 类别。
    pub fn trace(input: Option<&Utf16String>) -> Result<Utf16String, TokenError> {
        let input = input.ok_or(TokenError::NullPointer)?;
        let input_units = input.as_utf16();
        let mut traced = Vec::with_capacity(input_units.len().saturating_add(1));
        for position in 0..input_units.len() {
            if is_token_char_at(input_units, position) {
                traced.push(Self::TOKEN_SUBSTITUTE);
            } else {
                traced.push(input_units[position]);
            }
        }
        Ok(Utf16String::from_utf16(traced))
    }
}

fn position_in(context: &Utf16String, position: i32) -> Result<usize, TokenError> {
    let Ok(position_usize) = usize::try_from(position) else {
        return Err(TokenError::StringIndexOutOfBounds { position });
    };
    if position_usize >= context.len() {
        return Err(TokenError::StringIndexOutOfBounds { position });
    }
    Ok(position_usize)
}

fn is_token_char_at(context: &[u16], position: usize) -> bool {
    let current = context[position];

    if is_ascii_lower(current) || is_ascii_upper(current) || is_ascii_digit(current) {
        return true;
    }
    if matches!(
        current,
        0x0020
            | 0x000A
            | 0x0028
            | 0x0029
            | 0x0027
            | 0x0022
            | 0x003C
            | 0x003E
            | 0x007B
            | 0x007D
            | 0x003D
            | 0x002C
            | 0x003B
            | 0x003A
            | 0x002B
            | 0x002A
            | 0x0024
            | 0x0025
            | 0x0026
            | 0x0023
    ) {
        return false;
    }
    if matches!(current, 0x005B | 0x005D | 0x002E | 0x005F) {
        return true;
    }
    if current == u16::from(b'-') {
        // 向后扫描连续 token;发现非数字/点 code unit 时,连字符属于标识符。
        for index in (0..position).rev() {
            if !is_token_char_at(context, index) {
                break;
            }
            let candidate = context[index];
            if !is_ascii_digit(candidate) && candidate != u16::from(b'.') {
                return true;
            }
        }

        // 向前扫描时先识别另一个连字符,避免两个递归调用形成循环。
        for index in position.saturating_add(1)..context.len() {
            let candidate = context[index];
            if candidate == u16::from(b'-') {
                return true;
            }
            if !is_token_char_at(context, index) {
                break;
            }
            if !is_ascii_digit(candidate) && candidate != u16::from(b'.') {
                return true;
            }
        }
        return false;
    }

    current == 0x00B7
        || (0x00C0..=0x00D6).contains(&current)
        || (0x00D8..=0x00F6).contains(&current)
        || (0x00F8..=0x02FF).contains(&current)
        || (0x0300..=0x036F).contains(&current)
        || (0x0370..=0x037D).contains(&current)
        || (0x037F..=0x1FFF).contains(&current)
        || (0x200C..=0x200D).contains(&current)
        || (0x203F..=0x2040).contains(&current)
        || (0x2070..=0x218F).contains(&current)
        || (0x2C00..=0x2FEF).contains(&current)
        || (0x3001..=0xD7FF).contains(&current)
        || (0xF900..=0xFDCF).contains(&current)
        || (0xFDF0..=0xFFFD).contains(&current)
}

const fn is_ascii_lower(value: u16) -> bool {
    value >= 0x0061 && value <= 0x007A
}

const fn is_ascii_upper(value: u16) -> bool {
    value >= 0x0041 && value <= 0x005A
}

const fn is_ascii_digit(value: u16) -> bool {
    value >= 0x0030 && value <= 0x0039
}

#[cfg(test)]
mod tests {
    use super::{Token, TokenError, TokenParsingTracer, TokenStringResult, TokenValue};
    use crate::util::Utf16String;

    struct Probe {
        result: ProbeResult,
    }

    enum ProbeResult {
        Null,
        Value(Utf16String),
        Error,
    }

    impl TokenValue for Probe {
        fn token_to_string(&self) -> Result<TokenStringResult<'_>, TokenError> {
            match &self.result {
                ProbeResult::Null => Ok(TokenStringResult::Null),
                ProbeResult::Value(value) => Ok(TokenStringResult::Borrowed(value)),
                ProbeResult::Error => Err(TokenError::runtime(
                    "java.lang.IllegalStateException",
                    "boom",
                )),
            }
        }
    }

    #[test]
    fn preserves_value_identity_nullable_string_and_runtime_errors() {
        let string = Utf16String::from_rust_str("token");
        let token = Token::new(Some(string.clone()));
        assert_eq!(token.get_value(), Some(&string));
        assert!(matches!(
            token.get_string_representation(),
            Ok(TokenStringResult::Borrowed(value)) if value == &string
        ));

        let null_token = Token::<Utf16String>::new(None);
        assert_eq!(
            null_token.get_string_representation().err(),
            Some(TokenError::NullPointer)
        );

        let null_result = Token::new(Some(Probe {
            result: ProbeResult::Null,
        }));
        assert_eq!(
            std::mem::discriminant(&null_result.get_string_representation().unwrap()),
            std::mem::discriminant(&TokenStringResult::Null)
        );
        let owned_result = Token::new(Some(Probe {
            result: ProbeResult::Value(Utf16String::from_rust_str("owned")),
        }));
        let borrowed_result = owned_result.get_string_representation().unwrap();
        let expected_borrowed_value = Utf16String::from_rust_str("expected");
        assert_eq!(
            std::mem::discriminant(&borrowed_result),
            std::mem::discriminant(&TokenStringResult::Borrowed(&expected_borrowed_value))
        );
        let error_token = Token::new(Some(Probe {
            result: ProbeResult::Error,
        }));
        let error = error_token
            .get_string_representation()
            .err()
            .expect("runtime error");
        assert_eq!(error.class_name(), "java.lang.IllegalStateException");
        assert_eq!(error.to_string(), "boom");
    }

    #[test]
    fn preserves_null_index_and_trace_boundaries() {
        assert_eq!(
            Token::<Utf16String>::is_token_char(None, 0).err(),
            Some(TokenError::NullPointer)
        );
        let empty = Utf16String::from_rust_str("");
        for position in [-1, 0, i32::MAX] {
            let error = Token::<Utf16String>::is_token_char(Some(&empty), position)
                .expect_err("index failure");
            assert_eq!(
                error.class_name(),
                "java.lang.StringIndexOutOfBoundsException"
            );
        }
        assert_eq!(
            TokenParsingTracer::trace(None).err(),
            Some(TokenError::NullPointer)
        );
        assert_eq!(
            TokenParsingTracer::trace(Some(&empty))
                .expect("empty trace")
                .as_utf16(),
            &[] as &[u16]
        );
    }
}