loose_liquid_lib/stdlib/filters/string/
truncate.rs

1use std::cmp;
2
3use liquid_core::Expression;
4use liquid_core::Result;
5use liquid_core::Runtime;
6use liquid_core::{
7    Display_filter, Filter, FilterParameters, FilterReflection, FromFilterParameters, ParseFilter,
8};
9use liquid_core::{Value, ValueView};
10use unicode_segmentation::UnicodeSegmentation;
11
12#[derive(Debug, FilterParameters)]
13struct TruncateArgs {
14    #[parameter(
15        description = "The maximum length of the string, after which it will be truncated.",
16        arg_type = "integer"
17    )]
18    length: Option<Expression>,
19
20    #[parameter(
21        description = "The text appended to the end of the string if it is truncated. This text counts to the maximum length of the string. Defaults to \"...\".",
22        arg_type = "str"
23    )]
24    ellipsis: Option<Expression>,
25}
26
27/// `truncate` shortens a string down to the number of characters passed as a parameter.
28///
29/// Note that this function operates on [grapheme
30/// clusters](http://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries) (or *user-perceived
31/// character*), rather than Unicode code points.  Each grapheme cluster may be composed of more
32/// than one Unicode code point, and does not necessarily correspond to rust's conception of a
33/// character.
34///
35/// If the number of characters specified is less than the length of the string, an ellipsis
36/// (`...`) is appended to the string and is included in the character count.
37///
38/// ## Custom ellipsis
39///
40/// `truncate` takes an optional second parameter that specifies the sequence of characters to be
41/// appended to the truncated string. By default this is an ellipsis (`...`), but you can specify a
42/// different sequence.
43///
44/// The length of the second parameter counts against the number of characters specified by the
45/// first parameter. For example, if you want to truncate a string to exactly 10 characters, and
46/// use a 3-character ellipsis, use 13 for the first parameter of `truncate`, since the ellipsis
47/// counts as 3 characters.
48///
49/// ## No ellipsis
50///
51/// You can truncate to the exact number of characters specified by the first parameter and show no
52/// trailing characters by passing a blank string as the second parameter.
53#[derive(Clone, ParseFilter, FilterReflection)]
54#[filter(
55    name = "truncate",
56    description = "Shortens a string down to the number of characters passed as a parameter.",
57    parameters(TruncateArgs),
58    parsed(TruncateFilter)
59)]
60pub struct Truncate;
61
62#[derive(Debug, FromFilterParameters, Display_filter)]
63#[name = "truncate"]
64struct TruncateFilter {
65    #[parameters]
66    args: TruncateArgs,
67}
68
69impl Filter for TruncateFilter {
70    fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result<Value> {
71        let args = self.args.evaluate(runtime)?;
72
73        let length = args.length.unwrap_or(50) as usize;
74        let truncate_string = args.ellipsis.unwrap_or_else(|| "...".into());
75        let diff = if length >= truncate_string.len() {
76            length - truncate_string.len()
77        } else {
78            0
79        };
80        let l = cmp::max(diff, 0);
81
82        let input_string = input.to_kstr();
83        let result = if length < input_string.len() {
84            let result = UnicodeSegmentation::graphemes(input_string.as_str(), true)
85                .take(l)
86                .collect::<Vec<&str>>()
87                .join("")
88                + truncate_string.as_str();
89            Value::scalar(result)
90        } else {
91            input.to_value()
92        };
93        Ok(result)
94    }
95}
96
97#[derive(Debug, FilterParameters)]
98struct TruncateWordsArgs {
99    #[parameter(
100        description = "The maximum number of words, after which the string will be truncated.",
101        arg_type = "integer"
102    )]
103    length: Option<Expression>,
104
105    #[parameter(
106        description = "The text appended to the end of the string if it is truncated. This text counts to the maximum word-count of the string. Defaults to \"...\".",
107        arg_type = "str"
108    )]
109    ellipsis: Option<Expression>,
110}
111
112#[derive(Clone, ParseFilter, FilterReflection)]
113#[filter(
114    name = "truncatewords",
115    description = "Shortens a string down to the number of characters passed as a parameter.",
116    parameters(TruncateWordsArgs),
117    parsed(TruncateWordsFilter)
118)]
119pub struct TruncateWords;
120
121#[derive(Debug, FromFilterParameters, Display_filter)]
122#[name = "truncate"]
123struct TruncateWordsFilter {
124    #[parameters]
125    args: TruncateWordsArgs,
126}
127
128impl Filter for TruncateWordsFilter {
129    fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result<Value> {
130        let args = self.args.evaluate(runtime)?;
131
132        let words = args.length.unwrap_or(50) as usize;
133
134        let truncate_string = args.ellipsis.unwrap_or_else(|| "...".into());
135
136        let l = cmp::max(words, 0);
137
138        let input_string = input.to_kstr();
139
140        let word_list: Vec<&str> = input_string.split(' ').collect();
141        let result = if words < word_list.len() {
142            let result = itertools::join(word_list.iter().take(l), " ") + truncate_string.as_str();
143            Value::scalar(result)
144        } else {
145            input.to_value()
146        };
147        Ok(result)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn unit_truncate() {
157        assert_eq!(
158            liquid_core::call_filter!(
159                Truncate,
160                "I often quote myself.  It adds spice to my conversation.",
161                17i64
162            )
163            .unwrap(),
164            liquid_core::value!("I often quote ...")
165        );
166    }
167
168    #[test]
169    fn unit_truncate_negative_length() {
170        assert_eq!(
171            liquid_core::call_filter!(
172                Truncate,
173                "I often quote myself.  It adds spice to my conversation.",
174                -17i64
175            )
176            .unwrap(),
177            liquid_core::value!("I often quote myself.  It adds spice to my conversation.")
178        );
179    }
180
181    #[test]
182    fn unit_truncate_non_string() {
183        assert_eq!(
184            liquid_core::call_filter!(Truncate, 10000000f64, 5i64).unwrap(),
185            liquid_core::value!("10...")
186        );
187    }
188
189    #[test]
190    fn unit_truncate_shopify_liquid() {
191        // Tests from https://shopify.github.io/liquid/filters/truncate/
192        let input = "Ground control to Major Tom.";
193
194        assert_eq!(
195            liquid_core::call_filter!(Truncate, input, 20i64).unwrap(),
196            liquid_core::value!("Ground control to...")
197        );
198
199        assert_eq!(
200            liquid_core::call_filter!(Truncate, input, 25i64, ", and so on").unwrap(),
201            liquid_core::value!("Ground control, and so on")
202        );
203
204        assert_eq!(
205            liquid_core::call_filter!(Truncate, input, 20i64, "").unwrap(),
206            liquid_core::value!("Ground control to Ma")
207        );
208    }
209
210    #[test]
211    fn unit_truncate_three_arguments() {
212        liquid_core::call_filter!(
213            Truncate,
214            "I often quote myself.  It adds spice to my conversation.",
215            17i64,
216            "...",
217            0i64
218        )
219        .unwrap_err();
220    }
221
222    #[test]
223    fn unit_truncate_unicode_codepoints_examples() {
224        // The examples below came from the unicode_segmentation documentation.
225        //
226        // https://unicode-rs.github.io/unicode-segmentation/unicode_segmentation/ ...
227        //               ...  trait.UnicodeSegmentation.html#tymethod.graphemes
228        //
229        // Note that the accents applied to each letter are treated as part of the single grapheme
230        // cluster for the applicable letter.
231        assert_eq!(
232            liquid_core::call_filter!(
233                Truncate,
234                "Here is an a\u{310}, e\u{301}, and o\u{308}\u{332}.",
235                20i64
236            )
237            .unwrap(),
238            liquid_core::value!("Here is an a\u{310}, e\u{301}, ...")
239        );
240
241        // Note that the πŸ‡·πŸ‡ΊπŸ‡ΈπŸ‡Ή is treated as a single grapheme cluster.
242        assert_eq!(
243            liquid_core::call_filter!(Truncate, "Here is a RUST: πŸ‡·πŸ‡ΊπŸ‡ΈπŸ‡Ή.", 20i64).unwrap(),
244            liquid_core::value!("Here is a RUST: πŸ‡·πŸ‡Ί...")
245        );
246    }
247
248    #[test]
249    fn unit_truncate_zero_arguments() {
250        assert_eq!(
251            liquid_core::call_filter!(
252                Truncate,
253                "I often quote myself.  It adds spice to my conversation."
254            )
255            .unwrap(),
256            liquid_core::value!("I often quote myself.  It adds spice to my conv...")
257        );
258    }
259
260    #[test]
261    fn unit_truncatewords_negative_length() {
262        assert_eq!(
263            liquid_core::call_filter!(TruncateWords, "one two three", -1_i64).unwrap(),
264            liquid_core::value!("one two three")
265        );
266    }
267
268    #[test]
269    fn unit_truncatewords_zero_length() {
270        assert_eq!(
271            liquid_core::call_filter!(TruncateWords, "one two three", 0_i64).unwrap(),
272            liquid_core::value!("...")
273        );
274    }
275
276    #[test]
277    fn unit_truncatewords_no_truncation() {
278        assert_eq!(
279            liquid_core::call_filter!(TruncateWords, "one two three", 4_i64).unwrap(),
280            liquid_core::value!("one two three")
281        );
282    }
283
284    #[test]
285    fn unit_truncatewords_truncate() {
286        assert_eq!(
287            liquid_core::call_filter!(TruncateWords, "one two three", 2_i64).unwrap(),
288            liquid_core::value!("one two...")
289        );
290        assert_eq!(
291            liquid_core::call_filter!(TruncateWords, "one two three", 2_i64, 1_i64).unwrap(),
292            liquid_core::value!("one two1")
293        );
294    }
295
296    #[test]
297    fn unit_truncatewords_empty_string() {
298        assert_eq!(
299            liquid_core::call_filter!(TruncateWords, "", 1_i64).unwrap(),
300            liquid_core::value!("")
301        );
302    }
303}