Skip to content
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

Compute relative paths to support sources outside a spec's directory #524

Merged
merged 7 commits into from
Feb 26, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
#### Added
- Added `missingConfigFiles` to `options.disabledValidations` to optionally skip checking for the existence of config files.

#### Fixed
- Sources outside a project spec's directory will be correctly referenced as relative paths in the project file. [#524](https://github.com/yonaskolb/XcodeGen/pull/524)

## 2.2.0

#### Added
Expand Down
2 changes: 1 addition & 1 deletion Sources/XcodeGenKit/CacheFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class CacheFile {
guard #available(OSX 10.13, *) else { return nil }

let files = Array(Set(project.allFiles))
.map { $0.byRemovingBase(path: project.basePath).string }
.map { ((try? $0.relativePath(from: project.basePath)) ?? $0).string }
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
.joined(separator: "\n")

Expand Down
2 changes: 1 addition & 1 deletion Sources/XcodeGenKit/PBXProjGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -811,7 +811,7 @@ public class PBXProjGenerator {
searchForPlist = false
}
if let plistPath = plistPath {
buildSettings["INFOPLIST_FILE"] = plistPath.byRemovingBase(path: project.basePath)
buildSettings["INFOPLIST_FILE"] = (try? plistPath.relativePath(from: project.basePath)) ?? plistPath
}
}

Expand Down
66 changes: 63 additions & 3 deletions Sources/XcodeGenKit/PathExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,68 @@ import Foundation
import PathKit

extension Path {

public func byRemovingBase(path: Path) -> Path {
return Path(normalize().string.replacingOccurrences(of: "\(path.normalize().string)/", with: ""))
/// Returns a Path without any inner parent directory references.
///
/// Similar to `NSString.standardizingPath`, but works with relative paths.
///
/// ### Examples
/// - `a/b/../c` simplifies to `a/c`
/// - `../a/b` simplifies to `../a/b`
/// - `a/../../c` simplifies to `../c`
public func simplifyingParentDirectoryReferences() -> Path {
return normalize().components.reduce(Path(), +)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does normalize just do the same thing? What does adding the components together again do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalize calls NSString.standardizingPath, which almost does the same thing but doesn't remove parent directory references in relative paths:

For absolute paths only, resolving references to the parent directory (that is, the component “..”) to the real parent directory if possible using resolvingSymlinksInPath. For relative paths, references to the parent directory are left in place.

PathKit's + operator backs up a level if you try to add .. to a path, which means it can handle this normalization edge case for us. In other words:

Path("foo/../bar").normalize() == Path("foo/../bar")
Path("foo") + ".." + "bar" == Path("bar")

}

/// Returns the relative path necessary to go from `base` to `self`.
///
/// Both paths must be absolute or relative paths.
/// - throws: Throws an error when the path types do not match, or when `base` has so many parent path components
/// that it refers to an unknown parent directory.
public func relativePath(from base: Path) throws -> Path {
enum PathArgumentError: Error {
/// Can't back out of an unknown parent directory
case unknownParentDirectory
/// It's impossible to determine the path between an absolute and a relative path
case unmatchedAbsolutePath
}

func pathComponents(for path: ArraySlice<String>, relativeTo base: ArraySlice<String>, memo: [String]) throws -> [String] {
switch (base.first, path.first) {
// Base case: Paths are equivalent
case (.none, .none):
return memo

// No path to backtrack from
case (.none, .some(let rhs)):
guard rhs != "." else {
// Skip . instead of appending it
return try pathComponents(for: path.dropFirst(), relativeTo: base, memo: memo)
}
return try pathComponents(for: path.dropFirst(), relativeTo: base, memo: memo + [rhs])

// Both sides have a common parent
case (.some(let lhs), .some(let rhs)) where lhs == rhs:
return try pathComponents(for: path.dropFirst(), relativeTo: base.dropFirst(), memo: memo)

// `base` has a path to back out of
case (.some(let lhs), _):
guard lhs != ".." else {
throw PathArgumentError.unknownParentDirectory
}
guard lhs != "." else {
// Skip . instead of resolving it to ..
return try pathComponents(for: path, relativeTo: base.dropFirst(), memo: memo)
}
return try pathComponents(for: path, relativeTo: base.dropFirst(), memo: memo + [".."])
}
}

guard isAbsolute && base.isAbsolute || !isAbsolute && !base.isAbsolute else {
throw PathArgumentError.unmatchedAbsolutePath
}

return Path(components: try pathComponents(for: ArraySlice(simplifyingParentDirectoryReferences().components),
relativeTo: ArraySlice(base.simplifyingParentDirectoryReferences().components),
memo: []))
}
}
6 changes: 3 additions & 3 deletions Sources/XcodeGenKit/SourceGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class SourceGenerator {
if let fileReference = fileReferencesByPath[fileReferenceKey] {
return fileReference
} else {
let fileReferencePath = path.byRemovingBase(path: inPath)
let fileReferencePath = (try? path.relativePath(from: inPath)) ?? path
var fileReferenceName: String? = name ?? fileReferencePath.lastComponent
if fileReferencePath.string == fileReferenceName {
fileReferenceName = nil
Expand Down Expand Up @@ -254,7 +254,7 @@ class SourceGenerator {

let groupName = name ?? path.lastComponent
let groupPath = isTopLevelGroup ?
path.byRemovingBase(path: project.basePath).string :
((try? path.relativePath(from: project.basePath)) ?? path).string :
elliottwilliams marked this conversation as resolved.
Show resolved Hide resolved
path.lastComponent
let group = PBXGroup(
children: children,
Expand Down Expand Up @@ -474,7 +474,7 @@ class SourceGenerator {
var sourcePath = path
switch type {
case .folder:
let folderPath = Path(targetSource.path)
let folderPath = project.basePath + Path(targetSource.path)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to change this line to fix a test that broke when I swapped out byRemovingBase(path:) with relativePath(from:).

It seems like folder target sources were being created without the base directory as part of the path. In the tests, I saw that a folder "Sources/A" would passed to getFileReference as Path("Sources/A"), but a file "Sources/a.swift" would be passed as Path("TestDirectory/Sources/a.swift").

let fileReference = getFileReference(
path: folderPath,
inPath: project.basePath,
Expand Down
65 changes: 65 additions & 0 deletions Tests/XcodeGenKitTests/PathExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Spectre
import PathKit
import XCTest

class PathExtensionsTests: XCTestCase {

func testPathRelativeToPath() {
func relativePath(to path: String, from base: String) throws -> String {
return try Path(path).relativePath(from: Path(base)).string
}

// These are based on ruby's tests for Pathname#relative_path_from:
// https://github.com/ruby/ruby/blob/7c2bbd1c7d40a30583844d649045824161772e36/test/pathname/test_pathname.rb#L297
describe {
$0.it("resolves single-level paths") {
try expect(relativePath(to: "a", from: "b")) == "../a"
try expect(relativePath(to: "a", from: "b/")) == "../a"
try expect(relativePath(to: "a/", from: "b")) == "../a"
try expect(relativePath(to: "a/", from: "b/")) == "../a"
try expect(relativePath(to: "/a", from: "/b")) == "../a"
try expect(relativePath(to: "/a", from: "/b/")) == "../a"
try expect(relativePath(to: "/a/", from: "/b")) == "../a"
try expect(relativePath(to: "/a/", from: "/b/")) == "../a"
}

$0.it("resolves paths with a common parent") {
try expect(relativePath(to: "a/b", from: "a/c")) == "../b"
try expect(relativePath(to: "../a", from: "../b")) == "../a"
}

$0.it("resolves dot paths") {
try expect(relativePath(to: "a", from: ".")) == "a"
try expect(relativePath(to: ".", from: "a")) == ".."
try expect(relativePath(to: ".", from: ".")) == "."
try expect(relativePath(to: "..", from: "..")) == "."
try expect(relativePath(to: "..", from: ".")) == ".."
}

$0.it("resolves multi-level paths") {
try expect(relativePath(to: "/a/b/c/d", from: "/a/b")) == "c/d"
try expect(relativePath(to: "/a/b", from: "/a/b/c/d")) == "../.."
try expect(relativePath(to: "/e", from: "/a/b/c/d")) == "../../../../e"
try expect(relativePath(to: "a/b/c", from: "a/d")) == "../b/c"
try expect(relativePath(to: "/../a", from: "/b")) == "../a"
try expect(relativePath(to: "../a", from: "b")) == "../../a"
try expect(relativePath(to: "/a/../../b", from: "/b")) == "."
try expect(relativePath(to: "a/..", from: "a")) == ".."
try expect(relativePath(to: "a/../b", from: "b")) == "."
}

$0.it("backtracks on a non-normalized base path") {
try expect(relativePath(to: "a", from: "b/..")) == "a"
try expect(relativePath(to: "b/c", from: "b/..")) == "b/c"
}

$0.it("throws when given unresolvable paths") {
try expect(relativePath(to: "/", from: ".")).toThrow()
try expect(relativePath(to: ".", from: "/")).toThrow()
try expect(relativePath(to: "a", from: "..")).toThrow()
try expect(relativePath(to: ".", from: "..")).toThrow()
try expect(relativePath(to: "a", from: "b/../..")).toThrow()
}
}
}
}
2 changes: 1 addition & 1 deletion Tests/XcodeGenKitTests/SourceGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ class SourceGeneratorTests: XCTestCase {
let pbxProj = try project.generatePbxProj()
try pbxProj.expectFile(paths: ["Sources", "A", "b.swift"], buildPhase: .sources)
try pbxProj.expectFile(paths: ["Sources", "F", "G", "h.swift"], buildPhase: .sources)
try pbxProj.expectFile(paths: [(outOfRootPath + "C/D").string, "e.swift"], names: ["D", "e.swift"], buildPhase: .sources)
try pbxProj.expectFile(paths: ["../OtherDirectory/C/D", "e.swift"], names: ["D", "e.swift"], buildPhase: .sources)
try pbxProj.expectFile(paths: ["Sources/B", "b.swift"], names: ["B", "b.swift"], buildPhase: .sources)
}

Expand Down