Skip to content

Commit

Permalink
Implement file system synchronized directories, including at deeper l…
Browse files Browse the repository at this point in the history
…evels (non-root) and exceptions
  • Loading branch information
tomlokhorst committed Nov 3, 2024
1 parent 796710b commit 740dc1a
Show file tree
Hide file tree
Showing 2 changed files with 287 additions and 5 deletions.
99 changes: 94 additions & 5 deletions Sources/RswiftParsers/ProjectResources.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,14 @@ public struct ProjectResources {

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

let paths = try xcodeproj.resourcePaths(forTarget: targetName)
let urls = paths
.map { $0.url(with: sourceTreeURLs.url(for:)) }
.filter { !ignoreFile.matches(url: $0) }

var excludeURLs: [URL] = []
let infoPlists: [PropertyListResource]
let entitlements: [PropertyListResource]

if resourceTypes.contains(.info) {
infoPlists = try buildConfigurations.compactMap { config -> PropertyListResource? in
guard let url = infoPlistFile else { return nil }
excludeURLs.append(url)
return try parse(with: warning) {
try PropertyListResource.parse(url: url, buildConfigurationName: config.name)
}
Expand All @@ -75,12 +72,39 @@ public struct ProjectResources {
if resourceTypes.contains(.entitlements) {
entitlements = try buildConfigurations.compactMap { config -> PropertyListResource? in
guard let url = codeSignEntitlements else { return nil }
excludeURLs.append(url)
return try parse(with: warning) { try PropertyListResource.parse(url: url, buildConfigurationName: config.name) }
}
} else {
entitlements = []
}

let paths = try xcodeproj.resourcePaths(forTarget: targetName)
let pathURLs = paths.map { $0.url(with: sourceTreeURLs.url(for:)) }

let extraURLs = try xcodeproj.extraResourceURLs(forTarget: targetName, sourceTreeURLs: sourceTreeURLs)

// Combine URLs from Xcode project file with extra URLs found by scanning file system
var pathAndExtraURLs = Array(Set(pathURLs + extraURLs))

// Find all localized strings files for ignore extension so that those can be removed
let localizedExtensions = ["xib", "storyboard", "intentdefinition"]
let localizedStringURLs = findLocalizedStrings(inputURLs: pathAndExtraURLs, ignoreExtensions: localizedExtensions)

// These file types are compiled, and shouldn't be included as resources
// Note that this should be done after finding localized files
let sourceCodeExtensions = [
"swift", "h", "m", "mm", "c", "cpp", "metal",
"xcdatamodeld", "entitlements", "intentdefinition",
]
pathAndExtraURLs.removeAll(where: { sourceCodeExtensions.contains($0.pathExtension) })

// Remove all ignored files, excluded files and localized strings files
let urls = pathAndExtraURLs
.filter { !ignoreFile.matches(url: $0) }
.filter { !excludeURLs.contains($0) }
.filter { !localizedStringURLs.contains($0) }

return try parseURLs(
urls: urls,
infoPlists: infoPlists,
Expand Down Expand Up @@ -184,6 +208,71 @@ public struct ProjectResources {
}
}

// Finds strings files for Xcode generated files
//
// Example 1:
// some-dir/Base.lproj/MyIntents.intentdefinition
// some-dir/nl.lproj/MyIntents.string
//
// Example 2:
// some-dir/Base.lproj/Main.storyboard
// some-dir/nl.lproj/Main.string
private func findLocalizedStrings(inputURLs: [URL], ignoreExtensions: [String]) -> [URL] {
// Dictionary to map each parent directory to its `.lproj` subdirectories
var parentToLprojDirectories = [URL: [URL]]()

// Dictionary to keep track of files in each `.lproj` directory
var directoryContents = [URL: [URL]]()

// Populate the dictionaries
for url in inputURLs {
let directoryURL = url.deletingLastPathComponent()
let parentDirectory = directoryURL.deletingLastPathComponent()
if directoryURL.lastPathComponent.hasSuffix(".lproj") {
parentToLprojDirectories[parentDirectory, default: []].append(directoryURL)
directoryContents[directoryURL, default: []].append(url)
}
}

// Set of URLs to remove
var urlsToRemove = Set<URL>()

// Analyze each group of sibling `.lproj` directories under the same parent
for (_, lprojDirectories) in parentToLprojDirectories {
var baseFilenameToFileUrls = [String: [URL]]()
var baseFilenamesWithIgnoreExtension = Set<String>()

// Collect all files by base filename and check for files with an ignoreExtension
for directory in lprojDirectories {
guard let files = directoryContents[directory] else { continue }
for file in files {
let baseFilename = file.deletingPathExtension().lastPathComponent
let fileExtension = file.pathExtension

baseFilenameToFileUrls[baseFilename, default: []].append(file)

if ignoreExtensions.contains(fileExtension) {
baseFilenamesWithIgnoreExtension.insert(baseFilename)
}
}
}

// Determine which files to remove based on the presence of files with an ignoreExtension
for baseFilename in baseFilenamesWithIgnoreExtension {
if let files = baseFilenameToFileUrls[baseFilename] {
for file in files {
if file.pathExtension == "strings" {
urlsToRemove.insert(file)
}
}
}
}
}

return Array(urlsToRemove)
}


private func parse<R>(with warning: (String) -> Void, closure: () throws -> R) throws -> R? {
do {
return try closure()
Expand Down
193 changes: 193 additions & 0 deletions Sources/RswiftParsers/Shared/Xcodeproj.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import Foundation
import XcodeEdit
import RswiftResources

public struct Xcodeproj: SupportedExtensions {
static public let supportedExtensions: Set<String> = ["xcodeproj"]
Expand Down Expand Up @@ -76,6 +77,97 @@ public struct Xcodeproj: SupportedExtensions {
return fileRefPaths + variantGroupPaths
}

// Returns extra resource URLs by extracting fileSystemSynchronizedGroups and scanning file system recursively.
// Handles exceptions configured in fileSystemSynchronizedGroups
func extraResourceURLs(forTarget targetName: String, sourceTreeURLs: SourceTreeURLs) throws -> [URL] {
var resultURLs: [URL] = []

let (dirs, extraFiles, extraLocalizedFiles, exceptionPaths) = try fileSystemSynchronizedGroups(forTarget: targetName)

for dir in dirs {
let url = dir.url(with: sourceTreeURLs.url(for:))
resultURLs.append(contentsOf: recursiveContentsOf(url: url))
}

let extraURLs = extraFiles.map { $0.url(with: sourceTreeURLs.url(for:)) }
resultURLs.append(contentsOf: extraURLs)

let extraLocalizedURLs = try extraLocalizedFiles
.map { $0.url(with: sourceTreeURLs.url(for:)) }
.flatMap { try expandLocalizedFileURL($0) }
resultURLs.append(contentsOf: extraLocalizedURLs)

let exceptionURLs = exceptionPaths.map { $0.url(with: sourceTreeURLs.url(for:)) }
resultURLs.removeAll(where: { exceptionURLs.contains($0) })

let xcodeFilenames = ["Info.plist"]
resultURLs.removeAll(where: { xcodeFilenames.contains($0.lastPathComponent) })

return resultURLs
}

// For target, extract file system groups.
// Returns:
// - directories to scan
// - known files (based on exceptions of other targets)
// - known files that are localized (inside .lproj directory) (based on exceptions of other targets)
// - known exception files (based on exceptions of this target)
func fileSystemSynchronizedGroups(forTarget targetName: String) throws -> (dirs: [Path], extraFiles: [Path], extraLocalizedFiles: [Path], exceptionPaths: [Path]) {
var dirs: [Path] = []
var extraFiles: [Path] = []
var extraLocalizedFiles: [Path] = []
var exceptionPaths: [Path] = []

let target = try findTarget(name: targetName)

guard let mainGroup = projectFile.project.mainGroup.value else {
throw ResourceParsingError("Project file is missing mainGroup")
}

let targetFileSystemSynchronizedGroups = target.fileSystemSynchronizedGroups?.compactMap(\.value?.id) ?? []

let allFileSystemSynchronizedGroups = mainGroup.fileSystemSynchronizedGroups()

for synchronizedGroup in allFileSystemSynchronizedGroups {
guard let path = synchronizedGroup.fullPath else { continue }

let exceptions = (synchronizedGroup.exceptions ?? []).compactMap(\.value)

if targetFileSystemSynchronizedGroups.contains(synchronizedGroup.id) {
dirs.append(path)

for exception in exceptions {
guard exception.target.id == target.id else { continue }

let files = exception.membershipExceptions ?? []
let exPaths = files.map { file in path.map { dir in "\(dir)/\(file)" } }

exceptionPaths.append(contentsOf: exPaths)
}
} else {
for exception in exceptions {
guard exception.target.id == target.id else { continue }

let files = exception.membershipExceptions ?? []

let localized = "/Localized/"
for file in files {
if file.hasPrefix(localized) {
let cleanFile = String(file.dropFirst(localized.count))
let exPath = path.map { dir in "\(dir)/\(cleanFile)" }
extraLocalizedFiles.append(exPath)
} else {
let exPath = path.map { dir in "\(dir)/\(file)" }
extraFiles.append(exPath)
}
}
}
}
}

return (dirs: dirs, extraFiles: extraFiles, extraLocalizedFiles: extraLocalizedFiles, exceptionPaths: exceptionPaths)
}

public func buildConfigurations(forTarget targetName: String) throws -> [XCBuildConfiguration] {
let target = try findTarget(name: targetName)

Expand All @@ -87,3 +179,104 @@ public struct Xcodeproj: SupportedExtensions {
return buildConfigurations
}
}

extension PBXReference {
func fileSystemSynchronizedGroups() -> [PBXFileSystemSynchronizedRootGroup] {
if let root = self as? PBXFileSystemSynchronizedRootGroup {
return [root]
} else if let group = self as? PBXGroup {
let children = group.children.compactMap(\.value)

return children.flatMap { $0.fileSystemSynchronizedGroups() }
} else {
return []
}
}
}

extension Path {
func map(_ transform: (String) -> String) -> Path {
switch self {
case let .absolute(str):
return .absolute(transform(str))
case let .relativeTo(folder, str):
return .relativeTo(folder, transform(str))
}
}
}

// Returns all(*) recursive files/directories that that are found on file system in specified directory.
// (*): xcassets are returned once, no deeper contents.
private func recursiveContentsOf(url: URL) -> [URL] {
var resultURLs: [URL] = []

var excludedExtensions = AssetCatalog.supportedExtensions
excludedExtensions.insert("bundle")

if excludedExtensions.contains(url.pathExtension) {
return []
}

let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles])

// Enumerator gives directories in hierarchical order (I assume/hope).
// If we hit a directory that is an .xcassets, we don't want to scan deeper, so we add it to the skipDirectories.
// Subsequent files/directories that have a skipDirectory as prefix are ignored.
var skipDirectories: [URL] = []

guard let enumerator else { return [] }

for case let contentURL as URL in enumerator {
let shouldSkip = skipDirectories.contains { skip in contentURL.path.hasPrefix(skip.path) }
if shouldSkip {
continue
}

if excludedExtensions.contains(contentURL.pathExtension) {
resultURLs.append(contentURL)
skipDirectories.append(contentURL)
continue
}

if contentURL.hasDirectoryPath {
if excludedExtensions.contains(contentURL.pathExtension) {
resultURLs.append(contentURL)
skipDirectories.append(contentURL)
}
} else {
resultURLs.append(contentURL)
}
}

return resultURLs
}

// Returns the localized versions of an input URL
// Example: some-dir/Home.strings
// Becomes: some-dir/Base.lproj/Home.strings, some-dir/nl.lproj/Home.strings
private func expandLocalizedFileURL(_ url: URL) throws -> [URL] {
let fileManager = FileManager.default
var localizedURLs: [URL] = []

// Get the directory path and filename from the input URL
let directory = url.deletingLastPathComponent()
let filename = url.lastPathComponent

// Scan the directory for contents
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)

// Filter the contents to find directories with the ".lproj" suffix
for item in contents {
if item.pathExtension == "lproj" {
// Construct the localized file path by appending the filename to the `.lproj` folder path
let localizedFileURL = item.appendingPathComponent(filename)

// Check if the localized file exists
if fileManager.fileExists(atPath: localizedFileURL.path) {
localizedURLs.append(localizedFileURL)
}
}
}

return localizedURLs
}

0 comments on commit 740dc1a

Please sign in to comment.