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.
(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.
(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.