Skip to content

Commit

Permalink
Add executable that processes a mustache template (#41)
Browse files Browse the repository at this point in the history
* Add mustache executable

* load template, context and render output

* CommandLineApp

* Remove JSON parser as YAML parser loads JSON, Add help text

* Don't add additional newline when outputting template

* Add new line if output doesn't end with a newline

* Improve error messages

* Fix error tests
  • Loading branch information
adam-fowler authored Jul 18, 2024
1 parent 5bb66ac commit 5d2ddd1
Show file tree
Hide file tree
Showing 4 changed files with 193 additions and 29 deletions.
14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ let package = Package(
name: "swift-mustache",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
products: [
.executable(name: "mustache", targets: ["CommandLineApp"]),
.library(name: "Mustache", targets: ["Mustache"]),
],
dependencies: [],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.4.0"),
.package(url: "https://github.com/jpsim/yams", from: "5.1.0"),
],
targets: [
.target(name: "Mustache", dependencies: []),
.executableTarget(
name: "CommandLineApp",
dependencies: [
"Mustache",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Yams", package: "yams"),
]
),
.testTarget(name: "MustacheTests", dependencies: ["Mustache"]),
]
)
97 changes: 97 additions & 0 deletions Sources/CommandLineApp/app.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import ArgumentParser
import Foundation
import Mustache
import Yams

struct MustacheAppError: Error, CustomStringConvertible {
let description: String

init(_ description: String) {
self.description = description
}
}

@main
struct MustacheApp: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "mustache",
abstract: """
Mustache is a logic-less templating system for rendering
text files.
""",
usage: """
mustache <context-filename> <template-filename>
mustache - <template-filename>
""",
discussion: """
The mustache command processes a Mustache template with a context
defined in YAML/JSON. While the template is always loaded from a file
the context can be supplied to the process either from a file or from
stdin.
Examples:
mustache context.yml template.mustache
cat context.yml | mustache - template.mustache
"""
)

@Argument(help: "Context file")
var contextFile: String

@Argument(help: "Mustache template file")
var templateFile: String

func run() throws {
guard let templateString = loadString(filename: self.templateFile) else {
throw MustacheAppError("Failed to load template file \(self.templateFile)")
}
let template = try MustacheTemplate(string: templateString)
let context = try loadYaml(filename: self.contextFile)
let rendered = template.render(context)
if rendered.last?.isNewline == true {
print(rendered, terminator: "")
} else {
print(rendered)
}
}

/// Load file into string
func loadString(filename: String) -> String? {
guard let data = FileManager.default.contents(atPath: filename) else { return nil }
return String(decoding: data, as: Unicode.UTF8.self)
}

/// Pass stdin into a string
func loadStdin() -> String {
let input = AnyIterator { readLine(strippingNewline: false) }.joined(separator: "")
return input
}

func loadContext(filename: String) throws -> Any {
return try self.loadYaml(filename: filename)
}

func loadYaml(filename: String) throws -> Any {
func convertObject(_ object: Any) -> Any {
guard var dictionary = object as? [String: Any] else { return object }
for (key, value) in dictionary {
dictionary[key] = convertObject(value)
}
return dictionary
}

let yamlString: String
if filename == "-" {
yamlString = self.loadStdin()
} else {
guard let string = loadString(filename: filename) else {
throw MustacheAppError("Failed to load context file \(filename)")
}
yamlString = string
}
guard let yaml = try Yams.load(yaml: yamlString) else {
throw MustacheAppError("YAML context file is empty\(filename)")
}
return convertObject(yaml)
}
}
66 changes: 55 additions & 11 deletions Sources/Mustache/Template+Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,62 @@

extension MustacheTemplate {
/// Error return by `MustacheTemplate.parse`. Includes information about where error occurred
public struct ParserError: Swift.Error {
public struct ParserError: Swift.Error, CustomStringConvertible {
public let context: MustacheParserContext
public let error: Swift.Error

public var description: String {
"""
\(self.context.lineNumber):\(self.context.columnNumber) \(self.error)
\(self.context.line)
\((1..<self.context.columnNumber).map { _ in " " }.joined())^
"""
}
}

/// Error generated by `MustacheTemplate.parse`
public enum Error: Swift.Error {
public enum Error: Swift.Error, CustomStringConvertible {
/// the end section does not match the name of the start section
case sectionCloseNameIncorrect
case sectionCloseNameIncorrect(String, String)
/// No matching begin section for end section
case unmatchedSectionEnd(String)
/// tag was badly formatted
case unfinishedName
/// was expecting a section end
case expectedSectionEnd
case expectedSectionEnd(String)
/// set delimiter tag badly formatted
case invalidSetDelimiter
/// cannot apply transform to inherited section
case transformAppliedToInheritanceSection
/// illegal token inside inherit section of partial
case illegalTokenInsideInheritSection
/// text found inside inherit section of partial
case textInsideInheritSection
/// config variable syntax is wrong
case invalidConfigVariableSyntax
/// unrecognised config variable
case unrecognisedConfigVariable

public var description: String {
switch self {
case .sectionCloseNameIncorrect(let open, let close):
"Section close name \"\(close)\" does not match the open name \"\(open)\""
case .unmatchedSectionEnd(let sectionName):
"Section end \"\(sectionName)\" does not have matching section begin"
case .unfinishedName:
"Tag was badly formatted"
case .expectedSectionEnd(let sectionName):
"Section \"\(sectionName)\" doesn't have a section end"
case .invalidSetDelimiter:
"Set delimiter tag is badly formatted"
case .transformAppliedToInheritanceSection:
"Cannot apply transforms to partial definitions or expansions"
case .illegalTokenInsideInheritSection:
"Illegal token found inside a partial definition"
case .invalidConfigVariableSyntax:
"Config variable badly formatted"
case .unrecognisedConfigVariable:
"Unrecognized config variable"
}
}
}

struct ParserState {
Expand Down Expand Up @@ -192,7 +223,11 @@ extension MustacheTemplate {
let (name, transforms) = try parseName(&parser, state: state)
guard name == state.sectionName, transforms == state.sectionTransforms else {
parser.unsafeSetPosition(position)
throw Error.sectionCloseNameIncorrect
if let sectionName = state.sectionName {
throw Error.sectionCloseNameIncorrect(sectionName, name)
} else {
throw Error.unmatchedSectionEnd(name)
}
}
if self.isStandalone(&parser, state: state) {
setNewLine = true
Expand Down Expand Up @@ -322,8 +357,8 @@ extension MustacheTemplate {
state.newLine = setNewLine
}
// should never get here if reading section
guard state.sectionName == nil else {
throw Error.expectedSectionEnd
if let sectionName = state.sectionName {
throw Error.expectedSectionEnd(sectionName)
}
return tokens
}
Expand Down Expand Up @@ -355,10 +390,14 @@ extension MustacheTemplate {

/// parse variable name
static func parseName(_ parser: inout Parser, state: ParserState) throws -> (String, [String]) {
let position = parser.position
parser.read(while: \.isWhitespace)
let text = String(parser.read(while: self.sectionNameChars))
parser.read(while: \.isWhitespace)
guard try parser.read(string: state.endDelimiter) else { throw Error.unfinishedName }
guard try parser.read(string: state.endDelimiter) else {
parser.unsafeSetPosition(position)
throw Error.unfinishedName
}

// does the name include brackets. If so this is a transform call
var nameParser = Parser(String(text))
Expand All @@ -367,7 +406,10 @@ extension MustacheTemplate {
return (text, [])
} else {
// parse function parameter, as we have just parsed a function name
guard nameParser.current() == "(" else { throw Error.unfinishedName }
guard nameParser.current() == "(" else {
parser.unsafeSetPosition(position)
throw Error.unfinishedName
}
nameParser.unsafeAdvance()

func parseTransforms(existing: [Substring]) throws -> (Substring, [Substring]) {
Expand All @@ -380,6 +422,7 @@ extension MustacheTemplate {
guard nameParser.read(while: ")") + 1 == existing.count,
nameParser.reachedEnd()
else {
parser.unsafeSetPosition(position)
throw Error.unfinishedName
}
return (name, existing)
Expand All @@ -391,6 +434,7 @@ extension MustacheTemplate {
transforms.append(name)
return try parseTransforms(existing: transforms)
default:
parser.unsafeSetPosition(position)
throw Error.unfinishedName
}
}
Expand Down
45 changes: 28 additions & 17 deletions Tests/MustacheTests/ErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ final class ErrorTests: XCTestCase {
""")) { error in
switch error {
case let error as MustacheTemplate.ParserError:
XCTAssertEqual(error.error as? MustacheTemplate.Error, .sectionCloseNameIncorrect)
XCTAssertEqual(error.context.line, "{{/test2}}")
XCTAssertEqual(error.context.lineNumber, 3)
XCTAssertEqual(error.context.columnNumber, 4)
if let mustacheError = error.error as? MustacheTemplate.Error, case .sectionCloseNameIncorrect = mustacheError {
XCTAssertEqual(error.context.line, "{{/test2}}")
XCTAssertEqual(error.context.lineNumber, 3)
XCTAssertEqual(error.context.columnNumber, 4)
} else {
XCTFail("\(error)")
}

default:
XCTFail("\(error)")
Expand All @@ -43,11 +46,13 @@ final class ErrorTests: XCTestCase {
""")) { error in
switch error {
case let error as MustacheTemplate.ParserError:
XCTAssertEqual(error.error as? MustacheTemplate.Error, .unfinishedName)
XCTAssertEqual(error.context.line, "{{name}")
XCTAssertEqual(error.context.lineNumber, 2)
XCTAssertEqual(error.context.columnNumber, 7)

if let mustacheError = error.error as? MustacheTemplate.Error, case .unfinishedName = mustacheError {
XCTAssertEqual(error.context.line, "{{name}")
XCTAssertEqual(error.context.lineNumber, 2)
XCTAssertEqual(error.context.columnNumber, 3)
} else {
XCTFail("\(error)")
}
default:
XCTFail("\(error)")
}
Expand All @@ -61,10 +66,13 @@ final class ErrorTests: XCTestCase {
""")) { error in
switch error {
case let error as MustacheTemplate.ParserError:
XCTAssertEqual(error.error as? MustacheTemplate.Error, .expectedSectionEnd)
XCTAssertEqual(error.context.line, "{{.}}")
XCTAssertEqual(error.context.lineNumber, 2)
XCTAssertEqual(error.context.columnNumber, 6)
if let mustacheError = error.error as? MustacheTemplate.Error, case .expectedSectionEnd = mustacheError {
XCTAssertEqual(error.context.line, "{{.}}")
XCTAssertEqual(error.context.lineNumber, 2)
XCTAssertEqual(error.context.columnNumber, 6)
} else {
XCTFail("\(error)")
}

default:
XCTFail("\(error)")
Expand All @@ -80,10 +88,13 @@ final class ErrorTests: XCTestCase {
""")) { error in
switch error {
case let error as MustacheTemplate.ParserError:
XCTAssertEqual(error.error as? MustacheTemplate.Error, .invalidSetDelimiter)
XCTAssertEqual(error.context.line, "<%={{}}=%>")
XCTAssertEqual(error.context.lineNumber, 3)
XCTAssertEqual(error.context.columnNumber, 4)
if let mustacheError = error.error as? MustacheTemplate.Error, case .invalidSetDelimiter = mustacheError {
XCTAssertEqual(error.context.line, "<%={{}}=%>")
XCTAssertEqual(error.context.lineNumber, 3)
XCTAssertEqual(error.context.columnNumber, 4)
} else {
XCTFail("\(error)")
}

default:
XCTFail("\(error)")
Expand Down

0 comments on commit 5d2ddd1

Please sign in to comment.