Skip to content

Commit 2f2f267

Browse files
committed
Add range expressions
1 parent 72accb4 commit 2f2f267

9 files changed

Lines changed: 230 additions & 59 deletions

File tree

Examples/Cog.shape

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ define cog {
44
option teeth 6
55
path {
66
define step 1 / teeth
7-
for i in 1 to teeth {
7+
for 1 to teeth {
88
point -0.02 0.8
99
point 0.05 1
1010
rotate step

Help/expressions.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,34 @@ define averageColor (color.red + color.green + color.blue) / 3
6262
print averageColor // 0.5667
6363
```
6464

65+
66+
## Ranges
67+
68+
Another type of expression you can create is a *range* expression. This consists of two numeric values separated by a `to` keyword:
69+
70+
```swift
71+
1 to 5
72+
```
73+
74+
Ranges are mostly used in [for loops](loops.md):
75+
76+
```swift
77+
for i in 1 to 5 {
78+
print i
79+
}
80+
```
81+
82+
But they can also be assigned to a [symbol](symbols.md) using the `define` command, and then used later:
83+
84+
```swift
85+
define loops 1 to 5
86+
87+
for i in loops {
88+
print i
89+
}
90+
```
91+
92+
**Note:** Ranges are inclusive of both the start and end values. A loop from `0 to 5` would therefore loop *6* times and not 5 as you might expect.
93+
6594
---
6695
[Index](index.md) | Next: [Functions](functions.md)

Help/loops.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Loops
22
---
33

4-
To repeat an instruction (or sequence of instructions) you can use a `for` loop. The simplest form of the for loop takes a numeric range, and a block of instructions inside braces. The following loop creates a circle of 5 points (you might use this inside a `path`):
4+
To repeat an instruction (or sequence of instructions) you can use a `for` loop. The simplest form of the for loop takes a [numeric range](expressions.md#ranges), and a block of instructions inside braces. The following loop creates a circle of 5 points (you might use this inside a `path`):
55

66
```swift
77
for 1 to 5 {
@@ -12,7 +12,7 @@ for 1 to 5 {
1212

1313
The range `1 to 5` is inclusive of both the start and end values. A range of `0 to 5` would therefore loop *6* times and not 5 as you might expect.
1414

15-
The loop count does not have to be a literal value, you can use a previously defined symbol or expression instead:
15+
The loop range does not have to be a literal value, you can use a previously defined symbol or expression instead:
1616

1717
```swift
1818
define count 5
@@ -23,7 +23,7 @@ for 1 to count {
2323
}
2424
```
2525

26-
If you have used similar loops in other programming languages, you might be wondering why we don't need to use an index variable of some kind to keep track of the loop iteration.
26+
If you have used similar loops in other programming languages, you might be wondering why we don't need to use an index variable of some kind to keep track of the loop iteration?
2727

2828
Symbols defined inside the `{ ... }` block will not persist between loops (see [scope](scope.md) for details), but changes to the world transform will, which is why the `rotate` command doesn't need to reference the index - its effect is cumulative.
2929

@@ -37,7 +37,7 @@ for i in 1 to count {
3737

3838
This defines a [symbol](symbols.md) called `i` with the value of the current loop iteration. The `i` symbol only exists within the loop body itself and can't be referenced after the loop has ended.
3939

40-
(**Note:** The index symbol does not need to be called `i`, it can be any valid symbol name that you choose.)
40+
**Note:** The index symbol does not need to be called `i`, it can be any valid symbol name that you choose.
4141

4242
---
4343
[Index](index.md) | Next: [Blocks](blocks.md)

ShapeScript/Interpreter.swift

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ enum ValueType: String {
331331
case tuple
332332
case point
333333
case pair // Hack to support common math functions
334+
case range
334335
case void
335336
}
336337

@@ -345,6 +346,7 @@ enum Value {
345346
case mesh(Geometry)
346347
case point(PathPoint)
347348
case tuple([Value])
349+
case range(Double, Double)
348350

349351
static let void: Value = .tuple([])
350352

@@ -371,6 +373,7 @@ enum Value {
371373
case let .mesh(mesh): return mesh
372374
case let .point(point): return point
373375
case let .tuple(values): return values.map { $0.value }
376+
case let .range(start, end): return start ..< max(start, end + 1)
374377
}
375378
}
376379

@@ -394,6 +397,7 @@ enum Value {
394397
case .mesh: return .mesh
395398
case .point: return .point
396399
case .tuple: return .tuple
400+
case .range: return .range
397401
}
398402
}
399403

@@ -790,15 +794,25 @@ extension Statement {
790794
context.define(identifier.name, as: try definition.evaluate(in: context))
791795
case .option:
792796
throw RuntimeError(.unknownSymbol("option", options: []), at: range)
793-
case let .forloop(index, start, end, block):
794-
let start = try start.evaluate(as: .number, for: "start value", in: context)
795-
let end = try end.evaluate(as: .number, for: "end value", in: context)
796-
for i in stride(from: start.doubleValue, through: end.doubleValue, by: 1) {
797+
case let .forloop(identifier, in: expression, block):
798+
let range = try expression.evaluate(in: context)
799+
guard case let .range(start, end) = range else {
800+
throw RuntimeError(
801+
.typeMismatch(
802+
for: "range",
803+
index: 0,
804+
expected: ValueType.range.rawValue,
805+
got: range.type.rawValue
806+
),
807+
at: expression.range
808+
)
809+
}
810+
for i in stride(from: start, through: end, by: 1) {
797811
if context.isCancelled() {
798812
throw EvaluationCancelled()
799813
}
800814
try context.pushScope { context in
801-
if let name = index?.name {
815+
if let name = identifier?.name {
802816
context.define(name, as: .constant(.number(Double(i))))
803817
}
804818
for statement in block.statements {
@@ -937,6 +951,10 @@ extension Expression {
937951
case .divide:
938952
return .number(lhs.doubleValue / rhs.doubleValue)
939953
}
954+
case let .range(from: start, to: end):
955+
let start = try start.evaluate(as: .number, for: "start value", in: context)
956+
let end = try end.evaluate(as: .number, for: "end value", in: context)
957+
return .range(start.doubleValue, end.doubleValue)
940958
case let .member(expression, member):
941959
let value = try expression.evaluate(in: context)
942960
if let value = value[member.name] {
@@ -1089,7 +1107,7 @@ extension Expression {
10891107
)
10901108
}
10911109
})
1092-
case .number, .string, .texture, .path, .mesh, .point:
1110+
case .number, .string, .texture, .path, .mesh, .point, .range:
10931111
if values.count > 1, parameters.count > 1 {
10941112
throw RuntimeError(
10951113
.unexpectedArgument(for: name, max: 1),

ShapeScript/Logging.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,13 @@ extension Array: Loggable {
216216
"(\(logDescription))"
217217
}
218218
}
219+
220+
extension Range: Loggable where Bound == Double {
221+
public var logDescription: String {
222+
"\(lowerBound.logDescription) to \((upperBound - 1).logDescription)"
223+
}
224+
225+
public var nestedLogDescription: String {
226+
"(\(logDescription))"
227+
}
228+
}

ShapeScript/Parser.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public enum StatementType: Equatable {
2727
case block(Identifier, Block)
2828
case define(Identifier, Definition)
2929
case option(Identifier, Expression)
30-
case forloop(index: Identifier?, from: Expression, to: Expression, Block)
30+
case forloop(Identifier?, in: Expression, Block)
3131
case expression(Expression)
3232
case `import`(Expression)
3333
}
@@ -63,6 +63,7 @@ public enum ExpressionType: Equatable {
6363
indirect case tuple([Expression])
6464
indirect case prefix(PrefixOperator, Expression)
6565
indirect case infix(Expression, InfixOperator, Expression)
66+
indirect case range(from: Expression, to: Expression)
6667
indirect case member(Expression, Identifier)
6768
indirect case subexpression(Expression)
6869
}
@@ -220,18 +221,15 @@ private extension ArraySlice where Element == Token {
220221
guard readToken(.keyword(.for)) else {
221222
return nil
222223
}
223-
let index = readIdentifier()
224-
let start: Expression
225-
if let identifier = index, !readToken(.identifier("in")) {
226-
start = Expression(type: .identifier(identifier), range: identifier.range)
227-
try requireToken(.identifier("to"), as: "'to' or 'in'")
224+
let identifier = readIdentifier()
225+
let expression: Expression
226+
if let identifier = identifier, !readToken(.identifier("in")) {
227+
expression = Expression(type: .identifier(identifier), range: identifier.range)
228228
} else {
229-
start = try require(readExpression(), as: "starting index")
230-
try requireToken(.identifier("to"), as: "'to'")
229+
expression = try require(readExpression(), as: "range")
231230
}
232-
let end = try require(readExpression(), as: "end index")
233231
let body = try require(readBlock(), as: "loop body")
234-
return .forloop(index: index, from: start, to: end, body)
232+
return .forloop(identifier, in: expression, body)
235233
}
236234

237235
mutating func readImport() throws -> StatementType? {
@@ -331,6 +329,14 @@ private extension ArraySlice where Element == Token {
331329
range: lhs.range.lowerBound ..< rhs.range.upperBound
332330
)
333331
}
332+
if case .identifier("to") = nextToken.type {
333+
removeFirst()
334+
let rhs = try require(readExpression(), as: "end value")
335+
lhs = Expression(
336+
type: .range(from: lhs, to: rhs),
337+
range: lhs.range.lowerBound ..< rhs.range.upperBound
338+
)
339+
}
334340
return lhs
335341
}
336342

ShapeScriptTests/InterpreterTests.swift

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,65 @@ class InterpreterTests: XCTestCase {
925925
}
926926
}
927927

928+
// MARK: Ranges
929+
930+
func testRange() {
931+
let program = "print 0 to 3"
932+
let delegate = TestDelegate()
933+
XCTAssertNoThrow(try evaluate(parse(program), delegate: delegate))
934+
XCTAssertEqual(delegate.log, [0.0 ..< 4.0])
935+
}
936+
937+
func testInvalidRange() {
938+
let program = "print 4 to 3"
939+
let delegate = TestDelegate()
940+
XCTAssertNoThrow(try evaluate(parse(program), delegate: delegate))
941+
XCTAssertEqual(delegate.log, [4.0 ..< 4.0])
942+
}
943+
944+
func testNegativeRange() {
945+
let program = "print -3 to -2"
946+
let delegate = TestDelegate()
947+
XCTAssertNoThrow(try evaluate(parse(program), delegate: delegate))
948+
XCTAssertEqual(delegate.log, [-3.0 ..< -1.0])
949+
}
950+
951+
func testFloatRange() {
952+
let program = "print 0.5 to 1.5"
953+
let delegate = TestDelegate()
954+
XCTAssertNoThrow(try evaluate(parse(program), delegate: delegate))
955+
XCTAssertEqual(delegate.log, [0.5 ..< 2.5])
956+
}
957+
958+
func testRangeWithNonNumericStartValue() {
959+
let program = "define range \"foo\" to 10"
960+
XCTAssertThrowsError(try evaluate(parse(program), delegate: nil)) { error in
961+
let error = try? XCTUnwrap(error as? RuntimeError)
962+
XCTAssertEqual(error?.message, "Type mismatch")
963+
XCTAssertEqual(error?.type, .typeMismatch(
964+
for: "start value",
965+
index: 0,
966+
expected: "number",
967+
got: "string"
968+
))
969+
}
970+
}
971+
972+
func testRangeWithNonNumericEndValue() {
973+
let program = "define range 1 to \"bar\""
974+
let range = program.range(of: "\"bar\"")!
975+
XCTAssertThrowsError(try evaluate(parse(program), delegate: nil)) { error in
976+
let error = try? XCTUnwrap(error as? RuntimeError)
977+
XCTAssertEqual(error?.message, "Type mismatch")
978+
XCTAssertEqual(error, RuntimeError(.typeMismatch(
979+
for: "end value",
980+
index: 0,
981+
expected: "number",
982+
got: "string"
983+
), at: range))
984+
}
985+
}
986+
928987
// MARK: For loops
929988

930989
func testForLoopWithIndex() {
@@ -962,32 +1021,33 @@ class InterpreterTests: XCTestCase {
9621021
XCTAssertEqual(delegate.log, [0.5, 1.5])
9631022
}
9641023

965-
func testForLoopWithNonNumericStartIndex() {
966-
let program = "for i in \"foo\" to 10 { print i }"
967-
let range = program.range(of: "\"foo\"")!
1024+
func testForLoopWithNonRangeExpression() {
1025+
let program = "for 1 { print i }"
1026+
let range = program.range(of: "1")!
9681027
XCTAssertThrowsError(try evaluate(parse(program), delegate: nil)) { error in
9691028
let error = try? XCTUnwrap(error as? RuntimeError)
9701029
XCTAssertEqual(error?.message, "Type mismatch")
9711030
XCTAssertEqual(error, RuntimeError(.typeMismatch(
972-
for: "start value",
1031+
for: "range",
9731032
index: 0,
974-
expected: "number",
975-
got: "string"
1033+
expected: "range",
1034+
got: "number"
9761035
), at: range))
9771036
}
9781037
}
9791038

980-
func testForLoopWithNonNumericEndIndex() {
981-
let program = "for i in 1 to \"bar\" { print i }"
1039+
func testForLoopWithNonRangeExpression2() {
1040+
let program = "for i in \"foo\" { print i }"
1041+
let range = program.range(of: "\"foo\"")!
9821042
XCTAssertThrowsError(try evaluate(parse(program), delegate: nil)) { error in
9831043
let error = try? XCTUnwrap(error as? RuntimeError)
9841044
XCTAssertEqual(error?.message, "Type mismatch")
985-
XCTAssertEqual(error?.type, .typeMismatch(
986-
for: "end value",
1045+
XCTAssertEqual(error, RuntimeError(.typeMismatch(
1046+
for: "range",
9871047
index: 0,
988-
expected: "number",
1048+
expected: "range",
9891049
got: "string"
990-
))
1050+
), at: range))
9911051
}
9921052
}
9931053

ShapeScriptTests/LoggingTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,18 @@ class LoggingTests: XCTestCase {
274274
XCTAssertEqual(input.logDescription, "\"foo\" (\"bar\" \"baz\")")
275275
XCTAssertEqual(input.nestedLogDescription, "(\"foo\" (\"bar\" \"baz\"))")
276276
}
277+
278+
// MARK: Ranges
279+
280+
func testIntRange() {
281+
let input = 1.0 ..< 10.0
282+
XCTAssertEqual(input.logDescription, "1 to 9")
283+
XCTAssertEqual(input.nestedLogDescription, "(1 to 9)")
284+
}
285+
286+
func testFloatRange() {
287+
let input = 1.5 ..< 3.5
288+
XCTAssertEqual(input.logDescription, "1.5 to 2.5")
289+
XCTAssertEqual(input.nestedLogDescription, "(1.5 to 2.5)")
290+
}
277291
}

0 commit comments

Comments
 (0)