Introduction
Managing and automating repetitive tasks in your project can be challenging, especially as it grows, particularly when using shell scripts or Make tools. Shell scripts and Make lack type safety, are harder to read, and become cumbersome to maintain as projects scale and workflows grow more complex. That's where tools like Sake come into play. Sake is a Swift-based tool inspired by Make, designed to simplify the automation of common workflows, making them easier to define and execute. By leveraging Swift's type safety and expressiveness, Sake provides a powerful yet familiar way to manage tasks.
In this guide, we'll explore how to streamline your project's workflow using Sake. We'll start with simple examples like linting and formatting and gradually add more advanced features like managing using tools and testing. By the end of this article, you'll be able to define workflows that are easy to understand and maintain, helping you keep your project in top shape.
For executing CLI commands, we'll be using SwiftShell throughout this guide. However, feel free to use any tool that suits your project, as Sake is flexible enough to work with various options.
Part 1: Setting Up Basic Linting and Formatting Commands
Introduction to Linting and Formatting
Linting and formatting are essential practices in software development to maintain consistent code quality. Using automated tools, you can enforce code style guidelines and ensure your codebase remains clean and readable. In this section, we'll use swiftformat
to create commands for linting and formatting your Swift code.
Defining Simple Linting and Formatting Commands
Let's start by defining simple lint
and format
commands using Sake. These commands will run swiftformat
on specific directories to check the code style and apply formatting.
import Sake
import SwiftShell
@main
@CommandGroup
struct Commands: SakeApp {
public static var lint: Command {
Command(
description: "Lint code",
run: { _ in
try runAndPrint("swiftformat", "Sources", "--lint")
}
)
}
public static var format: Command {
Command(
description: "Format code",
run: { _ in
try runAndPrint("swiftformat", "Sources")
}
)
}
}
Once you've defined the command, you can invoke it as follows:
> sake lint
...
> sake format
...
Explanation
-
lint
Command: Thelint
command runsswiftformat
in linting mode to check for code style issues in theSources
directory. -
format
Command: Theformat
command runsswiftformat
to automatically apply code formatting in theSources
directory.
These commands are simple to define and demonstrate the basic structure of a Sake command. By leveraging these commands, you can easily enforce code style guidelines and maintain code quality throughout your project.
In the next part, we'll explore how to add dependencies to our commands to ensure the necessary tools are available before executing them.
Part 2: Ensuring Required Tools are Available
Problem Statement
When automating tasks, it's important to ensure that the necessary tools are available before running a command. In our case, we need to make sure that swiftformat
is installed before running the linting or formatting commands.
Adding a Dependency with a Pre-Installation Command
To address this, we'll add a command that checks if swiftformat
is installed and installs it if necessary. We'll keep this command private within our Commands
structure, ensuring that it is used internally as a dependency for lint
and format
.
@main
@CommandGroup
struct Commands: SakeApp {
public static var lint: Command {
Command(
description: "Lint code",
dependencies: [ensureSwiftFormatInstalled],
run: { _ in
try runAndPrint("swiftformat", "Sources", "--lint")
}
)
}
public static var format: Command {
Command(
description: "Format code",
dependencies: [ensureSwiftFormatInstalled],
run: { _ in
try runAndPrint("swiftformat", "Sources")
}
)
}
private static var ensureSwiftFormatInstalled: Command {
Command(
description: "Ensure swiftformat is installed",
skipIf: { _ in
run("which", "swiftformat").succeeded
},
run: { _ in
try runAndPrint("brew", "install", "swiftformat")
}
)
}
}
Explanation
-
ensureSwiftFormatInstalled
Command: This command checks ifswiftformat
is installed by runningwhich swiftformat
. If it is not installed, the command uses Homebrew to install it. By keeping this command private, we ensure that it is used only as a dependency and not exposed as a standalone command. -
Dependencies: The
lint
andformat
commands now depend onensureSwiftFormatInstalled
. This guarantees thatswiftformat
is available before running either of these commands.
Adding dependencies like this helps automate the setup process and ensures that your project environment is correctly configured before executing any tasks.
In the next part, we'll introduce testing commands and explore how to automate running unit and integration tests.
Part 3: Automating Project Testing
Why Testing Matters
Automated testing is a crucial part of maintaining software quality. It helps ensure that your code behaves as expected and that any new changes do not introduce regressions. In this part, we'll add simple commands to run unit and integration tests for our project.
Defining a Simple Testing Command
To start, we'll define a test
command that runs all of the project's tests. This command will use swift test
to execute the tests.
@main
@CommandGroup
struct Commands: SakeApp {
public static var test: Command {
Command(
description: "Run tests",
run: { _ in
try runAndPrint("swift", "test")
}
)
}
}
Explanation
-
test
Command: Thetest
command runs all tests usingswift test
. This command is straightforward and can be used to ensure that your code passes all checks before making any changes or releases.
Adding Separate Unit and Integration Test Commands
In more complex projects, it's often helpful to separate unit tests from integration tests. Let's add two new commands: unitTests
and integrationTests
.
@main
@CommandGroup
struct Commands: SakeApp {
public static var unitTests: Command {
Command(
description: "Run unit tests",
run: { _ in
try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
}
)
}
public static var integrationTests: Command {
Command(
description: "Run integration tests",
run: { _ in
try runAndPrint(bash: "swift test --filter IntegrationTests")
}
)
}
public static var test: Command {
Command(
description: "Run all tests",
dependencies: [unitTests, integrationTests]
)
}
}
Explanation
-
unitTests
Command: This command runs only the unit tests by excluding any tests that match the name patternIntegrationTests
. -
integrationTests
Command: This command runs only the integration tests by filtering for tests namedIntegrationTests
. -
test
Command: Thetest
command now has dependencies on bothunitTests
andintegrationTests
, which means it will run all tests in sequence.
By separating unit and integration tests, you gain more flexibility in how you run your tests, making it easier to focus on specific areas of your project during development.
In the next part, we'll add a clean step before running tests to ensure a clean environment.
Part 4: Ensuring a Clean Testing Environment
Problem Statement
When running tests, it's often important to start with a clean build environment to avoid any interference from previous builds or cached artifacts. This ensures that your tests are executed in a predictable state, reducing the risk of false positives or hard-to-reproduce bugs.
Adding a Clean Command Before Testing
To make this work, we are using ArgumentParser
, a powerful Swift package that allows us to define and parse command-line arguments easily. ArgumentParser
helps us handle the --clean
flag, which gives us control over whether to clean the build artifacts before running tests.
To solve this problem, we'll add a cleanIfNeeded
command that cleans the build artifacts before running tests. We'll make it possible to control whether the cleaning happens by adding a --clean
flag to our commands.
import ArgumentParser
@main
@CommandGroup
struct Commands: SakeApp {
struct TestArguments: ParsableArguments {
@Flag(name: .long, help: "Clean build artifacts before running tests")
var clean: Bool = false
}
static var cleanIfNeeded: Command {
Command(
description: "Clean build artifacts",
skipIf: { context in
let arguments = try TestArguments.parse(context.arguments)
return !arguments.clean
},
run: { _ in
try runAndPrint("swift", "package", "clean")
}
)
}
public static var unitTests: Command {
Command(
description: "Run unit tests",
dependencies: [cleanIfNeeded],
run: { _ in
try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
}
)
}
public static var integrationTests: Command {
Command(
description: "Run integration tests",
dependencies: [cleanIfNeeded],
run: { _ in
try runAndPrint(bash: "swift test --filter IntegrationTests")
}
)
}
}
Now tests can be run like this:
> sake unitTests --clean
Explanation
-
cleanIfNeeded
Command: This command runsswift package clean
to remove all build artifacts. It uses theskipIf
function to determine if the--clean
flag has been passed. If the flag is not present, the command is skipped. -
Test Commands: The
unitTests
andintegrationTests
commands now depend oncleanIfNeeded
, which ensures that the build environment is cleaned before tests are run, but only if the--clean
flag is provided.
Adding this cleaning step before running tests ensures that we start with a fresh environment, making our test results more reliable.
In the next part, we'll discuss how to handle redundant cleaning when multiple tests are run consecutively, and show a solution using argument mapping.
Part 5: Avoiding Redundant Cleaning with Argument Mapping
Problem Statement
When using the --clean
flag, the cleaning step runs before every test command. This leads to redundant cleaning when running multiple tests in sequence, like when running both unit and integration tests. Redundant cleaning not only adds unnecessary execution time but can also lead to inefficiencies in the build process.
Solving Redundant Cleaning with Argument Mapping
To solve this problem, we'll use argument mapping to modify the test command's behavior. The idea is to clean the environment once, before running the first test, and skip it for subsequent tests. We can achieve this by filtering out the --clean
flag for the dependent commands.
@main
@CommandGroup
struct Commands: SakeApp {
struct TestArguments: ParsableArguments {
@Flag(name: .long, help: "Clean build artifacts before running tests")
var clean: Bool = false
}
static var cleanIfNeeded: Command {
Command(
description: "Clean build artifacts",
skipIf: { context in
let arguments = try TestArguments.parse(context.arguments)
return !arguments.clean
},
run: { _ in
try runAndPrint("swift", "package", "clean")
}
)
}
public static var unitTests: Command {
Command(
description: "Run unit tests",
dependencies: [cleanIfNeeded],
run: { _ in
try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
}
)
}
public static var integrationTests: Command {
Command(
description: "Run integration tests",
dependencies: [cleanIfNeeded],
run: { _ in
try runAndPrint(bash: "swift test --filter IntegrationTests")
}
)
}
public static var test: Command {
Command(
description: "Run all tests",
dependencies: [
cleanIfNeeded,
unitTests.mapArguments { arguments in arguments.filter { $0 != "--clean" } },
integrationTests.mapArguments { arguments in arguments.filter { $0 != "--clean" } }
]
)
}
}
Explanation
test
Command with Argument Mapping: Thetest
command depends oncleanIfNeeded
, followed byunitTests
andintegrationTests
. By using.mapArguments
, we remove the--clean
flag from the arguments passed to the dependent commands. This ensures that the cleaning step runs only once, before the first test, and is not repeated for subsequent commands.Improved Efficiency: By mapping arguments and preventing redundant cleaning, we reduce unnecessary overhead and make the test execution process more efficient.
This approach helps streamline the testing process, allowing you to execute multiple tests in sequence without incurring redundant cleaning steps.
Conclusion
Throughout this guide, we've explored how Sake can help streamline your project's workflow, starting from basic tasks like linting and formatting, through testing, and even setting up a release train. By building commands in Swift, you've gained the ability to automate your processes in a type-safe and expressive way, fully leveraging the Swift ecosystem.
Now that you have the foundation, you can start automating even more aspects of your project. Whether it's adding argument parsing, running shell commands, or integrating with other Swift libraries, Sake provides the flexibility to fit your workflow.
We hope this guide has inspired you to make your project's routine tasks more efficient and enjoyable to work with.
For more information, check out the official Sake documentation and explore commands applied to Sake project itself.
Top comments (0)