Skip to content

Commit 8d87350

Browse files
authored
Support directory listing (#284)
Closes #222
1 parent 2d134d7 commit 8d87350

File tree

19 files changed

+311
-16
lines changed

19 files changed

+311
-16
lines changed

build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,19 @@ kotlin {
7373
applyDefaultHierarchyTemplate {
7474
common {
7575
group("native") {
76-
group("nonApple") {
76+
group("nativeNonApple") {
7777
group("mingw")
7878
group("unix") {
7979
group("linux")
8080
group("androidNative")
8181
}
8282
}
83+
84+
group("nativeNonAndroid") {
85+
group("apple")
86+
group("mingw")
87+
group("linux")
88+
}
8389
}
8490
group("nodeFilesystemShared") {
8591
withJs()

core/Module.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,8 @@ Core IO primitives.
8282
# Package kotlinx.io.files
8383

8484
Basic API for working with files.
85+
86+
#### Known issues
87+
88+
- [#312](https://github.com/Kotlin/kotlinx-io/issues/312) For `wasmWasi` target, directory listing ([kotlinx.io.files.FileSystem.list]) does not work with NodeJS runtime on Windows,
89+
as `fd_readdir` function is [not implemented there](https://github.com/nodejs/node/blob/6f4d6011ea1b448cf21f5d363c44e4a4c56ca34c/deps/uvwasi/src/uvwasi.c#L19).

core/androidNative/src/files/FileSystemAndroid.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
package kotlinx.io.files
77

8+
import kotlinx.cinterop.CPointer
89
import kotlinx.cinterop.ExperimentalForeignApi
10+
import kotlinx.cinterop.get
911
import kotlinx.cinterop.toKString
10-
import platform.posix.__posix_basename
11-
import platform.posix.dirname
12+
import kotlinx.io.IOException
13+
import platform.posix.*
1214

1315
@OptIn(ExperimentalForeignApi::class)
1416
internal actual fun dirnameImpl(path: String): String {
@@ -24,3 +26,22 @@ internal actual fun basenameImpl(path: String): String {
2426
}
2527

2628
internal actual fun isAbsoluteImpl(path: String): Boolean = path.startsWith('/')
29+
30+
@OptIn(ExperimentalForeignApi::class, ExperimentalStdlibApi::class)
31+
internal actual class OpaqueDirEntry constructor(private val dir: CPointer<cnames.structs.DIR>) : AutoCloseable {
32+
actual fun readdir(): String? {
33+
val entry = platform.posix.readdir(dir) ?: return null
34+
return entry[0].d_name.toKString()
35+
}
36+
37+
override fun close() {
38+
closedir(dir)
39+
}
40+
}
41+
42+
@OptIn(ExperimentalForeignApi::class)
43+
internal actual fun opendir(path: String): OpaqueDirEntry {
44+
val dirent = platform.posix.opendir(path)
45+
if (dirent != null) return OpaqueDirEntry(dirent)
46+
throw IOException("Can't open directory $path: ${strerror(errno)?.toKString() ?: "reason unknown"}")
47+
}

core/api/kotlinx-io-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ public abstract interface class kotlinx/io/files/FileSystem {
215215
public abstract fun delete (Lkotlinx/io/files/Path;Z)V
216216
public static synthetic fun delete$default (Lkotlinx/io/files/FileSystem;Lkotlinx/io/files/Path;ZILjava/lang/Object;)V
217217
public abstract fun exists (Lkotlinx/io/files/Path;)Z
218+
public abstract fun list (Lkotlinx/io/files/Path;)Ljava/util/Collection;
218219
public abstract fun metadataOrNull (Lkotlinx/io/files/Path;)Lkotlinx/io/files/FileMetadata;
219220
public abstract fun resolve (Lkotlinx/io/files/Path;)Lkotlinx/io/files/Path;
220221
public abstract fun sink (Lkotlinx/io/files/Path;Z)Lkotlinx/io/RawSink;

core/api/kotlinx-io-core.klib.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ sealed interface kotlinx.io.files/FileSystem { // kotlinx.io.files/FileSystem|nu
164164
abstract fun createDirectories(kotlinx.io.files/Path, kotlin/Boolean =...) // kotlinx.io.files/FileSystem.createDirectories|createDirectories(kotlinx.io.files.Path;kotlin.Boolean){}[0]
165165
abstract fun delete(kotlinx.io.files/Path, kotlin/Boolean =...) // kotlinx.io.files/FileSystem.delete|delete(kotlinx.io.files.Path;kotlin.Boolean){}[0]
166166
abstract fun exists(kotlinx.io.files/Path): kotlin/Boolean // kotlinx.io.files/FileSystem.exists|exists(kotlinx.io.files.Path){}[0]
167+
abstract fun list(kotlinx.io.files/Path): kotlin.collections/Collection<kotlinx.io.files/Path> // kotlinx.io.files/FileSystem.list|list(kotlinx.io.files.Path){}[0]
167168
abstract fun metadataOrNull(kotlinx.io.files/Path): kotlinx.io.files/FileMetadata? // kotlinx.io.files/FileSystem.metadataOrNull|metadataOrNull(kotlinx.io.files.Path){}[0]
168169
abstract fun resolve(kotlinx.io.files/Path): kotlinx.io.files/Path // kotlinx.io.files/FileSystem.resolve|resolve(kotlinx.io.files.Path){}[0]
169170
abstract fun sink(kotlinx.io.files/Path, kotlin/Boolean =...): kotlinx.io/RawSink // kotlinx.io.files/FileSystem.sink|sink(kotlinx.io.files.Path;kotlin.Boolean){}[0]

core/apple/src/files/FileSystemApple.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66

77
package kotlinx.io.files
88

9-
import kotlinx.cinterop.ExperimentalForeignApi
10-
import kotlinx.cinterop.cstr
11-
import kotlinx.cinterop.memScoped
12-
import kotlinx.cinterop.toKString
9+
import kotlinx.cinterop.*
1310
import kotlinx.io.IOException
1411
import platform.Foundation.*
1512
import platform.posix.*

core/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENCE file.
44
*/
55

6+
import org.gradle.internal.os.OperatingSystem
67
import org.jetbrains.dokka.gradle.DokkaTaskPartial
78

89
plugins {
@@ -30,6 +31,17 @@ kotlin {
3031
}
3132
}
3233
}
34+
wasmWasi {
35+
nodejs {
36+
testTask {
37+
// fd_readdir is unsupported on Windows:
38+
// https://github.com/nodejs/node/blob/6f4d6011ea1b448cf21f5d363c44e4a4c56ca34c/deps/uvwasi/src/uvwasi.c#L19
39+
if (OperatingSystem.current().isWindows) {
40+
filter.setExcludePatterns("*SmokeFileTest.listDirectory")
41+
}
42+
}
43+
}
44+
}
3345

3446
sourceSets {
3547
commonMain.dependencies {

core/common/src/files/FileSystem.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ public sealed interface FileSystem {
145145
* @throws FileNotFoundException if there is no file or directory corresponding to the specified path.
146146
*/
147147
public fun resolve(path: Path): Path
148+
149+
/**
150+
* Returns paths corresponding to [directory]'s immediate children.
151+
*
152+
* There are no guarantees on children paths order within a returned collection.
153+
*
154+
* If path [directory] was an absolute path, a returned collection will also contain absolute paths.
155+
* If it was a relative path, a returned collection will contain relative paths.
156+
*
157+
* *For `wasmWasi` target, function does not work with NodeJS runtime on Windows,
158+
* as `fd_readdir` function is [not implemented there](https://github.com/nodejs/node/blob/6f4d6011ea1b448cf21f5d363c44e4a4c56ca34c/deps/uvwasi/src/uvwasi.c#L19).*
159+
*
160+
* @param directory a directory to list.
161+
* @return a collection of [directory]'s immediate children.
162+
* @throws FileNotFoundException if [directory] does not exist.
163+
* @throws IOException if [directory] points to something other than directory.
164+
* @throws IOException if there was an underlying error preventing listing [directory] children.
165+
*/
166+
public fun list(directory: Path): Collection<Path>
148167
}
149168

150169
internal abstract class SystemFileSystemImpl : FileSystem

core/common/test/files/SmokeFileTest.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,36 @@ class SmokeFileTest {
443443
source.close() // there should be no error
444444
}
445445

446+
@Test
447+
fun listDirectory() {
448+
assertFailsWith<FileNotFoundException> { SystemFileSystem.list(createTempPath()) }
449+
450+
val tmpFile = createTempPath().also {
451+
SystemFileSystem.sink(it).close()
452+
}
453+
assertFailsWith<IOException> { SystemFileSystem.list(tmpFile) }
454+
455+
val dir = createTempPath().also {
456+
SystemFileSystem.createDirectories(it)
457+
}
458+
assertEquals(emptyList(), SystemFileSystem.list(dir))
459+
460+
val subdir = Path(dir, "subdir").also {
461+
SystemFileSystem.createDirectories(it)
462+
SystemFileSystem.sink(Path(it, "file")).close()
463+
}
464+
assertEquals(listOf(subdir), SystemFileSystem.list(dir))
465+
466+
val file = Path(dir, "file").also {
467+
SystemFileSystem.sink(it).close()
468+
}
469+
assertEquals(setOf(file, subdir), SystemFileSystem.list(dir).toSet())
470+
471+
SystemFileSystem.delete(file)
472+
SystemFileSystem.delete(Path(subdir, "file"))
473+
SystemFileSystem.delete(subdir)
474+
}
475+
446476
private fun constructAbsolutePath(vararg parts: String): String {
447477
return SystemPathSeparator.toString() + parts.joinToString(SystemPathSeparator.toString())
448478
}

core/jvm/src/files/FileSystemJvm.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl()
9696
if (!path.file.exists()) throw FileNotFoundException(path.file.absolutePath)
9797
return Path(path.file.canonicalFile)
9898
}
99+
100+
override fun list(directory: Path): Collection<Path> {
101+
val file = directory.file
102+
if (!file.exists()) throw FileNotFoundException(file.absolutePath)
103+
if (!file.isDirectory) throw IOException("Not a directory: ${file.absolutePath}")
104+
return buildList {
105+
file.list()?.forEach { childName ->
106+
add(Path(directory, childName))
107+
}
108+
}
109+
}
99110
}
100111

101112
@JvmField

core/native/src/files/FileSystemNative.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import kotlinx.io.RawSource
1212
import platform.posix.*
1313
import kotlin.experimental.ExperimentalNativeApi
1414

15-
@OptIn(ExperimentalForeignApi::class)
15+
@OptIn(ExperimentalForeignApi::class, ExperimentalStdlibApi::class)
1616
public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl() {
1717
override fun exists(path: Path): Boolean {
1818
return access(path.path, F_OK) == 0
@@ -86,6 +86,22 @@ public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl()
8686
?: throw IOException("Failed to open $path with ${strerror(errno)?.toKString()}")
8787
return FileSink(openFile)
8888
}
89+
90+
override fun list(directory: Path): Collection<Path> {
91+
val metadata = metadataOrNull(directory) ?: throw FileNotFoundException(directory.path)
92+
if (!metadata.isDirectory) throw IOException("Not a directory: ${directory.path}")
93+
return buildList {
94+
opendir(directory.path).use {
95+
var child = it.readdir()
96+
while (child != null) {
97+
if (child != "." && child != "..") {
98+
add(Path(directory, child))
99+
}
100+
child = it.readdir()
101+
}
102+
}
103+
}
104+
}
89105
}
90106

91107
internal expect fun metadataOrNullImpl(path: Path): FileMetadata?
@@ -105,3 +121,10 @@ internal const val PermissionAllowAll: UShort = 511u
105121

106122
@OptIn(ExperimentalNativeApi::class)
107123
internal actual val isWindows: Boolean = Platform.osFamily == OsFamily.WINDOWS
124+
125+
@OptIn(ExperimentalStdlibApi::class)
126+
internal expect class OpaqueDirEntry : AutoCloseable {
127+
fun readdir(): String?
128+
}
129+
130+
internal expect fun opendir(path: String): OpaqueDirEntry
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
3+
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
4+
*/
5+
6+
package kotlinx.io.files
7+
8+
import kotlinx.cinterop.CPointer
9+
import kotlinx.cinterop.ExperimentalForeignApi
10+
import kotlinx.cinterop.get
11+
import kotlinx.cinterop.toKString
12+
import kotlinx.io.IOException
13+
import platform.posix.DIR
14+
import platform.posix.closedir
15+
import platform.posix.errno
16+
import platform.posix.strerror
17+
18+
@OptIn(ExperimentalForeignApi::class, ExperimentalStdlibApi::class)
19+
internal actual class OpaqueDirEntry constructor(private val dir: CPointer<DIR>) : AutoCloseable {
20+
actual fun readdir(): String? {
21+
val entry = platform.posix.readdir(dir) ?: return null
22+
return entry[0].d_name.toKString()
23+
}
24+
25+
override fun close() {
26+
closedir(dir)
27+
}
28+
}
29+
30+
@OptIn(ExperimentalForeignApi::class)
31+
internal actual fun opendir(path: String): OpaqueDirEntry {
32+
val dirent = platform.posix.opendir(path)
33+
if (dirent != null) return OpaqueDirEntry(dirent)
34+
throw IOException("Can't open directory $path: ${strerror(errno)?.toKString() ?: "reason unknown"}")
35+
}

core/nodeFilesystemShared/src/files/FileSystemNodeJs.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl()
100100
if (!exists(path)) throw FileNotFoundException(path.path)
101101
return Path(fs.realpathSync.native(path.path))
102102
}
103+
104+
override fun list(directory: Path): Collection<Path> {
105+
val metadata = metadataOrNull(directory) ?: throw FileNotFoundException(directory.path)
106+
if (!metadata.isDirectory) throw IOException("Not a directory: ${directory.path}")
107+
val dir = fs.opendirSync(directory.path) ?: throw IOException("Unable to read directory: ${directory.path}")
108+
try {
109+
return buildList {
110+
var child = dir.readSync()
111+
while (child != null) {
112+
add(Path(directory, child.name))
113+
child = dir.readSync()
114+
}
115+
}
116+
} finally {
117+
dir.closeSync()
118+
}
119+
}
103120
}
104121

105122
public actual val SystemTemporaryDirectory: Path

core/nodeFilesystemShared/src/node/fs.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ internal external interface Fs {
5656
*/
5757
fun writeFileSync(fd: Int, buffer: Buffer)
5858

59+
/**
60+
* See https://nodejs.org/api/fs.html#fsopendirsyncpath-options
61+
*/
62+
fun opendirSync(path: String): Dir?
63+
5964
val realpathSync: realpathSync
6065

6166
val constants: constants
@@ -86,4 +91,20 @@ internal external interface realpathSync {
8691
fun native(path: String): String
8792
}
8893

94+
/**
95+
* See https://nodejs.org/api/fs.html#class-fsdir
96+
*/
97+
internal external interface Dir {
98+
fun closeSync()
99+
100+
fun readSync(): Dirent?
101+
}
102+
103+
/**
104+
* See https://nodejs.org/api/fs.html#class-fsdirent
105+
*/
106+
internal external interface Dirent {
107+
val name: String
108+
}
109+
89110
internal expect val fs: Fs

core/wasmWasi/src/-WasmUtils.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,14 @@ internal fun Pointer.allocateString(value: String): Int {
109109
*/
110110
@UnsafeWasmMemoryApi
111111
internal fun MemoryAllocator.allocateInt(): Pointer = allocate(Int.SIZE_BYTES)
112+
113+
/**
114+
* Decodes zero-terminated string from a sequence of bytes that should not exceed [maxLength] bytes in length.
115+
*/
116+
@UnsafeWasmMemoryApi
117+
internal fun Pointer.loadString(maxLength: Int): String {
118+
val bytes = loadBytes(maxLength)
119+
val firstZeroByte = bytes.indexOf(0)
120+
val length = if (firstZeroByte == -1) maxLength else firstZeroByte
121+
return bytes.decodeToString(0, length)
122+
}

0 commit comments

Comments
 (0)