How to join file paths

clojure

#1

This is something that always leaves me a bit stumped, the equivalent of Ruby’s Pathname#join

It resolves ./ and ../, when joining with an absolute path it uses that, and it respects the OS path name separator (less of interest to me but nice to have).

> Pathname('/he/lo/world').join('../foo/bar')
=> #<Pathname:/he/lo/foo/bar>
> Pathname('/he/lo/world').join('/var/www/foo/bar')
=> #<Pathname:/var/www/foo/bar>

Clojure itself has nothing like it AFAIK. I’m ok with falling back to Java, but the closest I could find, java.nio.file.Paths/get is a variadic method, meaning interop gets awkward. (you have to give it a java array). It also doesn’t resolve ./ or ../ unless you normalize, so this is what I ended up using.

(defn path-join [p & ps]
  (str (.normalize (java.nio.file.Paths/get p (into-array String ps)))))

How do other people do this? Or do just… not do this?


#2

My question would be: why bother? You can open an unnormalized path just fine. Or are you worried about displaying the resulting paths to the user?


#3

For reference, Python’s os.path.join also doesn’t by itself eliminate ..:

In [2]: os.path.join("bin", "..", "bin")
Out[2]: 'bin/../bin'

#4

What if you want to join /xyz/foo/bar.baz and qux.foo to get /xyz/foo/qux.foo?


#5
(-> (java.io.File. "/xyz/foo/bar.baz") .getParentFile (java.io.File. "qux.foo") .toString)
"/xyz/foo/qux.foo"

#6
(require '[clojure.java.io :as io])
;; io/file is variadic, taking strings, returns java.io.File instance
;; os aware, uses \ on windows
(io/file "thing" "path" "file.txt")

;; resolve .. and .
(-> (io/file ".." ".." "foo" "..") (.getCanonicalFile))


(.getAbsolutePath file)
(.getCanonicalPath file)

java.nio.file.Paths also works and does a bunch more stuff (eg. symlinks). Can call .toPath on file instances.


#7

In ClojureScript we use:

(ns x (:require ["path" :as path]))

(path/join "a" "b")

I think it resolves ../ like Ruby does… https://nodejs.org/api/path.html#path_path_join_paths

path.join('/foo', 'bar', 'baz/asdf', 'quux', '..');
// Returns: '/foo/bar/baz/asdf'

#8

reference C#:

string path1 = @"D:\temp";  
string path2 = "result.txt";  
string newPath = Path.Combine(path1, path2); 
// newPath = "D:\temp\result.txt";  

Combine(String, String)	
Combine(String, String, String)	
Combine(String, String, String, String)	
Combine(String[])

Clojure:


(defn path-combine [& paths]
  (-> paths
      (#(string/join "/" %))
      (string/replace ,  #"[\\/]+" "/")))

(path-combine "c:\\" "/he/lo/world" "t\\k" "\\" "/" "" "x.txt")
; =>  "c:/he/lo/world/t/k/x.txt"

#9

Thanks for all the suggestions, however none of these are general enough or have the semantics that I’m after.

Here are some test cases to make it more clear what I’m after

(is (= (join "/foo/bar" "baz") "/foo/baz")) ;; most already get this wrong and turn it into "/foo/bar/baz"
(is (= (join "/foo/bar/" "baz") "/foo/bar/baz"))
(is (= (join "/foo/bar/" "/baz") "/baz"))
(is (= (join "/foo/bar/" "./baz") "/foo/bar/baz")) ;; I would accept "/foo/bar/./baz"
(is (= (join "/foo/bar/qux" "../baz") "/foo/baz")) ;; I would accept "/foo/bar/../baz"

To give you a concrete example that I came across today (which is different from the thing I came across yesterday where I needed this): I’ve been working on code that “fingerprints” web assets, i.e. it adds a content-based hash to the file name so that files can safely be cached.

When a JS file has a source map, then that JS file needs to be rewritten so it points to the source map with hash in the filename. This sourceMappingURL is usually just a file name like my_code.js.map, but it could be an absolute path like /js/my_code.js.map, or a path relative to the original like ./source-maps/my_code.js.map or source_maps/my_code.js.map. I need to do path “arithmetic” on this find which file on my filesystem this corresponds with.


#10

In Node it’s supposed that the first parameter is a folder, so it does not satisfy:

(is (= (join "/foo/bar" "baz") "/foo/baz"))

and(surprisingly) // in path is regarded as /:

path.normalize('/foo/bar//baz/asdf/quux/..');
// Returns: '/foo/bar/baz/asdf'

which made Node also fail the third test. https://github.com/nodejs/node/blob/master/lib/path.js#L1243-L1260


#11

You could also use a build tool that already does this for you. Granted its not documented well but :module-hash-names true.

Seriously though that you are asking for is not standard join behaviour and you are probably going to need to do some (.isDirectory x) checks yourself. io/file accepts a file instance as its first argument so you’ll typically use that as a root. Benefit of this is that it works cross platform.

(let [some-dir (io/file "public" "js")
      some-file (io/file some-dir "thing.js")]
  (io/make-parents some-file)
  (spit some-file "console.log('hello world');"))

#12

@plexus:

This pure clojure function satisfies your needs:

(defn path-combine [s1 s2]
  (cond
    (string/starts-with? s2 "/") 
      s2
    (not (string/ends-with? s1 "/"))
      (-> (string/split s1 #"[\\/]")
          butlast
          (#(string/join "/" %))
          (str , "/")
          (path-combine , s2)) 
    :else  
      (-> (string/join "/" [s1 s2])
          (string/replace ,  #"[\\/]+" "/")))) 

#13

Windows uses \ to separate paths, so a pure Clojure version might not be enough.


#14

Paths in the form of “c:/tmp/x.txt” can also be applied in Windows, except that the console environment is not available.

or add fn like :

(defn get-os-path-sep-char []
  )


or

(defn path-combine [sep-char s1 s2]
  (cond
    (string/starts-with? s2 sep-char) 
      s2
    (not (string/ends-with? s1 sep-char))
      (-> (string/split s1 #"[\\/]")
          butlast
          (#(string/join sep-char %))
          (str , sep-char)
          (#(path-combine sep-char % s2))) 
    :else  
      (-> (string/join sep-char [s1 s2])
          (string/replace ,  #"[\\/]+" sep-char)))) 

#15

java.io.File has a few static properties to get the proper sep-char.

File/pathSeparator
File/pathSeparatorChar
File/separator
File/separatorChar

#16
(defn path-join [& args]
  (loop [chunks args
         result ""]
    (if (empty? chunks)
      result
      (recur (rest chunks) (->
                             (File. result (first chunks))
                             (.getAbsolutePath)
                             )))))

#17

You can check out pathetic which is a dependency of cemerick’s URL. I linked to the tests that might pique your interest