From 0bbb912c102872b37ddd21b81e88d3502dcf02b2 Mon Sep 17 00:00:00 2001 From: Janet Blackquill Date: Sat, 27 Aug 2022 12:49:46 -0400 Subject: [PATCH] feat: add the ability to iterate over dictionaries in a for loop (and specify the variable name of index for arrays) Fixes #105. --- Sources/LeafKit/LeafError.swift | 5 ++ Sources/LeafKit/LeafParser.swift | 28 +++++++--- .../LeafSerialize/LeafSerializer.swift | 54 +++++++++++++------ Tests/LeafKitTests/LeafTests.swift | 14 +++++ 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/Sources/LeafKit/LeafError.swift b/Sources/LeafKit/LeafError.swift index b82860c..bf33dc2 100644 --- a/Sources/LeafKit/LeafError.swift +++ b/Sources/LeafKit/LeafError.swift @@ -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) @@ -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): diff --git a/Sources/LeafKit/LeafParser.swift b/Sources/LeafKit/LeafParser.swift index 46244b0..3cc3b12 100644 --- a/Sources/LeafKit/LeafParser.swift +++ b/Sources/LeafKit/LeafParser.swift @@ -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`` @@ -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 { @@ -745,6 +760,7 @@ 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] @@ -752,10 +768,10 @@ public struct Statement: SExprRepresentable, Substitutable { 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()))"# diff --git a/Sources/LeafKit/LeafSerialize/LeafSerializer.swift b/Sources/LeafKit/LeafSerialize/LeafSerializer.swift index 0299109..bf2927c 100644 --- a/Sources/LeafKit/LeafSerialize/LeafSerializer.swift +++ b/Sources/LeafKit/LeafSerialize/LeafSerializer.swift @@ -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!)) } } } diff --git a/Tests/LeafKitTests/LeafTests.swift b/Tests/LeafKitTests/LeafTests.swift index c83b7aa..27dc59b 100644 --- a/Tests/LeafKitTests/LeafTests.swift +++ b/Tests/LeafKitTests/LeafTests.swift @@ -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):