Repository: mitsuhiko/minijinja
Pull request #522 (2024-06-11 18:14:46)

Adds more string methods to pycompat
This adds a few more common string methods.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6aa326b..91c3e16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
 
 All notable changes to MiniJinja are documented here.
 
+## 2.0.3
+
+- Added new methods to pycompat: `str.endswith`, `str.rfind`,
+  `str.isalnum`, `str.isalpha`, `str.isascii`, `str.isdigit`,
+  `str.isnumeric`, `str.join`, `str.startswith`.  #522
+
 ## 2.0.2
 
 - Implemented sequence (+ some iterator) and string repeating with the `*`
diff --git a/minijinja-contrib/src/pycompat.rs b/minijinja-contrib/src/pycompat.rs
index 3b48576..98d7472 100644
--- a/minijinja-contrib/src/pycompat.rs
+++ b/minijinja-contrib/src/pycompat.rs
@@ -23,15 +23,24 @@ use minijinja::{Error, ErrorKind, State, Value};
 /// * `list.count`
 /// * `str.capitalize`
 /// * `str.count`
+/// * `str.endswith`
 /// * `str.find`
+/// * `str.isalnum`
+/// * `str.isalpha`
+/// * `str.isascii`
+/// * `str.isdigit`
 /// * `str.islower`
+/// * `str.isnumeric`
 /// * `str.isupper`
+/// * `str.join`
 /// * `str.lower`
 /// * `str.lstrip`
 /// * `str.replace`
+/// * `str.rfind`
 /// * `str.rstrip`
 /// * `str.split`
 /// * `str.splitlines`
+/// * `str.startswith`
 /// * `str.strip`
 /// * `str.title`
 /// * `str.upper`
@@ -77,6 +86,24 @@ fn string_methods(value: &Value, method: &str, args: &[Value]) -> Result<Value,
             from_args(args)?;
             Ok(Value::from(s.chars().all(|x| x.is_whitespace())))
         }
+        "isdigit" | "isnumeric" => {
+            // this is not a perfect mapping to what Python does, but
+            // close enough for most uses in templates.
+            from_args(args)?;
+            Ok(Value::from(s.chars().all(|x| x.is_numeric())))
+        }
+        "isalnum" => {
+            from_args(args)?;
+            Ok(Value::from(s.chars().all(|x| x.is_alphanumeric())))
+        }
+        "isalpha" => {
+            from_args(args)?;
+            Ok(Value::from(s.chars().all(|x| x.is_alphabetic())))
+        }
+        "isascii" => {
+            from_args(args)?;
+            Ok(Value::from(s.chars().all(|x| x.is_ascii())))
+        }
         "strip" => {
             let (chars,): (Option<&str>,) = from_args(args)?;
             Ok(Value::from(if let Some(chars) = chars {
@@ -164,6 +191,83 @@ fn string_methods(value: &Value, method: &str, args: &[Value]) -> Result<Value,
                 None => -1,
             }))
         }
+        "rfind" => {
+            let (what,): (&str,) = from_args(args)?;
+            Ok(Value::from(match s.rfind(what) {
+                Some(x) => x as i64,
+                None => -1,
+            }))
+        }
+        "startswith" => {
+            let (prefix,): (&Value,) = from_args(args)?;
+            if let Some(prefix) = prefix.as_str() {
+                Ok(Value::from(s.starts_with(prefix)))
+            } else if matches!(prefix.kind(), ValueKind::Iterable | ValueKind::Seq) {
+                for prefix in prefix.try_iter()? {
+                    if s.starts_with(prefix.as_str().ok_or_else(|| {
+                        Error::new(
+                            ErrorKind::InvalidOperation,
+                            format!(
+                                "tuple for startswith must contain only strings, not {}",
+                                prefix.kind()
+                            ),
+                        )
+                    })?) {
+                        return Ok(Value::from(true));
+                    }
+                }
+                Ok(Value::from(false))
+            } else {
+                Err(Error::new(
+                    ErrorKind::InvalidOperation,
+                    format!(
+                        "startswith argument must be string or a tuple of strings, not {}",
+                        prefix.kind()
+                    ),
+                ))
+            }
+        }
+        "endswith" => {
+            let (suffix,): (&Value,) = from_args(args)?;
+            if let Some(suffix) = suffix.as_str() {
+                Ok(Value::from(s.ends_with(suffix)))
+            } else if matches!(suffix.kind(), ValueKind::Iterable | ValueKind::Seq) {
+                for suffix in suffix.try_iter()? {
+                    if s.ends_with(suffix.as_str().ok_or_else(|| {
+                        Error::new(
+                            ErrorKind::InvalidOperation,
+                            format!(
+                                "tuple for endswith must contain only strings, not {}",
+                                suffix.kind()
+                            ),
+                        )
+                    })?) {
+                        return Ok(Value::from(true));
+                    }
+                }
+                Ok(Value::from(false))
+            } else {
+                Err(Error::new(
+                    ErrorKind::InvalidOperation,
+                    format!(
+                        "endswith argument must be string or a tuple of strings, not {}",
+                        suffix.kind()
+                    ),
+                ))
+            }
+        }
+        "join" => {
+            use std::fmt::Write;
+            let (values,): (&Value,) = from_args(args)?;
+            let mut rv = String::new();
+            for (idx, value) in values.try_iter()?.enumerate() {
+                if idx > 0 {
+                    rv.push_str(s);
+                }
+                write!(rv, "{}", value).ok();
+            }
+            Ok(Value::from(rv))
+        }
         _ => Err(Error::from(ErrorKind::UnknownMethod)),
     }
 }


Test patch:
diff --git a/minijinja-contrib/tests/pycompat.rs b/minijinja-contrib/tests/pycompat.rs
index e4403b4..c01cc45 100644
--- a/minijinja-contrib/tests/pycompat.rs
+++ b/minijinja-contrib/tests/pycompat.rs
@@ -26,6 +26,9 @@ fn test_string_methods() {
     assert!(eval_expr("'foobar'.islower()").is_true());
     assert!(eval_expr("'FOOBAR'.isupper()").is_true());
     assert!(eval_expr("' \\n'.isspace()").is_true());
+    assert!(eval_expr("'abc'.isalpha()").is_true());
+    assert!(eval_expr("'abc123'.isalnum()").is_true());
+    assert!(eval_expr("'abc%@#'.isascii()").is_true());
     assert_eq!(
         eval_expr("'foobar'.replace('o', 'x')").as_str(),
         Some("fxxbar")
@@ -41,12 +44,20 @@ fn test_string_methods() {
     );
     assert_eq!(eval_expr("'foo barooo'.count('oo')").as_usize(), Some(2));
     assert_eq!(eval_expr("'foo barooo'.find('oo')").as_usize(), Some(1));
+    assert_eq!(eval_expr("'foo barooo'.rfind('oo')").as_usize(), Some(8));
     assert!(eval_expr("'a b c'.split() == ['a', 'b', 'c']").is_true());
     assert!(eval_expr("'a  b  c'.split() == ['a', 'b', 'c']").is_true());
     assert!(eval_expr("'a  b  c'.split(none, 1) == ['a', 'b  c']").is_true());
     assert!(eval_expr("'abcbd'.split('b', 1) == ['a', 'cbd']").is_true());
     assert!(eval_expr("'a\\nb\\r\\nc'.splitlines() == ['a', 'b', 'c']").is_true());
     assert!(eval_expr("'a\\nb\\r\\nc'.splitlines(true) == ['a\\n', 'b\\r\\n', 'c']").is_true());
+    assert!(eval_expr("'foobarbaz'.startswith('foo')").is_true());
+    assert!(eval_expr("'foobarbaz'.startswith(('foo', 'bar'))").is_true());
+    assert!(!eval_expr("'barfoobaz'.startswith(('foo', 'baz'))").is_true());
+    assert!(eval_expr("'foobarbaz'.endswith('baz')").is_true());
+    assert!(eval_expr("'foobarbaz'.endswith(('baz', 'bar'))").is_true());
+    assert!(!eval_expr("'foobarbazblah'.endswith(('baz', 'bar'))").is_true());
+    assert_eq!(eval_expr("'|'.join([1, 2, 3])").as_str(), Some("1|2|3"));
 }
 
 #[test]