Skip to content

Commit

Permalink
New workspace project discovery
Browse files Browse the repository at this point in the history
Rewrite workspace project discovery to break it up into more sensible & logical pieces.
Add a test asset (test to come).

The biggest change is that the group naming only follows the nearest group parent - not all of them
  • Loading branch information
NinjaLikesCheez committed Oct 19, 2023
1 parent 253b1cd commit 98ca442
Show file tree
Hide file tree
Showing 17 changed files with 1,059 additions and 114 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ output/
*.xcarchive/
*.bc
*.dia
_build/
_build/
**/.build/*
87 changes: 87 additions & 0 deletions PBXProjParser/Sources/PBXProjParser/Workspace/Reference.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// Reference.swift
//
//
// Created by Thomas Hedderwick on 18/10/2023.
//
import Foundation

protocol Reference {
var location: Location { get }
static var elementName: String { get }
}

enum Location {
// TODO: Find where we can get a definitive list of these. Xcode must have them somewhere?
case container(String)
case group(String)

enum Error: Swift.Error {
case invalidLocation(String)
}

init(_ location: String) throws {
let split = location
.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
.map(String.init)

guard
let key = split.first,
let value = split.last
else { throw Error.invalidLocation("Couldn't extract key/value pair from split: \(split)") }

switch key {
case "container": self = .container(value)
case "group": self = .group(value)
default: throw Error.invalidLocation("Key didn't match a supported location key: \(key)")
}
}

var path: String {
switch self {
case .container(let path): return path
case .group(let path): return path
}
}
}

class Group: Reference {
static let elementName: String = "Group"

let location: Location
let name: String?
var references: [Reference] = []

init(location: String, name: String?) throws {
self.location = try .init(location)
self.name = name
}
}

struct FileRef: Reference {
static let elementName: String = "FileRef"

let location: Location
let enclosingGroup: Group?

init(location: String, enclosingGroup: Group? = nil) throws {
self.location = try .init(location)
self.enclosingGroup = enclosingGroup
}

var path: String {
guard
let enclosingGroup
else { return location.path }

switch enclosingGroup.location {
case let .group(path), let .container(path):
if path.last == "/" {
return path + location.path
}

return path + "/" + location.path
// return URL(fileURLWithPath: path).appendingPathComponent(location.path).path
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// WorkspaceParser.swift
//
//
// Created by Thomas Hedderwick on 18/10/2023.
//

import Foundation

struct Workspace {
private(set) var fileReferences: [FileRef] = []
private(set) var groupReferences: [Group] = []
}

struct WorkspaceParser {
static func parse(_ path: URL) throws -> Workspace {
// Parse the `contents.xcworkspacedata` (XML) file and get the list of projects
let contentsPath = path.appendingPathComponent("contents.xcworkspacedata")

let data = try Data(contentsOf: contentsPath)
let delegate = WorkspaceDataParserDelegate()
let parser = XMLParser(data: data)
parser.delegate = delegate
parser.parse()

return .init(
fileReferences: delegate.fileReferences,
groupReferences: delegate.groupReferences
)
}
}

private class WorkspaceDataParserDelegate: NSObject, XMLParserDelegate {
private(set) var fileReferences: [FileRef] = []
private(set) var groupReferences: [Group] = []

static let supportedElements = [Group.elementName, FileRef.elementName]

private var groupPath: [Group] = []

func parser(
_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]
) {
guard Self.supportedElements.contains(elementName) else {
logger.debug("Skipping parsing of unsupported element: \(elementName)")
return
}

guard
let location = attributeDict["location"]
else {
logger.debug("Location attribute for element \(elementName) is nil, this shouldn't be the case: \(attributeDict)")
return
}

do {
switch elementName {
case Group.elementName:
let group = try Group(location: location, name: attributeDict["name"])
groupPath.append(group)
groupReferences.append(group)
case FileRef.elementName:
let file = try FileRef(location: location, enclosingGroup: groupPath.last)
fileReferences.append(file)
groupPath.last?.references.append(file)
// Ignore any element that doesn't match the search space
default:
break
}
} catch {
logger.debug("Parsing element: \(elementName) failed. Reason: \(error)")
}
}

func parser(
_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?
) {
guard elementName == Group.elementName else { return }
groupPath.removeLast()
}
}
119 changes: 6 additions & 113 deletions PBXProjParser/Sources/PBXProjParser/XcodeWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ class XcodeWorkspace {
self.path = path

// Parse the `contents.xcworkspacedata` (XML) file and get the list of projects
let contentsPath = path.appendingPathComponent("contents.xcworkspacedata")

let data = try Data(contentsOf: contentsPath)
let parser = XCWorkspaceDataParser(data: data)
let workspace = try WorkspaceParser.parse(path)
let paths = workspace
.fileReferences
.map { $0.path }
.filter { $0.hasSuffix("xcodeproj") }

let baseFolder = path.deletingLastPathComponent()
projectPaths = parser.projects
projectPaths = paths
.map { baseFolder.appendingPathComponent($0, isDirectory: true) }

projects = try projectPaths.map(XcodeProject.init(path:))
Expand Down Expand Up @@ -58,111 +59,3 @@ class XcodeWorkspace {
projects.flatMap { $0.packages }
}
}

// swiftlint:disable private_over_fileprivate
/// A xcworkspace parser
fileprivate class XCWorkspaceDataParser: NSObject, XMLParserDelegate {
let parser: XMLParser
var projects = [String]()

var isInGroup = false
var currentGroupPath: [String] = []

let groupTag = "Group"
let fileRefTag = "FileRef"

init(data: Data) {
parser = .init(data: data)

super.init()

parser.delegate = self
parser.parse()
}

func parser(
_ parser: XMLParser,
didStartElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?,
attributes attributeDict: [String: String] = [:]
) {
switch elementName {
case groupTag:
handleGroupTag(attributeDict)
case fileRefTag:
handleFileRefTag(attributeDict)
default:
break
}
}

/// Returns the location attribute value from the provided attributes, if one exists
/// - Parameter attributeDict: the attribute dictionary to extract a location attribute from
/// - Returns: the path of the location attribute value
private func extractLocation(_ attributeDict: [String: String]) -> String? {
guard let location = attributeDict["location"] else { return nil }

if location.starts(with: "group:") {
return location.replacingOccurrences(of: "group:", with: "")
} else if location.starts(with: "container:") {
let location = location.replacingOccurrences(of: "container:", with: "")

if !location.isEmpty { return location }

// Sometimes, location could be empty, in this case _normally_ you'll have a name attribute
return attributeDict["name"]
}

return nil
}

/// Handle a Group tag
///
/// Group tags require additional logic - since they can contain nested child paths via either additional group tags or file ref tags.
/// Set a flag in this function that's handled in `handleFileRefTag(_:)`
/// - Parameter attributeDict: the attributes attached to this tag
private func handleGroupTag(_ attributeDict: [String: String]) {
// For groups, we want to track the 'sub' path as we go deeper into the tree,
// this will allow us to create 'full' paths as we see file refs
guard let location = extractLocation(attributeDict) else { return }
currentGroupPath.append(location)
isInGroup = true
}

/// Handle a FileRef tag
///
/// Since Group tags can build out parts of paths, we also handle cases where this file ref is part of a group structure.
/// - Parameter attributeDict: the attributes attached to this tag
private func handleFileRefTag(_ attributeDict: [String: String]) {
// For file refs, we have two options - if we're not in a group we can just use the path as-is.
// If we're in a group, we will need to construct the current path from the depth we're currently in
guard
let location = extractLocation(attributeDict),
location.hasSuffix(".xcodeproj")
else { return }

if isInGroup {
// Add a '/' in between group subpaths, then add the current location to the end
let fullLocation = currentGroupPath.reduce(into: "") { $0.append($1); $0.append("/") }.appending(location)
projects.append(fullLocation)
} else {
projects.append(location)
}
}

func parser(
_ parser: XMLParser,
didEndElement elementName: String,
namespaceURI: String?,
qualifiedName qName: String?
) {
// If we're ending a group tag, we can pop the matching group off of the stack as we're done with it
guard elementName == groupTag else { return }

_ = currentGroupPath.popLast()

isInGroup = !currentGroupPath.isEmpty
}
}
// swiftlint:enable private_over_fileprivate
Loading

0 comments on commit 98ca442

Please sign in to comment.