Skip to content

Commit

Permalink
Adds a new #with() tag to make it easier to embed and extend tags (#…
Browse files Browse the repository at this point in the history
…111)

* feat: add #with tag

This is similar to Go `text/template`'s `{{ with }}` statement, in that it
allows overriding the context for the contained block.

* feat: add #with sugar for #extend

This allows passing a context parameter to #extend, which will cause it
to desugar into a #with around the extended content, instead of just
the extended content itself.
  • Loading branch information
pontaoski committed Aug 16, 2022
1 parent 55b81a0 commit 08fb9b2
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 39 deletions.
8 changes: 7 additions & 1 deletion Sources/LeafKit/LeafAST.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ public struct LeafAST: Hashable {
}

// replace the original Syntax with the results of inlining, potentially 1...n
let replacementSyntax = ast[pos].inlineRefs(providedExts, [:])
let replacementSyntax: [Syntax]
if case .extend(let extend) = ast[pos], let context = extend.context {
let inner = ast[pos].inlineRefs(providedExts, [:])
replacementSyntax = [.with(.init(context: context, body: inner))]
} else {
replacementSyntax = ast[pos].inlineRefs(providedExts, [:])
}
ast.replaceSubrange(pos...pos, with: replacementSyntax)
// any returned new inlined syntaxes can't be further resolved at this point
// but we need to add their unresolvable references to the global set
Expand Down
2 changes: 2 additions & 0 deletions Sources/LeafKit/LeafParser/LeafParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,8 @@ internal struct LeafParser {
return try .export(.init(params, body: body))
case "extend":
return try .extend(.init(params, body: body))
case "with":
return try .with(.init(params, body: body))
case "import":
guard body.isEmpty else { throw "import does not accept a body" }
return try .import(.init(params))
Expand Down
3 changes: 1 addition & 2 deletions Sources/LeafKit/LeafRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,11 @@ public final class LeafRenderer {

var serializer = LeafSerializer(
ast: doc.ast,
context: context,
tags: self.tags,
userInfo: self.userInfo,
ignoreUnfoundImports: self.configuration._ignoreUnfoundImports
)
return try serializer.serialize()
return try serializer.serialize(context: context)
}

// MARK: `expand()` obviated
Expand Down
59 changes: 33 additions & 26 deletions Sources/LeafKit/LeafSerialize/LeafSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ internal struct LeafSerializer {

init(
ast: [Syntax],
context data: [String: LeafData],
tags: [String: LeafTag] = defaultTags,
userInfo: [AnyHashable: Any] = [:],
ignoreUnfoundImports: Bool
Expand All @@ -14,17 +13,18 @@ internal struct LeafSerializer {
self.ast = ast
self.offset = 0
self.buffer = ByteBufferAllocator().buffer(capacity: 0)
self.data = data
self.tags = tags
self.userInfo = userInfo
self.ignoreUnfoundImports = ignoreUnfoundImports
}

mutating func serialize() throws -> ByteBuffer {
mutating func serialize(
context data: [String: LeafData]
) throws -> ByteBuffer {
self.offset = 0
while let next = self.peek() {
self.pop()
try self.serialize(next)
try self.serialize(next, context: data)
}
return self.buffer
}
Expand All @@ -34,18 +34,18 @@ internal struct LeafSerializer {
private let ast: [Syntax]
private var offset: Int
private var buffer: ByteBuffer
private var data: [String: LeafData]
private let tags: [String: LeafTag]
private let userInfo: [AnyHashable: Any]
private let ignoreUnfoundImports: Bool

private mutating func serialize(_ syntax: Syntax) throws {
private mutating func serialize(_ syntax: Syntax, context data: [String: LeafData]) throws {
switch syntax {
case .raw(var byteBuffer): buffer.writeBuffer(&byteBuffer)
case .custom(let custom): try serialize(custom)
case .conditional(let c): try serialize(c)
case .loop(let loop): try serialize(loop)
case .expression(let exp): try serialize(expression: exp)
case .custom(let custom): try serialize(custom, context: data)
case .conditional(let c): try serialize(c, context: data)
case .loop(let loop): try serialize(loop, context: data)
case .with(let with): try serialize(with, context: data)
case .expression(let exp): try serialize(expression: exp, context: data)
case .import:
if (self.ignoreUnfoundImports) {
break
Expand All @@ -57,31 +57,31 @@ internal struct LeafSerializer {
}
}

private mutating func serialize(expression: [ParameterDeclaration]) throws {
let resolved = try self.resolve(parameters: [.expression(expression)])
private mutating func serialize(expression: [ParameterDeclaration], context data: [String: LeafData]) throws {
let resolved = try self.resolve(parameters: [.expression(expression)], context: data)
guard resolved.count == 1, let leafData = resolved.first else {
throw "expressions should resolve to single value"
}
try? leafData.htmlEscaped().serialize(buffer: &self.buffer)
}

private mutating func serialize(body: [Syntax]) throws {
try body.forEach { try serialize($0) }
private mutating func serialize(body: [Syntax], context data: [String: LeafData]) throws {
try body.forEach { try serialize($0, context: data) }
}

private mutating func serialize(_ conditional: Syntax.Conditional) throws {
private mutating func serialize(_ conditional: Syntax.Conditional, context data: [String: LeafData]) throws {
evaluate:
for block in conditional.chain {
let evaluated = try resolveAtomic(block.condition.expression())
let evaluated = try resolveAtomic(block.condition.expression(), context: data)
guard (evaluated.bool ?? false) || (!evaluated.isNil && evaluated.celf != .bool) else { continue }
try serialize(body: block.body)
try serialize(body: block.body, context: data)
break evaluate
}
}

private mutating func serialize(_ tag: Syntax.CustomTagDeclaration) throws {
private mutating func serialize(_ tag: Syntax.CustomTagDeclaration, context data: [String: LeafData]) throws {
let sub = try LeafContext(
parameters: self.resolve(parameters: tag.params),
parameters: self.resolve(parameters: tag.params, context: data),
data: data,
body: tag.body,
userInfo: self.userInfo
Expand All @@ -103,8 +103,16 @@ internal struct LeafSerializer {
try? leafData.serialize(buffer: &self.buffer)
}

private mutating func serialize(_ with: Syntax.With, context data: [String: LeafData]) throws {
let resolved = try self.resolve(parameters: [.expression(with.context)], context: data)
guard resolved.count == 1,
let dict = resolved[0].dictionary
else { throw "expressions should resolve to a single dictionary value" }

private mutating func serialize(_ loop: Syntax.Loop) throws {
try? serialize(body: with.body, context: dict)
}

private mutating func serialize(_ loop: Syntax.Loop, context data: [String: LeafData]) throws {
let finalData: [String: LeafData]
let pathComponents = loop.array.split(separator: ".")

Expand All @@ -129,7 +137,7 @@ internal struct LeafSerializer {
}

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

innerContext["isFirst"] = .bool(idx == array.startIndex)
innerContext["isLast"] = .bool(idx == array.index(before: array.endIndex))
Expand All @@ -138,17 +146,16 @@ internal struct LeafSerializer {

var serializer = LeafSerializer(
ast: loop.body,
context: innerContext,
tags: self.tags,
userInfo: self.userInfo,
ignoreUnfoundImports: self.ignoreUnfoundImports
)
var loopBody = try serializer.serialize()
var loopBody = try serializer.serialize(context: innerContext)
self.buffer.writeBuffer(&loopBody)
}
}

private func resolve(parameters: [ParameterDeclaration]) throws -> [LeafData] {
private func resolve(parameters: [ParameterDeclaration], context data: [String: LeafData]) throws -> [LeafData] {
let resolver = ParameterResolver(
params: parameters,
data: data,
Expand All @@ -159,15 +166,15 @@ internal struct LeafSerializer {
}

// Directive resolver for a [ParameterDeclaration] where only one parameter is allowed that must resolve to a single value
private func resolveAtomic(_ parameters: [ParameterDeclaration]) throws -> LeafData {
private func resolveAtomic(_ parameters: [ParameterDeclaration], context data: [String: LeafData]) throws -> LeafData {
guard parameters.count == 1 else {
if parameters.isEmpty {
throw LeafError(.unknownError("Parameter statement can't be empty"))
} else {
throw LeafError(.unknownError("Parameter statement must hold a single value"))
}
}
return try resolve(parameters: parameters).first ?? .trueNil
return try resolve(parameters: parameters, context: data).first ?? .trueNil
}

private func peek() -> Syntax? {
Expand Down
82 changes: 80 additions & 2 deletions Sources/LeafKit/LeafSyntax/LeafSyntax.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public indirect enum Syntax {
case expression([ParameterDeclaration])
// MARK: .custom - Unmakeable, Semi-Readable
case custom(CustomTagDeclaration)
// MARK: .with - Makeable, Entirely Readable
case with(With)

// MARK: .conditional - Makeable, Entirely Readable
case conditional(Conditional)
Expand Down Expand Up @@ -181,11 +183,18 @@ extension Syntax {
public struct Extend: BodiedSyntax {
public let key: String
public private(set) var exports: [String: Export]
public private(set) var context: [ParameterDeclaration]?
private var externalsSet: Set<String>
private var importSet: Set<String>

public init(_ params: [ParameterDeclaration], body: [Syntax]) throws {
guard params.count == 1 else { throw "extend only supports single param \(params)" }
guard params.count == 1 || params.count == 2 else { throw "extend only supports one or two parameters \(params)" }
if params.count == 2 {
guard let context = With.extract(params: Array(params[1...])) else {
throw "#extend's context requires a single expression"
}
self.context = context
}
guard case .parameter(let p) = params[0] else { throw "extend expected parameter type, got \(params[0])" }
guard case .stringLiteral(let s) = p else { throw "import only supports string literals" }
self.key = s
Expand Down Expand Up @@ -286,7 +295,11 @@ extension Syntax {

func print(depth: Int) -> String {
var print = indent(depth)
print += "extend(" + key.debugDescription + ")"
if let context = self.context {
print += "extend(" + key.debugDescription + "," + context.debugDescription + ")"
} else {
print += "extend(" + key.debugDescription + ")"
}
if !exports.isEmpty {
print += ":\n" + exports.sorted { $0.key < $1.key } .map { $0.1.print(depth: depth + 1) } .joined(separator: "\n")
}
Expand Down Expand Up @@ -451,6 +464,70 @@ extension Syntax {
}
}

public struct With: BodiedSyntax {
public internal(set) var body: [Syntax]
public internal(set) var context: [ParameterDeclaration]

private var externalsSet: Set<String>
private var importSet: Set<String>

func externals() -> Set<String> {
self.externalsSet
}

func imports() -> Set<String> {
self.importSet
}

func inlineRefs(_ externals: [String : LeafAST], _ imports: [String : Syntax.Export]) -> [Syntax] {
guard !externalsSet.isEmpty || !importSet.isEmpty else { return [.with(self)] }
return [.with(.init(context: context, body: body.inlineRefs(externals, imports)))]
}

internal init(context: [ParameterDeclaration], body: [Syntax]) {
self.context = context
self.body = body
self.externalsSet = body.externals()
self.importSet = body.imports()
}

static internal func extract(params: [ParameterDeclaration]) -> [ParameterDeclaration]? {
if
params.count == 1,
case .expression(let list) = params[0] {
return list
}

if
params.count == 1,
case .parameter = params[0] {
return params
}

return nil
}

public init(_ params: [ParameterDeclaration], body: [Syntax]) throws {
Swift.print(params)
guard let params = With.extract(params: params) else {
throw "with statements expect a single expression"
}

guard !body.isEmpty else { throw "with statements require a body" }
self.body = body
self.context = params
self.externalsSet = body.externals()
self.importSet = body.imports()
}

func print(depth: Int) -> String {
var print = indent(depth)
print += "with(\(context)):\n"
print += body.map { $0.print(depth: depth + 1) } .joined(separator: "\n")
return print
}
}

public struct Loop: BodiedSyntax {
/// the key to use when accessing items
public let item: String
Expand Down Expand Up @@ -575,6 +652,7 @@ extension Syntax: CustomStringConvertible {
case .import(let imp): return imp.print(depth: depth)
case .extend(let ext): return ext.print(depth: depth)
case .export(let export): return export.print(depth: depth)
case .with(let with): return with.print(depth: depth)
case .raw(var bB):
let string = bB.readString(length: bB.readableBytes) ?? ""
return indent(depth) + "raw(\(string.debugDescription))"
Expand Down
4 changes: 2 additions & 2 deletions Tests/LeafKitTests/LeafKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ final class LeafKitTests: XCTestCase {
let tokens = try lexer.lex()
var parser = LeafParser(name: "nested-echo", tokens: tokens)
let ast = try parser.parse()
var serializer = LeafSerializer(ast: ast, context: ["todo": ["title": "Leaf!"]], ignoreUnfoundImports: false)
let view = try serializer.serialize()
var serializer = LeafSerializer(ast: ast, ignoreUnfoundImports: false)
let view = try serializer.serialize(context: ["todo": ["title": "Leaf!"]])
XCTAssertEqual(view.string, "Todo: Leaf!")
}

Expand Down
8 changes: 4 additions & 4 deletions Tests/LeafKitTests/LeafSerializerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ final class SerializerTests: XCTestCase {
]))
]))

var serializer = LeafSerializer(ast: syntax, context: ["people": people], ignoreUnfoundImports: false)
var serialized = try serializer.serialize()
var serializer = LeafSerializer(ast: syntax, ignoreUnfoundImports: false)
var serialized = try serializer.serialize(context: ["people": people])
let str = (serialized.readString(length: serialized.readableBytes) ?? "<err>")
.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down Expand Up @@ -58,9 +58,9 @@ final class SerializerTests: XCTestCase {
]))
]))

var serializer = LeafSerializer(ast: syntax, context: ["people": people], ignoreUnfoundImports: false)
var serializer = LeafSerializer(ast: syntax, ignoreUnfoundImports: false)

XCTAssertThrowsError(try serializer.serialize()) { error in
XCTAssertThrowsError(try serializer.serialize(context: ["people": people])) { error in
XCTAssertEqual("\(error)", "expected dictionary at key: person.profile")
}
}
Expand Down
39 changes: 39 additions & 0 deletions Tests/LeafKitTests/LeafTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,45 @@ final class LeafTests: XCTestCase {

}

func testWith() throws {
let template = """
#with(parent):#(child)#endwith
"""
let expected = """
Elizabeth
"""

try XCTAssertEqual(render(template, ["parent": ["child": "Elizabeth"]]), expected)
}

func testExtendWithSugar() throws {
let header = """
<h1>#(child)</h1>
"""

let base = """
#extend("header", parent)
"""

let expected = """
<h1>Elizabeth</h1>
"""

let headerAST = try LeafAST(name: "header", ast: parse(header))
let baseAST = try LeafAST(name: "base", ast: parse(base))

let baseResolved = LeafAST(from: baseAST, referencing: ["header": headerAST])

var serializer = LeafSerializer(
ast: baseResolved.ast,
ignoreUnfoundImports: false
)
let view = try serializer.serialize(context: ["parent": ["child": "Elizabeth"]])
let str = view.getString(at: view.readerIndex, length: view.readableBytes) ?? ""

XCTAssertEqual(str, expected)
}

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

0 comments on commit 08fb9b2

Please sign in to comment.