Metaprogramming in Swift, and the issue with Process and relative paths

Find out why your Swift CLI tool isn't capable of dealing with relative paths, and how to solve it!

While fooling around trying to create a metaprogramming tool written in Swift, I noticed a curious issue whenever I asked said tool to run shell scrips using relative paths, such as ../../test2.

~/P/c/t/Project (main) [1]> swift run Project ../../test2/build_and_run.sh
Error: Could not execute file build_and_run.sh Error Domain=NSCocoaErrorDomain Code=4 "The file “test2” doesn’t exist." UserInfo={NSFilePath=/Users/user/Projects/currentProject/test/Project/test2}

In this case, we are currently in the ~/Projects/currentProject/test/Project/ directory, trying to get our cli tool to run build_and_run.sh in the ~/Projects/currentProject/test2` directory. And it's not going well.

I had an inkling this was due to the use of relative paths, as you can see below:

if file.filename.hasSuffix(".sh") {
            do {
                print("Executing file: \(file.filename)")
                let task = Process()
                task.currentDirectoryURL = URL(fileURLWithPath: outputPath)
                task.executableURL = URL(fileURLWithPath: outputPath).absoluteURL.appendingPathComponent(file.filename)
                task.launchPath = task.executableURL?.absoluteURL.path
                task.arguments = []
                let pipe = Pipe()
                task.standardOutput = pipe
                try task.run()
                task.waitUntilExit()
                let data = pipe.fileHandleForReading.readDataToEndOfFile()
                let output = String(data: data, encoding: .utf8)
                print("Output of \(file.filename): \n\(output ?? "No output").")
            } catch (let error) {
                print("Error: Could not execute file \(file.filename)", error)
            }
        }

Printing them, I noticed the paths did indeed not make any sense. It seemed as if the relative part of the URL, ../ was simply ignored.

Executing file: build_and_run.sh
Output Path:  file:///Users/user/Projects/currentProject/test/Project/test2/
Current Directory URL:  Optional("file:////Users/user/Projects/currentProject/test/Project/test2/")
Executable URL:  Optional("file:///Users/user/Projects/currentProject/test/Project/test2/build_and_run.sh")
Launchpath:  Optional("/Users/user/Projects/currentProject/test/Project/test2/build_and_run.sh")

It turns, as kindly explained by our dear Quinn on the Swift Forums, that's actually the case:

Here’s what’s going on…

The currentDirectoryURL property is a relatively recent addition. Historically, NSTask (and hence Process) only supported currentDirectoryPath.

The new currentDirectoryURL property is a wrapper around the old currentDirectoryPath property. Internally the setter converts the URL to a path and the getter does the reverse.

The conversion process in the setter uses url.standardizedURL.path.

The standardizedURL property is documented to return A copy of the URL with any instances of ".." or "." removed from its path. The tricky part here is that it looks just at the URL’s path. If the URL is relative, it ignores the path in the base URL.

In your case the URL is relative:

print(directoryURL)
// prints: ../usr/ -- file:///Users/ 

and thus standardized returns unhelpful results:

print(directoryURL.standardized)
usr/ -- file:///Users` 

You can avoid the problem with a judicious application of absoluteURL.

`process.currentDirectoryURL = directoryURL.absoluteUR` 

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

So, just to be on the safe side, let's use absolute URLs everywhere, like this:

if file.filename.hasSuffix(".sh") {
            do {
                print("Executing file: \(file.filename)")
                let task = Process()
                task.currentDirectoryURL = URL(fileURLWithPath: outputPath).absoluteURL
                task.executableURL = URL(fileURLWithPath: outputPath).absoluteURL.appendingPathComponent(file.filename)
                task.launchPath = task.executableURL?.absoluteURL.path
                task.arguments = []
                let pipe = Pipe()
                task.standardOutput = pipe
                try task.run()
                task.waitUntilExit()
                let data = pipe.fileHandleForReading.readDataToEndOfFile()
                let output = String(data: data, encoding: .utf8)
                print("Output of \(file.filename): \n\(output ?? "No output").")
            } catch (let error) {
                print("Error: Could not execute file \(file.filename)", error)
            }
        }

And lo and behold, it works.

~/P/c/t/Project (main) [1]> swift run Project ../../test2/build_and_run.sh
Executing file: build_and_run.sh
Building for debugging...
Build complete! (0.13s)
Output of build_and_run.sh:
[0/1] Planning build
Building for debugging...
[1/3] Compiling Project main.swift
[2/3] Emitting module Project
[2/3] Linking Project
Build complete! (2.55s)
Usage: ./Project outputDirectoryPath pathToFile

Meanwhile, Xcode was as useless as ever

Epilogue

Anyway, if you've landed here, it's probably because you had a similar issue, so I hope it helped, see you around!