Generate content with stencil.

This commit is contained in:
Marek Fořt 2020-03-20 16:20:47 +01:00
parent 79867adb01
commit 1edfa4d39e
9 changed files with 416 additions and 18 deletions

View File

@ -46,15 +46,6 @@
"version": "0.4.1"
}
},
{
"package": "PathKit",
"repositoryURL": "https://github.com/kylef/PathKit",
"state": {
"branch": null,
"revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511",
"version": "1.0.0"
}
},
{
"package": "RxSwift",
"repositoryURL": "https://github.com/ReactiveX/RxSwift.git",
@ -73,6 +64,15 @@
"version": "0.9.0"
}
},
{
"package": "Stencil",
"repositoryURL": "https://github.com/stencilproject/Stencil",
"state": {
"branch": "master",
"revision": "9c3468e300ba75ede0d7eb4c495897e0ac3591c3",
"version": null
}
},
{
"package": "StringScanner",
"repositoryURL": "https://github.com/getGuaka/StringScanner.git",

View File

@ -34,6 +34,7 @@ let package = Package(
.package(url: "https://github.com/rnine/Checksum.git", .upToNextMajor(from: "1.0.2")),
.package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/thii/xcbeautify.git", .upToNextMajor(from: "0.7.3")),
.package(url: "https://github.com/stencilproject/Stencil", .branch("master")),
],
targets: [
.target(
@ -138,12 +139,16 @@ let package = Package(
),
.target(
name: "TuistScaffold",
dependencies: ["SPMUtility", "TuistCore", "TuistSupport"]
dependencies: ["SPMUtility", "TuistCore", "TuistSupport", "Stencil"]
),
.target(
name: "TuistScaffoldTesting",
dependencies: ["TuistScaffold"]
),
.testTarget(
name: "TuistScaffoldTests",
dependencies: ["TuistScaffold", "TuistSupportTesting"]
),
.testTarget(
name: "TuistScaffoldIntegrationTests",
dependencies: ["TuistScaffold", "TuistSupportTesting"]

View File

@ -1,6 +1,7 @@
import Basic
import Foundation
import SPMUtility
import TuistCore
import TuistLoader
import TuistScaffold
import TuistSupport
@ -11,6 +12,7 @@ enum ScaffoldCommandError: FatalError, Equatable {
case templateNotFound(String)
case templateNotProvided
case nonEmptyDirectory(AbsolutePath)
case attributeNotProvided(String)
var description: String {
switch self {
@ -20,6 +22,8 @@ enum ScaffoldCommandError: FatalError, Equatable {
return "You must provide template name"
case let .nonEmptyDirectory(path):
return "Can't generate a template in the non-empty directory at path \(path.pathString)."
case let .attributeNotProvided(name):
return "You must provide \(name) option. Add --\(name) desired_value to your command."
}
}
}
@ -37,18 +41,21 @@ class ScaffoldCommand: NSObject, Command {
private let templateLoader: TemplateLoading
private let templatesDirectoryLocator: TemplatesDirectoryLocating
private let templateGenerator: TemplateGenerating
// MARK: - Init
public required convenience init(parser: ArgumentParser) {
self.init(parser: parser,
templateLoader: TemplateLoader(),
templatesDirectoryLocator: TemplatesDirectoryLocator())
templatesDirectoryLocator: TemplatesDirectoryLocator(),
templateGenerator: TemplateGenerator())
}
init(parser: ArgumentParser,
templateLoader: TemplateLoading,
templatesDirectoryLocator: TemplatesDirectoryLocating) {
templatesDirectoryLocator: TemplatesDirectoryLocating,
templateGenerator: TemplateGenerating) {
subParser = parser.add(subparser: ScaffoldCommand.command, overview: ScaffoldCommand.overview)
listArgument = subParser.add(option: "--list",
shortName: "-l",
@ -67,6 +74,7 @@ class ScaffoldCommand: NSObject, Command {
completion: .filename)
self.templateLoader = templateLoader
self.templatesDirectoryLocator = templatesDirectoryLocator
self.templateGenerator = templateGenerator
}
func parse(with parser: ArgumentParser, arguments: [String]) throws -> ArgumentParser.Result {
@ -100,7 +108,7 @@ class ScaffoldCommand: NSObject, Command {
}
func run(with arguments: ArgumentParser.Result) throws {
guard let template = arguments.get(templateArgument) else { throw ScaffoldCommandError.templateNotProvided }
guard let templateName = arguments.get(templateArgument) else { throw ScaffoldCommandError.templateNotProvided }
let path = self.path(arguments: arguments)
let templateDirectories = try templatesDirectoryLocator.templateDirectories(at: path)
@ -116,12 +124,20 @@ class ScaffoldCommand: NSObject, Command {
try verifyDirectoryIsEmpty(path: path)
_ = try templateDirectory(templateDirectories: templateDirectories,
template: template)
let templateDirectory = try self.templateDirectory(templateDirectories: templateDirectories,
template: templateName)
let template = try templateLoader.loadTemplate(at: templateDirectory)
let parsedAttributes = try validateAttributes(attributesArguments,
template: template,
arguments: arguments)
// TODO: Generate templates
try templateGenerator.generate(template: template,
to: path,
attributes: parsedAttributes)
logger.notice("Template \(template) was successfully generated", metadata: .success)
logger.notice("Template \(templateName) was successfully generated", metadata: .success)
}
// MARK: - Helpers
@ -143,6 +159,35 @@ class ScaffoldCommand: NSObject, Command {
throw ScaffoldCommandError.nonEmptyDirectory(path)
}
}
/// Validates if all `attributes` from `template` have been provided
/// If those attributes are optional, they default to `default` if not provided
/// - Returns: Array of parsed attributes
private func validateAttributes(_ attributes: [String: OptionArgument<String>],
template: Template,
arguments: ArgumentParser.Result) throws -> [String: String] {
try template.attributes.reduce([:]) {
var mutableDict = $0
switch $1 {
case let .required(name):
guard
let argument = attributes[name],
let value = arguments.get(argument)
else { throw ScaffoldCommandError.attributeNotProvided(name) }
mutableDict[name] = value
case let .optional(name, default: defaultValue):
guard
let argument = attributes[name],
let value: String = arguments.get(argument)
else {
mutableDict[name] = defaultValue
return mutableDict
}
mutableDict[name] = value
}
return mutableDict
}
}
/// Finds template directory
/// - Parameters:

View File

@ -0,0 +1,95 @@
import Basic
import Foundation
import TuistSupport
import TuistCore
import struct Stencil.Environment
enum TemplateGeneratorError: FatalError, Equatable {
var type: ErrorType { .abort }
case attributeNotFound(String)
var description: String {
switch self {
case let .attributeNotFound(attribute):
return "You must provide \(attribute) attribute"
}
}
}
/// Interface for generating content defined in template manifest
public protocol TemplateGenerating {
/// Generate files for template manifest at `path`
/// - Parameters:
/// - destinationPath: Path of directory where files should be generated to
/// - attributes: Attributes from user input
func generate(template: Template,
to destinationPath: AbsolutePath,
attributes: [String: String]) throws
}
public final class TemplateGenerator: TemplateGenerating {
// Public initializer
public init() { }
public func generate(template: Template,
to destinationPath: AbsolutePath,
attributes: [String: String]) throws {
let renderedFiles = renderFiles(template: template,
attributes: attributes)
try generateDirectories(renderedFiles: renderedFiles,
destinationPath: destinationPath)
try generateFiles(renderedFiles: renderedFiles,
attributes: attributes,
destinationPath: destinationPath)
}
// MARK: - Helpers
/// Renders files' paths in format path_to_dir/{{ attribute_name }} with `attributes`
private func renderFiles(template: Template,
attributes: [String: String]) -> [Template.File] {
attributes.reduce(template.files) { files, attribute in
files.map {
let path = RelativePath($0.path.pathString.replacingOccurrences(of: "{{ \(attribute.key) }}", with: attribute.value))
return Template.File(path: path, contents: $0.contents)
}
}
}
/// Generate all necessary directories
private func generateDirectories(renderedFiles: [Template.File],
destinationPath: AbsolutePath) throws {
try renderedFiles
.map(\.path)
.map {
destinationPath.appending(component: $0.dirname)
}
.filter { !FileHandler.shared.exists($0) }
.forEach(FileHandler.shared.createFolder)
}
/// Generate all `renderedFiles`
private func generateFiles(renderedFiles: [Template.File],
attributes: [String: String],
destinationPath: AbsolutePath) throws {
let environment = Environment()
try renderedFiles.forEach {
let renderedContents: String
switch $0.contents {
case let .string(contents):
renderedContents = try environment.renderTemplate(string: contents,
context: attributes)
case let .file(path):
let fileContents = try FileHandler.shared.readTextFile(path)
renderedContents = try environment.renderTemplate(string: fileContents,
context: attributes)
}
try FileHandler.shared.write(renderedContents,
path: destinationPath.appending($0.path),
atomically: true)
}
}
}

View File

@ -0,0 +1,233 @@
import Basic
import Foundation
import TuistSupport
import XCTest
@testable import TuistSupportTesting
@testable import TuistTemplate
@testable import TuistTemplateTesting
final class TemplateGeneratorTests: TuistTestCase {
var subject: TemplateGenerator!
var templateLoader: MockTemplateLoader!
override func setUp() {
super.setUp()
templateLoader = MockTemplateLoader()
subject = TemplateGenerator(templateLoader: templateLoader)
}
override func tearDown() {
super.tearDown()
subject = nil
templateLoader = nil
}
func test_directories_are_generated() throws {
// Given
let directories = [RelativePath("a"), RelativePath("a/b"), RelativePath("c")]
templateLoader.loadTemplateStub = { _ in
Template(description: "",
directories: directories)
}
let destinationPath = try temporaryPath()
let expectedDirectories = directories.map(destinationPath.appending)
// When
try subject.generate(at: try temporaryPath(),
to: destinationPath,
attributes: [])
// Then
XCTAssertTrue(expectedDirectories.allSatisfy(FileHandler.shared.exists))
}
func test_directories_with_attributes() throws {
// Given
let name = "GenName"
let bName = "BName"
let defaultName = "defaultName"
let directories = [RelativePath("{{ name }}"), RelativePath("{{ aName }}"), RelativePath("{{ bName }}")]
templateLoader.loadTemplateStub = { _ in
Template(description: "",
attributes: [
.required("name"),
.optional("aName", default: defaultName),
.optional("bName", default: ""),
],
directories: directories)
}
let destinationPath = try temporaryPath()
let expectedDirectories = [name, defaultName, bName].map(destinationPath.appending)
// When
try subject.generate(at: try temporaryPath(),
to: destinationPath,
attributes: ["--name", name, "--bName", bName])
// Then
XCTAssertTrue(expectedDirectories.allSatisfy(FileHandler.shared.exists))
}
func test_fails_when_required_attribute_not_provided() throws {
// Given
templateLoader.loadTemplateStub = { _ in
Template(description: "",
attributes: [.required("required")])
}
// Then
XCTAssertThrowsSpecific(try subject.generate(at: try temporaryPath(),
to: try temporaryPath(),
attributes: []),
TemplateGeneratorError.attributeNotFound("required"))
}
func test_files_are_generated() throws {
// Given
let files: [(path: RelativePath, contents: Template.Contents)] = [
(path: RelativePath("a"), contents: .static("aContent")),
(path: RelativePath("b"), contents: .static("bContent")),
]
templateLoader.loadTemplateStub = { _ in
Template(description: "",
files: files)
}
let destinationPath = try temporaryPath()
let expectedFiles: [(AbsolutePath, String)] = files.compactMap {
let content: String
switch $0.contents {
case let .static(staticContent):
content = staticContent
case .generated:
XCTFail("Unexpected type")
return nil
}
return (destinationPath.appending($0.path), content)
}
// When
try subject.generate(at: try temporaryPath(),
to: destinationPath,
attributes: [])
// Then
try expectedFiles.forEach {
XCTAssertEqual(try FileHandler.shared.readTextFile($0.0), $0.1)
}
}
func test_files_are_generated_with_attributes() throws {
// Given
templateLoader.loadTemplateStub = { _ in
Template(description: "",
attributes: [
.required("name"),
.required("contentName"),
.required("directoryName"),
.required("fileName"),
],
files: [
(path: RelativePath("{{ name }}"), contents: .static("{{ contentName }}")),
(path: RelativePath("{{ directoryName }}/{{ fileName }}"), contents: .static("bContent")),
],
directories: [RelativePath("{{ directoryName }}")])
}
let name = "test name"
let contentName = "test content"
let directoryName = "test directory"
let fileName = "test file"
let destinationPath = try temporaryPath()
let expectedFiles: [(AbsolutePath, String)] = [
(destinationPath.appending(component: name), contentName),
(destinationPath.appending(components: directoryName, fileName), "bContent"),
]
// When
try subject.generate(at: try temporaryPath(),
to: destinationPath,
attributes: [
"--name", name,
"--contentName", contentName,
"--directoryName", directoryName,
"--fileName", fileName,
])
// Then
try expectedFiles.forEach {
XCTAssertEqual(try FileHandler.shared.readTextFile($0.0), $0.1)
}
}
func test_generated_files() throws {
// Given
let sourcePath = try temporaryPath()
let destinationPath = try temporaryPath()
let name = "test name"
let aContent = "test a content"
let bContent = "test b content"
templateLoader.loadTemplateStub = { _ in
Template(description: "",
attributes: [.required("name")],
files: [
(path: RelativePath("a"), contents: .generated(sourcePath.appending(component: "a"))),
(path: RelativePath("b/{{ name }}"), contents: .generated(sourcePath.appending(components: "b"))),
],
directories: [RelativePath("b")])
}
templateLoader.loadGenerateFileStub = { filePath, _ in
if filePath == destinationPath.appending(components: "a") {
return aContent
} else if filePath == destinationPath.appending(components: "b") {
return bContent
} else {
XCTFail("unexpected path \(filePath)")
return ""
}
}
let expectedFiles: [(AbsolutePath, String)] = [
(destinationPath.appending(component: "a"), aContent),
(destinationPath.appending(components: "b", name), bContent),
]
// When
try subject.generate(at: sourcePath,
to: destinationPath,
attributes: ["--name", name])
// Then
try expectedFiles.forEach {
XCTAssertEqual(try FileHandler.shared.readTextFile($0.0), $0.1)
}
}
func test_attributes_passed_to_generated_file() throws {
// Given
let sourcePath = try temporaryPath()
let destinationPath = try temporaryPath()
var parsedAttributes: [ParsedAttribute] = []
templateLoader.loadGenerateFileStub = { filePath, attributes in
if filePath == sourcePath.appending(component: "a") {
parsedAttributes = attributes
} else {
XCTFail("unexpected path \(filePath)")
}
return ""
}
templateLoader.loadTemplateStub = { _ in
Template(description: "",
attributes: [.required("name")],
files: [(path: RelativePath("a"),
contents: .generated(sourcePath.appending(component: "a")))])
}
let expectedParsedAttributes: [ParsedAttribute] = [ParsedAttribute(name: "name", value: "attribute name")]
// When
try subject.generate(at: sourcePath,
to: destinationPath,
attributes: ["--name", "attribute name"])
// Then
XCTAssertEqual(parsedAttributes, expectedParsedAttributes)
}
}

View File

@ -0,0 +1,8 @@
Feature: Scaffold a project using Tuist
Scenario: The project is an application with templates (ios_app_with_templates)
Given that tuist is available
And I have a working directory
Then I copy the fixture ios_app_with_templates into the working directory
Then tuist scaffolds a custom template to TemplateProject named TemplateProject and platform ios
Then content of a file named custom_dir/custom.swift in a directory TemplateProject should be equal to "// this is test TemplateProject content"

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
Then(/tuist scaffolds a (.+) template to (.+) named (.+) and platform (.+)/) do |template, path, name, platform|
system("swift", "run", "tuist", "scaffold", "--list")
system("swift", "run", "tuist", "scaffold", template, "--path", File.join(@dir, path), "--attributes", "--name", name, "--platform", platform)
end
Then(/content of a file named (.+) in a directory (.+) should be equal to (.+)/) do |file, dir, content|
File.read(File.join(@dir, dir, file)) != content
end

View File

@ -14,6 +14,7 @@ let template = Template(
platformAttribute
],
files: [
.string(path: "\(nameAttribute)/custom.swift", contents: testContents)
.string(path: "\(nameAttribute)/custom.swift", contents: testContents),
.file(path: "\(nameAttribute)/generated.swift", templatePath: "platform.stencil"),
]
)

View File

@ -0,0 +1 @@
// Generated file with platform: {{ platform }} and name: {{ name }}