Skip to content

Commit 13e7bd7

Browse files
authored
feat: add collection functions, string interpolation, and if() (#53)
- Add collection functions: keys(), values(), unique(), first(), last(), sum(), group_by() - Add conditional function: if(condition, then, else) - Add type checking: is_array() - Add string interpolation with {expr} syntax - Add 17 integration tests Fixes #24
1 parent 3ab9ca7 commit 13e7bd7

File tree

6 files changed

+601
-13
lines changed

6 files changed

+601
-13
lines changed

src/mapping/ast.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ pub enum Expr {
7373
},
7474
/// A unary operation: `not .active`, `-.value`.
7575
UnaryOp { op: UnaryOp, expr: Box<Expr> },
76+
/// A string interpolation: `"Hello, {.name}!"`
77+
StringInterpolation { parts: Vec<InterpolationPart> },
78+
}
79+
80+
/// A part of an interpolated string.
81+
#[derive(Debug, Clone, PartialEq)]
82+
pub enum InterpolationPart {
83+
Literal(String),
84+
Expr(Expr),
7685
}
7786

7887
/// A statement in the mapping language.

src/mapping/eval.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,19 @@ fn eval_expr(expr: &Expr, context: &Value) -> error::Result<Value> {
404404
let val = eval_expr(expr, context)?;
405405
eval_unary_op(*op, &val)
406406
}
407+
Expr::StringInterpolation { parts } => {
408+
let mut result = String::new();
409+
for part in parts {
410+
match part {
411+
crate::mapping::ast::InterpolationPart::Literal(s) => result.push_str(s),
412+
crate::mapping::ast::InterpolationPart::Expr(expr) => {
413+
let val = eval_expr(expr, context)?;
414+
result.push_str(&functions::to_str(&val));
415+
}
416+
}
417+
}
418+
Ok(Value::String(result))
419+
}
407420
}
408421
}
409422

src/mapping/functions.rs

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,22 @@ pub fn call_function(name: &str, args: &[Value]) -> error::Result<Value> {
3838

3939
// Null / existence
4040
"is_null" => fn_is_null(args),
41+
"is_array" => fn_is_array(args),
4142
"coalesce" => fn_coalesce(args),
4243
"default" => fn_default(args),
4344

45+
// Collection functions
46+
"keys" => fn_keys(args),
47+
"values" => fn_values(args),
48+
"unique" => fn_unique(args),
49+
"first" => fn_first(args),
50+
"last" => fn_last(args),
51+
"sum" => fn_sum(args),
52+
"group_by" | "groupby" => fn_group_by(args),
53+
54+
// Conditional
55+
"if" => fn_if(args),
56+
4457
_ => Err(error::MorphError::mapping(format!(
4558
"unknown function: {name}"
4659
))),
@@ -71,7 +84,7 @@ fn expect_min_args(name: &str, args: &[Value], min: usize) -> error::Result<()>
7184
Ok(())
7285
}
7386

74-
fn to_str(value: &Value) -> String {
87+
pub fn to_str(value: &Value) -> String {
7588
match value {
7689
Value::String(s) => s.clone(),
7790
Value::Int(i) => i.to_string(),
@@ -410,6 +423,163 @@ fn fn_default(args: &[Value]) -> error::Result<Value> {
410423
}
411424
}
412425

426+
// ---------------------------------------------------------------------------
427+
// Collection functions
428+
// ---------------------------------------------------------------------------
429+
430+
fn fn_keys(args: &[Value]) -> error::Result<Value> {
431+
expect_args("keys", args, 1)?;
432+
match &args[0] {
433+
Value::Map(m) => Ok(Value::Array(
434+
m.keys().map(|k| Value::String(k.clone())).collect(),
435+
)),
436+
_ => Err(error::MorphError::mapping("keys() expects a map")),
437+
}
438+
}
439+
440+
fn fn_values(args: &[Value]) -> error::Result<Value> {
441+
expect_args("values", args, 1)?;
442+
match &args[0] {
443+
Value::Map(m) => Ok(Value::Array(m.values().cloned().collect())),
444+
_ => Err(error::MorphError::mapping("values() expects a map")),
445+
}
446+
}
447+
448+
fn fn_unique(args: &[Value]) -> error::Result<Value> {
449+
expect_args("unique", args, 1)?;
450+
match &args[0] {
451+
Value::Array(arr) => {
452+
let mut seen = Vec::new();
453+
for item in arr {
454+
if !seen.contains(item) {
455+
seen.push(item.clone());
456+
}
457+
}
458+
Ok(Value::Array(seen))
459+
}
460+
_ => Err(error::MorphError::mapping("unique() expects an array")),
461+
}
462+
}
463+
464+
fn fn_first(args: &[Value]) -> error::Result<Value> {
465+
expect_args("first", args, 1)?;
466+
match &args[0] {
467+
Value::Array(arr) => Ok(arr.first().cloned().unwrap_or(Value::Null)),
468+
_ => Err(error::MorphError::mapping("first() expects an array")),
469+
}
470+
}
471+
472+
fn fn_last(args: &[Value]) -> error::Result<Value> {
473+
expect_args("last", args, 1)?;
474+
match &args[0] {
475+
Value::Array(arr) => Ok(arr.last().cloned().unwrap_or(Value::Null)),
476+
_ => Err(error::MorphError::mapping("last() expects an array")),
477+
}
478+
}
479+
480+
fn fn_sum(args: &[Value]) -> error::Result<Value> {
481+
expect_args("sum", args, 1)?;
482+
match &args[0] {
483+
Value::Array(arr) => {
484+
let mut int_sum: i64 = 0;
485+
let mut is_float = false;
486+
let mut float_sum: f64 = 0.0;
487+
for item in arr {
488+
match item {
489+
Value::Int(i) => {
490+
int_sum += i;
491+
float_sum += *i as f64;
492+
}
493+
Value::Float(f) => {
494+
is_float = true;
495+
float_sum += f;
496+
}
497+
_ => {
498+
return Err(error::MorphError::mapping(
499+
"sum() array must contain only numbers",
500+
));
501+
}
502+
}
503+
}
504+
if is_float {
505+
Ok(Value::Float(float_sum))
506+
} else {
507+
Ok(Value::Int(int_sum))
508+
}
509+
}
510+
_ => Err(error::MorphError::mapping("sum() expects an array")),
511+
}
512+
}
513+
514+
fn fn_group_by(args: &[Value]) -> error::Result<Value> {
515+
expect_args("group_by", args, 2)?;
516+
let arr = match &args[0] {
517+
Value::Array(a) => a,
518+
_ => {
519+
return Err(error::MorphError::mapping(
520+
"group_by() first argument must be an array",
521+
));
522+
}
523+
};
524+
let key_field = match &args[1] {
525+
Value::String(s) => s.clone(),
526+
_ => {
527+
return Err(error::MorphError::mapping(
528+
"group_by() second argument must be a string field name",
529+
));
530+
}
531+
};
532+
533+
let mut groups: indexmap::IndexMap<String, Vec<Value>> = indexmap::IndexMap::new();
534+
for item in arr {
535+
let key_val = match item {
536+
Value::Map(m) => m.get(&key_field).cloned().unwrap_or(Value::Null),
537+
_ => Value::Null,
538+
};
539+
let key_str = to_str(&key_val);
540+
groups.entry(key_str).or_default().push(item.clone());
541+
}
542+
543+
let mut result = indexmap::IndexMap::new();
544+
for (key, values) in groups {
545+
result.insert(key, Value::Array(values));
546+
}
547+
Ok(Value::Map(result))
548+
}
549+
550+
// ---------------------------------------------------------------------------
551+
// Conditional functions
552+
// ---------------------------------------------------------------------------
553+
554+
fn fn_if(args: &[Value]) -> error::Result<Value> {
555+
expect_args("if", args, 3)?;
556+
let condition = &args[0];
557+
let is_truthy = match condition {
558+
Value::Null => false,
559+
Value::Bool(b) => *b,
560+
Value::Int(i) => *i != 0,
561+
Value::Float(f) => *f != 0.0,
562+
Value::String(s) => !s.is_empty(),
563+
Value::Array(a) => !a.is_empty(),
564+
Value::Map(m) => !m.is_empty(),
565+
Value::Bytes(b) => !b.is_empty(),
566+
};
567+
if is_truthy {
568+
Ok(args[1].clone())
569+
} else {
570+
Ok(args[2].clone())
571+
}
572+
}
573+
574+
// ---------------------------------------------------------------------------
575+
// Null / existence functions
576+
// ---------------------------------------------------------------------------
577+
578+
fn fn_is_array(args: &[Value]) -> error::Result<Value> {
579+
expect_args("is_array", args, 1)?;
580+
Ok(Value::Bool(matches!(&args[0], Value::Array(_))))
581+
}
582+
413583
#[cfg(test)]
414584
mod tests {
415585
use super::*;

src/mapping/lexer.rs

Lines changed: 94 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ pub enum TokenKind {
6464
Dot, // .
6565

6666
// Literals
67+
InterpolatedString(Vec<InterpolatedPart>),
6768
StringLit(String),
6869
IntLit(i64),
6970
FloatLit(f64),
@@ -78,6 +79,13 @@ pub enum TokenKind {
7879
Newline,
7980
}
8081

82+
/// Part of an interpolated string token.
83+
#[derive(Debug, Clone, PartialEq)]
84+
pub enum InterpolatedPart {
85+
Literal(String),
86+
Expression(String),
87+
}
88+
8189
/// A token with its position in the source.
8290
#[derive(Debug, Clone, PartialEq)]
8391
pub struct Token {
@@ -371,24 +379,90 @@ impl<'a> Lexer<'a> {
371379
let span = self.span();
372380
self.advance(); // skip opening "
373381

374-
let mut s = String::new();
382+
let mut parts: Vec<InterpolatedPart> = Vec::new();
383+
let mut current_literal = String::new();
384+
let mut has_interpolation = false;
385+
375386
loop {
376-
match self.advance() {
387+
match self.peek() {
377388
None => {
378389
return Err(error::MorphError::mapping_at(
379390
"unterminated string literal",
380391
span.line,
381392
span.column,
382393
));
383394
}
384-
Some(b'"') => break,
395+
Some(b'"') => {
396+
self.advance();
397+
break;
398+
}
399+
Some(b'{') => {
400+
// Check if this is an interpolation
401+
has_interpolation = true;
402+
self.advance(); // consume '{'
403+
if !current_literal.is_empty() {
404+
parts.push(InterpolatedPart::Literal(std::mem::take(
405+
&mut current_literal,
406+
)));
407+
}
408+
let mut expr_str = String::new();
409+
let mut depth = 1;
410+
loop {
411+
match self.advance() {
412+
None => {
413+
return Err(error::MorphError::mapping_at(
414+
"unterminated interpolation in string",
415+
span.line,
416+
span.column,
417+
));
418+
}
419+
Some(b'{') => {
420+
depth += 1;
421+
expr_str.push('{');
422+
}
423+
Some(b'}') => {
424+
depth -= 1;
425+
if depth == 0 {
426+
break;
427+
}
428+
expr_str.push('}');
429+
}
430+
Some(c) => {
431+
if c < 0x80 {
432+
expr_str.push(c as char);
433+
} else {
434+
self.pos -= 1;
435+
self.column -= 1;
436+
let remaining = &self.input[self.pos..];
437+
let remaining_str =
438+
std::str::from_utf8(remaining).map_err(|_| {
439+
error::MorphError::mapping_at(
440+
"invalid UTF-8 in string",
441+
self.line,
442+
self.column,
443+
)
444+
})?;
445+
let ch = remaining_str.chars().next().unwrap();
446+
expr_str.push(ch);
447+
let len = ch.len_utf8();
448+
for _ in 0..len {
449+
self.advance();
450+
}
451+
}
452+
}
453+
}
454+
}
455+
parts.push(InterpolatedPart::Expression(expr_str));
456+
}
385457
Some(b'\\') => {
458+
self.advance(); // consume backslash
386459
match self.advance() {
387-
Some(b'"') => s.push('"'),
388-
Some(b'\\') => s.push('\\'),
389-
Some(b'n') => s.push('\n'),
390-
Some(b't') => s.push('\t'),
391-
Some(b'r') => s.push('\r'),
460+
Some(b'"') => current_literal.push('"'),
461+
Some(b'\\') => current_literal.push('\\'),
462+
Some(b'n') => current_literal.push('\n'),
463+
Some(b't') => current_literal.push('\t'),
464+
Some(b'r') => current_literal.push('\r'),
465+
Some(b'{') => current_literal.push('{'),
392466
Some(b'u') => {
393467
// Unicode escape: \uXXXX
394468
let mut hex = String::with_capacity(4);
@@ -406,7 +480,7 @@ impl<'a> Lexer<'a> {
406480
}
407481
let code = u32::from_str_radix(&hex, 16).unwrap();
408482
match char::from_u32(code) {
409-
Some(c) => s.push(c),
483+
Some(c) => current_literal.push(c),
410484
None => {
411485
return Err(error::MorphError::mapping_at(
412486
format!("invalid unicode code point: \\u{hex}"),
@@ -433,9 +507,10 @@ impl<'a> Lexer<'a> {
433507
}
434508
}
435509
Some(c) => {
510+
self.advance();
436511
// Handle multi-byte UTF-8
437512
if c < 0x80 {
438-
s.push(c as char);
513+
current_literal.push(c as char);
439514
} else {
440515
// Rewind one byte and read the full UTF-8 character
441516
self.pos -= 1;
@@ -449,7 +524,7 @@ impl<'a> Lexer<'a> {
449524
)
450525
})?;
451526
let ch = remaining_str.chars().next().unwrap();
452-
s.push(ch);
527+
current_literal.push(ch);
453528
let len = ch.len_utf8();
454529
for _ in 0..len {
455530
self.advance();
@@ -459,7 +534,14 @@ impl<'a> Lexer<'a> {
459534
}
460535
}
461536

462-
Ok(Token::new(TokenKind::StringLit(s), span))
537+
if has_interpolation {
538+
if !current_literal.is_empty() {
539+
parts.push(InterpolatedPart::Literal(current_literal));
540+
}
541+
Ok(Token::new(TokenKind::InterpolatedString(parts), span))
542+
} else {
543+
Ok(Token::new(TokenKind::StringLit(current_literal), span))
544+
}
463545
}
464546

465547
fn read_number(&mut self, span: Span, negative: bool) -> error::Result<Token> {

0 commit comments

Comments
 (0)