Adding option .directory for scaffolding templates (#2985)

* [WIP] Adding  option for scaffolding files

* Use of file manager for copying folder, removed recursive copy files

* adding documentation in `scaffold.md` and unit test modified for support `.directory` option

* Update CHANGELOG.md

Co-authored-by: Daniele Formichelli <df@bendingspoons.com>

* Adding acceptance testing

* changing `Template.File` for `Template.Item` and other comment in PR

* Fixing references for items/files in templates

Co-authored-by: Daniele Formichelli <df@bendingspoons.com>
This commit is contained in:
Santiago A. Delgado 2021-05-25 05:32:03 -05:00 committed by GitHub
parent 52dc811a73
commit 74fbf0179b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 227 additions and 82 deletions

View File

@ -4,6 +4,10 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
## Next
### Added
- Add option to `Scaffolding` for copy folder with option `.directory(path: "destinationContainerFolder", sourcePath: "sourceFolder")`. [#2985](https://github.com/tuist/tuist/pull/2985) by [@santi-d](https://github.com/santi-d)
## 1.43.0 - Peroxide
### Added

View File

@ -6,20 +6,31 @@ public struct Template: Codable, Equatable {
public let description: String
/// Attributes to be passed to template
public let attributes: [Attribute]
/// Files to generate
public let files: [File]
/// Items to generate
public let items: [Item]
public init(description: String,
attributes: [Attribute] = [],
files: [File] = [])
items: [Item] = [])
{
self.description = description
self.attributes = attributes
self.files = files
self.items = items
dumpIfNeeded(self)
}
/// Enum containing information about how to generate file
@available(*, deprecated, message: "Use init with `items: [Item]` instead")
public init(description: String,
attributes: [Attribute] = [],
files: [Item] = [])
{
self.description = description
self.attributes = attributes
items = files
dumpIfNeeded(self)
}
/// Enum containing information about how to generate item
public enum Contents: Codable, Equatable {
/// String Contents is defined in `name_of_template.swift` and contains a simple `String`
/// Can not contain any additional logic apart from plain `String` from `arguments`
@ -27,6 +38,9 @@ public struct Template: Codable, Equatable {
/// File content is defined in a different file from `name_of_template.swift`
/// Can contain additional logic and anything that is defined in `ProjectDescriptionHelpers`
case file(Path)
/// Directory content is defined in a path
/// It is just for copying files without modifications and logic inside
case directory(Path)
private enum CodingKeys: String, CodingKey {
case type
@ -42,6 +56,9 @@ public struct Template: Codable, Equatable {
} else if type == "file" {
let value = try container.decode(Path.self, forKey: .value)
self = .file(value)
} else if type == "directory" {
let value = try container.decode(Path.self, forKey: .value)
self = .directory(value)
} else {
fatalError("Argument '\(type)' not supported")
}
@ -57,12 +74,15 @@ public struct Template: Codable, Equatable {
case let .file(path):
try container.encode("file", forKey: .type)
try container.encode(path, forKey: .value)
case let .directory(path):
try container.encode("directory", forKey: .type)
try container.encode(path, forKey: .value)
}
}
}
/// File description for generating
public struct File: Codable, Equatable {
public struct Item: Codable, Equatable {
public let path: String
public let contents: Contents
@ -115,21 +135,29 @@ public struct Template: Codable, Equatable {
}
}
public extension Template.File {
public extension Template.Item {
/// - Parameters:
/// - path: Path where to generate file
/// - contents: String Contents
/// - Returns: `Template.File` that is `.string`
static func string(path: String, contents: String) -> Template.File {
Template.File(path: path, contents: .string(contents))
/// - Returns: `Template.Item` that is `.string`
static func string(path: String, contents: String) -> Template.Item {
Template.Item(path: path, contents: .string(contents))
}
/// - Parameters:
/// - path: Path where to generate file
/// - templatePath: Path of file where the template is defined
/// - Returns: `Template.File` that is `.file`
static func file(path: String, templatePath: Path) -> Template.File {
Template.File(path: path, contents: .file(templatePath))
/// - Returns: `Template.Item` that is `.file`
static func file(path: String, templatePath: Path) -> Template.Item {
Template.Item(path: path, contents: .file(templatePath))
}
/// - Parameters:
/// - path: Path where will be copied the folder
/// - sourcePath: Path of folder which will be copied
/// - Returns: `Template.Item` that is `.directory`
static func directory(path: String, sourcePath: Path) -> Template.Item {
Template.Item(path: path, contents: .directory(sourcePath))
}
}

View File

@ -3,15 +3,27 @@ import TSCBasic
public struct Template: Equatable {
public let description: String
public let attributes: [Attribute]
public let files: [File]
public let items: [Item]
public init(description: String,
attributes: [Attribute] = [],
files: [File] = [])
items: [Item] = [])
{
self.description = description
self.attributes = attributes
self.files = files
self.items = items
}
@available(*, deprecated, message: "Use init with `items: [Item]` instead")
public init(description: String,
attributes: [Attribute] = [],
files: [Item] = [])
{
self.init(
description: description,
attributes: attributes,
items: files
)
}
public enum Attribute: Equatable {
@ -40,9 +52,10 @@ public struct Template: Equatable {
public enum Contents: Equatable {
case string(String)
case file(AbsolutePath)
case directory(AbsolutePath)
}
public struct File: Equatable {
public struct Item: Equatable {
public let path: RelativePath
public let contents: Contents

View File

@ -5,21 +5,21 @@ import TSCBasic
extension Template {
public static func test(description: String = "Template",
attributes: [Attribute] = [],
files: [Template.File] = []) -> Template
items: [Template.Item] = []) -> Template
{
Template(
description: description,
attributes: attributes,
files: files
items: items
)
}
}
extension Template.File {
extension Template.Item {
public static func test(path: RelativePath,
contents: Template.Contents = .string("test content")) -> Template.File
contents: Template.Contents = .string("test content")) -> Template.Item
{
Template.File(
Template.Item(
path: path,
contents: contents
)

View File

@ -38,7 +38,7 @@ public class TemplateLoader: TemplateLoading {
extension TuistGraph.Template {
static func from(manifest: ProjectDescription.Template, generatorPaths: GeneratorPaths) throws -> TuistGraph.Template {
let attributes = try manifest.attributes.map(TuistGraph.Template.Attribute.from)
let files = try manifest.files.map { File(
let items = try manifest.items.map { Item(
path: RelativePath($0.path),
contents: try TuistGraph.Template.Contents.from(
manifest: $0.contents,
@ -48,7 +48,7 @@ extension TuistGraph.Template {
return TuistGraph.Template(
description: manifest.description,
attributes: attributes,
files: files
items: items
)
}
}
@ -73,6 +73,8 @@ extension TuistGraph.Template.Contents {
return .string(contents)
case let .file(templatePath):
return .file(try generatorPaths.resolve(path: templatePath))
case let .directory(sourcePath):
return .directory(try generatorPaths.resolve(path: sourcePath))
}
}
}

View File

@ -6,6 +6,6 @@ import TuistLoader
public final class MockTemplateLoader: TemplateLoading {
public var loadTemplateStub: ((AbsolutePath) throws -> Template)?
public func loadTemplate(at path: AbsolutePath) throws -> Template {
try loadTemplateStub?(path) ?? Template(description: "", attributes: [], files: [])
try loadTemplateStub?(path) ?? Template(description: "", attributes: [], items: [])
}
}

View File

@ -12,12 +12,12 @@ extension Config {
extension Template {
public static func test(description: String = "Template",
attributes: [Attribute] = [],
files: [Template.File] = []) -> Template
items: [Template.Item] = []) -> Template
{
Template(
description: description,
attributes: attributes,
files: files
items: items
)
}
}

View File

@ -25,17 +25,17 @@ public final class TemplateGenerator: TemplateGenerating {
to destinationPath: AbsolutePath,
attributes: [String: String]) throws
{
let renderedFiles = renderFiles(
let renderedItems = renderItems(
template: template,
attributes: attributes
)
try generateDirectories(
renderedFiles: renderedFiles,
renderedItems: renderedItems,
destinationPath: destinationPath
)
try generateFiles(
renderedFiles: renderedFiles,
try generateItems(
renderedItems: renderedItems,
attributes: attributes,
destinationPath: destinationPath
)
@ -43,12 +43,12 @@ public final class TemplateGenerator: TemplateGenerating {
// MARK: - Helpers
/// Renders files' paths in format path_to_dir/{{ attribute_name }} with `attributes`
private func renderFiles(template: Template,
attributes: [String: String]) -> [Template.File]
/// Renders items' paths in format path_to_dir/{{ attribute_name }} with `attributes`
private func renderItems(template: Template,
attributes: [String: String]) -> [Template.Item]
{
attributes.reduce(template.files) { files, attribute in
files.map {
attributes.reduce(template.items) { items, attribute in
items.map {
let path = RelativePath($0.path.pathString.replacingOccurrences(of: "{{ \(attribute.key) }}", with: attribute.value))
var contents = $0.contents
@ -62,16 +62,16 @@ public final class TemplateGenerator: TemplateGenerating {
)
}
return Template.File(path: path, contents: contents)
return Template.Item(path: path, contents: contents)
}
}
}
/// Generate all necessary directories
private func generateDirectories(renderedFiles: [Template.File],
private func generateDirectories(renderedItems: [Template.Item],
destinationPath: AbsolutePath) throws
{
try renderedFiles
try renderedItems
.map(\.path)
.map {
destinationPath.appending(RelativePath($0.dirname))
@ -82,14 +82,14 @@ public final class TemplateGenerator: TemplateGenerating {
}
}
/// Generate all `renderedFiles`
private func generateFiles(renderedFiles: [Template.File],
/// Generate all `renderedItems`
private func generateItems(renderedItems: [Template.Item],
attributes: [String: String],
destinationPath: AbsolutePath) throws
{
let environment = stencilSwiftEnvironment()
try renderedFiles.forEach {
let renderedContents: String
try renderedItems.forEach {
let renderedContents: String?
switch $0.contents {
case let .string(contents):
renderedContents = try environment.renderTemplate(
@ -107,11 +107,22 @@ public final class TemplateGenerator: TemplateGenerating {
} else {
renderedContents = fileContents
}
case let .directory(path):
let destinationDirectoryPath = destinationPath.appending(components: $0.path.pathString, path.basename)
// workaround for creating folder tree of destinationDirectoryPath
if !FileHandler.shared.exists(destinationDirectoryPath.parentDirectory) {
try FileHandler.shared.createFolder(destinationDirectoryPath.parentDirectory)
}
if FileHandler.shared.exists(destinationDirectoryPath) {
try FileHandler.shared.delete(destinationDirectoryPath)
}
try FileHandler.shared.copy(from: path, to: destinationDirectoryPath)
renderedContents = nil
}
// Generate file only when it has some content
guard !renderedContents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
guard let rendered = renderedContents, !rendered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
try FileHandler.shared.write(
renderedContents,
rendered,
path: destinationPath.appending($0.path),
atomically: true
)

View File

@ -12,9 +12,10 @@ class TemplateTests: XCTestCase {
.optional("aName", default: "defaultName"),
.optional("bName", default: ""),
],
files: [
items: [
.string(path: "static.swift", contents: "content"),
.file(path: "generated.swift", templatePath: "generate.swift"),
.directory(path: "destinationFolder", sourcePath: "sourceFolder"),
]
)

View File

@ -51,7 +51,7 @@ final class ListServiceTests: TuistUnitTestCase {
}
templateLoader.loadTemplateStub = { _ in
Template(description: "description")
Template(description: "description", items: [])
}
// When
@ -82,7 +82,7 @@ final class ListServiceTests: TuistUnitTestCase {
}
templateLoader.loadTemplateStub = { _ in
Template(description: "description")
Template(description: "description", items: [])
}
// When
@ -113,7 +113,7 @@ final class ListServiceTests: TuistUnitTestCase {
}
templateLoader.loadTemplateStub = { _ in
Template(description: "description")
Template(description: "description", items: [])
}
// When

View File

@ -54,7 +54,8 @@ final class ScaffoldServiceTests: TuistUnitTestCase {
attributes: [
.required("required"),
.optional("optional", default: ""),
]
],
items: []
)
}
@ -83,7 +84,8 @@ final class ScaffoldServiceTests: TuistUnitTestCase {
attributes: [
.required("required"),
.optional("optional", default: ""),
]
],
items: []
)
}

View File

@ -132,7 +132,8 @@ final class ManifestLoaderTests: TuistTestCase {
import ProjectDescription
let template = Template(
description: "Template description"
description: "Template description",
items: []
)
"""
@ -158,7 +159,8 @@ final class ManifestLoaderTests: TuistTestCase {
import ProjectDescription
let template = Template(
description: "Template description"
description: "Template description",
items: []
)
"""

View File

@ -45,7 +45,7 @@ final class TemplateLoaderTests: TuistUnitTestCase {
manifestLoader.loadTemplateStub = { _ in
ProjectDescription.Template(
description: "desc",
files: [ProjectDescription.Template.File(
items: [ProjectDescription.Template.Item(
path: "generateOne",
contents: .file("fileOne")
)]
@ -58,7 +58,7 @@ final class TemplateLoaderTests: TuistUnitTestCase {
// Then
XCTAssertEqual(got, TuistGraph.Template(
description: "desc",
files: [Template.File(
items: [Template.Item(
path: RelativePath("generateOne"),
contents: .file(temporaryPath.appending(component: "fileOne"))
)]

View File

@ -27,11 +27,11 @@ final class TemplateGeneratorTests: TuistTestCase {
let directories = [RelativePath("a"), RelativePath("a/b"), RelativePath("c")]
let destinationPath = try temporaryPath()
let expectedDirectories = directories.map(destinationPath.appending)
let files = directories.map {
Template.File.test(path: RelativePath($0.pathString + "/file.swift"))
let items = directories.map {
Template.Item.test(path: RelativePath($0.pathString + "/file.swift"))
}
let template = Template.test(files: files)
let template = Template.test(items: items)
// When
try subject.generate(
@ -47,10 +47,10 @@ final class TemplateGeneratorTests: TuistTestCase {
func test_directories_with_attributes() throws {
// Given
let directories = [RelativePath("{{ name }}"), RelativePath("{{ aName }}"), RelativePath("{{ name }}/{{ bName }}")]
let files = directories.map {
Template.File.test(path: RelativePath($0.pathString + "/file.swift"))
let items = directories.map {
Template.Item.test(path: RelativePath($0.pathString + "/file.swift"))
}
let template = Template.test(files: files)
let template = Template.test(items: items)
let destinationPath = try temporaryPath()
let expectedDirectories = [RelativePath("test_name"),
RelativePath("test"),
@ -71,19 +71,19 @@ final class TemplateGeneratorTests: TuistTestCase {
func test_files_are_generated() throws {
// Given
let files: [Template.File] = [
Template.File(path: RelativePath("a"), contents: .string("aContent")),
Template.File(path: RelativePath("b"), contents: .string("bContent")),
let items: [Template.Item] = [
Template.Item(path: RelativePath("a"), contents: .string("aContent")),
Template.Item(path: RelativePath("b"), contents: .string("bContent")),
]
let template = Template.test(files: files)
let template = Template.test(items: items)
let destinationPath = try temporaryPath()
let expectedFiles: [(AbsolutePath, String)] = files.compactMap {
let expectedFiles: [(AbsolutePath, String)] = items.compactMap {
let content: String
switch $0.contents {
case let .string(staticContent):
content = staticContent
case .file:
case .file, .directory:
XCTFail("Unexpected type")
return nil
}
@ -106,12 +106,12 @@ final class TemplateGeneratorTests: TuistTestCase {
func test_files_are_generated_with_attributes() throws {
// Given
let sourcePath = try temporaryPath()
let files = [
Template.File(path: RelativePath("{{ name }}"), contents: .string("{{ contentName }}")),
Template.File(path: RelativePath("{{ directoryName }}/{{ fileName }}"), contents: .string("bContent")),
Template.File(path: RelativePath("file"), contents: .file(sourcePath.appending(component: "{{ filePath }}"))),
let items = [
Template.Item(path: RelativePath("{{ name }}"), contents: .string("{{ contentName }}")),
Template.Item(path: RelativePath("{{ directoryName }}/{{ fileName }}"), contents: .string("bContent")),
Template.Item(path: RelativePath("file"), contents: .file(sourcePath.appending(component: "{{ filePath }}"))),
]
let template = Template.test(files: files)
let template = Template.test(items: items)
let name = "test name"
let contentName = "test content"
let fileContent = "test file content"
@ -152,11 +152,11 @@ final class TemplateGeneratorTests: TuistTestCase {
let name = "test name"
let aContent = "test a content"
let bContent = "test b content"
let files = [
Template.File(path: RelativePath("a/file"), contents: .file(sourcePath.appending(component: "testFile"))),
Template.File(path: RelativePath("b/{{ name }}/file"), contents: .file(sourcePath.appending(components: "bTestFile"))),
let items = [
Template.Item(path: RelativePath("a/file"), contents: .file(sourcePath.appending(component: "testFile"))),
Template.Item(path: RelativePath("b/{{ name }}/file"), contents: .file(sourcePath.appending(components: "bTestFile"))),
]
let template = Template.test(files: files)
let template = Template.test(items: items)
try FileHandler.shared.write(aContent, path: sourcePath.appending(component: "testFile"), atomically: true)
try FileHandler.shared.write(bContent, path: sourcePath.appending(component: "bTestFile"), atomically: true)
let expectedFiles: [(AbsolutePath, String)] = [
@ -187,7 +187,7 @@ final class TemplateGeneratorTests: TuistTestCase {
path: sourcePath.appending(component: "a.stencil"),
atomically: true
)
let template = Template.test(files: [Template.File(
let template = Template.test(items: [Template.Item(
path: RelativePath("a"),
contents: .file(sourcePath.appending(component: "a.stencil"))
)])
@ -222,12 +222,12 @@ final class TemplateGeneratorTests: TuistTestCase {
path: sourcePath.appending(component: "a.swift"),
atomically: true
)
let template = Template.test(files: [
Template.File(
let template = Template.test(items: [
Template.Item(
path: RelativePath("unrendered"),
contents: .file(sourcePath.appending(component: "a.swift"))
),
Template.File(
Template.Item(
path: RelativePath("rendered"),
contents: .file(sourcePath.appending(component: "a.stencil"))
),
@ -260,8 +260,8 @@ final class TemplateGeneratorTests: TuistTestCase {
path: sourcePath.appending(component: "b.stencil"),
atomically: true
)
let template = Template.test(files: [
Template.File(
let template = Template.test(items: [
Template.Item(
path: RelativePath("ignore"),
contents: .file(sourcePath.appending(component: "b.stencil"))
),
@ -277,4 +277,39 @@ final class TemplateGeneratorTests: TuistTestCase {
// Then
XCTAssertFalse(FileHandler.shared.exists(destinationPath.appending(component: "ignore")))
}
func test_copy_directory() throws {
// Given
let sourcePath = try temporaryPath().appending(components: "folder")
try FileHandler.shared.createFolder(sourcePath)
let destinationPath = try temporaryPath()
let expectedContentFile = "File's content"
try FileHandler.shared.write(
expectedContentFile,
path: sourcePath.appending(component: "file1.txt"),
atomically: true
)
let template = Template.test(items: [
Template.Item(
path: RelativePath("destination"),
contents: .directory(sourcePath)
),
])
// When
try subject.generate(
template: template,
to: destinationPath,
attributes: [:]
)
// Then
XCTAssertEqual(
try FileHandler.shared.readTextFile(destinationPath.appending(components: "destination", "folder", "file1.txt")),
expectedContentFile
)
}
}

View File

@ -38,6 +38,10 @@ let template = Template(
path: "generated/Up.swift",
templatePath: "generate.stencil"
),
.directory(
path: "destinationFolder",
sourcePath: "sourceFolder"
),
]
)
```
@ -54,4 +58,6 @@ Since platform is an optional argument, we can also call the command without the
If `.string` and `.files` don't provide enough flexibility, you can leverage the [Stencil](https://github.com/stencilproject/Stencil) templating language via the `.file` case. Besides that, you can also use additional filters defined [here](https://github.com/SwiftGen/StencilSwiftKit#filters)
You can also use `.directory` which gives the possibility to copy entire folders to a given path.
Templates can import [project description helpers](/guides/helpers/). Just add `import ProjectDescriptionHelpers` at the top, and extract reusable logic into the helpers.

View File

@ -25,6 +25,23 @@ Feature: Scaffold a project using Tuist
"""
// Generated file with platform: iOS and snake case name: template_project
"""
Then tuist scaffolds a custom_using_copy_folder template to TemplateProject named TemplateProject
Then content of a file named TemplateProject/custom.swift in a directory TemplateProject should be equal to // this is test TemplateProject content
Then content of a file named TemplateProject/generated.swift in a directory TemplateProject should be equal to:
"""
// Generated file with platform: ios and name: TemplateProject
"""
Then content of a file named TemplateProject/sourceFolder/file1.txt in a directory TemplateProject should be equal to:
"""
Content of file 1
"""
Then content of a file named TemplateProject/sourceFolder/subFolder/file2.txt in a directory TemplateProject should be equal to:
"""
Content of file 2
"""
Scenario: The project is an application with templates from plugins (app_with_plugins)

View File

@ -0,0 +1,21 @@
import ProjectDescription
let nameAttributeFour: Template.Attribute = .required("name")
let platformAttributeFour: Template.Attribute = .optional("platform", default: "ios")
let testContentsFour = """
// this is test \(nameAttributeFour) content
"""
let templateFour = Template(
description: "Custom template",
attributes: [
nameAttributeFour,
platformAttributeFour
],
files: [
.string(path: "\(nameAttributeFour)/custom.swift", contents: testContentsFour),
.file(path: "\(nameAttributeFour)/generated.swift", templatePath: "platform_four.stencil"),
.directory(path: "\(nameAttributeFour)",sourcePath: "sourceFolder")
]
)

View File

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