From 5d2ddd161702a23ad4a4fd98d3a7512df4d777d5 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Thu, 18 Jul 2024 08:34:24 +0100 Subject: [PATCH] Add executable that processes a mustache template (#41) * 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 --- Package.swift | 14 +++- Sources/CommandLineApp/app.swift | 97 ++++++++++++++++++++++++++ Sources/Mustache/Template+Parser.swift | 66 +++++++++++++++--- Tests/MustacheTests/ErrorTests.swift | 45 +++++++----- 4 files changed, 193 insertions(+), 29 deletions(-) create mode 100644 Sources/CommandLineApp/app.swift diff --git a/Package.swift b/Package.swift index b82f3be..bbebb68 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), ] ) diff --git a/Sources/CommandLineApp/app.swift b/Sources/CommandLineApp/app.swift new file mode 100644 index 0000000..374bb65 --- /dev/null +++ b/Sources/CommandLineApp/app.swift @@ -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 + mustache - + """, + 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) + } +} diff --git a/Sources/Mustache/Template+Parser.swift b/Sources/Mustache/Template+Parser.swift index 0e61ea2..cadae3a 100644 --- a/Sources/Mustache/Template+Parser.swift +++ b/Sources/Mustache/Template+Parser.swift @@ -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.. (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)) @@ -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]) { @@ -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) @@ -391,6 +434,7 @@ extension MustacheTemplate { transforms.append(name) return try parseTransforms(existing: transforms) default: + parser.unsafeSetPosition(position) throw Error.unfinishedName } } diff --git a/Tests/MustacheTests/ErrorTests.swift b/Tests/MustacheTests/ErrorTests.swift index 42ccb4b..bd3f957 100644 --- a/Tests/MustacheTests/ErrorTests.swift +++ b/Tests/MustacheTests/ErrorTests.swift @@ -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)") @@ -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)") } @@ -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)") @@ -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)")