Skip to content

Commit

Permalink
feat: add the ability to iterate over dictionaries in a for loop (and…
Browse files Browse the repository at this point in the history
… specify the variable name of index for arrays)

Fixes vapor#105.
  • Loading branch information
pontaoski committed Aug 27, 2022
1 parent 205d067 commit 0bbb912
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 23 deletions.
5 changes: 5 additions & 0 deletions Sources/LeafKit/LeafError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ public struct LeafError: Error {
/// A typeError specialised for Double | Int
case expectedNumeric(got: LeafData.NaturalType)

/// A typeError specialised for non-iterable things
case expectedIterable(got: LeafData.NaturalType)

/// A typeError specialised for binary operators of (T, T) -> T
case badOperation(on: LeafData.NaturalType, what: String)

Expand Down Expand Up @@ -124,6 +127,8 @@ public struct LeafError: Error {
return "Type error: I was expecting \(shouldHaveBeen), but I got \(got) instead"
case .badOperation(let on, let what):
return "Type error: \(on) cannot do \(what)"
case .expectedIterable(let got):
return "Type error: I was something that I could iterate over (like an array or dictionary), but I got \(got) instead"
case .expectedNumeric(let got):
return "Type error: I was expecting a numeric type, but I got \(got) instead"
case .badParameterCount(let tag, let expected, let got):
Expand Down
28 changes: 22 additions & 6 deletions Sources/LeafKit/LeafParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,15 @@ public class LeafParser {
return (span, inner)
}

private func expect(oneOf expects: [LeafScanner.Token], while doing: String) throws {
@discardableResult
private func expect(oneOf expects: [LeafScanner.Token], while doing: String) throws -> (LeafScanner.Span, LeafScanner.Token) {
guard let (span, token) = try read() else {
throw error(.earlyEOF(wasExpecting: "one of " + expects.map { $0.description }.joined(separator: ", ")), .eof)
}
guard expects.contains(token) else {
throw error(.expectedOneOfGot(expected: expects, got: token, while: doing), span)
}
return (span, token)
}

/// expects that you've just parsed the ``.bodyStart``
Expand Down Expand Up @@ -271,13 +273,26 @@ public class LeafParser {
return .init(.with(.init(context: expr, body: statements)), span: combine(span, endSpan))
case "for":
try expect(token: .enterExpression, while: "looking for start of for loop")
let (_, varName) = try parseIdent(while: "looking for foreach loop variable")
try expect(token: .expression(.identifier("in")), while: "looking for 'in' keyword in foreach loop")
let (_, firstName) = try parseIdent(while: "looking for foreach loop variable")
let (_, tok) = try expect(oneOf: [.expression(.identifier("in")), .expression(.comma)], while: "looking for 'in' keyword or comma in foreach loop")
let secondName: Substring?
switch tok {
case .expression(.identifier("in")):
secondName = nil
case .expression(.comma):
let (_, second) = try parseIdent(while: "looking for foreach loop variable")
try expect(token: .expression(.identifier("in")), while: "looking for 'in' keyword in foreach loop")
secondName = second
default:
throw LeafError(.internalError(what: "for loop parsing shouldn't have gotten to where it's trying to discriminate between something that isn't 'in' or ','"))
}
let expr = try parseExpression(minimumPrecedence: 0)
try expect(token: .exitExpression, while: "looking for closing parenthesis of foreach loop header")
try expect(token: .bodyStart, while: "looking for start of for loop body")
let (endSpan, statements) = try parseTagBody(name: tag)
return .init(.forLoop(.init(name: varName, inValue: expr, body: statements)), span: combine(span, endSpan))
let contentName = secondName ?? firstName
let indexName = secondName != nil ? firstName : nil
return .init(.forLoop(.init(name: contentName, indexName: indexName, inValue: expr, body: statements)), span: combine(span, endSpan))
case "import":
let (endSpan, params) = try parseEnterExitParams()
guard params.count == 1 else {
Expand Down Expand Up @@ -745,17 +760,18 @@ public struct Statement: SExprRepresentable, Substitutable {

public struct ForLoop: SExprRepresentable, Substitutable {
public let name: Substring
public let indexName: Substring?
public let inValue: Expression
public let body: [Statement]

public func unsubstitutedExtends() -> Set<String> {
self.body.unsubstitutedExtends()
}
public func substituteImport(name: String, with statement: Statement) -> Statement.ForLoop {
.init(name: self.name, inValue: inValue, body: body.substituteImport(name: name, with: statement))
.init(name: self.name, indexName: self.indexName, inValue: inValue, body: body.substituteImport(name: name, with: statement))
}
public func substituteExtend(name: String, with statement: ([Statement.Export]) -> Statement) -> Statement.ForLoop {
.init(name: self.name, inValue: inValue, body: body.substituteExtend(name: name, with: statement))
.init(name: self.name, indexName: self.indexName, inValue: inValue, body: body.substituteExtend(name: name, with: statement))
}
public func sexpr() -> String {
return #"(for \#(inValue.sexpr()) \#(body.sexpr()))"#
Expand Down
54 changes: 37 additions & 17 deletions Sources/LeafKit/LeafSerialize/LeafSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,46 @@ internal struct LeafSerializer {

private mutating func serialize(_ loop: Statement.ForLoop, context data: [String: LeafData]) throws {
let evalled = try evaluate(loop.inValue, context: data)
guard let elements = evalled.array else {
throw LeafError(.typeError(shouldHaveBeen: .array, got: evalled.concreteType!))
}
if let array = evalled.array {
for (idx, item) in array.enumerated() {
var innerContext = data

innerContext["isFirst"] = .bool(idx == array.startIndex)
innerContext["isLast"] = .bool(idx == array.index(before: array.endIndex))
innerContext[loop.indexName.map { String($0) } ?? "index"] = .int(idx)
innerContext[String(loop.name)] = item

var serializer = LeafSerializer(
ast: loop.body,
tags: self.tags,
userInfo: self.userInfo,
ignoreUnfoundImports: self.ignoreUnfoundImports
)
var loopBody = try serializer.serialize(context: innerContext)
self.buffer.writeBuffer(&loopBody)
}
} else if let dict = evalled.dictionary {
for idx in dict.indices {
let item = dict[idx]

for (idx, item) in elements.enumerated() {
var innerContext = data
var innerContext = data

innerContext["isFirst"] = .bool(idx == elements.startIndex)
innerContext["isLast"] = .bool(idx == elements.index(before: elements.endIndex))
innerContext["index"] = .int(idx)
innerContext[String(loop.name)] = item
innerContext["isFirst"] = .bool(idx == dict.startIndex)
innerContext["isLast"] = .bool(dict.index(after: idx) == dict.endIndex)
innerContext[loop.indexName.map { String($0) } ?? "index"] = .string(item.key)
innerContext[String(loop.name)] = item.value

var serializer = LeafSerializer(
ast: loop.body,
tags: self.tags,
userInfo: self.userInfo,
ignoreUnfoundImports: self.ignoreUnfoundImports
)
var loopBody = try serializer.serialize(context: innerContext)
self.buffer.writeBuffer(&loopBody)
var serializer = LeafSerializer(
ast: loop.body,
tags: self.tags,
userInfo: self.userInfo,
ignoreUnfoundImports: self.ignoreUnfoundImports
)
var loopBody = try serializer.serialize(context: innerContext)
self.buffer.writeBuffer(&loopBody)
}
} else {
throw LeafError(.expectedIterable(got: evalled.concreteType!))
}
}
}
14 changes: 14 additions & 0 deletions Tests/LeafKitTests/LeafTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,20 @@ final class LeafTests: XCTestCase {
XCTAssertEqual(page.string, expected)
}

func testDictionaryForLoop() throws {
try XCTAssertEqual(render("""
#for(key, value in ["orwell": "1984"]):literally #(value) by george #(key)#endfor
"""), """
literally 1984 by george orwell
""")

try XCTAssertEqual(render("""
#for(key, value in ["orwell": "1984", "jorjor": "1984"]):#(value)#endfor
"""), """
19841984
""")
}

func testEmptyForLoop() throws {
let template = """
#for(category in categories):
Expand Down

0 comments on commit 0bbb912

Please sign in to comment.