Skip to content

Commit 9c2eeff

Browse files
authored
Add support for third-party build systems (#569)
1 parent 670c0c0 commit 9c2eeff

File tree

14 files changed

+189
-63
lines changed

14 files changed

+189
-63
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
##### Enhancements
88

99
- Add CodeClimate output formatter available via the `--format codeclimate` option.
10+
- Add support for third-party build systems, such as Bazel.
1011

1112
##### Bug Fixes
1213

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
- [Xcode Integration](#xcode-integration)
3737
- [Excluding Files](#excluding-files)
3838
- [Continuous Integration](#continuous-integration)
39+
- [Build Systems](#build-systems)
3940
- [Platforms](#platforms)
4041
- [Troubleshooting](#troubleshooting)
4142
- [Known Bugs](#known-bugs)
@@ -414,6 +415,31 @@ The index store generated by `xcodebuild` exists in DerivedData at a location de
414415

415416
By default Periphery, looks for the index store at `.build/debug/index/store`. Therefore, if you intend to run Periphery directly after calling `swift test`, you can omit the `--index-store-path` option, and Periphery will use the index store created when the project was built for testing. However if this isn't the case, then you must provide Periphery the location of the index store with `--index-store-path`.
416417

418+
## Build Systems
419+
420+
Periphery can analyze projects using third-party build systems such as Bazel, though it cannot drive them automatically like SwiftPM and xcodebuild. Instead, you need to specify the index store location and provide a file-target mapping file.
421+
422+
A file-target mapping file contains a simple mapping of source files to build targets. You will need to generate this file yourself using the appropriate tooling for your build system. The format is as follows:
423+
424+
```
425+
{
426+
"file_targets": {
427+
"path/to/file_a.swift": ["TargetA"],
428+
"path/to/file_b.swift": ["TargetB", "TargetC"]
429+
}
430+
}
431+
```
432+
433+
> Relative paths are assumed to be relative to the current directory.
434+
435+
You can then invoke periphery as follows:
436+
437+
```
438+
periphery scan --file-targets-path map.json --index-store-path index/store
439+
```
440+
441+
> Both options support multiple paths.
442+
417443
## Platforms
418444

419445
Periphery supports both macOS and Linux. macOS supports both Xcode and Swift Package Manager (SPM) projects, whereas only SPM projects are supported on Linux.

Sources/Frontend/Commands/ScanCommand.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import ArgumentParser
3+
import SystemPackage
34
import Shared
45

56
struct ScanCommand: FrontendCommand {
@@ -23,6 +24,9 @@ struct ScanCommand: FrontendCommand {
2324
@Option(help: "Path to your project's .xcodeproj - supply this option if your project doesn't have an .xcworkspace. Xcode projects only")
2425
var project: String?
2526

27+
@Option(parsing: .upToNextOption, help: "Path to file targets mapping. For use with third-party build systems. Multiple paths may be specified")
28+
var fileTargetsPath: [FilePath] = defaultConfiguration.$fileTargetsPath.defaultValue
29+
2630
@Option(help: "Comma-separated list of schemes that must be built in order to produce the targets passed to the --targets option. Xcode projects only", transform: split(by: ","))
2731
var schemes: [String] = defaultConfiguration.$schemes.defaultValue
2832

@@ -41,10 +45,10 @@ struct ScanCommand: FrontendCommand {
4145
@Option(help: "Path glob of source files which should be included from the results. Note that this option is purely cosmetic, these files will still be indexed. Multiple globs may be delimited by a pipe", transform: split(by: "|"))
4246
var reportInclude: [String] = defaultConfiguration.$reportInclude.defaultValue
4347

44-
@Option(help: "Path to index store to use. Implies '--skip-build'")
45-
var indexStorePath: String?
48+
@Option(parsing: .upToNextOption, help: "Path to the index store. Multiple paths may be specified. Implies '--skip-build'")
49+
var indexStorePath: [FilePath] = defaultConfiguration.$indexStorePath.defaultValue
4650

47-
@Flag(help: "Retain all public declarations - you'll likely want to enable this if you're scanning a framework/library project")
51+
@Flag(help: "Retain all public declarations, recommended for framework/library projects")
4852
var retainPublic: Bool = defaultConfiguration.$retainPublic.defaultValue
4953

5054
@Flag(help: "Disable identification of redundant public accessibility")
@@ -96,6 +100,7 @@ struct ScanCommand: FrontendCommand {
96100
configuration.guidedSetup = setup
97101
configuration.apply(\.$workspace, workspace)
98102
configuration.apply(\.$project, project)
103+
configuration.apply(\.$fileTargetsPath, fileTargetsPath)
99104
configuration.apply(\.$schemes, schemes)
100105
configuration.apply(\.$targets, targets)
101106
configuration.apply(\.$indexExclude, indexExclude)
@@ -131,3 +136,9 @@ struct ScanCommand: FrontendCommand {
131136
}
132137

133138
extension OutputFormat: ExpressibleByArgument {}
139+
140+
extension FilePath: ExpressibleByArgument {
141+
public init?(argument: String) {
142+
self.init(argument)
143+
}
144+
}

Sources/Frontend/Project.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ final class Project {
1313

1414
if configuration.workspace != nil || configuration.project != nil {
1515
return self.init(kind: .xcode)
16+
} else if !configuration.fileTargetsPath.isEmpty {
17+
return self.init(kind: .generic)
1618
} else if SPM.isSupported {
1719
return self.init(kind: .spm)
1820
}
@@ -59,6 +61,8 @@ final class Project {
5961
#endif
6062
case .spm:
6163
return try SPMProjectDriver.build()
64+
case .generic:
65+
return try GenericProjectDriver.build()
6266
}
6367
}
6468
}

Sources/Frontend/Scan.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ final class Scan {
1212
}
1313

1414
func perform(project: Project) throws -> [ScanResult] {
15-
if configuration.indexStorePath != nil, !configuration.skipBuild {
15+
if !configuration.indexStorePath.isEmpty, !configuration.skipBuild {
1616
logger.warn("The '--index-store-path' option implies '--skip-build', specify it to silence this warning")
1717
configuration.skipBuild = true
1818
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
import SystemPackage
3+
import Shared
4+
5+
public final class GenericProjectDriver {
6+
public static func build() throws -> Self {
7+
let configuration = Configuration.shared
8+
let decoder = JSONDecoder()
9+
decoder.keyDecodingStrategy = .convertFromSnakeCase
10+
11+
let sourceFiles = try configuration.fileTargetsPath
12+
.reduce(into: [FilePath: Set<String>]()) { result, mapPath in
13+
guard mapPath.exists else {
14+
throw PeripheryError.pathDoesNotExist(path: mapPath.string)
15+
}
16+
17+
let data = try Data(contentsOf: mapPath.url)
18+
let map = try decoder
19+
.decode(FileTargetMapContainer.self, from: data)
20+
.fileTargets
21+
.reduce(into: [FilePath: Set<String>](), { (result, tuple) in
22+
let (key, value) = tuple
23+
let path: FilePath
24+
if key.hasPrefix("/") {
25+
path = FilePath(key)
26+
} else {
27+
path = FilePath.current.appending(key)
28+
}
29+
30+
if !path.exists {
31+
throw PeripheryError.pathDoesNotExist(path: path.string)
32+
}
33+
34+
result[path] = value
35+
})
36+
result.merge(map) { $0.union($1) }
37+
}
38+
39+
return self.init(sourceFiles: sourceFiles, configuration: configuration)
40+
}
41+
42+
private let sourceFiles: [FilePath: Set<String>]
43+
private let configuration: Configuration
44+
45+
init(sourceFiles: [FilePath: Set<String>], configuration: Configuration) {
46+
self.sourceFiles = sourceFiles
47+
self.configuration = configuration
48+
}
49+
}
50+
51+
extension GenericProjectDriver: ProjectDriver {
52+
public func build() throws {}
53+
54+
public func index(graph: SourceGraph) throws {
55+
try SwiftIndexer(sourceFiles: sourceFiles, graph: graph, indexStorePaths: configuration.indexStorePath).perform()
56+
graph.indexingComplete()
57+
}
58+
}
59+
60+
struct FileTargetMapContainer: Decodable {
61+
let fileTargets: [String: Set<String>]
62+
}

Sources/PeripheryKit/Indexer/SwiftIndexer.swift

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,48 @@ import SystemPackage
55
import Shared
66

77
public final class SwiftIndexer: Indexer {
8-
private let sourceFiles: [FilePath: [String]]
8+
private let sourceFiles: [FilePath: Set<String>]
99
private let graph: SourceGraph
1010
private let logger: ContextualLogger
1111
private let configuration: Configuration
12-
private let indexStore: IndexStore
13-
private let indexStoreURL: URL
12+
private let indexStorePaths: [FilePath]
1413

1514
public required init(
16-
sourceFiles: [FilePath: [String]],
15+
sourceFiles: [FilePath: Set<String>],
1716
graph: SourceGraph,
18-
indexStoreURL: URL,
17+
indexStorePaths: [FilePath],
1918
logger: Logger = .init(),
2019
configuration: Configuration = .shared
21-
) throws {
20+
) {
2221
self.sourceFiles = sourceFiles
2322
self.graph = graph
24-
self.indexStore = try .open(store: indexStoreURL, lib: .open())
25-
self.indexStoreURL = indexStoreURL
23+
self.indexStorePaths = indexStorePaths
2624
self.logger = logger.contextualized(with: "index:swift")
2725
self.configuration = configuration
2826
super.init(configuration: configuration)
2927
}
3028

3129
public func perform() throws {
32-
var unitsByFile: [FilePath: [IndexStoreUnit]] = [:]
30+
var unitsByFile: [FilePath: [(IndexStore, IndexStoreUnit)]] = [:]
3331
let allSourceFiles = Set(sourceFiles.keys)
3432
let (includedFiles, excludedFiles) = filterIndexExcluded(from: allSourceFiles)
3533
excludedFiles.forEach { self.logger.debug("Excluding \($0.string)") }
3634

37-
try indexStore.forEachUnits(includeSystem: false) { unit -> Bool in
38-
guard let filePath = try indexStore.mainFilePath(for: unit) else { return true }
35+
for indexStorePath in indexStorePaths {
36+
logger.debug("Reading \(indexStorePath)")
37+
let indexStore = try IndexStore.open(store: URL(fileURLWithPath: indexStorePath.string), lib: .open())
3938

40-
let file = FilePath(filePath)
39+
try indexStore.forEachUnits(includeSystem: false) { unit -> Bool in
40+
guard let filePath = try indexStore.mainFilePath(for: unit) else { return true }
4141

42-
if includedFiles.contains(file) {
43-
unitsByFile[file, default: []].append(unit)
44-
}
42+
let file = FilePath(filePath)
43+
44+
if includedFiles.contains(file) {
45+
unitsByFile[file, default: []].append((indexStore, unit))
46+
}
4547

46-
return true
48+
return true
49+
}
4750
}
4851

4952
let indexedFiles = Set(unitsByFile.keys)
@@ -52,15 +55,16 @@ public final class SwiftIndexer: Indexer {
5255
if !unindexedFiles.isEmpty {
5356
unindexedFiles.forEach { logger.debug("Source file not indexed: \($0)") }
5457
let targets: Set<String> = Set(unindexedFiles.flatMap { sourceFiles[$0] ?? [] })
55-
throw PeripheryError.unindexedTargetsError(targets: targets, indexStorePath: indexStoreURL.path)
58+
throw PeripheryError.unindexedTargetsError(targets: targets, indexStorePaths: indexStorePaths)
5659
}
5760

5861
let jobs = try unitsByFile.map { (file, units) -> Job in
59-
let modules = try units.reduce(into: Set<String>()) { (set, unit) in
62+
let modules = try units.reduce(into: Set<String>()) { (set, tuple) in
63+
let (indexStore, unit) = tuple
6064
if let name = try indexStore.moduleName(for: unit) {
6165
let (didInsert, _) = set.insert(name)
6266
if !didInsert {
63-
let targets = try Set(units.compactMap { try indexStore.target(for: $0) })
67+
let targets = try Set(units.compactMap { try indexStore.target(for: $0.1) })
6468
throw PeripheryError.conflictingIndexUnitsError(file: file, module: name, unitTargets: targets)
6569
}
6670
}
@@ -71,7 +75,6 @@ public final class SwiftIndexer: Indexer {
7175
file: sourceFile,
7276
units: units,
7377
graph: graph,
74-
indexStore: indexStore,
7578
logger: logger,
7679
configuration: configuration
7780
)
@@ -103,24 +106,21 @@ public final class SwiftIndexer: Indexer {
103106
private class Job {
104107
let file: SourceFile
105108

106-
private let units: [IndexStoreUnit]
109+
private let units: [(IndexStore, IndexStoreUnit)]
107110
private let graph: SourceGraph
108-
private let indexStore: IndexStore
109111
private let logger: ContextualLogger
110112
private let configuration: Configuration
111113

112114
required init(
113115
file: SourceFile,
114-
units: [IndexStoreUnit],
116+
units: [(IndexStore, IndexStoreUnit)],
115117
graph: SourceGraph,
116-
indexStore: IndexStore,
117118
logger: ContextualLogger,
118119
configuration: Configuration
119120
) {
120121
self.file = file
121122
self.units = units
122123
self.graph = graph
123-
self.indexStore = indexStore
124124
self.logger = logger
125125
self.configuration = configuration
126126
}
@@ -164,7 +164,7 @@ public final class SwiftIndexer: Indexer {
164164
func phaseOne() throws {
165165
var rawDeclsByKey: [RawDeclaration.Key: [(RawDeclaration, [RawRelation])]] = [:]
166166

167-
for unit in units {
167+
for (indexStore, unit) in units {
168168
try indexStore.forEachRecordDependencies(for: unit) { dependency in
169169
guard case let .record(record) = dependency else { return true }
170170

@@ -175,17 +175,17 @@ public final class SwiftIndexer: Indexer {
175175
else { return true }
176176

177177
if !occurrence.roles.intersection([.definition, .declaration]).isEmpty {
178-
if let (decl, relations) = try parseRawDeclaration(occurrence, usr, location) {
178+
if let (decl, relations) = try parseRawDeclaration(occurrence, usr, location, indexStore) {
179179
rawDeclsByKey[decl.key, default: []].append((decl, relations))
180180
}
181181
}
182182

183183
if !occurrence.roles.intersection([.reference]).isEmpty {
184-
try parseReference(occurrence, usr, location)
184+
try parseReference(occurrence, usr, location, indexStore)
185185
}
186186

187187
if !occurrence.roles.intersection([.implicit]).isEmpty {
188-
try parseImplicit(occurrence, usr, location)
188+
try parseImplicit(occurrence, usr, location, indexStore)
189189
}
190190

191191
return true
@@ -438,7 +438,8 @@ public final class SwiftIndexer: Indexer {
438438
private func parseRawDeclaration(
439439
_ occurrence: IndexStoreOccurrence,
440440
_ usr: String,
441-
_ location: SourceLocation
441+
_ location: SourceLocation,
442+
_ indexStore: IndexStore
442443
) throws -> (RawDeclaration, [RawRelation])? {
443444
guard let kind = transformDeclarationKind(occurrence.symbol.kind, occurrence.symbol.subKind)
444445
else { return nil }
@@ -528,7 +529,8 @@ public final class SwiftIndexer: Indexer {
528529
private func parseImplicit(
529530
_ occurrence: IndexStoreOccurrence,
530531
_ occurrenceUsr: String,
531-
_ location: SourceLocation
532+
_ location: SourceLocation,
533+
_ indexStore: IndexStore
532534
) throws {
533535
var refs = [Reference]()
534536

@@ -557,7 +559,8 @@ public final class SwiftIndexer: Indexer {
557559
private func parseReference(
558560
_ occurrence: IndexStoreOccurrence,
559561
_ occurrenceUsr: String,
560-
_ location: SourceLocation
562+
_ location: SourceLocation,
563+
_ indexStore: IndexStore
561564
) throws {
562565
guard let kind = transformReferenceKind(occurrence.symbol.kind, occurrence.symbol.subKind)
563566
else { return }

Sources/PeripheryKit/SPM/SPMProjectDriver.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,20 @@ extension SPMProjectDriver: ProjectDriver {
5858
}
5959

6060
public func index(graph: SourceGraph) throws {
61-
let sourceFiles = targets.reduce(into: [FilePath: [String]]()) { result, target in
61+
let sourceFiles = targets.reduce(into: [FilePath: Set<String>]()) { result, target in
6262
let targetPath = absolutePath(for: target)
63-
target.sources.forEach { result[targetPath.appending($0), default: []].append(target.name) }
63+
target.sources.forEach { result[targetPath.appending($0), default: []].insert(target.name) }
6464
}
6565

66-
let storePath: String
66+
let storePaths: [FilePath]
6767

68-
if let path = configuration.indexStorePath {
69-
storePath = path
68+
if !configuration.indexStorePath.isEmpty {
69+
storePaths = configuration.indexStorePath
7070
} else {
71-
storePath = FilePath(package.path).appending(".build/debug/index/store").string
71+
storePaths = [FilePath(package.path).appending(".build/debug/index/store")]
7272
}
7373

74-
try SwiftIndexer(sourceFiles: sourceFiles, graph: graph, indexStoreURL: URL(fileURLWithPath: storePath)).perform()
74+
try SwiftIndexer(sourceFiles: sourceFiles, graph: graph, indexStorePaths: storePaths).perform()
7575

7676
graph.indexingComplete()
7777
}

0 commit comments

Comments
 (0)