-
-
Notifications
You must be signed in to change notification settings - Fork 320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add API to search for files in a group with path. Handles non-unique filenames in groups #671
Conversation
@@ -120,12 +120,26 @@ public extension PBXGroup { | |||
/// | |||
/// - Parameter name: file name. | |||
/// - Returns: file with the given name contained in the given parent group. | |||
@available(*, deprecated, message: "Please use file(with:relativeTo:). This method assumes all names in the group are unique") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be going a little far! But arguably the functionality is too broad, and unclear to the user
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps updating the doc comments here may suffice? It can clarify the usage and point to the other method for cases where searching by path is needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good. Let me take care of that right away
/// - Parameter basePath: a base path to search from. | ||
/// - Returns: file with the given absolute path contained in the given parent group. | ||
func file(with path: Path, relativeTo basePath: Path) -> PBXFileReference? { | ||
let normalized = path.normalized(relativeTo: basePath) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1: Use the basePath
to generate a complete path to path
; normalization takes care of various path traversal possibilities
func file(with path: Path, relativeTo basePath: Path) -> PBXFileReference? { | ||
let normalized = path.normalized(relativeTo: basePath) | ||
return children | ||
.first { $0.normalizedPath(relativeTo: basePath) == normalized } as? PBXFileReference |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2: Do likewise for each file in the group; if the normalized result matches, then it is at the same location on the disk
XCTAssertEqual( | ||
group.file(with: "file", relativeTo: rootPath), | ||
try XCTUnwrap(filesToRefs[file1]) | ||
) | ||
XCTAssertEqual( | ||
group.file(with: "file", relativeTo: rootPath + "subdir"), | ||
try XCTUnwrap(filesToRefs[file2]) | ||
) | ||
XCTAssertEqual( | ||
group.file(with: "subdir/file", relativeTo: rootPath), | ||
try XCTUnwrap(filesToRefs[file2]) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could add further cases here, they are mostly to communicate the basic use cases
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
awesome thanks for adding this!
One aspect to consider is, typically the paths dealt with within project files are relative rather absolute paths either to source root, or to their parent group (this is so projects are portable / can work on different machines / directories etc...)
It's worth exploring the behaviour when that is the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes indeed; I verified this in our parent project. The paths just need to share a common root, that doesn't need to be /
, and often it would be the root of the project (i.e. .
). I'll adjust this test to include that
Codecov Report
@@ Coverage Diff @@
## main #671 +/- ##
==========================================
- Coverage 84.75% 84.74% -0.01%
==========================================
Files 157 157
Lines 8987 8995 +8
==========================================
+ Hits 7617 7623 +6
- Misses 1370 1372 +2
Continue to review full report at Codecov.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for taking the time to contribute this @itsthejb
@@ -120,12 +120,26 @@ public extension PBXGroup { | |||
/// | |||
/// - Parameter name: file name. | |||
/// - Returns: file with the given name contained in the given parent group. | |||
@available(*, deprecated, message: "Please use file(with:relativeTo:). This method assumes all names in the group are unique") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps updating the doc comments here may suffice? It can clarify the usage and point to the other method for cases where searching by path is needed.
XCTAssertEqual( | ||
group.file(with: "file", relativeTo: rootPath), | ||
try XCTUnwrap(filesToRefs[file1]) | ||
) | ||
XCTAssertEqual( | ||
group.file(with: "file", relativeTo: rootPath + "subdir"), | ||
try XCTUnwrap(filesToRefs[file2]) | ||
) | ||
XCTAssertEqual( | ||
group.file(with: "subdir/file", relativeTo: rootPath), | ||
try XCTUnwrap(filesToRefs[file2]) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
awesome thanks for adding this!
One aspect to consider is, typically the paths dealt with within project files are relative rather absolute paths either to source root, or to their parent group (this is so projects are portable / can work on different machines / directories etc...)
It's worth exploring the behaviour when that is the case.
625f61f
to
8acf6f4
Compare
/// - Note: Performs a linear search based on the file's name _only_. To search for a specific file | ||
/// based on file-system location, use the `file(with path:)` method. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note on the limitations, instead of deprecation
func absolutePath(for fileRef: PBXFileElement) -> Path? { | ||
try? fileRef.fullPath(sourceRoot: ".") | ||
} | ||
|
||
guard let groupAbsPath = absolutePath(for: self) else { return nil } | ||
let fileNormalizedAbsolutePath = (groupAbsPath + path).normalize() | ||
return children.first { | ||
guard let candidateAbsPath = absolutePath(for: $0) else { return false } | ||
return candidateAbsPath.normalize() == fileNormalizedAbsolutePath | ||
} as? PBXFileReference |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The algorithm has now quite importantly changed, and is much improved, but requires some feedback:
- I realised that the functionality already exists (
fullPath(sourceRoot:)
) to recursively generate an absolute path for all files involved. Passing.
to represent "from current dir" seems to work just fine for "give me a path from the root of the file system"..? - Therefore a relative directory is not required by the caller: simply convert the argument path, and all group paths to normalized absolute paths, and search for that
This way, it's guaranteed that any kind of path (relative, absolute, traversals) work as input, since absolute paths are always generated internally as the file system "source of truth"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think using "." for the sourceRoot is wrong and will only work correctly for cases when the working dir is the same as project location. In other cases it will work in unpredictable way e.g. if you will have absolute file reference in project and a relative one which will resolve in incorrect way they will not be comparable. I'd advise to pass a real sourceRoot to the method.
a37d710
to
e743f56
Compare
assert( | ||
filePath: Path(UUID().uuidString), | ||
hasReference: nil | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This got a little involved, but the basic idea is to recreate Xcode-like scenarios:
- You have a
projectDir
which can be located anywhere on disk - You have a
group
, which has some path relative to parents, or absolute, etc. (as expected in Xcode) - The
group
contains files that have paths that likewise relate to the group, or absolute etc., might have messy traversals to parent directories - Specifics of these are irrelevant, since internally we generate those normalized absolute paths
Ok! This has now actually changed somewhat, and much improved (I hope!). The only real question is whether or not |
e743f56
to
51abda8
Compare
filePath: groupDir + subDirectoryName + ".." + fileName, | ||
hasReference: fileReferenceFor[file1Path] | ||
) | ||
assert( | ||
filePath: groupDir + "foo/.." + subDirectoryName + fileName, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Testing traversals to parents
func absolutePath(for fileRef: PBXFileElement) -> Path? { | ||
try? fileRef.fullPath(sourceRoot: ".") | ||
} | ||
|
||
guard let groupAbsPath = absolutePath(for: self) else { return nil } | ||
let fileNormalizedAbsolutePath = (groupAbsPath + path).normalize() | ||
return children.first { | ||
guard let candidateAbsPath = absolutePath(for: $0) else { return false } | ||
return candidateAbsPath.normalize() == fileNormalizedAbsolutePath | ||
} as? PBXFileReference |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think using "." for the sourceRoot is wrong and will only work correctly for cases when the working dir is the same as project location. In other cases it will work in unpredictable way e.g. if you will have absolute file reference in project and a relative one which will resolve in incorrect way they will not be comparable. I'd advise to pass a real sourceRoot to the method.
import XcodeProj | ||
import XCTest | ||
|
||
final class PBXGroupPathSearchTests: XCTestCase { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Split into own file. Could combine again, but it did get a little messy
func test_whenFilesHaveAbsoluteSourceTree_thenCanBeFoundByPath() throws { | ||
try checkAssertions(for: .absolute) | ||
} | ||
|
||
func test_whenFilesHaveGroupSourceTree_thenCanBeFoundByPath() throws { | ||
try checkAssertions(for: .group) | ||
} | ||
func test_whenFilesHaveSourceRootSourceTree_thenCanBeFoundByPath() throws { | ||
try checkAssertions(for: .sourceRoot) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactored to test all the PBXSourceTree
types that are actually used in the fullPath(sourceRoot:)
method
As far as I can see, as long as the ($SRCROOT)
is passed to the find method, everything works as expected
/// - sourceRoot: path to project's source root. | ||
/// - Returns: an existing reference to that file, or `nil` if not found | ||
func file(with path: Path, sourceRoot: Path) -> PBXFileReference? { | ||
func absolutePath(for fileRef: PBXFileElement) -> Path? { | ||
try? fileRef.fullPath(sourceRoot: sourceRoot) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Going with the convention of the other methods, then, and "just pass in $(SRCROOT)
here and everything should work". Tests seem to show it does
9f543f7
to
a3188c3
Compare
if override, let existingFileReference = try projectObjects.fileReferences.first(where: { | ||
// Optimization: compare lastComponent before fullPath compare | ||
guard let fileRefPath = $0.value.path else { | ||
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | ||
} | ||
let fileRefLastPathComponent = fileRefPath.split(separator: "/").last! | ||
if filePath.lastComponent == fileRefLastPathComponent { | ||
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | ||
} | ||
return false | ||
}) { | ||
if override, let existingFileReference = try self.existingFileReference( | ||
from: projectObjects, | ||
with: filePath, | ||
sourceRoot: sourceRoot |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having spent some time going round and round on this, trying to create comprehensive tests, whilst still being slightly confused by how PBXSourceTree
s actually work, I finally realised that the functionality needed is actually already here. It's only necessary to extract is and create a public driver method @kwridan @damirdavletov.
Therefore I decided not to assume too much about my knowledge of this lib, or PBXProj in general, and simply assume that this functionality works and expose it as we require. Seems much simpler. I can't guarantee it previously worked, but it should not break anything either...?
private extension PBXGroup { | ||
|
||
func existingFileReference( | ||
from objects: PBXObjects? = nil, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the original already accessed a PBXObjects
, I thought to at least keep that as an optional arg. It seems to be just a constant reference, but playing it safe here:
XcodeProj/Sources/XcodeProj/Objects/Project/PBXObject.swift
Lines 75 to 81 in 6943d5c
func objects() throws -> PBXObjects { | |
guard let objects = reference.objects else { | |
let objectType = String(describing: type(of: self)) | |
throw PBXObjectError.orphaned(type: objectType, reference: reference.value) | |
} | |
return objects | |
} |
try (objects ?? self.objects()).fileReferences.first(where: { | ||
// Optimization: compare lastComponent before fullPath compare | ||
guard let fileRefPath = $0.value.path else { | ||
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | ||
} | ||
let fileRefLastPathComponent = fileRefPath.split(separator: "/").last! | ||
if filePath.lastComponent == fileRefLastPathComponent { | ||
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | ||
} | ||
return false | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved from
XcodeProj/Sources/XcodeProj/Objects/Files/PBXGroup.swift
Lines 190 to 200 in 6943d5c
if override, let existingFileReference = try projectObjects.fileReferences.first(where: { | |
// Optimization: compare lastComponent before fullPath compare | |
guard let fileRefPath = $0.value.path else { | |
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | |
} | |
let fileRefLastPathComponent = fileRefPath.split(separator: "/").last! | |
if filePath.lastComponent == fileRefLastPathComponent { | |
return try filePath == $0.value.fullPath(sourceRoot: sourceRoot) | |
} | |
return false | |
}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the follow ups and updates @itsthejb 👍
Having dug in a little further the exposed method does introduce some API awkwardness 🤔 - arguably existing in the addFile
method.
Taking the following simple demo project
func testFileWithPath() throws {
// Given
let xodeProj = try XcodeProj(path: demoPath)
let mainGroup = try XCTUnwrap(try xodeProj.pbxproj.rootGroup())
let sourcesGroup = try XCTUnwrap(mainGroup.children.first(where: { $0.path == "Sources" }) as? PBXGroup)
// When
// Expected 👍
let readmeReference1 = mainGroup.file(with: "README.md", sourceRoot: ".")
let helloSwift1 = sourcesGroup.file(with: "Sources/Hello.swift", sourceRoot: ".")
let helloSwift2 = sourcesGroup.file(with: demoPathSourceRoot + "Sources/Hello.swift", sourceRoot: demoPathSourceRoot)
// Possibly expected 🤔
let helloSwift3 = mainGroup.file(with: "Sources/Hello.swift", sourceRoot: ".")
// Not expected ... 🙈
let readmeReference2 = sourcesGroup.file(with: "README.md", sourceRoot: ".")
let readmeReference3 = sourcesGroup.file(with: demoPathSourceRoot + "README.md", sourceRoot: demoPathSourceRoot)
// Then
XCTAssertNotNil(readmeReference1)
XCTAssertNotNil(helloSwift1)
XCTAssertNotNil(helloSwift2)
XCTAssertNotNil(helloSwift3)
XCTAssertNil(readmeReference2) // ❌ Fails
XCTAssertNil(readmeReference3) // ❌ Fails
}
The last few cases seem a bit odd, where we are asking the Sources
group for README.md
(which shouldn't exist within it) - yet it returns the reference from its parent. Perhaps I am not using the API correctly?
I don't have great any suggestions yet sadly - will have another look through the code to get a better understanding.
cc: @tuist/core incase any of you have further insights.
Thanks again @itsthejb
@kwridan Thanks for investigating further. To be honest, going deeper into this in order to validate it worked as expected for all cases was beginning to stretch my understanding of So, when I realised there was already an implementation in there, I went with the approach of exposing that, rather than assuming DIY could be any better (probably worse) Actually our original use case was avoiding duplicate files in groups, and this whole exercise has taught me that |
Hola 👋, We want to let you know that your pull request has been marked as stale. It seems that there hasn't been any activity or updates on it for a while. If you're still interested in having this pull request merged or reviewed, please provide any necessary updates or address any feedback that may have been given. We would be happy to continue the review process and consider merging it into the However, if this pull request is no longer a priority or if you've decided to take a different approach, please let us know so we can close it accordingly. Thank you for your understanding and contribution. |
Hola 👋, We want to let you know that we have decided to close your pull request #456 due to prolonged inactivity. Despite the initial interest and efforts put into the pull request, we haven't seen any updates or responses for a considerable period of time. We understand that circumstances change and priorities shift, which may have led to this inactivity. If you still wish to contribute or have further discussions on this feature or bug fix, please don't hesitate to reopen the pull request and engage with the community. We appreciate your understanding and the time you invested in submitting the pull request. Your contributions are valuable, and we hope to collaborate with you on future endeavors. Thank you. |
Resolves #670
Short description 📝
Solution 📦
Implementation 👩💻👨💻