Clojure File System Essentials for Node.js Developers

Clojure File System Essentials for Node.js Developers

This will be a straightforward post with Javascript to Clojure (on JVM) examples of filesystem operations: listing, reading, writing, renaming, and deleting files. Let me know if you're interested in how to use Node packages from Clojurescript.

So, let's get to it.

In Javascript, we'd import the fs-module.

const fs = require('node:fs/promises')

I'll be running all of the Javascript blocks inside a (async () => { ...code... })() closure.

In Clojure, we'd reach out for clojure.java.io

(require '[clojure.java.io :as io])

Node Filehandle and Java File

In Javascript, we can get a reference to a file with a Filehandle.

let directory = await fs.open(".");

console.log(directory)

// FileHandle {
//     close: [Function: close],
//     [Symbol(kHandle)]: FileHandle {},
//     [Symbol(kFd)]: 20,
//     [Symbol(kRefs)]: 1,
//     [Symbol(kClosePromise)]: null
// }

FileHandle can be either a directory or a file.

let directoryStats = await fs.stat(".");
directoryStats.isFile(); // false
directoryStats.isDirectory(); // true

let fileStats = await fs.stat("files/a.txt");
fileStats.isFile()); // true
fileStats.isDirectory(); // false

The basic building block in Clojure is java.io.File.

User interfaces and operating systems use system-dependent pathname strings to name files and directories. This class presents an abstract, system-independent view of hierarchical pathnames.

Docs: java.io.File

(io/file ".")
;; => #object[java.io.File 0x54162701 "."]

Similarly to Filehandle in JS, java.io.File can be either a directory or a file. The File has isDirectory and isFile methods that can be called via Java-interop.

(.isDirectory (io/file "."))
;; => true

(.isFile (io/file "."))
;; => false

(.isDirectory (io/file "deps.edn"))
;; => false

(.isFile (io/file "deps.edn"))
;; => true

Let's see what other properties the java.io.File has with the bean function.

bean

Takes a Java object and returns a read-only implementation of the map abstraction based upon its JavaBean properties.

Source

(bean (io/file "."))
;; => 
{:path ".",
 :freeSpace 146245873664,
 :parent nil,
 :directory true,
 :parentFile nil,
 :name ".",
 :file false,
 :canonicalFile
 #object[java.io.File 0x4f2abe7e "/home/tvaisanen/projects/tmp"],
 :absolute false,
 :absoluteFile
 #object[java.io.File 0x5594224b "/home/tvaisanen/projects/tmp/."],
 :hidden true,
 :class java.io.File,
 :canonicalPath "/home/tvaisanen/projects/tmp",
 :usableSpace 124331769856,
 :totalSpace 429923737600,
 :absolutePath "/home/tvaisanen/projects/tmp/."}

Listing Files

Given we have the following file structure.

❯ tree files
files
├── a.txt
├── b.txt
├── c.txt
└── nest
    └── a.txt

In Javascript, we'd typically write.

let dir = await fs.readdir("files");
console.log(dir);
// [ 'a.txt', 'b.txt', 'c.txt', 'nest' ]

And in Clojure, we'll take the folder we want to list as java.io.File and list all the files with file-seq.

(file-seq (io/file "files"))
;; =>
(#object[java.io.File 0x7e2b40d7 "files"]
 #object[java.io.File 0x22aaad8f "files/a.txt"]
 #object[java.io.File 0x3730fe92 "files/b.txt"]
 #object[java.io.File 0x62aaf1b "files/c.txt"]
 #object[java.io.File 0x22236af1 "files/nest"]
 #object[java.io.File 0x55126def "files/nest/a.txt"])

I'd expect this to return files similarly fs.readdir, but now we have the nested files in the listing. Let's see why that happens and if we could get it to work similarly to the listing in Javascript.

If we take a look at the file-seq source code

(defn file-seq
  "A tree seq on java.io.Files"
  {:added "1.0"
   :static true}
  [dir]
    (tree-seq
     (fn [^java.io.File f] (. f (isDirectory)))
     (fn [^java.io.File d] (seq (. d (listFiles))))
     dir))

The docstring says that the function returns a tree seq, which sounds like a non-flat structure. And if we look deeper into tree-seq function and get the docstring.

Returns a lazy sequence of the nodes in a tree, via a depth-first walk. ...

And here's the explanation of what is happening: "a depth-first walk." This function will go first till the end of the file tree and start coming back towards the root directory. The first argument is a function that returns a boolean value to decide whether the node (read file or directory) needs to be traversed (read looked into). If it returns true, the same file is passed to the second function to list its nodes (read files and subdirectories). Based on this, the tree-seq will do a depth-first search and, therefore, read all the subdirectories.

You might have already noticed that the second function argument calls (. d (listFiles)) to get the files for our directory d. Let's use this to mimic the JS version readdir.

(seq (. (io/file "files") listFiles))
;; =>
(#object[java.io.File 0x356bf4d3 "files/a.txt"]
 #object[java.io.File 0x13c28931 "files/b.txt"]
 #object[java.io.File 0x444349ab "files/c.txt"]
 #object[java.io.File 0x2e64f09c "files/nest"])

At this point, the expected result is almost identical to the Javascript's fs.readdir. We have the files listed in the directory, but instead of file names, we have java.io.File instances. Let's iterate over the files and once again use Java-interop with the getName method.

(for [file (seq (. (io/file "files") listFiles))]
  (.getName file))
;; => ("a.txt" "b.txt" "c.txt" "nest")

The fs.readdir function will throw if called with a filename that is not a directory. If we try to create a file sequence from a java.io.File that is not a directory, we get only the file itself in a list.

(file-seq (io/file "files/a.txt"))
;; => (#object[java.io.File 0x37eeb5e7 "files/a.txt"])

Let's combine what we've learned from the previous examples into a new function read-dir that takes a path, checks that it is not a file, and then lists the files and directories in the folder but not the subpages.

(defn read-dir [d]
  (let [directory (io/file d)]
    (when (. directory isFile)
      (throw (ex-info "Path is not a directory" {:path directory})))
    (doall
     (for [file (seq (. directory listFiles))]
       (.getName file)))))

(read-dir "files")
;; => ("a.txt" "b.txt" "c.txt" "nest")

(read-dir "files/a.txt")
;; Unhandled clojure.lang.ExceptionInfo
;; Path is not a directory
;; {:path #object[java.io.File 0x7c7a2da9 "files/a.txt"]}

That's enough on listing files for now. Next, let's look into reading the files.

Read Files

Let's see the content for a couple of the example files.

❯ cat files/a.txt
AAAAA
❯ cat files/b.txt
BBBB
1111
2222
3333

In Javascript, we'd typically use the fs.readFile.

let content = await fs.readFile("files/a.txt", {encoding: "utf8"});
console.log(content);
// AAAAA

In Clojure, we default to slurp.

(slurp (io/file "files/a.txt"))
;; => "AAAAA\n"

Slurp also works with the filename since if the argument is a string; it's first coerced (read interpreted as) as a URI and second as a local file.

(slurp "files/a.txt")
;; => "AAAAA\n"

Both fs.readdir and slurp read the file into memory at once; therefore, you might want to avoid it in production!

The more responsible way is to read the files as streams line by line.

let file = await fs.open('files/b.txt',);

for await (const line of file.readLines()) {
    console.log(line);
}
// BBBB
// 1111
// 2222
// 3333

In Clojure, we can use the java.io.Reader to create a buffered reader for streaming over the files. slurp uses the same mechanism, but instead of reading a file line by line, it reads it all simultaneously.

(io/reader  "files/a.txt")
;; => #object[java.io.BufferedReader 0x4754cc18 "java.io.BufferedReader@4754cc18"]

We can open this stream (read file) to create a lazy line sequence that can be processed one line at a time.

(with-open [reader (io/reader  "files/b.txt")]
  (doall (for [line (line-seq reader)]
           (str "read: " line))))
;; => ("read: BBBB" "read: 1111" "read: 2222" "read: 3333")

Next, to writing files.

Writing Files

With Javascript, we'd typically do this with fs.writeFile or use fs.createFileStream for extra control.

await fs.writeFile("files/write-with-js.txt", "writing from JS");

let content_01 = await fs.readFile("files/write-with-js.txt", 
                                   {encoding: "utf8"});
console.log(content); 
// writing from JS

In Clojure, similarly to slurp we have spit to write data.

(spit "files/new.txt" "New content here")
;; => nil
(slurp "files/new.txt")
;; => "New content here"

By default, spit overrides the file with the given content. To add to the file, use arguments :append true.

(spit "files/new.txt" "Append to file")
;; => nil
(slurp "files/new.txt")
;; => "Append to file"
(spit "files/new.txt" " Try again" :append true)
;; => nil
(slurp "files/new.txt")
;; => "Append to file Try again"

spit wraps the java.io.writer, which can be used directly. Similarly to slurp java.io.writer replaces the file content if :append true is not passed as an option.

(with-open [writer (io/writer "files/other.txt")]
  (.write writer "text here"))

(slurp "files/other.txt")
;; => "text here"

(with-open [writer (io/writer "files/other.txt" :append true)]
  (.write writer " more text"))
;; => nil

(slurp "files/other.txt")
;; => "text here more text"

Let's go through a couple of more use cases.

Renaming Files

In Javascript, we rename files with fs.rename.

await fs.rename("files/c.txt", "files/d.txt");

In Clojure, we use the renameTo method.

(.renameTo (io/file "files/new.txt")
           (io/file "files/newer.txt"))
;; => true
(.isFile (io/file "files/new.txt"))
;; => false
(.isFile (io/file "files/newer.txt"))
;; => true

Deleting Files

In Javascript, we delete files with fs.unlink

 await fs.unlink("files/d.txt");

In Clojure, we have clojure.java.io/delete-file for the same task.

(.isFile (io/file "files/other.txt"))
;; => true
(io/delete-file "files/other.txt")
;; => true
(.isFile (io/file "files/other.txt"))
;; => false

Conclusion

Clojure can do the same tasks as Javascript on the filesystem level since clojure.java.io provides utility functions as a layer on top of java.io classes. If there's no function matching your need, you can use Java-interop by, for example, using the java.io.File methods. If you hit a roadblock, check the following resources for more hints.

Once again, I hope you found this helpful. Feel free to reach out and let me know what pain points you might have encountered when learning Clojure and what type of posts would help make the jump—social links in the menu.

Thank you for reading.