Skip to content

Commit

Permalink
Merge pull request #534 from kwhitehouse/propose-addition-of-builtin-…
Browse files Browse the repository at this point in the history
…range

Introducing `range` builtin
  • Loading branch information
djmitche authored Sep 27, 2024
2 parents 15feacc + 8908563 commit 1879ce9
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 10 deletions.
46 changes: 46 additions & 0 deletions docs/src/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,49 @@ template: {$eval: 'len([1, 2, 3])'}
context: {}
result: 3
```

## Range

The `range()` built-in generates an array based on the following inputs:
* `start` - An integer specifying the lower bound of the range (inclusive).
This can be negative, in which case the generated array of integers will
start with this negative value (inclusive).
* `end` - An integer specifying the upper bound of the range (exclusive). This
can be negative, in which case the generated array of integers will end with
this negative value (exclusive).
* `step` - Optional. An integer specifying a step to apply to each value within
the range. If not specified, defaults to `1`. Can be negative, but cannot be
zero.

The contents of a range r are determined by the following formula:

```yaml,json-e
IF step > 0 THEN
i = start
WHILE i < end:
r(i) = i
i = i + step
END WHILE
ELSE if step < 0 THEN
i = start
WHILE i > end:
r(i) = i
i = i + step
END WHILE
END IF
```

Notably, the resulting range will be empty if `start >= end` and `step` is
positive or if `start <= end` and `step` is negative.

```yaml,json-e
template:
$map: {$eval: 'range(1, 5)'}
each(x): {$eval: 'x'}
context: {}
result: [1, 2, 3, 4]
```
5 changes: 5 additions & 0 deletions internal/interpreter/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ func isBool(v interface{}) bool {
return ok
}

func IsInteger(v interface{}) bool {
floatRep, _ := v.(float64)
return floatRep == float64(int(floatRep))
}

// IsJSON returns true, if v is pure JSON
func IsJSON(v interface{}) bool {
if isString(v) || isNumber(v) || isBool(v) || v == nil {
Expand Down
46 changes: 38 additions & 8 deletions internal/jsone.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,40 @@ var builtin = map[string]interface{}{
}
return n
}),
"sqrt": i.WrapFunction(math.Sqrt),
"ceil": i.WrapFunction(math.Ceil),
"floor": i.WrapFunction(math.Floor),
"abs": i.WrapFunction(math.Abs),
"sqrt": i.WrapFunction(math.Sqrt),
"ceil": i.WrapFunction(math.Ceil),
"floor": i.WrapFunction(math.Floor),
"abs": i.WrapFunction(math.Abs),
"range": i.WrapFunction(
func(start float64, stop float64, args ...float64) ([]interface{}, error) {
result := make([]interface{}, 0)
step := 1.0
if len(args) > 1 {
return result, fmt.Errorf("range(start, stop, step) requires start and stop argument and optionally takes step")
}
if len(args) == 1 {
step = args[0]
}

if !i.IsInteger(start) || !i.IsInteger(stop) || !i.IsInteger(step) {
return result, fmt.Errorf("invalid argument `step` to builtin: range")
}

if step > 0 {
for i := start; i < stop; i += step {
result = append(result, float64(i))
}
} else if step < 0 {
for i := start; i > stop; i += step {
result = append(result, float64(i))
}
} else {
return result, fmt.Errorf("invalid argument `step` to builtin: range")
}

return result, nil
},
),
"lowercase": i.WrapFunction(strings.ToLower),
"uppercase": i.WrapFunction(strings.ToUpper),
"strip": i.WrapFunction(strings.TrimSpace),
Expand Down Expand Up @@ -684,7 +714,7 @@ var operators = map[string]operator{
Template: template,
}
}

for idx, entry := range val {
c := make(map[string]interface{}, len(context)+additionalContextVars)
for k, v := range context {
Expand All @@ -694,7 +724,7 @@ var operators = map[string]operator{
if len(eachIndex) > 0 {
c[eachIndex] = float64(idx)
}

s, ok := eachTemplate.(string)
if !ok {
return nil, TemplateError{
Expand All @@ -719,10 +749,10 @@ var operators = map[string]operator{
Template: template,
}
}
return r, nil;
return r, nil
}
}

return deleteMarker, nil
},
"$match": func(template, context map[string]interface{}) (interface{}, error) {
Expand Down
17 changes: 15 additions & 2 deletions js/src/builtins.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ var {BuiltinError} = require('./error');
var fromNow = require('./from-now');
var {
isString, isNumber, isBool,
isArray, isObject,
isInteger, isArray, isObject,
isNull, isFunction,
} = require('./type-utils');

let types = {
string: isString,
number: isNumber,
integer: isInteger,
boolean: isBool,
array: isArray,
object: isObject,
Expand Down Expand Up @@ -38,7 +39,7 @@ module.exports = (context) => {
}

if (variadic) {
argumentTests = args.map(() => variadic);
argumentTests = args.map((_, i) => i < argumentTests.length ? argumentTests[i] : variadic);
}

args.forEach((arg, i) => {
Expand Down Expand Up @@ -78,6 +79,18 @@ module.exports = (context) => {
});
});

define('range', builtins, {
minArgs: 2,
argumentTests: ['integer', 'integer', 'integer'],
variadic: 'number',
invoke: (start, stop, step=1) => {
return Array.from(
{length: Math.ceil((stop - start) / step)},
(_, i) => start + i * step
)
},
});

// String manipulation
define('lowercase', builtins, {
argumentTests: ['string'],
Expand Down
10 changes: 10 additions & 0 deletions py/jsone/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def invoke(context, *args):
def is_number(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)

def is_int(v):
return isinstance(v, int)

def is_string(v):
return isinstance(v, string)

Expand Down Expand Up @@ -101,6 +104,13 @@ def ceil(v):
def floor(v):
return int(math.floor(v))

@builtin("range", minArgs=2)
def range_builtin(start, stop, step=1):
if step == 0 or not all([is_int(n) for n in [start, stop, step]]):
raise BuiltinError("invalid arguments to builtin: range")

return list(range(start, stop, step))

@builtin("lowercase", argument_tests=[is_string])
def lowercase(v):
return v.lower()
Expand Down
38 changes: 38 additions & 0 deletions rs/src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use crate::interpreter::Context;
use crate::value::{Function, Value};
use anyhow::Result;
use lazy_static::lazy_static;
use std::convert::TryInto;

lazy_static! {
pub(crate) static ref BUILTINS: Context<'static> = {
Expand Down Expand Up @@ -34,6 +35,7 @@ lazy_static! {
"strip",
Value::Function(Function::new("strip", strip_builtin)),
);
builtins.insert("range", Value::Function(Function::new("range", range_builtin)));
builtins.insert(
"rstrip",
Value::Function(Function::new("rstrip", rstrip_builtin)),
Expand Down Expand Up @@ -188,6 +190,42 @@ fn strip_builtin(_context: &Context, args: &[Value]) -> Result<Value> {
unary_string(args, |s| str::trim(s).to_owned())
}

fn range_builtin(_context: &Context, args: &[Value]) -> Result<Value> {
if args.len() < 2 || args.len() > 3 {
return Err(interpreter_error!("range requires two arguments and optionally supports a third"));
}
let start = &args[0];
let start: i64 = match start {
Value::Number(n) if n.fract() == 0.0 => n.round() as i64,
_ => return Err(interpreter_error!("invalid arguments to builtin: range")),
};
let stop = &args[1];
let stop: i64 = match stop {
Value::Number(n) if n.fract() == 0.0 => n.round() as i64,
_ => return Err(interpreter_error!("invalid arguments to builtin: range")),
};
let step: i64 = match args.get(2) {
// If step is not provided by the user, it defaults to 1.
None => 1,
Some(val) => match val {
Value::Number(n) if n.fract() == 0.0 => n.round() as i64,
_ => return Err(interpreter_error!("invalid arguments to builtin: range")),
}
};

if step > 0 {
let step: usize = step.try_into()?;
let range = (start..stop).step_by(step).map(|i| Value::Number(i as f64)).collect();
Ok(Value::Array(range))
} else if step < 0 {
let step: usize = (step * -1).try_into()?;
let range = (stop+1..=start).rev().step_by(step).map(|i| Value::Number(i as f64)).collect();
Ok(Value::Array(range))
} else {
return Err(interpreter_error!("invalid argument `step` to builtin: range"));
}
}

fn rstrip_builtin(_context: &Context, args: &[Value]) -> Result<Value> {
unary_string(args, |s| str::trim_end(s).to_owned())
}
Expand Down
111 changes: 111 additions & 0 deletions specification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,117 @@ template: {$map: {a: 1, b: 2, c: 3}, 'each(y)': {'${y.key}x': {$eval: 'y.val + 1
result: {ax: 2, bx: 3, cx: 4}
################################################################################
---
section: range
---
title: range without step creates list of numbers
context: {}
template:
$map: {$eval: 'range(1, 5)'}
each(x): {$eval: 'x'}
result: [1, 2, 3, 4]
---
title: range with end > start creates empty list
context: {}
template:
{$eval: 'len(range(5, 1))'}
result: 0
---
title: range with step creates list of numbers
context: {}
template:
$map: {$eval: 'range(1, 10, 2)'}
each(x): {$eval: 'x'}
result: [1, 3, 5, 7, 9]
---
title: range with negative step creates list of numbers
context: {}
template:
$map: {$eval: 'range(1, -2, -1)'}
each(x): {$eval: 'x'}
result: [1, 0, -1]
---
title: range with negative step and negative start and end creates list of numbers
context: {}
template:
$map: {$eval: 'range(-7, -10, -1)'}
each(x): {$eval: 'x'}
result: [-7, -8, -9]
---
title: range with negative step that does not divide evenly
context: {}
template:
$map: {$eval: 'range(20, 0, -7)'}
each(x): {$eval: 'x'}
result: [20, 13, 6]
---
title: range with step=0 throws error
context: {}
template:
$map: {$eval: 'range(1, 5, 0)'}
each(x): {$eval: 'x'}
error: true
---
title: range with float step throws error
context: {}
template:
$map: {$eval: 'range(1, 5, 1.2)'}
each(x): {$eval: 'x'}
error: true
---
title: range with float start throws error
context: {}
template:
$map: {$eval: 'range(1.3, 5)'}
each(x): {$eval: 'x'}
error: true
---
title: range with string start throws error
context: {}
template:
$map: {$eval: 'range("1", 5)'}
each(x): {$eval: 'x'}
error: true
---
title: range without step creates list of objects
context: {}
template:
$map: {$eval: 'range(1, 4)'}
each(x):
asText: '${x}'
integer: {$eval: 'x'}
result:
- {asText: '1', integer: 1}
- {asText: '2', integer: 2}
- {asText: '3', integer: 3}
---
title: range without step creates list of strings
context: {}
template:
$map: {$eval: 'range(10, 12)'}
each(x,i): 'id_${x}_${i}'
result: ['id_10_0', 'id_11_1']
---
title: range with step creates list of strings
context: {}
template:
$map: {$eval: 'range(10, 20, 5)'}
each(x,i): 'id_${x}_${i}'
result: ['id_10_0', 'id_15_1']
---
title: range without step creates objects
context: {}
template:
$map: {$eval: 'range(1, 3)'}
each(y):
k: {$eval: 'y + 1'}
v: 'before=${y}'
result:
- k: 2
v: 'before=1'
- k: 3
v: 'before=2'
################################################################################
---
section: $find operator
---
title: $find, simple find
Expand Down

0 comments on commit 1879ce9

Please sign in to comment.