Skip to content

Commit 740dc1a

Browse files
committed
Implement file system synchronized directories, including at deeper levels (non-root) and exceptions
1 parent 796710b commit 740dc1a

File tree

2 files changed

+287
-5
lines changed

2 files changed

+287
-5
lines changed

Sources/RswiftParsers/ProjectResources.swift

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,14 @@ public struct ProjectResources {
5353

5454
let buildConfigurations = try xcodeproj.buildConfigurations(forTarget: targetName)
5555

56-
let paths = try xcodeproj.resourcePaths(forTarget: targetName)
57-
let urls = paths
58-
.map { $0.url(with: sourceTreeURLs.url(for:)) }
59-
.filter { !ignoreFile.matches(url: $0) }
60-
56+
var excludeURLs: [URL] = []
6157
let infoPlists: [PropertyListResource]
6258
let entitlements: [PropertyListResource]
6359

6460
if resourceTypes.contains(.info) {
6561
infoPlists = try buildConfigurations.compactMap { config -> PropertyListResource? in
6662
guard let url = infoPlistFile else { return nil }
63+
excludeURLs.append(url)
6764
return try parse(with: warning) {
6865
try PropertyListResource.parse(url: url, buildConfigurationName: config.name)
6966
}
@@ -75,12 +72,39 @@ public struct ProjectResources {
7572
if resourceTypes.contains(.entitlements) {
7673
entitlements = try buildConfigurations.compactMap { config -> PropertyListResource? in
7774
guard let url = codeSignEntitlements else { return nil }
75+
excludeURLs.append(url)
7876
return try parse(with: warning) { try PropertyListResource.parse(url: url, buildConfigurationName: config.name) }
7977
}
8078
} else {
8179
entitlements = []
8280
}
8381

82+
let paths = try xcodeproj.resourcePaths(forTarget: targetName)
83+
let pathURLs = paths.map { $0.url(with: sourceTreeURLs.url(for:)) }
84+
85+
let extraURLs = try xcodeproj.extraResourceURLs(forTarget: targetName, sourceTreeURLs: sourceTreeURLs)
86+
87+
// Combine URLs from Xcode project file with extra URLs found by scanning file system
88+
var pathAndExtraURLs = Array(Set(pathURLs + extraURLs))
89+
90+
// Find all localized strings files for ignore extension so that those can be removed
91+
let localizedExtensions = ["xib", "storyboard", "intentdefinition"]
92+
let localizedStringURLs = findLocalizedStrings(inputURLs: pathAndExtraURLs, ignoreExtensions: localizedExtensions)
93+
94+
// These file types are compiled, and shouldn't be included as resources
95+
// Note that this should be done after finding localized files
96+
let sourceCodeExtensions = [
97+
"swift", "h", "m", "mm", "c", "cpp", "metal",
98+
"xcdatamodeld", "entitlements", "intentdefinition",
99+
]
100+
pathAndExtraURLs.removeAll(where: { sourceCodeExtensions.contains($0.pathExtension) })
101+
102+
// Remove all ignored files, excluded files and localized strings files
103+
let urls = pathAndExtraURLs
104+
.filter { !ignoreFile.matches(url: $0) }
105+
.filter { !excludeURLs.contains($0) }
106+
.filter { !localizedStringURLs.contains($0) }
107+
84108
return try parseURLs(
85109
urls: urls,
86110
infoPlists: infoPlists,
@@ -184,6 +208,71 @@ public struct ProjectResources {
184208
}
185209
}
186210

211+
// Finds strings files for Xcode generated files
212+
//
213+
// Example 1:
214+
// some-dir/Base.lproj/MyIntents.intentdefinition
215+
// some-dir/nl.lproj/MyIntents.string
216+
//
217+
// Example 2:
218+
// some-dir/Base.lproj/Main.storyboard
219+
// some-dir/nl.lproj/Main.string
220+
private func findLocalizedStrings(inputURLs: [URL], ignoreExtensions: [String]) -> [URL] {
221+
// Dictionary to map each parent directory to its `.lproj` subdirectories
222+
var parentToLprojDirectories = [URL: [URL]]()
223+
224+
// Dictionary to keep track of files in each `.lproj` directory
225+
var directoryContents = [URL: [URL]]()
226+
227+
// Populate the dictionaries
228+
for url in inputURLs {
229+
let directoryURL = url.deletingLastPathComponent()
230+
let parentDirectory = directoryURL.deletingLastPathComponent()
231+
if directoryURL.lastPathComponent.hasSuffix(".lproj") {
232+
parentToLprojDirectories[parentDirectory, default: []].append(directoryURL)
233+
directoryContents[directoryURL, default: []].append(url)
234+
}
235+
}
236+
237+
// Set of URLs to remove
238+
var urlsToRemove = Set<URL>()
239+
240+
// Analyze each group of sibling `.lproj` directories under the same parent
241+
for (_, lprojDirectories) in parentToLprojDirectories {
242+
var baseFilenameToFileUrls = [String: [URL]]()
243+
var baseFilenamesWithIgnoreExtension = Set<String>()
244+
245+
// Collect all files by base filename and check for files with an ignoreExtension
246+
for directory in lprojDirectories {
247+
guard let files = directoryContents[directory] else { continue }
248+
for file in files {
249+
let baseFilename = file.deletingPathExtension().lastPathComponent
250+
let fileExtension = file.pathExtension
251+
252+
baseFilenameToFileUrls[baseFilename, default: []].append(file)
253+
254+
if ignoreExtensions.contains(fileExtension) {
255+
baseFilenamesWithIgnoreExtension.insert(baseFilename)
256+
}
257+
}
258+
}
259+
260+
// Determine which files to remove based on the presence of files with an ignoreExtension
261+
for baseFilename in baseFilenamesWithIgnoreExtension {
262+
if let files = baseFilenameToFileUrls[baseFilename] {
263+
for file in files {
264+
if file.pathExtension == "strings" {
265+
urlsToRemove.insert(file)
266+
}
267+
}
268+
}
269+
}
270+
}
271+
272+
return Array(urlsToRemove)
273+
}
274+
275+
187276
private func parse<R>(with warning: (String) -> Void, closure: () throws -> R) throws -> R? {
188277
do {
189278
return try closure()

Sources/RswiftParsers/Shared/Xcodeproj.swift

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import Foundation
1010
import XcodeEdit
11+
import RswiftResources
1112

1213
public struct Xcodeproj: SupportedExtensions {
1314
static public let supportedExtensions: Set<String> = ["xcodeproj"]
@@ -76,6 +77,97 @@ public struct Xcodeproj: SupportedExtensions {
7677
return fileRefPaths + variantGroupPaths
7778
}
7879

80+
// Returns extra resource URLs by extracting fileSystemSynchronizedGroups and scanning file system recursively.
81+
// Handles exceptions configured in fileSystemSynchronizedGroups
82+
func extraResourceURLs(forTarget targetName: String, sourceTreeURLs: SourceTreeURLs) throws -> [URL] {
83+
var resultURLs: [URL] = []
84+
85+
let (dirs, extraFiles, extraLocalizedFiles, exceptionPaths) = try fileSystemSynchronizedGroups(forTarget: targetName)
86+
87+
for dir in dirs {
88+
let url = dir.url(with: sourceTreeURLs.url(for:))
89+
resultURLs.append(contentsOf: recursiveContentsOf(url: url))
90+
}
91+
92+
let extraURLs = extraFiles.map { $0.url(with: sourceTreeURLs.url(for:)) }
93+
resultURLs.append(contentsOf: extraURLs)
94+
95+
let extraLocalizedURLs = try extraLocalizedFiles
96+
.map { $0.url(with: sourceTreeURLs.url(for:)) }
97+
.flatMap { try expandLocalizedFileURL($0) }
98+
resultURLs.append(contentsOf: extraLocalizedURLs)
99+
100+
let exceptionURLs = exceptionPaths.map { $0.url(with: sourceTreeURLs.url(for:)) }
101+
resultURLs.removeAll(where: { exceptionURLs.contains($0) })
102+
103+
let xcodeFilenames = ["Info.plist"]
104+
resultURLs.removeAll(where: { xcodeFilenames.contains($0.lastPathComponent) })
105+
106+
return resultURLs
107+
}
108+
109+
// For target, extract file system groups.
110+
// Returns:
111+
// - directories to scan
112+
// - known files (based on exceptions of other targets)
113+
// - known files that are localized (inside .lproj directory) (based on exceptions of other targets)
114+
// - known exception files (based on exceptions of this target)
115+
func fileSystemSynchronizedGroups(forTarget targetName: String) throws -> (dirs: [Path], extraFiles: [Path], extraLocalizedFiles: [Path], exceptionPaths: [Path]) {
116+
var dirs: [Path] = []
117+
var extraFiles: [Path] = []
118+
var extraLocalizedFiles: [Path] = []
119+
var exceptionPaths: [Path] = []
120+
121+
let target = try findTarget(name: targetName)
122+
123+
guard let mainGroup = projectFile.project.mainGroup.value else {
124+
throw ResourceParsingError("Project file is missing mainGroup")
125+
}
126+
127+
let targetFileSystemSynchronizedGroups = target.fileSystemSynchronizedGroups?.compactMap(\.value?.id) ?? []
128+
129+
let allFileSystemSynchronizedGroups = mainGroup.fileSystemSynchronizedGroups()
130+
131+
for synchronizedGroup in allFileSystemSynchronizedGroups {
132+
guard let path = synchronizedGroup.fullPath else { continue }
133+
134+
let exceptions = (synchronizedGroup.exceptions ?? []).compactMap(\.value)
135+
136+
if targetFileSystemSynchronizedGroups.contains(synchronizedGroup.id) {
137+
dirs.append(path)
138+
139+
for exception in exceptions {
140+
guard exception.target.id == target.id else { continue }
141+
142+
let files = exception.membershipExceptions ?? []
143+
let exPaths = files.map { file in path.map { dir in "\(dir)/\(file)" } }
144+
145+
exceptionPaths.append(contentsOf: exPaths)
146+
}
147+
} else {
148+
for exception in exceptions {
149+
guard exception.target.id == target.id else { continue }
150+
151+
let files = exception.membershipExceptions ?? []
152+
153+
let localized = "/Localized/"
154+
for file in files {
155+
if file.hasPrefix(localized) {
156+
let cleanFile = String(file.dropFirst(localized.count))
157+
let exPath = path.map { dir in "\(dir)/\(cleanFile)" }
158+
extraLocalizedFiles.append(exPath)
159+
} else {
160+
let exPath = path.map { dir in "\(dir)/\(file)" }
161+
extraFiles.append(exPath)
162+
}
163+
}
164+
}
165+
}
166+
}
167+
168+
return (dirs: dirs, extraFiles: extraFiles, extraLocalizedFiles: extraLocalizedFiles, exceptionPaths: exceptionPaths)
169+
}
170+
79171
public func buildConfigurations(forTarget targetName: String) throws -> [XCBuildConfiguration] {
80172
let target = try findTarget(name: targetName)
81173

@@ -87,3 +179,104 @@ public struct Xcodeproj: SupportedExtensions {
87179
return buildConfigurations
88180
}
89181
}
182+
183+
extension PBXReference {
184+
func fileSystemSynchronizedGroups() -> [PBXFileSystemSynchronizedRootGroup] {
185+
if let root = self as? PBXFileSystemSynchronizedRootGroup {
186+
return [root]
187+
} else if let group = self as? PBXGroup {
188+
let children = group.children.compactMap(\.value)
189+
190+
return children.flatMap { $0.fileSystemSynchronizedGroups() }
191+
} else {
192+
return []
193+
}
194+
}
195+
}
196+
197+
extension Path {
198+
func map(_ transform: (String) -> String) -> Path {
199+
switch self {
200+
case let .absolute(str):
201+
return .absolute(transform(str))
202+
case let .relativeTo(folder, str):
203+
return .relativeTo(folder, transform(str))
204+
}
205+
}
206+
}
207+
208+
// Returns all(*) recursive files/directories that that are found on file system in specified directory.
209+
// (*): xcassets are returned once, no deeper contents.
210+
private func recursiveContentsOf(url: URL) -> [URL] {
211+
var resultURLs: [URL] = []
212+
213+
var excludedExtensions = AssetCatalog.supportedExtensions
214+
excludedExtensions.insert("bundle")
215+
216+
if excludedExtensions.contains(url.pathExtension) {
217+
return []
218+
}
219+
220+
let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles])
221+
222+
// Enumerator gives directories in hierarchical order (I assume/hope).
223+
// If we hit a directory that is an .xcassets, we don't want to scan deeper, so we add it to the skipDirectories.
224+
// Subsequent files/directories that have a skipDirectory as prefix are ignored.
225+
var skipDirectories: [URL] = []
226+
227+
guard let enumerator else { return [] }
228+
229+
for case let contentURL as URL in enumerator {
230+
let shouldSkip = skipDirectories.contains { skip in contentURL.path.hasPrefix(skip.path) }
231+
if shouldSkip {
232+
continue
233+
}
234+
235+
if excludedExtensions.contains(contentURL.pathExtension) {
236+
resultURLs.append(contentURL)
237+
skipDirectories.append(contentURL)
238+
continue
239+
}
240+
241+
if contentURL.hasDirectoryPath {
242+
if excludedExtensions.contains(contentURL.pathExtension) {
243+
resultURLs.append(contentURL)
244+
skipDirectories.append(contentURL)
245+
}
246+
} else {
247+
resultURLs.append(contentURL)
248+
}
249+
}
250+
251+
return resultURLs
252+
}
253+
254+
// Returns the localized versions of an input URL
255+
// Example: some-dir/Home.strings
256+
// Becomes: some-dir/Base.lproj/Home.strings, some-dir/nl.lproj/Home.strings
257+
private func expandLocalizedFileURL(_ url: URL) throws -> [URL] {
258+
let fileManager = FileManager.default
259+
var localizedURLs: [URL] = []
260+
261+
// Get the directory path and filename from the input URL
262+
let directory = url.deletingLastPathComponent()
263+
let filename = url.lastPathComponent
264+
265+
// Scan the directory for contents
266+
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
267+
268+
// Filter the contents to find directories with the ".lproj" suffix
269+
for item in contents {
270+
if item.pathExtension == "lproj" {
271+
// Construct the localized file path by appending the filename to the `.lproj` folder path
272+
let localizedFileURL = item.appendingPathComponent(filename)
273+
274+
// Check if the localized file exists
275+
if fileManager.fileExists(atPath: localizedFileURL.path) {
276+
localizedURLs.append(localizedFileURL)
277+
}
278+
}
279+
}
280+
281+
return localizedURLs
282+
}

0 commit comments

Comments
 (0)