Skip to content

Commit

Permalink
Use BUILD_WORKSPACE_DIRECTORY in SwiftLintPlugin (realm#4758)
Browse files Browse the repository at this point in the history
  • Loading branch information
garricn authored and Oleg Kokhtenko committed Mar 8, 2024
1 parent 40428ba commit 02684d6
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 83 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,10 @@
* Catch more valid `no_magic_numbers` violations.
[JP Simard](https://github.com/jpsim)

* Rewrite `SwiftLintPlugin` using `BUILD_WORKSPACE_DIRECTORY` without relying
on the `--config` option.
[Garric Nahapetian](https://github.com/garricn)

* Add `blanket_disable_command` rule that checks whether
rules are re-enabled after being disabled.
[Martin Redington](https://github.com/mildm8nnered)
Expand Down
54 changes: 23 additions & 31 deletions Plugins/SwiftLintPlugin/Path+Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,42 +1,34 @@
import Foundation
import PackagePlugin

#if os(Linux)
import Glibc
#else
import Darwin
#endif

extension Path {
/// Scans the receiver, then all of its parents looking for a configuration file with the name ".swiftlint.yml".
///
/// - returns: Path to the configuration file, or nil if one cannot be found.
func firstConfigurationFileInParentDirectories() -> Path? {
let defaultConfigurationFileName = ".swiftlint.yml"
let proposedDirectory = sequence(
first: self,
next: { path in
guard path.stem.count > 1 else {
// Check we're not at the root of this filesystem, as `removingLastComponent()`
// will continually return the root from itself.
return nil
}
var directoryContainsConfigFile: Bool {
FileManager.default.fileExists(atPath: "\(self)/.swiftlint.yml")
}

return path.removingLastComponent()
}
).first { path in
let potentialConfigurationFile = path.appending(subpath: defaultConfigurationFileName)
return potentialConfigurationFile.isAccessible()
}
return proposedDirectory?.appending(subpath: defaultConfigurationFileName)
var depth: Int {
URL(fileURLWithPath: "\(self)").pathComponents.count
}

/// Safe way to check if the file is accessible from within the current process sandbox.
private func isAccessible() -> Bool {
let result = string.withCString { pointer in
access(pointer, R_OK)
func isDescendant(of path: Path) -> Bool {
"\(self)".hasPrefix("\(path)")
}

func resolveWorkingDirectory(in directory: Path) throws -> Path {
guard "\(self)".hasPrefix("\(directory)") else {
throw SwiftLintPluginError.pathNotInDirectory(path: self, directory: directory)
}

let path: Path? = sequence(first: self) { path in
let path: Path = path.removingLastComponent()
guard "\(path)".hasPrefix("\(directory)") else {
return nil
}
return path
}
.reversed()
.first(where: \.directoryContainsConfigFile)

return result == 0
return path ?? directory
}
}
154 changes: 107 additions & 47 deletions Plugins/SwiftLintPlugin/SwiftLintPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,141 @@ import PackagePlugin

@main
struct SwiftLintPlugin: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
guard let sourceTarget = target as? SourceModuleTarget else {
return []
}
return createBuildCommands(
inputFiles: sourceTarget.sourceFiles(withSuffix: "swift").map(\.path),
packageDirectory: context.package.directory,
workingDirectory: context.pluginWorkDirectory,
tool: try context.tool(named: "swiftlint")
)
func createBuildCommands(
context: PluginContext,
target: Target
) async throws -> [Command] {
try makeCommand(executable: context.tool(named: "swiftlint"),
swiftFiles: (target as? SourceModuleTarget).flatMap(swiftFiles) ?? [],
environment: environment(context: context, target: target),
pluginWorkDirectory: context.pluginWorkDirectory)
}

/// Collects the paths of the Swift files to be linted.
private func swiftFiles(target: SourceModuleTarget) -> [Path] {
target
.sourceFiles(withSuffix: "swift")
.map(\.path)
}

/// Creates an environment dictionary containing a value for the `BUILD_WORKSPACE_DIRECTORY` key.
///
/// This method locates the topmost `.swiftlint.yml` config file within the package directory for this target
/// and sets the config file's containing directory as the `BUILD_WORKSPACE_DIRECTORY` value. The package
/// directory is used if a config file is not found.
///
/// The `BUILD_WORKSPACE_DIRECTORY` environment variable controls SwiftLint's working directory.
///
/// Reference: [https://github.com/realm/SwiftLint/blob/0.50.3/Source/swiftlint/Commands/SwiftLint.swift#L7](
/// https://github.com/realm/SwiftLint/blob/0.50.3/Source/swiftlint/Commands/SwiftLint.swift#L7
/// )
private func environment(
context: PluginContext,
target: Target
) throws -> [String: String] {
let workingDirectory: Path = try target.directory.resolveWorkingDirectory(in: context.package.directory)
return ["BUILD_WORKSPACE_DIRECTORY": "\(workingDirectory)"]
}

private func createBuildCommands(
inputFiles: [Path],
packageDirectory: Path,
workingDirectory: Path,
tool: PluginContext.Tool
) -> [Command] {
if inputFiles.isEmpty {
// Don't lint anything if there are no Swift source files in this target
private func makeCommand(
executable: PluginContext.Tool,
swiftFiles: [Path],
environment: [String: String],
pluginWorkDirectory path: Path
) throws -> [Command] {
// Don't lint anything if there are no Swift source files in this target
guard !swiftFiles.isEmpty else {
return []
}

var arguments = [
// Outputs the environment to the build log for reference.
print("Environment:", environment)
let arguments: [String] = [
"lint",
"--quiet",
// We always pass all of the Swift source files in the target to the tool,
// so we need to ensure that any exclusion rules in the configuration are
// respected.
"--force-exclude"
]

// Determine whether we need to enable cache or not (for Xcode Cloud we don't)
let cacheArguments: [String]
if ProcessInfo.processInfo.environment["CI_XCODE_CLOUD"] == "TRUE" {
arguments.append("--no-cache")
cacheArguments = ["--no-cache"]
} else {
arguments.append("--cache-path")
arguments.append("\(workingDirectory)")
}

// Manually look for configuration files, to avoid issues when the plugin does not execute our tool from the
// package source directory.
if let configuration = packageDirectory.firstConfigurationFileInParentDirectories() {
arguments.append(contentsOf: ["--config", "\(configuration.string)"])
let cachePath: Path = path.appending("Cache")
try FileManager.default.createDirectory(atPath: cachePath.string, withIntermediateDirectories: true)
cacheArguments = ["--cache-path", "\(cachePath)"]
}
arguments += inputFiles.map(\.string)

// We are not producing output files and this is needed only to not include cache files into bundle
let outputFilesDirectory = workingDirectory.appending("Output")

let outputPath: Path = path.appending("Output")
try FileManager.default.createDirectory(atPath: outputPath.string, withIntermediateDirectories: true)
return [
.prebuildCommand(
displayName: "SwiftLint",
executable: tool.path,
arguments: arguments,
outputFilesDirectory: outputFilesDirectory
)
executable: executable.path,
arguments: arguments + cacheArguments + swiftFiles.map(\.string),
environment: environment,
outputFilesDirectory: outputPath)
]
}
}

#if canImport(XcodeProjectPlugin)

import XcodeProjectPlugin

// swiftlint:disable:next no_grouping_extension
extension SwiftLintPlugin: XcodeBuildToolPlugin {
func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
let inputFilePaths = target.inputFiles
func createBuildCommands(
context: XcodePluginContext,
target: XcodeTarget
) throws -> [Command] {
try makeCommand(executable: context.tool(named: "swiftlint"),
swiftFiles: swiftFiles(target: target),
environment: environment(context: context, target: target),
pluginWorkDirectory: context.pluginWorkDirectory)
}

/// Collects the paths of the Swift files to be linted.
private func swiftFiles(target: XcodeTarget) -> [Path] {
target
.inputFiles
.filter { $0.type == .source && $0.path.extension == "swift" }
.map(\.path)
return createBuildCommands(
inputFiles: inputFilePaths,
packageDirectory: context.xcodeProject.directory,
workingDirectory: context.pluginWorkDirectory,
tool: try context.tool(named: "swiftlint")
)
}

/// Creates an environment dictionary containing a value for the `BUILD_WORKSPACE_DIRECTORY` key.
///
/// This method locates the topmost `.swiftlint.yml` config file within the project directory for this target's
/// Swift source files and sets the config file's containing directory as the `BUILD_WORKSPACE_DIRECTORY` value.
/// The project directory is used if a config file is not found.
///
/// The `BUILD_WORKSPACE_DIRECTORY` environment variable controls SwiftLint's working directory.
///
/// Reference: [https://github.com/realm/SwiftLint/blob/0.50.3/Source/swiftlint/Commands/SwiftLint.swift#L7](
/// https://github.com/realm/SwiftLint/blob/0.50.3/Source/swiftlint/Commands/SwiftLint.swift#L7
/// )
private func environment(
context: XcodePluginContext,
target: XcodeTarget
) throws -> [String: String] {
let projectDirectory: Path = context.xcodeProject.directory
let swiftFiles: [Path] = swiftFiles(target: target)
let swiftFilesNotInProjectDirectory: [Path] = swiftFiles.filter { !$0.isDescendant(of: projectDirectory) }

guard swiftFilesNotInProjectDirectory.isEmpty else {
throw SwiftLintPluginError.swiftFilesNotInProjectDirectory(projectDirectory)
}

let directories: [Path] = try swiftFiles.map { try $0.resolveWorkingDirectory(in: projectDirectory) }
let workingDirectory: Path = directories.min { $0.depth < $1.depth } ?? projectDirectory
let swiftFilesNotInWorkingDirectory: [Path] = swiftFiles.filter { !$0.isDescendant(of: workingDirectory) }

guard swiftFilesNotInWorkingDirectory.isEmpty else {
throw SwiftLintPluginError.swiftFilesNotInWorkingDirectory(workingDirectory)
}

return ["BUILD_WORKSPACE_DIRECTORY": "\(workingDirectory)"]
}
}

#endif
18 changes: 18 additions & 0 deletions Plugins/SwiftLintPlugin/SwiftLintPluginError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import PackagePlugin

enum SwiftLintPluginError: Error, CustomStringConvertible {
case pathNotInDirectory(path: Path, directory: Path)
case swiftFilesNotInProjectDirectory(Path)
case swiftFilesNotInWorkingDirectory(Path)

var description: String {
switch self {
case let .pathNotInDirectory(path, directory):
"Path '\(path)' is not in directory '\(directory)'."
case let .swiftFilesNotInProjectDirectory(directory):
"Swift files are not in project directory '\(directory)'."
case let .swiftFilesNotInWorkingDirectory(directory):
"Swift files are not in working directory '\(directory)'."
}
}
}
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,12 +200,36 @@ If you've installed SwiftLint via CocoaPods the script should look like this:

### Plug-in Support

SwiftLint can be used as a build tool plug-in for both Xcode projects as well as
Swift packages.
SwiftLint can be used as a build tool plugin for both Swift Package projects
and Xcode projects.

> Due to limitations with Swift Package Manager Plug-ins this is only
recommended for projects that have a SwiftLint configuration in their root directory as
there is currently no way to pass any additional options to the SwiftLint executable.
The build tool plugin determines the SwiftLint working directory by locating
the topmost config file within the package/project directory. If a config file
is not found therein, the package/project directory is used as the working
directory.

The plugin throws an error when it is unable to resolve the SwiftLint working
directory. For example, this will occur in Xcode projects where the target's
Swift files are not located within the project directory.

To maximize compatibility with the plugin, avoid project structures that require
the use of the `--config` option.

#### Unexpected Project Structures

Project structures where SwiftLint's configuration file is located
outside of the package/project directory are not directly supported
by the build tool plugin. This is because it isn't possible to pass
arguments to build tool plugins (e.g., passing the config file path).

If your project structure doesn't work directly with the build tool
plugin, please consider one of the following options:

- To use a config file located outside the package/project directory, a config
file may be added to that directory specifying a parent config path to the
other config file, e.g., `parent_config: path/to/.swiftlint.yml`.
- You can also consider the use of a Run Script Build Phase in place of the
build tool plugin.

#### Xcode

Expand Down

0 comments on commit 02684d6

Please sign in to comment.