diff --git a/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts b/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts
index 05b978353..2e26a7749 100644
--- a/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts
+++ b/build-logic/src/main/kotlin/kotlinx/io/conventions/kotlinx-io-multiplatform.gradle.kts
@@ -97,12 +97,14 @@ kotlin {
group("androidNative")
}
}
-
- group("nativeNonAndroid") {
+ group("appleAndLinux") {
group("apple")
- group("mingw")
group("linux")
}
+ group("posix") {
+ group("apple")
+ group("unix")
+ }
}
group("nodeFilesystemShared") {
withJs()
diff --git a/core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt b/core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt
similarity index 95%
rename from core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt
rename to core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt
index a75172231..86e051ae8 100644
--- a/core/nativeNonAndroid/src/files/FileSystemNativeNonAndroid.kt
+++ b/core/appleAndLinux/src/files/FileSystemAppleAndLinux.kt
@@ -13,8 +13,10 @@ import kotlinx.io.IOException
import platform.posix.DIR
import platform.posix.closedir
import platform.posix.errno
+import platform.posix.opendir
import platform.posix.strerror
+
@OptIn(ExperimentalForeignApi::class)
internal actual class OpaqueDirEntry(private val dir: CPointer
) : AutoCloseable {
actual fun readdir(): String? {
@@ -33,7 +35,7 @@ internal actual class OpaqueDirEntry(private val dir: CPointer) : AutoClose
@OptIn(ExperimentalForeignApi::class)
internal actual fun opendir(path: String): OpaqueDirEntry {
- val dirent = platform.posix.opendir(path)
+ val dirent = opendir(path)
if (dirent != null) return OpaqueDirEntry(dirent)
val err = errno
diff --git a/core/common/test/files/SmokeFileTestWindows.kt b/core/common/test/files/SmokeFileTestWindows.kt
index 23994807e..ecf7096a5 100644
--- a/core/common/test/files/SmokeFileTestWindows.kt
+++ b/core/common/test/files/SmokeFileTestWindows.kt
@@ -11,13 +11,20 @@ class SmokeFileTestWindows {
@Test
fun isAbsolute() {
if (!isWindows) return
- assertFalse(Path("C:").isAbsolute)
- assertTrue(Path("C:\\").isAbsolute)
- assertTrue(Path("C:/").isAbsolute)
- assertTrue(Path("C:/../").isAbsolute)
- assertFalse(Path("C:file").isAbsolute)
- assertFalse(Path("bla\\bla\\bla").isAbsolute)
- assertTrue(Path("\\\\server\\share").isAbsolute)
+ fun assertIsAbsolute(path: String) {
+ assertTrue(Path(path).isAbsolute, "Expected absolute path: $path")
+ }
+ fun assertIsRelative(path: String) {
+ assertFalse(Path(path).isAbsolute, "Expected relative path: $path")
+ }
+ assertIsRelative("C:")
+ assertIsAbsolute("C:\\")
+ assertIsAbsolute("C:/")
+ assertIsAbsolute("C:/../")
+ assertIsRelative("C:file")
+ assertIsRelative("bla\\bla\\bla")
+ assertIsAbsolute("\\\\server\\share")
+ assertIsAbsolute("\\\\?\\C:\\Test\\Foo.txt")
}
@Test
diff --git a/core/mingw/src/files/Error.kt b/core/mingw/src/files/Error.kt
new file mode 100644
index 000000000..459563b13
--- /dev/null
+++ b/core/mingw/src/files/Error.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+package kotlinx.io.files
+
+import kotlinx.cinterop.*
+import platform.windows.*
+
+@OptIn(ExperimentalForeignApi::class)
+internal fun formatWin32ErrorMessage(code: UInt = GetLastError()): String {
+ memScoped {
+ val r = alloc()
+ val n = FormatMessageW(
+ dwFlags = (FORMAT_MESSAGE_ALLOCATE_BUFFER or FORMAT_MESSAGE_IGNORE_INSERTS or FORMAT_MESSAGE_FROM_SYSTEM).toUInt(),
+ lpSource = null,
+ dwMessageId = code,
+ dwLanguageId = 0u,
+ lpBuffer = r.ptr.reinterpret(),
+ nSize = 0u,
+ Arguments = null,
+ )
+ if (n == 0u) {
+ return "unknown error (${code.toHexString()})"
+ }
+ val s = r.value!!.toKString().trimEnd()
+ LocalFree(r.value)
+ return "$s (${code.toHexString()})"
+ }
+
+}
diff --git a/core/mingw/src/files/FileSystemMingw.kt b/core/mingw/src/files/FileSystemMingw.kt
index e7ad2cc8a..525c6f057 100644
--- a/core/mingw/src/files/FileSystemMingw.kt
+++ b/core/mingw/src/files/FileSystemMingw.kt
@@ -9,54 +9,134 @@ package kotlinx.io.files
import kotlinx.cinterop.*
import kotlinx.io.IOException
-import platform.posix.*
import platform.windows.*
+
internal actual fun atomicMoveImpl(source: Path, destination: Path) {
- if (MoveFileExA(source.path, destination.path, MOVEFILE_REPLACE_EXISTING.convert()) == 0) {
- // TODO: get formatted error message
- throw IOException("Move failed with error code: ${GetLastError()}")
+ if (MoveFileExW(source.path, destination.path, MOVEFILE_REPLACE_EXISTING.convert()) == 0) {
+ throw IOException("Move failed with error code: ${formatWin32ErrorMessage()}")
}
}
internal actual fun dirnameImpl(path: String): String {
- if (!path.contains(UnixPathSeparator) && !path.contains(WindowsPathSeparator)) {
- return ""
- }
+ val path = path.replace(UnixPathSeparator, WindowsPathSeparator)
memScoped {
- return dirname(path.cstr.ptr)?.toKString() ?: ""
+ val p = path.wcstr.ptr
+ // This function is deprecated, should use PathCchRemoveFileSpec,
+ // but it's not available in current version of Kotlin
+ PathRemoveFileSpecW(p)
+ return p.toKString()
}
}
internal actual fun basenameImpl(path: String): String {
- memScoped {
- return basename(path.cstr.ptr)?.toKString() ?: ""
- }
+ if (PathIsRootW(path) == TRUE) return ""
+ return PathFindFileNameW(path)?.toKString() ?: ""
}
internal actual fun isAbsoluteImpl(path: String): Boolean {
- if (path.startsWith(SystemPathSeparator)) return true
- if (path.length > 1 && path[1] == ':') {
- if (path.length == 2) return false
- val next = path[2]
- return next == WindowsPathSeparator || next == SystemPathSeparator
+ val p = path.replace(UnixPathSeparator, WindowsPathSeparator)
+ if (PathIsRelativeW(p) == TRUE) {
+ return false
+ }
+ // PathIsRelativeW returns FALSE for paths like "C:relative\path" which are not absolute, in DoS
+ if (p.length >= 2 && p[0].isLetter() && p[1] == ':') {
+ return p.length > 2 && (p[2] == WindowsPathSeparator || p[2] == UnixPathSeparator)
}
- return PathIsRelativeA(path) == 0
+ return true
}
internal actual fun mkdirImpl(path: String) {
- if (mkdir(path) != 0) {
- throw IOException("mkdir failed: ${strerror(errno)?.toKString()}")
+ if (CreateDirectoryW(path, null) == FALSE) {
+ throw IOException("mkdir failed: $path: ${formatWin32ErrorMessage()}")
}
}
-private const val MAX_PATH_LENGTH = 32767
-
internal actual fun realpathImpl(path: String): String {
memScoped {
- val buffer = allocArray(MAX_PATH_LENGTH)
- val len = GetFullPathNameA(path, MAX_PATH_LENGTH.convert(), buffer, null)
- if (len == 0u) throw IllegalStateException()
- return buffer.toKString()
+ // in practice, MAX_PATH is enough for most cases
+ var buf = allocArray(MAX_PATH)
+ var r = GetFullPathNameW(path, MAX_PATH.convert(), buf, null)
+ if (r >= MAX_PATH.toUInt()) {
+ // if not, we will retry with the required size
+ buf = allocArray(r.toInt())
+ r = GetFullPathNameW(path, r, buf, null)
+ }
+ if (r == 0u) {
+ error("GetFullPathNameW failed for $path: ${formatWin32ErrorMessage()}")
+ }
+ return buf.toKString()
+ }
+}
+
+internal actual class OpaqueDirEntry(private val directory: String) : AutoCloseable {
+ private val arena = Arena()
+ private val data = arena.alloc()
+ private var handle: HANDLE? = INVALID_HANDLE_VALUE
+ private var firstName: String? = null
+
+ init {
+ try {
+ // since the root
+ val directory0 =
+ if (directory.endsWith(UnixPathSeparator) || directory.endsWith(WindowsPathSeparator)) "$directory*" else "$directory/*"
+ handle = FindFirstFileW(directory0, data.ptr)
+ if (handle != INVALID_HANDLE_VALUE) {
+ firstName = data.cFileName.toKString()
+ } else {
+ val e = GetLastError()
+ if (e != ERROR_FILE_NOT_FOUND.toUInt()) {
+ throw IOException("Can't open directory $directory: ${formatWin32ErrorMessage(e)}")
+ }
+ }
+ } catch (th: Throwable) {
+ if (handle != INVALID_HANDLE_VALUE) {
+ CloseHandle(handle)
+ }
+ arena.clear()
+ throw th
+ }
+ }
+
+ actual fun readdir(): String? {
+ if (firstName != null) {
+ return firstName.also { firstName = null }
+ }
+ if (handle == INVALID_HANDLE_VALUE) {
+ return null
+ }
+ if (FindNextFileW(handle, data.ptr) == TRUE) {
+ return data.cFileName.toKString()
+ }
+ val le = GetLastError()
+ if (le == ERROR_NO_MORE_FILES.toUInt()) {
+ return null
+ }
+ throw IOException("Can't readdir from $directory: ${formatWin32ErrorMessage(le)}")
+ }
+
+ actual override fun close() {
+ if (handle != INVALID_HANDLE_VALUE) {
+ FindClose(handle)
+ }
+ arena.clear()
+ }
+
+}
+
+internal actual fun opendir(path: String): OpaqueDirEntry = OpaqueDirEntry(path)
+
+internal actual fun existsImpl(path: String): Boolean = PathFileExistsW(path) == TRUE
+
+internal actual fun deleteNoCheckImpl(path: String) {
+ if (DeleteFileW(path) != FALSE) return
+ var e = GetLastError()
+ if (e == ERROR_FILE_NOT_FOUND.toUInt()) return // ignore it
+ if (e == ERROR_ACCESS_DENIED.toUInt()) {
+ // might be a directory
+ if (RemoveDirectoryW(path) != FALSE) return
+ e = GetLastError()
+ if (e == ERROR_FILE_NOT_FOUND.toUInt()) return // ignore it
}
+ throw IOException("Delete failed for $path: ${formatWin32ErrorMessage(e)}")
}
diff --git a/core/mingw/src/files/PathsMingw.kt b/core/mingw/src/files/PathsMingw.kt
new file mode 100644
index 000000000..8329c1d1b
--- /dev/null
+++ b/core/mingw/src/files/PathsMingw.kt
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+package kotlinx.io.files
+
+public actual val SystemPathSeparator: Char get() = WindowsPathSeparator
diff --git a/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt b/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt
index 2dcf04021..38a5d48c0 100644
--- a/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt
+++ b/core/mingw/test/files/SmokeFileTestWindowsMinGW.kt
@@ -5,13 +5,83 @@
package kotlinx.io.files
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.cstr
+import kotlinx.cinterop.toKString
+import platform.posix.dirname
+import platform.windows.ERROR_TOO_MANY_OPEN_FILES
+import platform.windows.GetConsoleCP
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalForeignApi::class)
+class SmokeFileTestWindowsMinGW {
+ private val testDir = Path("""./mingw/testdir""")
+
+ @OptIn(ExperimentalForeignApi::class)
+ @Test
+ fun mingwProblem() {
+ // Skipping test because console code page is UTF-8,
+ // use when clause because I'm not sure which codepage should be skipped
+ when (GetConsoleCP()) {
+ 65001u -> return
+ }
+ assertEquals("""C:\foo""", dirname("""C:\foo\bar""".cstr)!!.toKString())
+ assertFails {
+ assertEquals(
+ """C:\あいうえお""",
+ dirname("""C:\あいうえお\かきくけこ""".cstr)!!.toKString(),
+ )
+ }
+ assertFails {
+ assertEquals(
+ """C:\一二三四""",
+ dirname("""C:\一二三四\五六七八""".cstr)!!.toKString(),
+ )
+ }
+ }
+
+ @Test
+ fun parent() {
+ assertEquals(Path("""C:\foo"""), Path("""C:\foo\bar""").parent)
+ assertEquals(Path("""C:\あいうえお"""), Path("""C:\あいうえお\かきくけこ""").parent)
+ assertEquals(Path("""C:\一二三四"""), Path("""C:\一二三四\五六七八""").parent)
+ assertEquals(null, Path("""C:\""").parent)
+ }
-class SmokeFileTestWindowsMinGW {
@Test
fun uncParent() {
- assertEquals(Path("\\\\server"), Path("\\\\server\\share").parent)
- assertEquals(Path("\\\\server\\share"), Path("\\\\server\\share\\dir").parent)
+ assertEquals(Path("""\\server\share"""), Path("""\\server\share\dir""").parent)
+ // This is a root UNC path, so parent is
+ assertEquals(null, Path("""\\server\share""").parent)
+ }
+
+ @Test
+ fun basename() {
+ assertEquals("あいうえお", Path("""C:\あいうえお""").name)
+ assertEquals("", Path("""C:\""").name)
+ }
+
+ @Test
+ fun testFormatError() {
+ val s = formatWin32ErrorMessage(ERROR_TOO_MANY_OPEN_FILES.toUInt())
+ // it should be trimmed, drop the trailing rubbish
+ assertEquals(s.trim(), s)
+ }
+
+ @Test
+ fun testReadDir() {
+ val expected = listOf("foo", "いろは歌", "天地玄黄")
+ val actual = SystemFileSystem.list(testDir).map { it.name }.sorted()
+ assertEquals(expected, actual)
+ }
+
+ @Test
+ fun testExists() {
+ for (path in SystemFileSystem.list(testDir)) {
+ assertTrue(SystemFileSystem.exists(path), path.toString())
+ }
}
}
diff --git a/core/mingw/testdir/foo b/core/mingw/testdir/foo
new file mode 100644
index 000000000..e69de29bb
diff --git "a/core/mingw/testdir/\343\201\204\343\202\215\343\201\257\346\255\214" "b/core/mingw/testdir/\343\201\204\343\202\215\343\201\257\346\255\214"
new file mode 100644
index 000000000..e69de29bb
diff --git "a/core/mingw/testdir/\345\244\251\345\234\260\347\216\204\351\273\204" "b/core/mingw/testdir/\345\244\251\345\234\260\347\216\204\351\273\204"
new file mode 100644
index 000000000..e69de29bb
diff --git a/core/native/src/files/FileSystemNative.kt b/core/native/src/files/FileSystemNative.kt
index 1fa719b15..9859d7efa 100644
--- a/core/native/src/files/FileSystemNative.kt
+++ b/core/native/src/files/FileSystemNative.kt
@@ -16,9 +16,7 @@ import kotlin.experimental.ExperimentalNativeApi
@OptIn(ExperimentalForeignApi::class)
public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl() {
- override fun exists(path: Path): Boolean {
- return access(path.path, F_OK) == 0
- }
+ override fun exists(path: Path): Boolean = existsImpl(path.path)
@OptIn(ExperimentalForeignApi::class)
override fun delete(path: Path, mustExist: Boolean) {
@@ -28,12 +26,7 @@ public actual val SystemFileSystem: FileSystem = object : SystemFileSystemImpl()
}
return
}
- if (remove(path.path) != 0) {
- if (errno == EACCES) {
- if (rmdir(path.path) == 0) return
- }
- throw IOException("Delete failed for $path: ${strerror(errno)?.toKString()}")
- }
+ deleteNoCheckImpl(path.path)
}
override fun createDirectories(path: Path, mustCreate: Boolean) {
@@ -114,6 +107,10 @@ internal expect fun mkdirImpl(path: String)
internal expect fun realpathImpl(path: String): String
+internal expect fun existsImpl(path: String): Boolean
+
+internal expect fun deleteNoCheckImpl(path: String)
+
public actual open class FileNotFoundException actual constructor(
message: String?
) : IOException(message)
diff --git a/core/native/src/files/PathsNative.kt b/core/native/src/files/PathsNative.kt
index e013afd60..85ed8bbc4 100644
--- a/core/native/src/files/PathsNative.kt
+++ b/core/native/src/files/PathsNative.kt
@@ -54,7 +54,6 @@ public actual class Path internal constructor(
}
}
-public actual val SystemPathSeparator: Char get() = UnixPathSeparator
internal expect fun dirnameImpl(path: String): String
diff --git a/core/posix/src/files/FileSystemNativePosix.kt b/core/posix/src/files/FileSystemNativePosix.kt
new file mode 100644
index 000000000..80658473e
--- /dev/null
+++ b/core/posix/src/files/FileSystemNativePosix.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+package kotlinx.io.files
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import kotlinx.io.IOException
+import platform.posix.*
+
+internal actual fun existsImpl(path: String): Boolean = access(path, F_OK) == 0
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun deleteNoCheckImpl(path: String) {
+ if (remove(path) != 0) {
+ if (errno == EACCES) {
+ if (rmdir(path) == 0) return
+ }
+ throw IOException("Delete failed for $path: ${strerror(errno)?.toKString()}")
+ }
+}
diff --git a/core/posix/src/files/PathsPosix.kt b/core/posix/src/files/PathsPosix.kt
new file mode 100644
index 000000000..784c00e47
--- /dev/null
+++ b/core/posix/src/files/PathsPosix.kt
@@ -0,0 +1,7 @@
+/*
+ * Copyright 2010-2025 JetBrains s.r.o. and Kotlin Programming Language contributors.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+package kotlinx.io.files
+
+public actual val SystemPathSeparator: Char get() = UnixPathSeparator