Generate content with stencil.
This commit is contained in:
parent
79867adb01
commit
1edfa4d39e
|
@ -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",
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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
|
|
@ -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"),
|
||||
]
|
||||
)
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
// Generated file with platform: {{ platform }} and name: {{ name }}
|
Loading…
Reference in New Issue