From a5837a7d3598841d224fd5b40c6b2dff7eac17a4 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:57:36 -0700 Subject: [PATCH] More string extractors --- crates/pile-value/src/extract/string.rs | 180 +++++++++++++++++++++++- 1 file changed, 175 insertions(+), 5 deletions(-) diff --git a/crates/pile-value/src/extract/string.rs b/crates/pile-value/src/extract/string.rs index 34dafe2..4f4e144 100644 --- a/crates/pile-value/src/extract/string.rs +++ b/crates/pile-value/src/extract/string.rs @@ -4,6 +4,20 @@ use std::sync::Arc; use crate::{extract::traits::ObjectExtractor, value::PileValue}; +fn parse_name(s: &str) -> (&str, Option<&str>) { + match s.find('(') { + None => (s, None), + Some(i) => { + let name = &s[..i]; + let rest = &s[i + 1..]; + match rest.strip_suffix(')') { + Some(args) => (name, Some(args)), + None => (name, None), + } + } + } +} + pub struct StringExtractor { item: Arc>, } @@ -17,24 +31,49 @@ impl StringExtractor { #[async_trait::async_trait] impl ObjectExtractor for StringExtractor { async fn field(&self, name: &Label) -> Result, std::io::Error> { - Ok(match name.as_str() { - "trim" => Some(PileValue::String(Arc::new( + let (name, args) = parse_name(name.as_str()); + Ok(match (name, args) { + ("trim", None) => Some(PileValue::String(Arc::new( self.item.as_str().trim().into(), ))), - "upper" => Some(PileValue::String(Arc::new( + ("upper", None) => Some(PileValue::String(Arc::new( self.item.as_str().to_lowercase().into(), ))), - "lower" => Some(PileValue::String(Arc::new( + ("lower", None) => Some(PileValue::String(Arc::new( self.item.as_str().to_uppercase().into(), ))), - "nonempty" => Some(match self.item.is_empty() { + ("nonempty", None) => Some(match self.item.is_empty() { true => PileValue::Null, false => PileValue::String(self.item.clone()), }), + ("trimprefix", Some(prefix)) => Some(PileValue::String(Arc::new( + self.item + .as_str() + .strip_prefix(prefix) + .unwrap_or(self.item.as_str()) + .into(), + ))), + + ("trimsuffix", Some(suffix)) => Some(PileValue::String(Arc::new( + self.item + .as_str() + .strip_suffix(suffix) + .unwrap_or(self.item.as_str()) + .into(), + ))), + + ("split", Some(by)) => Some(PileValue::Array(Arc::new( + self.item + .as_str() + .split(by) + .map(|s| PileValue::String(Arc::new(s.into()))) + .collect(), + ))), + _ => None, }) } @@ -49,3 +88,134 @@ impl ObjectExtractor for StringExtractor { ]); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn extractor(s: &str) -> StringExtractor { + StringExtractor::new(&Arc::new(s.into())) + } + + #[expect(clippy::unwrap_used)] + async fn field(ext: &StringExtractor, name: &str) -> Option { + ext.field(&Label::new(name).unwrap()).await.unwrap() + } + + fn string(v: Option) -> Option { + match v? { + PileValue::String(s) => Some(s.as_str().to_owned()), + _ => panic!("expected string"), + } + } + + fn array(v: Option) -> Vec { + match v.expect("expected Some") { + PileValue::Array(arr) => arr + .iter() + .map(|v| match v { + PileValue::String(s) => s.as_str().to_owned(), + _ => panic!("expected string element"), + }) + .collect(), + _ => panic!("expected array"), + } + } + + #[tokio::test] + async fn trim() { + assert_eq!( + string(field(&extractor(" hi "), "trim").await), + Some("hi".into()) + ); + } + + #[tokio::test] + async fn trim_no_args() { + assert!(field(&extractor("x"), "trim(foo)").await.is_none()); + } + + #[tokio::test] + async fn nonempty_with_content() { + assert!(matches!( + field(&extractor("hello"), "nonempty").await, + Some(PileValue::String(_)) + )); + } + + #[tokio::test] + async fn nonempty_empty_string() { + assert!(matches!( + field(&extractor(""), "nonempty").await, + Some(PileValue::Null) + )); + } + + #[tokio::test] + async fn trimprefix_present() { + assert_eq!( + string(field(&extractor("foobar"), "trimprefix(foo)").await), + Some("bar".into()) + ); + } + + #[tokio::test] + async fn trimprefix_absent() { + assert_eq!( + string(field(&extractor("foobar"), "trimprefix(baz)").await), + Some("foobar".into()) + ); + } + + #[tokio::test] + async fn trimprefix_no_args() { + assert!(field(&extractor("foobar"), "trimprefix").await.is_none()); + } + + #[tokio::test] + async fn trimsuffix_present() { + assert_eq!( + string(field(&extractor("foobar"), "trimsuffix(bar)").await), + Some("foo".into()) + ); + } + + #[tokio::test] + async fn trimsuffix_absent() { + assert_eq!( + string(field(&extractor("foobar"), "trimsuffix(baz)").await), + Some("foobar".into()) + ); + } + + #[tokio::test] + async fn split_basic() { + assert_eq!( + array(field(&extractor("a,b,c"), "split(,)").await), + vec!["a", "b", "c"] + ); + } + + #[tokio::test] + async fn split_no_match() { + assert_eq!( + array(field(&extractor("abc"), "split(,)").await), + vec!["abc"] + ); + } + + #[tokio::test] + async fn split_no_args() { + assert!(field(&extractor("abc"), "split").await.is_none()); + } + + #[tokio::test] + async fn split_unclosed_paren() { + assert!(field(&extractor("abc"), "split(,").await.is_none()); + } + + #[tokio::test] + async fn unknown_field() { + assert!(field(&extractor("abc"), "bogus").await.is_none()); + } +}