* Create Tasks model

* Run custom task

* Create tasks fixture test

* Add task options

* Rename load arguments for Tasks

* Foprmat code

* WIP: Change tasks to work with multiple files

* Run task without options

* Parse options with regex

* Remove optional and required options

* Skip building project description helpers for tasks

* Move Task model to ProjectAutomation

* WIP: Editing tasks

* Editing tasks

* Add task documentation

* WIP tests

* Add tests

* Format code

* Limit file named in step definitions

* Fix ruby code format

* Rename task command to exec

* Trigger CI workflows

Co-authored-by: Pedro Piñera <pedro@ppinera.es>
This commit is contained in:
Marek Fořt 2021-05-20 12:28:11 +02:00 committed by GitHub
parent 8d9dc8c7f5
commit 1f776e728f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1308 additions and 104 deletions

View File

@ -25,6 +25,11 @@ let package = Package(
type: .dynamic,
targets: ["ProjectDescription"]
),
.library(
name: "ProjectAutomation",
type: .dynamic,
targets: ["ProjectAutomation"]
),
.library(
name: "TuistGraph",
targets: ["TuistGraph"]
@ -138,6 +143,7 @@ let package = Package(
"TuistCache",
"TuistAutomation",
"ProjectDescription",
"ProjectAutomation",
signalsDependency,
rxSwiftDependency,
rxBlockingDependency,
@ -155,6 +161,7 @@ let package = Package(
"TuistAnalytics",
"TuistPlugin",
"TuistGraph",
"TuistTasks",
]
),
.testTarget(
@ -165,6 +172,7 @@ let package = Package(
"TuistSupportTesting",
"TuistCoreTesting",
"ProjectDescription",
"ProjectAutomation",
rxBlockingDependency,
"TuistLoaderTesting",
"TuistCacheTesting",
@ -181,6 +189,7 @@ let package = Package(
"TuistGraphTesting",
"TuistPlugin",
"TuistPluginTesting",
"TuistTasksTesting",
]
),
.testTarget(
@ -190,6 +199,7 @@ let package = Package(
"TuistCoreTesting",
"TuistSupportTesting",
"ProjectDescription",
"ProjectAutomation",
rxBlockingDependency,
"TuistLoaderTesting",
"TuistCloudTesting",
@ -201,6 +211,7 @@ let package = Package(
dependencies: [
"TuistKit",
"ProjectDescription",
"ProjectAutomation",
]
),
.target(
@ -237,6 +248,10 @@ let package = Package(
"TuistSupportTesting",
]
),
.target(
name: "ProjectAutomation",
dependencies: []
),
.target(
name: "TuistSupport",
dependencies: [
@ -396,6 +411,29 @@ let package = Package(
"TuistGraphTesting",
]
),
.target(
name: "TuistTasks",
dependencies: [
swiftToolsSupportDependency,
"TuistCore",
"TuistSupport",
]
),
.target(
name: "TuistTasksTesting",
dependencies: [
"TuistTasks",
"TuistGraphTesting",
]
),
.testTarget(
name: "TuistTasksIntegrationTests",
dependencies: [
"TuistTasks",
"TuistSupportTesting",
"TuistGraphTesting",
]
),
.target(
name: "TuistScaffold",
dependencies: [

View File

@ -68,6 +68,15 @@ def package
"-Xswiftc", "-emit-module-interface-path",
"-Xswiftc", ".build/release/ProjectDescription.swiftinterface"
)
system(
"swift", "build",
"--product", "ProjectAutomation",
"--configuration", "release",
"-Xswiftc", "-enable-library-evolution",
"-Xswiftc", "-emit-module-interface",
"-Xswiftc", "-emit-module-interface-path",
"-Xswiftc", ".build/release/ProjectAutomation.swiftinterface"
)
system("swift", "build", "--product", "tuistenv", "--configuration", "release")
build_templates_path = File.join(__dir__, ".build/release/Templates")
@ -91,6 +100,10 @@ def package
"ProjectDescription.swiftdoc",
"libProjectDescription.dylib",
"ProjectDescription.swiftinterface",
"ProjectAutomation.swiftmodule",
"ProjectAutomation.swiftdoc",
"libProjectAutomation.dylib",
"ProjectAutomation.swiftinterface",
"Templates",
"vendor",
"script"

View File

@ -0,0 +1,35 @@
import Foundation
public struct Task {
public let options: [Option]
public let task: ([String: String]) throws -> Void
public enum Option: Equatable {
case option(String)
}
public init(
options: [Option],
task: @escaping ([String: String]) throws -> Void
) {
self.options = options
self.task = task
runIfNeeded()
}
private func runIfNeeded() {
guard
let taskCommandLineIndex = CommandLine.arguments.firstIndex(of: "--tuist-task"),
CommandLine.argc > taskCommandLineIndex
else { return }
let attributesString = CommandLine.arguments[taskCommandLineIndex + 1]
// swiftlint:disable force_try
let attributes: [String: String] = try! JSONDecoder().decode(
[String: String].self,
from: attributesString.data(using: .utf8)!
)
try! task(attributes)
// swiftlint:enable force_try
}
}

View File

@ -1,15 +1,15 @@
import Foundation
func dumpIfNeeded<E: Encodable>(_ entity: E) {
if CommandLine.argc > 0 {
if CommandLine.arguments.contains("--tuist-dump") {
let encoder = JSONEncoder()
// swiftlint:disable:next force_try
let data = try! encoder.encode(entity)
let manifest = String(data: data, encoding: .utf8)!
print("TUIST_MANIFEST_START")
print(manifest)
print("TUIST_MANIFEST_END")
}
}
guard
CommandLine.argc > 0,
CommandLine.arguments.contains("--tuist-dump")
else { return }
let encoder = JSONEncoder()
// swiftlint:disable:next force_try
let data = try! encoder.encode(entity)
let manifest = String(data: data, encoding: .utf8)!
print("TUIST_MANIFEST_START")
print(manifest)
print("TUIST_MANIFEST_END")
}

View File

@ -4,7 +4,7 @@ import TSCBasic
extension Template {
public static func test(description: String = "Template",
attributes: [Template.Attribute] = [],
attributes: [Attribute] = [],
files: [Template.File] = []) -> Template
{
Template(

View File

@ -0,0 +1,166 @@
import ArgumentParser
import Foundation
import TuistSupport
enum ExecCommandError: FatalError, Equatable {
case taskNotProvided
var description: String {
switch self {
case .taskNotProvided:
return "You must provide a task name."
}
}
var type: ErrorType {
switch self {
case .taskNotProvided:
return .bug
}
}
}
struct ExecCommand: ParsableCommand {
static var configuration: CommandConfiguration {
CommandConfiguration(
commandName: "exec",
abstract: "Runs a task defined in Tuist/Tasks directory."
)
}
@Argument(
help: "Name of a task you want to run."
)
var task: String
@Option(
name: .shortAndLong,
help: "The path to the directory where the tasks are run from.",
completion: .directory
)
var path: String?
func run() throws {
try ExecService().run(
task,
options: taskOptions,
path: path
)
}
init() {}
var taskOptions: [String: String] = [:]
// Custom decoding to decode dynamic options
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
task = try container.decode(Argument<String>.self, forKey: .task).wrappedValue
path = try container.decodeIfPresent(Option<String>.self, forKey: .path)?.wrappedValue
try ExecCommand.options.forEach { option in
guard let value = try container.decode(
Option<String?>.self,
forKey: .option(option.name)
)
.wrappedValue
else { return }
taskOptions[option.name] = value
}
}
}
// MARK: - Preprocessing
extension ExecCommand {
static var options: [(name: String, option: Option<String?>)] = []
/// We do not know template's option in advance -> we need to dynamically add them
static func preprocess(_ arguments: [String]? = nil) throws {
guard
let arguments = arguments,
arguments.count >= 2
else { throw ExecCommandError.taskNotProvided }
guard !configuration.subcommands.contains(where: { $0.configuration.commandName == arguments[1] }) else { return }
// We want to parse only the name of a task, not its arguments which will be dynamically added
// Plucking out path arguments
let pairedArguments: [[String]] = stride(from: 2, to: arguments.count, by: 2).map {
Array(arguments[$0 ..< min($0 + 2, arguments.count)])
}
let filteredArguments = pairedArguments
.filter {
$0.first == "--path" || $0.first == "-p"
}
.flatMap { $0 }
guard let command = try parseAsRoot([arguments[1]] + filteredArguments) as? ExecCommand else { return }
ExecCommand.options = try ExecService().loadTaskOptions(
taskName: command.task,
path: command.path
)
.map {
(name: $0, option: Option<String?>())
}
}
}
// MARK: - TaskCommand.CodingKeys
extension ExecCommand {
enum CodingKeys: CodingKey {
case task
case path
case option(String)
var stringValue: String {
switch self {
case .task:
return "task"
case .path:
return "path"
case let .option(option):
return option
}
}
init?(stringValue: String) {
switch stringValue {
case "task":
self = .task
case "path":
self = .path
default:
if ExecCommand.options.map(\.name).contains(stringValue) {
self = .option(stringValue)
} else {
return nil
}
}
}
// Not used
var intValue: Int? { nil }
init?(intValue _: Int) { nil }
}
}
/// ArgumentParser library gets the list of options from a mirror.
/// Since we do not declare task's options in advance, we need to rewrite the mirror implementation and add them ourselves.
extension ExecCommand: CustomReflectable {
var customMirror: Mirror {
let optionsChildren = ExecCommand.options
.map { Mirror.Child(label: $0.name, value: $0.option) }
let children = [
Mirror.Child(label: "task", value: _task),
Mirror.Child(label: "path", value: _path),
]
.filter {
// Prefer attributes defined in a template if it clashes with predefined ones
$0.label.map { label in
!ExecCommand.options.map(\.name)
.contains(label)
} ?? true
}
return Mirror(ExecCommand(), children: children + optionsChildren)
}
}

View File

@ -32,6 +32,7 @@ public struct TuistCommand: ParsableCommand {
CleanCommand.self,
DocCommand.self,
DependenciesCommand.self,
ExecCommand.self,
]
)
}
@ -53,6 +54,9 @@ public struct TuistCommand: ParsableCommand {
if processedArguments.first == InitCommand.configuration.commandName {
try InitCommand.preprocess(processedArguments)
}
if processedArguments.first == ExecCommand.configuration.commandName {
try ExecCommand.preprocess(processedArguments)
}
command = try parseAsRoot(processedArguments)
} catch {
let exitCode = self.exitCode(for: error).rawValue

View File

@ -6,6 +6,7 @@ import TuistGraph
import TuistLoader
import TuistScaffold
import TuistSupport
import TuistTasks
enum ProjectEditorError: FatalError, Equatable {
/// This error is thrown when we try to edit in a project in a directory that has no editable files.
@ -60,8 +61,8 @@ final class ProjectEditor: ProjectEditing {
let templatesDirectoryLocator: TemplatesDirectoryLocating
private let cacheDirectoryProviderFactory: CacheDirectoriesProviderFactoring
private let projectDescriptionHelpersBuilderFactory: ProjectDescriptionHelpersBuilderFactoring
private let tasksLocator: TasksLocating
/// Xcode Project writer
private let writer: XcodeProjWriting
@ -75,7 +76,8 @@ final class ProjectEditor: ProjectEditing {
writer: XcodeProjWriting = XcodeProjWriter(),
templatesDirectoryLocator: TemplatesDirectoryLocating = TemplatesDirectoryLocator(),
cacheDirectoryProviderFactory: CacheDirectoriesProviderFactoring = CacheDirectoriesProviderFactory(),
projectDescriptionHelpersBuilderFactory: ProjectDescriptionHelpersBuilderFactoring = ProjectDescriptionHelpersBuilderFactory()
projectDescriptionHelpersBuilderFactory: ProjectDescriptionHelpersBuilderFactoring = ProjectDescriptionHelpersBuilderFactory(),
tasksLocator: TasksLocating = TasksLocator()
) {
self.generator = generator
self.projectEditorMapper = projectEditorMapper
@ -86,6 +88,7 @@ final class ProjectEditor: ProjectEditing {
self.templatesDirectoryLocator = templatesDirectoryLocator
self.cacheDirectoryProviderFactory = cacheDirectoryProviderFactory
self.projectDescriptionHelpersBuilderFactory = projectDescriptionHelpersBuilderFactory
self.tasksLocator = tasksLocator
}
func edit(
@ -110,6 +113,8 @@ final class ProjectEditor: ProjectEditing {
FileHandler.shared.glob($0, glob: "**/*.swift") + FileHandler.shared.glob($0, glob: "**/*.stencil")
} ?? []
let tasks = try tasksLocator.locateTasks(at: editingPath)
let editablePluginManifests = locateEditablePluginManifests(at: editingPath, plugins: plugins)
let builtPluginHelperModules = try buildRemotePluginModules(
in: editingPath,
@ -140,7 +145,9 @@ final class ProjectEditor: ProjectEditing {
pluginProjectDescriptionHelpersModule: builtPluginHelperModules,
helpers: helpers,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: tasks,
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: try resourceLocator.projectAutomation()
)
let graphTraverser = ValueGraphTraverser(graph: graph)

View File

@ -19,7 +19,9 @@ protocol ProjectEditorMapping: AnyObject {
pluginProjectDescriptionHelpersModule: [ProjectDescriptionHelpersModule],
helpers: [AbsolutePath],
templates: [AbsolutePath],
projectDescriptionPath: AbsolutePath
tasks: [AbsolutePath],
projectDescriptionPath: AbsolutePath,
projectAutomationPath: AbsolutePath
) throws -> ValueGraph
}
@ -39,7 +41,9 @@ final class ProjectEditorMapper: ProjectEditorMapping {
pluginProjectDescriptionHelpersModule: [ProjectDescriptionHelpersModule],
helpers: [AbsolutePath],
templates: [AbsolutePath],
projectDescriptionPath: AbsolutePath
tasks: [AbsolutePath],
projectDescriptionPath: AbsolutePath,
projectAutomationPath: AbsolutePath
) throws -> ValueGraph {
let swiftVersion = try System.shared.swiftVersion()
@ -55,12 +59,14 @@ final class ProjectEditorMapper: ProjectEditorMapping {
let manifestsProject = mapManifestsProject(
projectManifests: projectManifests,
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: projectAutomationPath,
swiftVersion: swiftVersion,
sourceRootPath: sourceRootPath,
destinationDirectory: destinationDirectory,
tuistPath: tuistPath,
helpers: helpers,
templates: templates,
tasks: tasks,
setupPath: setupPath,
configPath: configPath,
dependenciesPath: dependenciesPath,
@ -122,12 +128,14 @@ final class ProjectEditorMapper: ProjectEditorMapping {
private func mapManifestsProject(
projectManifests: [AbsolutePath],
projectDescriptionPath: AbsolutePath,
projectAutomationPath: AbsolutePath,
swiftVersion: String,
sourceRootPath: AbsolutePath,
destinationDirectory: AbsolutePath,
tuistPath: AbsolutePath,
helpers: [AbsolutePath],
templates: [AbsolutePath],
tasks: [AbsolutePath],
setupPath: AbsolutePath?,
configPath: AbsolutePath?,
dependenciesPath: AbsolutePath?,
@ -165,6 +173,19 @@ final class ProjectEditorMapper: ProjectEditorMapping {
)
}()
let tasksTargets = tasks.map { taskPath in
editorHelperTarget(
name: taskPath.basenameWithoutExt,
filesGroup: manifestsFilesGroup,
targetSettings: Settings(
base: targetBaseSettings(for: [projectAutomationPath], swiftVersion: swiftVersion),
configurations: Settings.default.configurations,
defaultSettings: .recommended
),
sourcePaths: [taskPath]
)
}
let setupTarget: Target? = {
guard let setupPath = setupPath else { return nil }
return editorHelperTarget(
@ -221,7 +242,10 @@ final class ProjectEditorMapper: ProjectEditorMapping {
setupTarget,
configTarget,
dependenciesTarget,
].compactMap { $0 } + manifestsTargets
]
.compactMap { $0 }
+ manifestsTargets
+ tasksTargets
let buildAction = BuildAction(targets: targets.map { TargetReference(projectPath: projectPath, name: $0.name) })
let arguments = Arguments(launchArguments: [LaunchArgument(name: "generate --path \(sourceRootPath)", isEnabled: true)])

View File

@ -0,0 +1,107 @@
import Foundation
import ProjectDescription
import TSCBasic
import TuistCore
import TuistGraph
import TuistLoader
import TuistPlugin
import TuistSupport
import TuistTasks
enum ExecError: FatalError, Equatable {
case taskNotFound(String, [String])
var description: String {
switch self {
case let .taskNotFound(task, tasks):
return "Task \(task) not found. Available tasks are: \(tasks.joined(separator: ", "))"
}
}
var type: ErrorType {
switch self {
case .taskNotFound:
return .abort
}
}
}
struct ExecService {
private let manifestLoader: ManifestLoading
private let tasksLocator: TasksLocating
init(
manifestLoader: ManifestLoading = ManifestLoader(),
tasksLocator: TasksLocating = TasksLocator()
) {
self.manifestLoader = manifestLoader
self.tasksLocator = tasksLocator
}
func run(
_ taskName: String,
options: [String: String],
path: String?
) throws {
let path = self.path(path)
let taskPath = try task(with: taskName, path: path)
let runArguments = try manifestLoader.taskLoadArguments(at: taskPath)
+ [
"--tuist-task",
String(data: try JSONEncoder().encode(options), encoding: .utf8)!,
]
try ProcessEnv.chdir(path)
try System.shared.runAndPrint(
runArguments,
verbose: false,
environment: Environment.shared.manifestLoadingVariables
)
}
func loadTaskOptions(
taskName: String,
path: String?
) throws -> [String] {
let path = self.path(path)
let taskPath = try task(with: taskName, path: path)
let taskContents = try FileHandler.shared.readTextFile(taskPath)
let optionsRegex = try NSRegularExpression(pattern: "\\.option\\(\"([^\"]*)\"\\),?", options: [])
var options: [String] = []
optionsRegex.enumerateMatches(
in: taskContents,
options: [],
range: NSRange(location: 0, length: taskContents.count)
) { match, _, _ in
guard
let match = match,
match.numberOfRanges == 2,
let range = Range(match.range(at: 1), in: taskContents)
else { return }
options.append(
String(taskContents[range])
)
}
return options
}
// MARK: - Helpers
private func task(with name: String, path: AbsolutePath) throws -> AbsolutePath {
let tasks: [String: AbsolutePath] = try tasksLocator.locateTasks(at: path)
.reduce(into: [:]) { acc, current in
acc[current.basenameWithoutExt.camelCaseToKebabCase()] = current
}
guard let task = tasks[name] else { throw ExecError.taskNotFound(name, tasks.map(\.key).sorted()) }
return task
}
private func path(_ path: String?) -> AbsolutePath {
if let path = path {
return AbsolutePath(path, relativeTo: FileHandler.shared.currentPath)
} else {
return FileHandler.shared.currentPath
}
}
}

View File

@ -96,6 +96,10 @@ public class CachedManifestLoader: ManifestLoading {
try manifestLoader.loadDependencies(at: path)
}
public func taskLoadArguments(at path: AbsolutePath) throws -> [String] {
try manifestLoader.taskLoadArguments(at: path)
}
public func manifests(at path: AbsolutePath) -> Set<Manifest> {
manifestLoader.manifests(at: path)
}

View File

@ -88,6 +88,10 @@ public protocol ManifestLoading {
/// - path: Path to the directory that contains Dependencies.swift
func loadDependencies(at path: AbsolutePath) throws -> ProjectDescription.Dependencies
/// Returns arguments for loading `Tasks.swift`
/// You can append this list to insert your own custom flag
func taskLoadArguments(at path: AbsolutePath) throws -> [String]
/// Loads the Plugin.swift in the given directory.
/// - Parameter path: Path to the directory that contains Plugin.swift
func loadPlugin(at path: AbsolutePath) throws -> ProjectDescription.Plugin
@ -199,6 +203,10 @@ public class ManifestLoader: ManifestLoading {
return try decoder.decode(Dependencies.self, from: dependenciesData)
}
public func taskLoadArguments(at path: AbsolutePath) throws -> [String] {
try buildArguments(.task, at: path)
}
public func loadPlugin(at path: AbsolutePath) throws -> ProjectDescription.Plugin {
try loadManifest(.plugin, at: path)
}
@ -213,6 +221,22 @@ public class ManifestLoader: ManifestLoading {
_ manifest: Manifest,
at path: AbsolutePath
) throws -> T {
let manifestPath = try self.manifestPath(
manifest,
at: path
)
let data = try loadDataForManifest(manifest, at: manifestPath)
if Environment.shared.isVerbose {
let string = String(data: data, encoding: .utf8)
logger.debug("Trying to load the manifest represented by the following JSON representation:\n\(string ?? "")")
}
return try decoder.decode(T.self, from: data)
}
private func manifestPath(
_ manifest: Manifest,
at path: AbsolutePath
) throws -> AbsolutePath {
var fileNames = [manifest.fileName(path)]
if let deprecatedFileName = manifest.deprecatedFileName {
fileNames.insert(deprecatedFileName, at: 0)
@ -221,12 +245,7 @@ public class ManifestLoader: ManifestLoading {
for fileName in fileNames {
let manifestPath = path.appending(component: fileName)
if !FileHandler.shared.exists(manifestPath) { continue }
let data = try loadDataForManifest(manifest, at: manifestPath)
if Environment.shared.isVerbose {
let string = String(data: data, encoding: .utf8)
logger.debug("Trying to load the manifest represented by the following JSON representation:\n\(string ?? "")")
}
return try decoder.decode(T.self, from: data)
return manifestPath
}
throw ManifestLoaderError.manifestNotFound(manifest, path)
@ -237,52 +256,10 @@ public class ManifestLoader: ManifestLoading {
_ manifest: Manifest,
at path: AbsolutePath
) throws -> Data {
let projectDescriptionPath = try resourceLocator.projectDescription()
let searchPaths = ProjectDescriptionSearchPaths.paths(for: projectDescriptionPath)
var arguments = [
"/usr/bin/xcrun",
"swiftc",
"--driver-mode=swift",
"-suppress-warnings",
"-I", searchPaths.includeSearchPath.pathString,
"-L", searchPaths.librarySearchPath.pathString,
"-F", searchPaths.frameworkSearchPath.pathString,
"-lProjectDescription",
"-framework", "ProjectDescription",
]
let projectDescriptionHelpersCacheDirectory = try cacheDirectoryProviderFactory
.cacheDirectories(config: nil)
.projectDescriptionHelpersCacheDirectory
let projectDescriptionHelperArguments: [String] = try {
switch manifest {
case .config,
.plugin:
return []
case .dependencies,
.galaxy,
.project,
.setup,
.template,
.workspace:
return try projectDescriptionHelpersBuilderFactory
.projectDescriptionHelpersBuilder(cacheDirectory: projectDescriptionHelpersCacheDirectory)
.build(
at: path,
projectDescriptionSearchPaths: searchPaths,
projectDescriptionHelperPlugins: plugins.projectDescriptionHelpers
).flatMap { [
"-I", $0.path.parentDirectory.pathString,
"-L", $0.path.parentDirectory.pathString,
"-F", $0.path.parentDirectory.pathString,
"-l\($0.name)",
] }
}
}()
arguments.append(contentsOf: projectDescriptionHelperArguments)
arguments.append(path.pathString)
arguments.append("--tuist-dump")
let arguments = try buildArguments(
manifest,
at: path
) + ["--tuist-dump"]
let result = System.shared
.observable(arguments, verbose: false, environment: environment.manifestLoadingVariables)
@ -312,6 +289,75 @@ public class ManifestLoader: ManifestLoading {
}
}
private func buildArguments(
_ manifest: Manifest,
at path: AbsolutePath
) throws -> [String] {
let projectDescriptionPath = try resourceLocator.projectDescription()
let searchPaths = ProjectDescriptionSearchPaths.paths(for: projectDescriptionPath)
let frameworkName: String
switch manifest {
case .task:
frameworkName = "ProjectAutomation"
case .config,
.plugin,
.dependencies,
.galaxy,
.project,
.setup,
.template,
.workspace:
frameworkName = "ProjectDescription"
}
var arguments = [
"/usr/bin/xcrun",
"swiftc",
"--driver-mode=swift",
"-suppress-warnings",
"-I", searchPaths.includeSearchPath.pathString,
"-L", searchPaths.librarySearchPath.pathString,
"-F", searchPaths.frameworkSearchPath.pathString,
"-l\(frameworkName)",
"-framework", frameworkName,
]
let projectDescriptionHelpersCacheDirectory = try cacheDirectoryProviderFactory
.cacheDirectories(config: nil)
.projectDescriptionHelpersCacheDirectory
let projectDescriptionHelperArguments: [String] = try {
switch manifest {
case .config,
.plugin,
.task:
return []
case .dependencies,
.galaxy,
.project,
.setup,
.template,
.workspace:
return try projectDescriptionHelpersBuilderFactory.projectDescriptionHelpersBuilder(
cacheDirectory: projectDescriptionHelpersCacheDirectory
)
.build(
at: path,
projectDescriptionSearchPaths: searchPaths,
projectDescriptionHelperPlugins: plugins.projectDescriptionHelpers
).flatMap { [
"-I", $0.path.parentDirectory.pathString,
"-L", $0.path.parentDirectory.pathString,
"-F", $0.path.parentDirectory.pathString,
"-l\($0.name)",
] }
}
}()
arguments.append(contentsOf: projectDescriptionHelperArguments)
arguments.append(path.pathString)
return arguments
}
private func logUnexpectedImportErrorIfNeeded(in path: AbsolutePath, error: Error, manifest: Manifest) {
guard case let TuistSupport.SystemError.terminated(command, _, standardError) = error,
manifest == .config || manifest == .plugin,

View File

@ -10,6 +10,7 @@ public enum Manifest: CaseIterable {
case galaxy
case dependencies
case plugin
case task
/// This was introduced to rename a file name without breaking existing projects.
public var deprecatedFileName: String? {
@ -44,6 +45,8 @@ public enum Manifest: CaseIterable {
return "Dependencies.swift"
case .plugin:
return "Plugin.swift"
case .task:
return "\(path.basenameWithoutExt).swift"
}
}
}

View File

@ -3,6 +3,7 @@ import TSCBasic
import TuistSupport
public protocol ResourceLocating: AnyObject {
func projectAutomation() throws -> AbsolutePath
func projectDescription() throws -> AbsolutePath
func cliPath() throws -> AbsolutePath
}
@ -37,6 +38,10 @@ public final class ResourceLocator: ResourceLocating {
// MARK: - ResourceLocating
public func projectAutomation() throws -> AbsolutePath {
try frameworkPath("ProjectAutomation")
}
public func projectDescription() throws -> AbsolutePath {
try frameworkPath("ProjectDescription")
}

View File

@ -79,5 +79,10 @@ public final class MockManifestLoader: ManifestLoading {
return try loadPluginStub?(path) ?? Plugin.test()
}
public var taskLoadArgumentsStub: ((AbsolutePath) throws -> [String])?
public func taskLoadArguments(at path: AbsolutePath) throws -> [String] {
try taskLoadArgumentsStub?(path) ?? []
}
public func register(plugins _: Plugins) {}
}

View File

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

View File

@ -10,6 +10,11 @@ public final class MockResourceLocator: ResourceLocating {
public var embedPathCount: UInt = 0
public var embedPathStub: (() throws -> AbsolutePath)?
public var projectAutomationStub: (() throws -> AbsolutePath)?
public func projectAutomation() throws -> AbsolutePath {
try projectAutomationStub?() ?? AbsolutePath("/")
}
public func projectDescription() throws -> AbsolutePath {
projectDescriptionCount += 1
return try projectDescriptionStub?() ?? AbsolutePath("/")

View File

@ -13,6 +13,7 @@ public enum Constants {
public static let helpersDirectoryName: String = "ProjectDescriptionHelpers"
public static let signingDirectoryName: String = "Signing"
public static let tasksDirectoryName: String = "Tasks"
public static let masterKey = "master.key"
public static let encryptedExtension = "encrypted"

View File

@ -122,17 +122,28 @@ extension String {
return ([first] + rest).joined(separator: "")
}
public func camelCaseToSnakeCase() -> String {
let acronymPattern = "([A-Z]+)([A-Z][a-z]|[0-9])"
let normalPattern = "([a-z0-9])([A-Z])"
return processCamalCaseRegex(pattern: acronymPattern)?
.processCamalCaseRegex(pattern: normalPattern)?.lowercased() ?? lowercased()
public func camelCaseToKebabCase() -> String {
convertCamelCase(separator: "-")
}
fileprivate func processCamalCaseRegex(pattern: String) -> String? {
public func camelCaseToSnakeCase() -> String {
convertCamelCase(separator: "_")
}
private func convertCamelCase(separator: String) -> String {
let acronymPattern = "([A-Z]+)([A-Z][a-z]|[0-9])"
let normalPattern = "([a-z0-9])([A-Z])"
return processCamelCaseRegex(pattern: acronymPattern, separator: separator)?
.processCamelCaseRegex(pattern: normalPattern, separator: separator)?.lowercased() ?? lowercased()
}
private func processCamelCaseRegex(
pattern: String,
separator: String
) -> String? {
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(location: 0, length: count)
return regex?.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1_$2")
return regex?.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "$1\(separator)$2")
}
// MARK: - Shell

View File

@ -0,0 +1,29 @@
import Foundation
import TSCBasic
import TuistCore
import TuistSupport
/// Finds tasks.
public protocol TasksLocating {
/// Returns paths to user-defined tasks.
func locateTasks(at path: AbsolutePath) throws -> [AbsolutePath]
}
public final class TasksLocator: TasksLocating {
private let rootDirectoryLocator: RootDirectoryLocating
/// Default constructor.
public init(rootDirectoryLocator: RootDirectoryLocating = RootDirectoryLocator()) {
self.rootDirectoryLocator = rootDirectoryLocator
}
public func locateTasks(at path: AbsolutePath) throws -> [AbsolutePath] {
guard let rootDirectory = rootDirectoryLocator.locate(from: path) else { return [] }
let tasksDirectory = rootDirectory.appending(
components: Constants.tuistDirectoryName, Constants.tasksDirectoryName
)
guard FileHandler.shared.exists(tasksDirectory) else { return [] }
return try FileHandler.shared.contentsOfDirectory(tasksDirectory)
.filter { $0.extension == "swift" }
}
}

View File

@ -0,0 +1,11 @@
import TSCBasic
import TuistTasks
public final class MockTasksLocator: TasksLocating {
public init() {}
public var locateTasksStub: ((AbsolutePath) throws -> [AbsolutePath])?
public func locateTasks(at path: AbsolutePath) throws -> [AbsolutePath] {
try locateTasksStub?(path) ?? []
}
}

View File

@ -23,7 +23,9 @@ final class MockProjectEditorMapper: ProjectEditorMapping {
pluginProjectDescriptionHelpersModule: [ProjectDescriptionHelpersModule],
helpers: [AbsolutePath],
templates: [AbsolutePath],
projectDescriptionPath: AbsolutePath
tasks: [AbsolutePath],
projectDescriptionPath: AbsolutePath,
projectAutomationPath: AbsolutePath
)] = []
func map(
@ -39,7 +41,9 @@ final class MockProjectEditorMapper: ProjectEditorMapping {
pluginProjectDescriptionHelpersModule: [ProjectDescriptionHelpersModule],
helpers: [AbsolutePath],
templates: [AbsolutePath],
projectDescriptionPath: AbsolutePath
tasks: [AbsolutePath],
projectDescriptionPath: AbsolutePath,
projectAutomationPath: AbsolutePath
) throws -> ValueGraph {
mapArgs.append((
name: name,
@ -54,7 +58,9 @@ final class MockProjectEditorMapper: ProjectEditorMapping {
pluginProjectDescriptionHelpersModule: pluginProjectDescriptionHelpersModule,
helpers: helpers,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: tasks,
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: projectAutomationPath
))
if let mapStub = mapStub { return mapStub }

View File

@ -23,7 +23,7 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
super.tearDown()
}
func test_edit_when_there_are_helpers_and_setup_and_config_and_dependencies() throws {
func test_edit_when_there_are_helpers_and_setup_and_config_and_dependencies_and_tasks() throws {
// Given
let sourceRootPath = try temporaryPath()
let projectManifestPaths = [sourceRootPath].map { $0.appending(component: "Project.swift") }
@ -36,6 +36,10 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
let tuistPath = AbsolutePath("/usr/bin/foo/bar/tuist")
let projectName = "Manifests"
let projectsGroup = ProjectGroup.group(name: projectName)
let tasksPaths = [
sourceRootPath.appending(component: "TaskOne.swift"),
sourceRootPath.appending(component: "TaskTwo.swift"),
]
// When
let graph = try subject.map(
@ -51,7 +55,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: tasksPaths,
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)
@ -63,7 +69,7 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
// Then
XCTAssertEqual(graph.name, "TestManifests")
XCTAssertEqual(targets.count, 6)
XCTAssertEqual(targets.count, 8)
XCTAssertEqual(project.targets.sorted { $0.name < $1.name }, targets)
// Generated Manifests target
@ -137,6 +143,30 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
XCTAssertEqual(dependenciesTarget.filesGroup, projectsGroup)
XCTAssertEmpty(dependenciesTarget.dependencies)
// Generated TaskOne target
let taskOneTarget = try XCTUnwrap(project.targets.last(where: { $0.name == "TaskOne" }))
XCTAssertTrue(targets.contains(taskOneTarget))
XCTAssertEqual(taskOneTarget.name, "TaskOne")
XCTAssertEqual(taskOneTarget.platform, .macOS)
XCTAssertEqual(taskOneTarget.product, .staticFramework)
XCTAssertEqual(taskOneTarget.settings, expectedSettings(includePaths: [sourceRootPath]))
XCTAssertEqual(taskOneTarget.sources.map(\.path), [tasksPaths[0]])
XCTAssertEqual(taskOneTarget.filesGroup, projectsGroup)
XCTAssertEmpty(taskOneTarget.dependencies)
// Generated TaskTwo target
let taskTwoTarget = try XCTUnwrap(project.targets.last(where: { $0.name == "TaskTwo" }))
XCTAssertTrue(targets.contains(taskTwoTarget))
XCTAssertEqual(taskTwoTarget.name, "TaskTwo")
XCTAssertEqual(taskTwoTarget.platform, .macOS)
XCTAssertEqual(taskTwoTarget.product, .staticFramework)
XCTAssertEqual(taskTwoTarget.settings, expectedSettings(includePaths: [sourceRootPath]))
XCTAssertEqual(taskTwoTarget.sources.map(\.path), [tasksPaths[1]])
XCTAssertEqual(taskTwoTarget.filesGroup, projectsGroup)
XCTAssertEmpty(taskTwoTarget.dependencies)
// Generated Project
XCTAssertEqual(project.path, sourceRootPath.appending(component: projectName))
XCTAssertEqual(project.name, projectName)
@ -187,7 +217,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)
@ -266,7 +298,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)
@ -375,7 +409,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)
@ -451,7 +487,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)
@ -558,7 +596,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
// Then
@ -597,7 +637,9 @@ final class ProjectEditorMapperTests: TuistUnitTestCase {
pluginProjectDescriptionHelpersModule: [.init(name: "Plugin", path: pluginHelpersPath)],
helpers: helperPaths,
templates: templates,
projectDescriptionPath: projectDescriptionPath
tasks: [],
projectDescriptionPath: projectDescriptionPath,
projectAutomationPath: sourceRootPath.appending(component: "ProjectAutomation.framework")
)
let project = try XCTUnwrap(graph.projects.values.first)

View File

@ -9,6 +9,7 @@ import TuistPluginTesting
import TuistSupport
import XCTest
import TuistTasksTesting
@testable import TuistCoreTesting
@testable import TuistGeneratorTesting
@testable import TuistKit
@ -27,16 +28,17 @@ final class ProjectEditorErrorTests: TuistUnitTestCase {
}
final class ProjectEditorTests: TuistUnitTestCase {
var generator: MockDescriptorGenerator!
var projectEditorMapper: MockProjectEditorMapper!
var resourceLocator: MockResourceLocator!
var manifestFilesLocator: MockManifestFilesLocator!
var helpersDirectoryLocator: MockHelpersDirectoryLocator!
var writer: MockXcodeProjWriter!
var templatesDirectoryLocator: MockTemplatesDirectoryLocator!
var projectDescriptionHelpersBuilder: MockProjectDescriptionHelpersBuilder!
var projectDescriptionHelpersBuilderFactory: MockProjectDescriptionHelpersBuilderFactory!
var subject: ProjectEditor!
private var generator: MockDescriptorGenerator!
private var projectEditorMapper: MockProjectEditorMapper!
private var resourceLocator: MockResourceLocator!
private var manifestFilesLocator: MockManifestFilesLocator!
private var helpersDirectoryLocator: MockHelpersDirectoryLocator!
private var writer: MockXcodeProjWriter!
private var templatesDirectoryLocator: MockTemplatesDirectoryLocator!
private var projectDescriptionHelpersBuilder: MockProjectDescriptionHelpersBuilder!
private var projectDescriptionHelpersBuilderFactory: MockProjectDescriptionHelpersBuilderFactory!
private var tasksLocator: MockTasksLocator!
private var subject: ProjectEditor!
override func setUp() {
super.setUp()
@ -50,6 +52,7 @@ final class ProjectEditorTests: TuistUnitTestCase {
projectDescriptionHelpersBuilder = MockProjectDescriptionHelpersBuilder()
projectDescriptionHelpersBuilderFactory = MockProjectDescriptionHelpersBuilderFactory()
projectDescriptionHelpersBuilderFactory.projectDescriptionHelpersBuilderStub = { _ in self.projectDescriptionHelpersBuilder }
tasksLocator = MockTasksLocator()
subject = ProjectEditor(
generator: generator,
@ -59,25 +62,28 @@ final class ProjectEditorTests: TuistUnitTestCase {
helpersDirectoryLocator: helpersDirectoryLocator,
writer: writer,
templatesDirectoryLocator: templatesDirectoryLocator,
projectDescriptionHelpersBuilderFactory: projectDescriptionHelpersBuilderFactory
projectDescriptionHelpersBuilderFactory: projectDescriptionHelpersBuilderFactory,
tasksLocator: tasksLocator
)
}
override func tearDown() {
super.tearDown()
generator = nil
projectEditorMapper = nil
resourceLocator = nil
manifestFilesLocator = nil
helpersDirectoryLocator = nil
templatesDirectoryLocator = nil
tasksLocator = nil
subject = nil
super.tearDown()
}
func test_edit() throws {
// Given
let directory = try temporaryPath()
let projectDescriptionPath = directory.appending(component: "ProjectDescription.framework")
let projectAutomationPath = directory.appending(component: "ProjectAutomation.framework")
let graph = ValueGraph.test(name: "Edit")
let helpersDirectory = directory.appending(component: "ProjectDescriptionHelpers")
try FileHandler.shared.createFolder(helpersDirectory)
@ -88,13 +94,21 @@ final class ProjectEditorTests: TuistUnitTestCase {
let setupPath = directory.appending(components: "Setup.swift")
let configPath = directory.appending(components: "Tuist", "Config.swift")
let dependenciesPath = directory.appending(components: "Tuist", "Dependencies.swif")
let locateTasksPaths = [
directory.appending(components: "Tuist", "Tasks", "TaskOne.swift"),
directory.appending(components: "Tuist", "Tasks", "TaskTwo.swift"),
]
resourceLocator.projectDescriptionStub = { projectDescriptionPath }
resourceLocator.projectAutomationStub = { projectAutomationPath }
manifestFilesLocator.locateProjectManifestsStub = manifests
manifestFilesLocator.locateConfigStub = configPath
manifestFilesLocator.locateDependenciesStub = dependenciesPath
manifestFilesLocator.locateSetupStub = setupPath
helpersDirectoryLocator.locateStub = helpersDirectory
tasksLocator.locateTasksStub = { _ in
locateTasksPaths
}
projectEditorMapper.mapStub = graph
generator.generateWorkspaceStub = { _ in
.test(xcworkspacePath: directory.appending(component: "Edit.xcworkspacepath"))
@ -110,9 +124,11 @@ final class ProjectEditorTests: TuistUnitTestCase {
XCTAssertEqual(mapArgs?.helpers, helpers)
XCTAssertEqual(mapArgs?.sourceRootPath, directory)
XCTAssertEqual(mapArgs?.projectDescriptionPath, projectDescriptionPath)
XCTAssertEqual(mapArgs?.projectAutomationPath, projectAutomationPath)
XCTAssertEqual(mapArgs?.configPath, configPath)
XCTAssertEqual(mapArgs?.setupPath, setupPath)
XCTAssertEqual(mapArgs?.dependenciesPath, dependenciesPath)
XCTAssertEqual(mapArgs?.tasks, locateTasksPaths)
}
func test_edit_when_there_are_no_editable_files() throws {

View File

@ -0,0 +1,219 @@
import Foundation
import TSCBasic
import TuistCore
import TuistLoaderTesting
import TuistSupport
import TuistSupportTesting
import TuistTasksTesting
import XCTest
@testable import TuistKit
final class TaskServiceTests: TuistUnitTestCase {
private var manifestLoader: MockManifestLoader!
private var tasksLocator: MockTasksLocator!
private var subject: ExecService!
override func setUp() {
super.setUp()
manifestLoader = MockManifestLoader()
tasksLocator = MockTasksLocator()
subject = ExecService(
manifestLoader: manifestLoader,
tasksLocator: tasksLocator
)
}
override func tearDown() {
manifestLoader = nil
tasksLocator = nil
subject = nil
super.tearDown()
}
func test_load_task_options_when_task_not_found() throws {
// Given
let path = try temporaryPath()
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskB.swift"),
path.appending(component: "TaskC.swift"),
]
}
// When / Then
XCTAssertThrowsSpecific(
try subject.loadTaskOptions(taskName: "task-a", path: path.pathString),
ExecError.taskNotFound("task-a", ["task-b", "task-c"])
)
}
func test_load_task_options_when_none() throws {
// Given
let path = try temporaryPath()
let taskPath = path.appending(component: "TaskA.swift")
try fileHandler.write(
"""
import ProjectAutomation
import Foundation
let task = Task(
options: [
]
) { options in
// some task code
}
""",
path: taskPath,
atomically: true
)
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskA.swift"),
path.appending(component: "TaskB.swift"),
]
}
// When / Then
XCTAssertEmpty(
try subject.loadTaskOptions(
taskName: "task-a",
path: path.pathString
)
)
}
func test_load_task_options_when_defined_on_single_line() throws {
// Given
let path = try temporaryPath()
let taskPath = path.appending(component: "TaskA.swift")
try fileHandler.write(
"""
import ProjectAutomation
import Foundation
let task = Task(
options: [.option("option-a"), .option("option-b")]
) { options in
// some task code
}
""",
path: taskPath,
atomically: true
)
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskA.swift"),
path.appending(component: "TaskB.swift"),
]
}
// When / Then
XCTAssertEqual(
try subject.loadTaskOptions(
taskName: "task-a",
path: path.pathString
),
[
"option-a",
"option-b",
]
)
}
func test_load_task_options_when_defined_on_multiple_lines() throws {
// Given
let path = try temporaryPath()
let taskPath = path.appending(component: "TaskA.swift")
try fileHandler.write(
"""
import ProjectAutomation
import Foundation
let task = Task(
options: [
.option("option-a"),
.option("option-b")
]
) { options in
// some task code
}
""",
path: taskPath,
atomically: true
)
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskA.swift"),
path.appending(component: "TaskB.swift"),
]
}
// When / Then
XCTAssertEqual(
try subject.loadTaskOptions(
taskName: "task-a",
path: path.pathString
),
[
"option-a",
"option-b",
]
)
}
func test_run_when_task_not_found() throws {
// Given
let path = try temporaryPath()
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskB.swift"),
path.appending(component: "TaskC.swift"),
]
}
// When / Then
XCTAssertThrowsSpecific(
try subject.run("task-a", options: [:], path: path.pathString),
ExecError.taskNotFound("task-a", ["task-b", "task-c"])
)
}
func test_run_when_task_found() throws {
// Given
let path = try temporaryPath()
let taskPath = path.appending(component: "TaskA.swift")
tasksLocator.locateTasksStub = { path in
[
path.appending(component: "TaskA.swift"),
path.appending(component: "TaskB.swift"),
path.appending(component: "TaskC.swift"),
]
}
manifestLoader.taskLoadArgumentsStub = {
[
"load",
$0.pathString,
]
}
system.succeedCommand(
"load",
taskPath.pathString,
"--tuist-task",
"{\"option-a\":\"Value\"}"
)
// When / Then
XCTAssertNoThrow(
try subject.run(
"task-a",
options: ["option-a": "Value"],
path: path.pathString
)
)
}
}

View File

@ -0,0 +1,59 @@
import Foundation
import TSCBasic
import TuistCore
import TuistSupport
import TuistSupportTesting
import XCTest
@testable import TuistTasks
final class TasksLocatorIntegrationTests: TuistTestCase {
var subject: TasksLocator!
override func setUp() {
super.setUp()
subject = TasksLocator()
}
override func tearDown() {
subject = nil
super.tearDown()
}
func test_locate_when_a_tasks_directory_exists() throws {
// Given
let temporaryDirectory = try temporaryPath()
try createFolders(["this/is/a/very/nested/directory", "this/is/Tuist/Tasks"])
let tasksPath = temporaryDirectory.appending(RelativePath("this/is/Tuist/Tasks"))
try createFiles(
[
"this/is/Tuist/Tasks/TaskA.swift",
"this/is/Tuist/Tasks/TaskB.swift",
]
)
// When
let got = try subject.locateTasks(at: temporaryDirectory.appending(RelativePath("this/is/a/very/nested/directory")))
// Then
XCTAssertEqual(
got.sorted(),
[
tasksPath.appending(component: "TaskA.swift"),
tasksPath.appending(component: "TaskB.swift"),
]
)
}
func test_locate_when_a_tasks_directory_does_not_exist() throws {
// Given
let temporaryDirectory = try temporaryPath()
try createFolders(["this/is/a/very/nested/directory"])
// When
let got = try subject.locateTasks(at: temporaryDirectory.appending(RelativePath("this/is/a/very/nested/directory")))
// Then
XCTAssertEmpty(got)
}
}

View File

@ -0,0 +1,43 @@
---
title: Run tasks
slug: '/commands/task'
description: 'Learn how to to automate arbitrary tasks with tuist Swift.'
---
### Context
When we write apps, it is often necessary to write some supporting code for e.g. releasing, downloading localizations, etc.
These are often written in Shell or Ruby which only a handful of developers on your team might be familiar with.
This means that these files are edited by an exclusive group and they are sort of "magical" for others.
We try to fix that by introducing a concept of "Tasks" where you can define custom commands - in Swift!
### Defining a task
To define a task, you can run `tuist edit` and then create a file `NameOfCommand.swift` in `Tuist/Tasks` directory.
Afterwards, you will need to define the task's options (if there are any) and the code that should be executed when the task is run.
Below you can find an example of the `CreateFile` task:
```swift
import ProjectAutomation
import Foundation
let task = Task(
options: [
.option("file-name"),
]
) { options in
let fileName = options["file-name"] ?? "file"
try "File created with a task".write(
to: URL(fileURLWithPath: "\(fileName).txt"),
atomically: true,
encoding: .utf8
)
print("File created!")
}
```
If you added this file to `Tuist/Tasks/CreateFile.swift`, you can run it by `tuist exec create-file --file-name MyFileName`.
The `Task` accepts two parameters - `options: [Option]` which defines the possible options of the task.
Then there is a parameter `task: ([String: String]) throws -> Void` which is a simple closure that is executed when the task is run.
Note that the closure has input of `[String: String]` -
this is a dictionary of options defined by the user where the key is the name of the option and value is the option's value.

View File

@ -4,10 +4,10 @@ Then(/tuist scaffolds a (.+) template to (.+) named (.+)/) do |template, path, n
system("swift", "run", "tuist", "scaffold", template, "--path", File.join(@dir, path), "--name", name)
end
Then(/content of a file named (.+) in a directory (.+) should be equal to (.+)/) do |file, dir, content|
Then(/content of a file named ([a-zA-Z\-_]+) in a directory (.+) should be equal to (.+)/) do |file, dir, content|
assert_equal File.read(File.join(@dir, dir, file)), content
end
Then(/content of a file named (.+) in a directory (.+) should be equal to:$/) do |file, dir, content|
Then(/content of a file named ([a-zA-Z\-_]+) in a directory (.+) should be equal to:$/) do |file, dir, content|
assert_equal File.read(File.join(@dir, dir, file)), content
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
Then(/^tuist runs a task ([a-zA-Z\-]+)$/) do |name|
system("swift", "run", "tuist", "exec", name, "--path", @dir)
end
Then(/^tuist runs a task (.+) with attribute (.+) as (.+)$/) do |name, attribute, attribute_value|
system("swift", "run", "tuist", "exec", name, "--#{attribute}", attribute_value, "--path", @dir)
end
Then(/^content of a file named ([a-zA-Z\-_]+) should be equal to (.+)$/) do |file, content|
assert_equal File.read(File.join(@dir, file)), content
end

View File

@ -0,0 +1,10 @@
Feature: Run tasks
Scenario: The project is an application with tasks (app_with_tasks)
Given that tuist is available
And I have a working directory
Then I copy the fixture app_with_tasks into the working directory
Then tuist runs a task create-file
Then content of a file named file.txt should be equal to File created with a task
Then tuist runs a task create-file with attribute file-name as custom-file
Then content of a file named custom-file.txt should be equal to File created with a task

View File

@ -0,0 +1,72 @@
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Xcode ###
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint
*.xccheckout
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
build/
DerivedData/
*.moved-aside
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
### Xcode Patch ###
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcworkspace/contents.xcworkspacedata
/*.gcno
### Projects ###
*.xcodeproj
*.xcworkspace
### Tuist derived files ###
graph.dot
Derived/
### Tuist managed dependencies ###
Tuist/Dependencies
*.txt

View File

@ -0,0 +1,25 @@
import ProjectDescription
import ProjectDescriptionHelpers
/*
+-------------+
| |
| App | Contains App App target and App unit-test target
| |
+------+-------------+-------+
| depends on |
| |
+----v-----+ +-----v-----+
| | | |
| Kit | | UI | Two independent frameworks to share code and start modularising your app
| | | |
+----------+ +-----------+
*/
// MARK: - Project
// Creates our project using a helper function defined in ProjectDescriptionHelpers
let project = Project.app(name: "App",
platform: .iOS,
additionalTargets: ["AppKit", "AppUI"])

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,25 @@
import UIKit
import AppKit
import AppUI
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let viewController = UIViewController()
viewController.view.backgroundColor = .white
window?.rootViewController = viewController
window?.makeKeyAndVisible()
AppKit.hello()
AppUI.hello()
return true
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import XCTest
final class AppTests: XCTestCase {
func test_twoPlusTwo_isFour() {
XCTAssertEqual(2+2, 4)
}
}

View File

@ -0,0 +1,7 @@
import Foundation
public final class AppKit {
public static func hello() {
print("Hello, from your Kit framework")
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import XCTest
final class AppKitTests: XCTestCase {
func test_example() {
XCTAssertEqual("AppKit", "AppKit")
}
}

View File

@ -0,0 +1,7 @@
import Foundation
public final class AppUI {
public static func hello() {
print("Hello, from your UI framework")
}
}

View File

@ -0,0 +1,8 @@
import Foundation
import XCTest
final class AppUITests: XCTestCase {
func test_example() {
XCTAssertEqual("AppUI", "AppUI")
}
}

View File

@ -0,0 +1,5 @@
import ProjectDescription
let config = Config(
generationOptions: []
)

View File

@ -0,0 +1,76 @@
import ProjectDescription
/// Project helpers are functions that simplify the way you define your project.
/// Share code to create targets, settings, dependencies,
/// Create your own conventions, e.g: a func that makes sure all shared targets are "static frameworks"
/// See https://tuist.io/docs/usage/helpers/
extension Project {
/// Helper function to create the Project for this ExampleApp
public static func app(name: String, platform: Platform, additionalTargets: [String]) -> Project {
var targets = makeAppTargets(name: name,
platform: platform,
dependencies: additionalTargets.map { TargetDependency.target(name: $0) })
targets += additionalTargets.flatMap({ makeFrameworkTargets(name: $0, platform: platform) })
return Project(name: name,
organizationName: "tuist.io",
targets: targets)
}
// MARK: - Private
/// Helper function to create a framework target and an associated unit test target
private static func makeFrameworkTargets(name: String, platform: Platform) -> [Target] {
let sources = Target(name: name,
platform: platform,
product: .framework,
bundleId: "io.tuist.\(name)",
infoPlist: .default,
sources: ["Targets/\(name)/Sources/**"],
resources: [],
dependencies: [])
let tests = Target(name: "\(name)Tests",
platform: platform,
product: .unitTests,
bundleId: "io.tuist.\(name)Tests",
infoPlist: .default,
sources: ["Targets/\(name)/Tests/**"],
resources: [],
dependencies: [.target(name: name)])
return [sources, tests]
}
/// Helper function to create the application target and the unit test target.
private static func makeAppTargets(name: String, platform: Platform, dependencies: [TargetDependency]) -> [Target] {
let platform: Platform = platform
let infoPlist: [String: InfoPlist.Value] = [
"CFBundleShortVersionString": "1.0",
"CFBundleVersion": "1",
"UIMainStoryboardFile": "",
"UILaunchStoryboardName": "LaunchScreen"
]
let mainTarget = Target(
name: name,
platform: platform,
product: .app,
bundleId: "io.tuist.\(name)",
infoPlist: .extendingDefault(with: infoPlist),
sources: ["Targets/\(name)/Sources/**"],
resources: ["Targets/\(name)/Resources/**"],
dependencies: dependencies
)
let testTarget = Target(
name: "\(name)Tests",
platform: platform,
product: .unitTests,
bundleId: "io.tuist.\(name)Tests",
infoPlist: .default,
sources: ["Targets/\(name)/Tests/**"],
dependencies: [
.target(name: "\(name)")
])
return [mainTarget, testTarget]
}
}

View File

@ -0,0 +1,16 @@
import ProjectAutomation
import Foundation
let task = Task(
options: [
.option("file-name"),
]
) { options in
let fileName = options["file-name"] ?? "file"
try "File created with a task".write(
to: URL(fileURLWithPath: "\(fileName).txt"),
atomically: true,
encoding: .utf8
)
print("File created!")
}