diff --git a/README.md b/README.md index bca9b5c..060a4ae 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# korlibs-library-template \ No newline at end of file +# korlibs-io-vfs \ No newline at end of file diff --git a/korlibs-simple/.gitignore b/korlibs-io-vfs/.gitignore similarity index 100% rename from korlibs-simple/.gitignore rename to korlibs-io-vfs/.gitignore diff --git a/korlibs-io-vfs/module.yaml b/korlibs-io-vfs/module.yaml new file mode 100644 index 0000000..f8ad868 --- /dev/null +++ b/korlibs-io-vfs/module.yaml @@ -0,0 +1,23 @@ +product: + type: lib + platforms: [jvm, js, wasm, android, linuxX64, linuxArm64, tvosArm64, tvosX64, tvosSimulatorArm64, macosX64, macosArm64, iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosArm32, watchosDeviceArm64, watchosSimulatorArm64, mingwX64] + +apply: [ ../common.module-template.yaml ] + +aliases: + - posix: [linuxX64, linuxArm64, tvosArm64, tvosX64, tvosSimulatorArm64, macosX64, macosArm64, iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosArm32, watchosDeviceArm64, watchosSimulatorArm64] + - jvmAndAndroid: [jvm, android] + - concurrent: [jvm, android, linuxX64, linuxArm64, tvosArm64, tvosX64, tvosSimulatorArm64, macosX64, macosArm64, iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosArm32, watchosDeviceArm64, watchosSimulatorArm64, mingwX64] + +dependencies: + - com.soywiz:korlibs-io-fs:6.0.0 + - com.soywiz:korlibs-concurrent:6.0.0 + - com.soywiz:korlibs-platform:6.0.0: exported + - com.soywiz:korlibs-io-stream:6.0.0: exported + - com.soywiz:korlibs-time-core:6.0.0: exported + - com.soywiz:korlibs-string:6.0.0: exported + - org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC: exported + +test-dependencies: + - org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC + - com.soywiz:korlibs-time:6.0.0 diff --git a/korlibs-io-vfs/src/korlibs/io/file/FinalVfsFile.kt b/korlibs-io-vfs/src/korlibs/io/file/FinalVfsFile.kt new file mode 100644 index 0000000..64aab1f --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/FinalVfsFile.kt @@ -0,0 +1,12 @@ +package korlibs.io.file + +suspend fun VfsFile.getUnderlyingUnscapedFile(): FinalVfsFile = vfs.getUnderlyingUnscapedFile(this.path) +fun VfsFile.toUnscaped() = FinalVfsFile(this) +fun FinalVfsFile.toFile() = this.file + +//inline class FinalVfsFile(val file: VfsFile) { +data class FinalVfsFile(val file: VfsFile) { + constructor(vfs: Vfs, path: String) : this(vfs[path]) + val vfs: Vfs get() = file.vfs + val path: String get() = file.path +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/PathInfo.kt b/korlibs-io-vfs/src/korlibs/io/file/PathInfo.kt new file mode 100644 index 0000000..2eeeafb --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/PathInfo.kt @@ -0,0 +1,249 @@ +package korlibs.io.file + +import korlibs.io.core.* +import kotlin.math.* + +val File_pathSeparatorChar: Char get() = SyncSystemFS.pathSeparatorChar +val File_separatorChar: Char get() = SyncSystemFS.fileSeparatorChar + +// @TODO: inline classes. Once done PathInfoExt won't be required to do clean allocation-free stuff. +inline class PathInfo(val fullPath: String) + +fun PathInfo.relativePathTo(relative: PathInfo): String? { + val thisParts = this.parts().toMutableList() + val relativeParts = relative.parts().toMutableList() + val maxNumParts = min(thisParts.size, relativeParts.size) + val outputParts = arrayListOf() + val commonCount = count { it < maxNumParts && thisParts[it] == relativeParts[it] } + while (relativeParts.size > commonCount) { + relativeParts.removeLast() + outputParts += ".." + } + outputParts += thisParts.slice(commonCount until thisParts.size) + return outputParts.joinToString("/") +} + +val String.pathInfo get() = PathInfo(this) + +/** + * /path\to/file.ext -> /path/to/file.ext + */ +val PathInfo.fullPathNormalized: String get() = fullPath.replace('\\', '/') + +/** + * /path\to/file.ext -> /path\to + */ +val PathInfo.folder: String get() = fullPath.substring(0, fullPathNormalized.lastIndexOfOrNull('/') ?: 0) + +/** + * /path\to/file.ext -> /path/to/ + */ +val PathInfo.folderWithSlash: String + get() = fullPath.substring(0, fullPathNormalized.lastIndexOfOrNull('/')?.plus(1) ?: 0) + +/** + * /path\to/file.ext -> file.ext + */ +val PathInfo.baseName: String get() = fullPathNormalized.substringAfterLast('/') + +/** + * /path\to/file.ext -> /path\to + */ +val PathInfo.parent: PathInfo get() = PathInfo(folder) + +/** + * /path\to/file.ext -> /path\to/file + */ +val PathInfo.fullPathWithoutExtension: String + get() { + val startIndex = fullPathNormalized.lastIndexOfOrNull('/')?.plus(1) ?: 0 + return fullPath.substring(0, fullPathNormalized.indexOfOrNull('.', startIndex) ?: fullPathNormalized.length) + } + +/** + * /path\to/file.ext -> /path\to/file.newext + */ +fun PathInfo.fullPathWithExtension(ext: String): String = + if (ext.isEmpty()) fullPathWithoutExtension else "$fullPathWithoutExtension.$ext" + +/** + * /path\to/file.1.ext -> file.1 + */ +val PathInfo.baseNameWithoutExtension: String get() = baseName.substringBeforeLast('.', + baseName +) + +/** + * /path\to/file.1.ext -> file + */ +val PathInfo.baseNameWithoutCompoundExtension: String get() = baseName.substringBefore('.', + baseName +) + +/** + * /path\to/file.1.ext -> /path\to/file.1 + */ +val PathInfo.fullNameWithoutExtension: String get() = "$folderWithSlash$baseNameWithoutExtension" + +/** + * /path\to/file.1.ext -> file + */ +val PathInfo.fullNameWithoutCompoundExtension: String get() = "$folderWithSlash$baseNameWithoutCompoundExtension" + +/** + * /path\to/file.1.ext -> file.1.newext + */ +fun PathInfo.baseNameWithExtension(ext: String): String = + if (ext.isEmpty()) baseNameWithoutExtension else "$baseNameWithoutExtension.$ext" + +/** + * /path\to/file.1.ext -> file.newext + */ +fun PathInfo.baseNameWithCompoundExtension(ext: String): String = + if (ext.isEmpty()) baseNameWithoutCompoundExtension else "$baseNameWithoutCompoundExtension.$ext" + +/** + * /path\to/file.1.EXT -> EXT + */ +val PathInfo.extension: String get() = baseName.substringAfterLast('.', "") + +/** + * /path\to/file.1.EXT -> ext + */ +val PathInfo.extensionLC: String get() = extension.lowercase() + +/** + * /path\to/file.1.EXT -> 1.EXT + */ +val PathInfo.compoundExtension: String get() = baseName.substringAfter('.', "") + +/** + * /path\to/file.1.EXT -> 1.ext + */ +val PathInfo.compoundExtensionLC: String get() = compoundExtension.lowercase() + +/** + * /path\to/file.1.ext -> listOf("", "path", "to", "file.1.ext") + */ +fun PathInfo.getPathComponents(): List = fullPathNormalized.split('/') + +/** + * /path\to/file.1.ext -> listOf("/path", "/path/to", "/path/to/file.1.ext") + */ +fun PathInfo.getPathFullComponents(): List { + val out = arrayListOf() + for (n in 0 until fullPathNormalized.length) { + when (fullPathNormalized[n]) { + '/', '\\' -> { + out += fullPathNormalized.substring(0, n) + } + } + } + out += fullPathNormalized + return out +} + +/** + * /path\to/file.1.ext -> /path\to/file.1.ext + */ +val PathInfo.fullName: String get() = fullPath + +interface Path { + val pathInfo: PathInfo +} + +val Path.fullPathNormalized: String get() = pathInfo.fullPathNormalized +val Path.folder: String get() = pathInfo.folder +val Path.folderWithSlash: String get() = pathInfo.folderWithSlash +val Path.baseName: String get() = pathInfo.baseName +val Path.fullPathWithoutExtension: String get() = pathInfo.fullPathWithoutExtension +fun Path.fullPathWithExtension(ext: String): String = pathInfo.fullPathWithExtension(ext) +val Path.fullNameWithoutExtension: String get() = pathInfo.fullNameWithoutExtension +val Path.baseNameWithoutExtension: String get() = pathInfo.baseNameWithoutExtension +val Path.fullNameWithoutCompoundExtension: String get() = pathInfo.fullNameWithoutCompoundExtension +val Path.baseNameWithoutCompoundExtension: String get() = pathInfo.baseNameWithoutCompoundExtension +fun Path.baseNameWithExtension(ext: String): String = pathInfo.baseNameWithExtension(ext) +fun Path.baseNameWithCompoundExtension(ext: String): String = pathInfo.baseNameWithCompoundExtension(ext) +val Path.extension: String get() = pathInfo.extension +val Path.extensionLC: String get() = pathInfo.extensionLC +val Path.compoundExtension: String get() = pathInfo.compoundExtension +val Path.compoundExtensionLC: String get() = pathInfo.compoundExtensionLC +fun Path.getPathComponents(): List = pathInfo.getPathComponents() +fun Path.getPathFullComponents(): List = pathInfo.getPathFullComponents() +val Path.fullName: String get() = pathInfo.fullPath + +open class VfsNamed(override val pathInfo: PathInfo) : Path + + +fun PathInfo.parts(): List = fullPath.split('/') +fun PathInfo.normalize(removeEndSlash: Boolean = true): String { + val path = this.fullPath + val schemeIndex = path.indexOf(":") + return if (schemeIndex >= 0) { + val take = if (path.substring(schemeIndex).startsWith("://")) 3 else 1 + path.substring(0, schemeIndex + take) + path.substring(schemeIndex + take).pathInfo.normalize(removeEndSlash = removeEndSlash) + } else { + val path2 = path.replace('\\', '/') + val out = ArrayList() + val path2PathLength: Int + path2.split("/").also { path2PathLength = it.size }.fastForEachWithIndex { index, part -> + when (part) { + "" -> if (out.isEmpty() || !removeEndSlash) out += "" + "." -> if (index == path2PathLength - 1 && !removeEndSlash) out += "" + ".." -> if (out.isNotEmpty() && index != 1) out.removeAt(out.size - 1) + else -> out += part + } + } + out.joinToString("/") + } +} + +fun PathInfo.combine(access: PathInfo): PathInfo { + val base = this.fullPath + val access = access.fullPath + return (if (access.pathInfo.isAbsolute()) access.pathInfo.normalize() else "$base/$access" + .pathInfo.normalize()).pathInfo +} + +fun PathInfo.lightCombine(access: PathInfo): PathInfo { + val base = this.fullPath + val access = access.fullPath + val res = if (base.isNotEmpty()) base.trimEnd('/') + "/" + access.trim('/') else access + return res.pathInfo +} + +fun PathInfo.isAbsolute(): Boolean { + val base = this.fullPath + if (base.isEmpty()) return false + val b = base.replace('\\', '/').substringBefore('/') + if (b.isEmpty()) return true + if (b.contains(':')) return true + return false +} + +fun PathInfo.normalizeAbsolute(): PathInfo { + val path = this.fullPath + //val res = path.replace('/', File.separatorChar).trim(File.separatorChar) + //return if (OS.isUnix) "/$res" else res + return PathInfo(path.replace('/', File_separatorChar)) +} + +private fun String.indexOfOrNull(char: Char, startIndex: Int = 0): Int? = + this.indexOf(char, startIndex).takeIf { it >= 0 } + +private fun String.lastIndexOfOrNull(char: Char, startIndex: Int = lastIndex): Int? = + this.lastIndexOf(char, startIndex).takeIf { it >= 0 } + +private inline fun count(cond: (index: Int) -> Boolean): Int { + var counter = 0 + while (cond(counter)) counter++ + return counter +} + +private inline fun List.fastForEachWithIndex(callback: (index: Int, value: T) -> Unit) { + var n = 0 + while (n < size) { + callback(n, this[n]) + n++ + } +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/Vfs.kt b/korlibs-io-vfs/src/korlibs/io/file/Vfs.kt new file mode 100644 index 0000000..6fd7360 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/Vfs.kt @@ -0,0 +1,407 @@ +@file:Suppress("EXPERIMENTAL_FEATURE_WARNING") + +package korlibs.io.file + +import korlibs.io.async.* +import korlibs.io.lang.* +import korlibs.io.stream.* +import korlibs.memory.* +import korlibs.time.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.* +import kotlin.math.min +import kotlin.reflect.* +import kotlinx.coroutines.async + +abstract class Vfs : AsyncCloseable { + open suspend fun isCaseSensitive(path: String): Boolean = true + + protected open val absolutePath: String get() = "" + + open fun getAbsolutePath(path: String) = absolutePath.pathInfo.lightCombine(path.pathInfo).fullPath + + //val root = VfsFile(this, "") + val root get() = VfsFile(this, "") + + open val supportedAttributeTypes: List> get() = emptyList>() + + operator fun get(path: String) = root[path] + + fun file(path: String) = root[path] + + override suspend fun close(): Unit = Unit + + fun createExistsStat( + path: String, isDirectory: Boolean, size: Long, device: Long = -1, inode: Long = -1, mode: Int = 511, + owner: String = "nobody", group: String = "nobody", createTime: DateTime = DateTime.EPOCH, modifiedTime: DateTime = DateTime.EPOCH, + lastAccessTime: DateTime = modifiedTime, extraInfo: Any? = null, id: String? = null, + cache: Boolean = false + ) = VfsStat( + file = file(path), exists = true, isDirectory = isDirectory, size = size, device = device, inode = inode, + mode = mode, owner = owner, group = group, createTime = createTime, modifiedTime = modifiedTime, + lastAccessTime = lastAccessTime, extraInfo = extraInfo, id = id + ).also { + if (cache) it.file.cachedStat = it + } + + fun createNonExistsStat(path: String, extraInfo: Any? = null, cache: Boolean = false, exception: Throwable? = null) = VfsStat( + file = file(path), exists = false, isDirectory = false, size = 0L, + device = -1L, inode = -1L, mode = 511, owner = "nobody", group = "nobody", + createTime = DateTime.EPOCH, modifiedTime = DateTime.EPOCH, lastAccessTime = DateTime.EPOCH, extraInfo = extraInfo, + exception = exception + ) + + protected suspend fun checkExecFolder(path: String, cmdAndArgs: List) { + val stat = stat(path) + //println("stat=$stat") + if (!stat.isDirectory) { + throw FileNotFoundException("'$path' is not a directory, to execute '${cmdAndArgs.first()}'") + } + } + + protected open fun getDefaultEnvironments(): Map = emptyMap() // = Environment.getAll() + + open suspend fun exec( + path: String, + cmdAndArgs: List, + env: Map = getDefaultEnvironments(), + handler: VfsProcessHandler = VfsProcessHandler() + ): Int { + checkExecFolder(path, cmdAndArgs) + unsupported() + } + + open suspend fun open(path: String, mode: VfsOpenMode): AsyncStream = unsupported() + + open suspend fun openInputStream(path: String): AsyncInputStream = open(path, VfsOpenMode.READ) + + open suspend fun readRange(path: String, range: LongRange): ByteArray = open(path, VfsOpenMode.READ).useIt { s -> + s.position = range.start + s.readBytesUpTo(min(Int.MAX_VALUE.toLong() - 1, (range.endInclusive - range.start)).toInt() + 1) + } + + interface Attribute + + inline fun List.getOrNull(): T? = filterIsInstance().firstOrNull() + + inline class UnixPermission(val bits: Int) { + constructor(readable: Boolean = true, writable: Boolean = true, executable: Boolean = false) : this( + 0.insert(readable, 2).insert(writable, 1).insert(executable, 0) + ) + val executable: Boolean get() = bits.extract(0) + val writable: Boolean get() = bits.extract(1) + val readable: Boolean get() = bits.extract(2) + } + + inline class UnixPermissions(val bits: Int) : Attribute { + override fun toString(): String = bits.toString(8).padStart(4, '0') + + constructor(owner: UnixPermission, group: UnixPermission = owner, other: UnixPermission = UnixPermission(0), extra: Int = 0) : this( + 0.insert(owner.bits, 6, 3).insert(group.bits, 3, 3).insert(other.bits, 0, 3).insert(extra, 9, 7) + ) + + fun copy( + owner: UnixPermission = this.owner, + group: UnixPermission = this.group, + other: UnixPermission = this.other, + extra: Int = 0, + ): UnixPermissions = UnixPermissions(owner, group, other, extra) + + val extra: Int get() = bits.extract(9, 7) + val rbits: Int get() = bits.extract(0, 9) + val owner: UnixPermission get() = UnixPermission(bits.extract(6, 3)) + val group: UnixPermission get() = UnixPermission(bits.extract(3, 3)) + val other: UnixPermission get() = UnixPermission(bits.extract(0, 3)) + } + + interface FileKind : Attribute { + val name: String + + companion object { + val BINARY get() = Standard.BINARY + val STRING get() = Standard.STRING + val LONG get() = Standard.LONG + val INT get() = Standard.INT + } + enum class Standard : FileKind { + BINARY, STRING, LONG, INT + } + } + + open fun getKind(value: Any?): FileKind = when (value) { + null, is ByteArray -> FileKind.BINARY + is String -> FileKind.STRING + is Int -> FileKind.INT + is Long -> FileKind.LONG + else -> FileKind.BINARY + } + + inline fun Iterable.get(): T? = this.firstOrNull { it is T } as T? + + open suspend fun put(path: String, content: AsyncInputStream, attributes: List = listOf()): Long { + return open(path, VfsOpenMode.CREATE_OR_TRUNCATE).useThis { + content.copyTo(this) + } + } + + suspend fun put(path: String, content: ByteArray, attributes: List = listOf()): Long { + return put(path, content.openAsync(), attributes) + } + + suspend fun readChunk(path: String, offset: Long, size: Int): ByteArray { + val s = open(path, VfsOpenMode.READ) + if (offset != 0L) s.setPosition(offset) + return s.readBytesUpTo(size) + } + + suspend fun writeChunk(path: String, data: ByteArray, offset: Long, resize: Boolean) { + val s = open(path, if (resize) VfsOpenMode.CREATE_OR_TRUNCATE else VfsOpenMode.CREATE) + s.setPosition(offset) + s.write(data) + } + + open suspend fun setSize(path: String, size: Long) { + open(path, mode = VfsOpenMode.CREATE).useThis { this.setLength(size) } + } + + open suspend fun setAttributes(path: String, attributes: List): Unit { + attributes.getOrNull()?.let { + chmod(path, it) + } + } + open suspend fun getAttributes(path: String): List = emptyList() + + /** + * Change Unix Permissions for [path] to [mode] + */ + open suspend fun chmod(path: String, mode: UnixPermissions): Unit = Unit + + open suspend fun stat(path: String): VfsStat = createNonExistsStat(path) + + private fun unsupported(): Nothing = throw UnsupportedOperationException("unsupported for ${this::class} : $this") + + suspend fun listSimple(path: String): List = this.listFlow(path).toList() + open suspend fun listFlow(path: String): Flow = unsupported() + + open suspend fun mkdir(path: String, attributes: List): Boolean = unsupported() + open suspend fun mkdirs(path: String, attributes: List): Boolean { + if (path == "") return false + if (stat(path).exists) return false // Already exists, and it is a directory + //println("mkdirs: $path") + if (mkdir(path, attributes)) return true + //println("::mkdirs: $parent") + mkdirs(PathInfo(path).parent.fullPath, attributes) + return mkdir(path, attributes) + } + open suspend fun rmdir(path: String): Boolean = delete(path) // For compatibility + open suspend fun delete(path: String): Boolean = unsupported() + open suspend fun rename(src: String, dst: String): Boolean { + if (file(src).isDirectory()) error("Unsupported renaming directories in $this") + file(src).copyTo(file(dst)) + delete(src) + return true + } + + open suspend fun watch(path: String, handler: (FileEvent) -> Unit): AutoCloseable = + DummyAutoCloseable + + open suspend fun touch(path: String, time: DateTime, atime: DateTime) = Unit + + open suspend fun getUnderlyingUnscapedFile(path: String): FinalVfsFile = + FinalVfsFile(this, path) + + abstract class Proxy : Vfs() { + //private val logger = Logger("Vfs.Proxy") + protected abstract suspend fun access(path: String): VfsFile + protected open suspend fun VfsFile.transform(): VfsFile = file(this.path) + //suspend protected fun transform2_f(f: VfsFile): VfsFile = transform(f) + + override suspend fun isCaseSensitive(path: String): Boolean = access(path).isCaseSensitive() + + final override suspend fun getUnderlyingUnscapedFile(path: String): FinalVfsFile = initOnce().access(path).getUnderlyingUnscapedFile() + + protected open suspend fun init() { + } + + private var initialized: Deferred? = null + protected suspend fun initOnce(): Proxy { + if (initialized == null) { + initialized = CoroutineScope(coroutineContext + SupervisorJob() + CoroutineName("Initializing.$this")).async { + try { + init() + } catch (e: Throwable) { + //logger.error { "Error initializing $this" } + e.printStackTrace() + } + } + } + initialized!!.await() + return this + } + + override suspend fun exec( + path: String, + cmdAndArgs: List, + env: Map, + handler: VfsProcessHandler + ): Int = initOnce().access(path).exec(cmdAndArgs, env, handler) + + override suspend fun open(path: String, mode: VfsOpenMode) = initOnce().access(path).open(mode) + + override suspend fun readRange(path: String, range: LongRange): ByteArray = + initOnce().access(path).readRangeBytes(range) + + override suspend fun put(path: String, content: AsyncInputStream, attributes: List) = + initOnce().access(path).put(content, *attributes.toTypedArray()) + + override suspend fun setSize(path: String, size: Long): Unit = initOnce().access(path).setSize(size) + override suspend fun stat(path: String): VfsStat = initOnce().access(path).stat().copy(file = file(path)) + override suspend fun listFlow(path: String): Flow = flow { + initOnce() + access(path).list().collect { emit(it.transform()) } + } + + override suspend fun delete(path: String): Boolean = initOnce().access(path).delete() + override suspend fun setAttributes(path: String, attributes: List) = + initOnce().access(path).setAttributes(*attributes.toTypedArray()) + override suspend fun getAttributes(path: String) = + initOnce().access(path).getAttributes() + + override suspend fun chmod(path: String, mode: Vfs.UnixPermissions) = + initOnce().access(path).chmod(mode) + + override suspend fun mkdir(path: String, attributes: List): Boolean = + initOnce().access(path).mkdir(*attributes.toTypedArray()) + + override suspend fun touch(path: String, time: DateTime, atime: DateTime): Unit = + initOnce().access(path).touch(time, atime) + + override suspend fun rename(src: String, dst: String): Boolean { + initOnce() + val srcFile = access(src) + val dstFile = access(dst) + if (srcFile.vfs != dstFile.vfs) throw IllegalArgumentException("Can't rename between filesystems. Use copyTo instead, and remove later.") + return srcFile.renameTo(dstFile.path) + } + + override suspend fun watch(path: String, handler: (FileEvent) -> Unit): AutoCloseable { + initOnce() + return access(path).watch { e -> + CoroutineScope(coroutineContext).launch { + val f1 = e.file.transform() + val f2 = e.other?.transform() + handler(e.copy(file = f1, other = f2)) + } + } + } + } + + open class Decorator(val parent: VfsFile) : Proxy() { + val parentVfs = parent.vfs + override suspend fun access(path: String): VfsFile = parentVfs[path] + } + + data class FileEvent(val kind: Kind, val file: VfsFile, val other: VfsFile? = null) { + enum class Kind { DELETED, MODIFIED, CREATED, RENAMED } + + override fun toString() = if (other != null) "$kind($file, $other)" else "$kind($file)" + } + + override fun toString(): String = this::class.portableSimpleName +} + +enum class VfsOpenMode( + val cmode: String, + val write: Boolean, + val createIfNotExists: Boolean = false, + val truncate: Boolean = false, + val read: Boolean = true, + val append: Boolean = false, + val createNew: Boolean = false, +) { + READ("rb", write = false), + WRITE("r+b", write = true, createIfNotExists = true), + APPEND("a+b", write = true, createIfNotExists = true, append = true), + CREATE_OR_TRUNCATE("w+b", write = true, createIfNotExists = true, truncate = true), + CREATE("w+b", write = true, createIfNotExists = true), + CREATE_NEW("w+b", write = true); +} + +//"r" Open for reading only. Invoking any of the write methods of the resulting object will cause an IOException to be thrown. +//"rw" Open for reading and writing. If the file does not already exist then an attempt will be made to create it. +//"rws" Open for reading and writing, as with "rw", and also require that every update to the file's content or metadata be written synchronously to the underlying storage device. +//"rwd" Open for reading and writing, as with "rw", and also require that every update to the file's content be written synchronously to the underlying storage device. + +open class VfsProcessHandler { + open suspend fun onOut(data: ByteArray): Unit = Unit + open suspend fun onErr(data: ByteArray): Unit = Unit +} + +class VfsProcessException(message: String) : IOException(message) + +data class VfsStat( + val file: VfsFile, + val exists: Boolean, + val isDirectory: Boolean, + val size: Long, + val device: Long = -1L, + val inode: Long = -1L, + val mode: Int = 511, + val owner: String = "nobody", + val group: String = "nobody", + val createTime: DateTime = DateTime.EPOCH, + val modifiedTime: DateTime = createTime, + val lastAccessTime: DateTime = modifiedTime, + val extraInfo: Any? = null, + val kind: Vfs.FileKind? = null, + val id: String? = null, + val exception: Throwable? = null, +) : Path by file { + val isFile: Boolean get() = exists && !isDirectory + + /** + * Gets the unix-like permissions inverse of [VfsFile.chmod] + */ + val permissions: Vfs.UnixPermissions get() = Vfs.UnixPermissions(mode) + + val enrichedFile: VfsFile get() = file.copy().also { it.cachedStat = this } + + fun toString(showFile: Boolean): String = "VfsStat(" + ArrayList(16).also { al -> + if (showFile) al.add("file=$file") else al.add("file=${file.absolutePath}") + al.add("exists=$exists") + al.add("isDirectory=$isDirectory") + al.add("size=$size") + al.add("device=$device") + al.add("inode=$inode") + al.add("mode=$mode") + al.add("owner=$owner") + al.add("group=$group") + al.add("createTime=$createTime") + al.add("modifiedTime=$modifiedTime") + al.add("lastAccessTime=$lastAccessTime") + al.add("extraInfo=$extraInfo") + if (kind != null) { + al.add("kind=$kind") + } + al.add("id=$id") + }.joinToString(", ") + ")" + + override fun toString(): String = toString(showFile = true) +} + +class VfsCachedStatContext(val stat: VfsStat?) : CoroutineContext.Element { + companion object : CoroutineContext.Key + + override val key get() = VfsCachedStatContext +} + +//val VfsStat.createLocalDate: LocalDateTime get() = LocalDateTime.ofEpochSecond(createTime / 1000L, ((createTime % 1_000L) * 1_000_000L).toInt(), ZoneOffset.UTC) +//val VfsStat.modifiedLocalDate: LocalDateTime get() = LocalDateTime.ofEpochSecond(modifiedTime / 1000L, ((modifiedTime % 1_000L) * 1_000_000L).toInt(), ZoneOffset.UTC) +//val VfsStat.lastAccessLocalDate: LocalDateTime get() = LocalDateTime.ofEpochSecond(lastAccessTime / 1000L, ((lastAccessTime % 1_000L) * 1_000_000L).toInt(), ZoneOffset.UTC) + +//val INIT = Unit.apply { println("UTC_OFFSET: $UTC_OFFSET") } + +val VfsStat.createDate: DateTime get() = createTime +val VfsStat.modifiedDate: DateTime get() = modifiedTime +val VfsStat.lastAccessDate: DateTime get() = lastAccessTime diff --git a/korlibs-io-vfs/src/korlibs/io/file/VfsFile.kt b/korlibs-io-vfs/src/korlibs/io/file/VfsFile.kt new file mode 100644 index 0000000..e115ea2 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/VfsFile.kt @@ -0,0 +1,338 @@ +@file:Suppress("EXPERIMENTAL_FEATURE_WARNING") +@file:OptIn(ExperimentalStdlibApi::class) + +package korlibs.io.file + +import korlibs.datastructure.* +import korlibs.time.* +import korlibs.memory.* +import korlibs.io.async.* +import korlibs.io.file.std.* +import korlibs.io.lang.* +import korlibs.io.stream.* +import korlibs.io.util.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.* + +data class VfsFile( + val vfs: Vfs, + val path: String +) : VfsNamed(path.pathInfo), AsyncInputOpenable, Extra by Extra.Mixin() { + var cachedStat: VfsStat? = null + + suspend fun isCaseSensitive(): Boolean = vfs.isCaseSensitive(path) + + fun relativePathTo(relative: VfsFile): String? { + if (relative.vfs != this.vfs) return null + return this.pathInfo.relativePathTo(relative.pathInfo) + } + + val parent: VfsFile get() = VfsFile(vfs, folder) + val root: VfsFile get() = vfs.root + val absolutePath: String get() = vfs.getAbsolutePath(this.path) + val absolutePathInfo: PathInfo get() = PathInfo(absolutePath) + + operator fun get(path: String): VfsFile = + VfsFile(vfs, this.path.pathInfo.combine(path.pathInfo).fullPath) + + // @TODO: Kotlin suspend operator not supported yet! + //suspend fun set(path: String, content: String) = run { this[path].put(content.toByteArray(UTF8).openAsync()) } + //suspend fun set(path: String, content: ByteArray) = run { this[path].put(content.openAsync()) } + //suspend fun set(path: String, content: AsyncStream) = run { this[path].writeStream(content) } + //suspend fun set(path: String, content: VfsFile) = run { this[path].writeFile(content) } + + suspend fun put(content: AsyncInputStream, attributes: List = listOf()): Long = vfs.put(this.path, content, attributes) + suspend fun put(content: AsyncInputStream, vararg attributes: Vfs.Attribute): Long = vfs.put(this.path, content, attributes.toList()) + suspend fun write(data: ByteArray, vararg attributes: Vfs.Attribute): Long = vfs.put(this.path, data, attributes.toList()) + suspend fun writeBytes(data: ByteArray, vararg attributes: Vfs.Attribute): Long = vfs.put(this.path, data, attributes.toList()) + + suspend fun writeStream(src: AsyncInputStream, vararg attributes: Vfs.Attribute, autoClose: Boolean = true): Long { + try { + return put(src, *attributes) + } finally { + if (autoClose) src.close() + } + } + + suspend fun writeFile(file: VfsFile, vararg attributes: Vfs.Attribute): Long = file.copyTo(this, *attributes) + + suspend fun listNames(): List = listSimple().map { it.baseName } + + fun withExtension(ext: String): VfsFile = + VfsFile(vfs, fullNameWithoutExtension + if (ext.isNotEmpty()) ".$ext" else "") + + fun withCompoundExtension(ext: String): VfsFile = + VfsFile(vfs, fullNameWithoutCompoundExtension + if (ext.isNotEmpty()) ".$ext" else "") + + fun appendExtension(ext: String): VfsFile = + VfsFile(vfs, "$fullName.$ext") + + suspend fun open(mode: VfsOpenMode = VfsOpenMode.READ): AsyncStream = vfs.open(this.path, mode) + suspend fun openInputStream(): AsyncInputStream = vfs.openInputStream(this.path) + + override suspend fun openRead(): AsyncStream = open(VfsOpenMode.READ) + + suspend inline fun openUse(mode: VfsOpenMode = VfsOpenMode.READ, callback: AsyncStream.() -> T): T = open(mode).use(callback) + suspend inline fun openUseIt(mode: VfsOpenMode = VfsOpenMode.READ, callback: (AsyncStream) -> T): T = open(mode).use(callback) + + suspend fun readRangeBytes(range: LongRange): ByteArray = vfs.readRange(this.path, range) + suspend fun readRangeBytes(range: IntRange): ByteArray = vfs.readRange(this.path, range.toLongRange()) + + // Aliases + suspend fun readAll(): ByteArray = vfs.readRange(this.path, LONG_ZERO_TO_MAX_RANGE) + + suspend fun read(): ByteArray = readAll() + suspend fun readBytes(): ByteArray = readAll() + + suspend fun readLines(charset: Charset = UTF8): Sequence = readString(charset).lineSequence() + suspend fun writeLines(lines: Iterable, charset: Charset = UTF8) = + writeString(lines.joinToString("\n"), charset = charset) + + suspend fun readString(charset: Charset = UTF8): String = read().toString(charset) + + suspend fun writeString(data: String, vararg attributes: Vfs.Attribute, charset: Charset = UTF8): Unit = + run { write(data.toByteArray(charset), *attributes, Vfs.FileKind.STRING) } + + suspend fun readChunk(offset: Long, size: Int): ByteArray = vfs.readChunk(this.path, offset, size) + suspend fun writeChunk(data: ByteArray, offset: Long, resize: Boolean = false): Unit = + vfs.writeChunk(this.path, data, offset, resize) + + suspend fun stat(): VfsStat = cachedStat ?: vfs.stat(this.path) + suspend fun touch(time: DateTime, atime: DateTime = time): Unit = vfs.touch(this.path, time, atime) + suspend fun size(): Long = vfs.stat(this.path).size + suspend fun exists(): Boolean = runIgnoringExceptions { vfs.stat(this.path).exists } ?: false + suspend fun takeIfExists() = takeIf { it.exists() } + suspend fun isDirectory(): Boolean = stat().isDirectory + suspend fun isFile(): Boolean = stat().isFile + suspend fun setSize(size: Long): Unit = vfs.setSize(this.path, size) + + suspend fun delete() = vfs.delete(this.path) + + suspend fun setAttributes(attributes: List) = vfs.setAttributes(this.path, attributes) + suspend fun setAttributes(vararg attributes: Vfs.Attribute) = vfs.setAttributes(this.path, attributes.toList()) + suspend fun getAttributes(): List = vfs.getAttributes(this.path) + suspend inline fun getAttribute(): T? = getAttributes().filterIsInstance().firstOrNull() + suspend fun chmod(mode: Vfs.UnixPermissions): Unit = vfs.chmod(this.path, mode) + + suspend fun mkdir(attributes: List) = vfs.mkdir(this.path, attributes) + suspend fun mkdir(vararg attributes: Vfs.Attribute) = mkdir(attributes.toList()) + + suspend fun mkdirs(attributes: List) = vfs.mkdirs(this.path, attributes) + suspend fun mkdirs(vararg attributes: Vfs.Attribute) = mkdirs(attributes.toList()) + + /** + * Copies this [VfsFile] into the [target] VfsFile. + * + * If this node is a file, the content will be copied. + * If the node is a directory, a tree structure with the same content will be created in the target destination. + */ + suspend fun copyToRecursively( + target: VfsFile, + vararg attributes: Vfs.Attribute, + notify: suspend (Pair) -> Unit = {} + ) { + notify(this to target) + if (this.isDirectory()) { + target.mkdirs() + list().collect { file -> + file.copyToRecursively(target[file.baseName], *attributes, notify = notify) + } + } else { + //println("copyToTree: $this -> $target") + this.copyTo(target, *attributes) + } + } + + suspend fun ensureParents() = this.apply { parent.mkdir() } + + /** Renames this file into the [dstPath] relative to the root of this [vfs] */ + suspend fun renameTo(dstPath: String) = vfs.rename(this.path, dstPath) + + /** Renames the file determined by this plus [src] to this plus [dst] */ + suspend fun rename(src: String, dst: String) = vfs.rename("$path/$src", "$path/$dst") + + suspend fun listSimple(): List = vfs.listSimple(this.path) + suspend fun list(): Flow = vfs.listFlow(this.path) + + suspend fun listRecursiveSimple(filter: (VfsFile) -> Boolean = { true }): List = ArrayList().apply { + for (file in listSimple()) { + if (filter(file)) { + add(file) + val stat = file.stat() + if (stat.isDirectory) { + addAll(file.listRecursiveSimple(filter)) + } + } + } + } + + suspend fun listRecursive(filter: (VfsFile) -> Boolean = { true }): Flow = flow { + list().collect { file -> + if (filter(file)) { + emit(file) + val stat = file.stat() + if (stat.isDirectory) { + file.listRecursive(filter).collect { emit(it) } + } + } + } + } + + suspend fun exec( + cmdAndArgs: List, + env: Map = LinkedHashMap(), + handler: VfsProcessHandler = VfsProcessHandler() + ): Int = vfs.exec(this.path, cmdAndArgs, env, handler) + + data class ProcessResult(val exitCode: Int, val stdout: String, val stderr: String) + + suspend fun execProcess( + cmdAndArgs: List, + env: Map = LinkedHashMap(), + captureError: Boolean = false, + charset: Charset = UTF8, + ): ProcessResult { + val out = ByteArrayBuilder() + val err = ByteArrayBuilder() + + val result = exec(cmdAndArgs, env, object : VfsProcessHandler() { + override suspend fun onOut(data: ByteArray) { + out.append(data) + } + + override suspend fun onErr(data: ByteArray) { + if (captureError) out.append(data) + err.append(data) + } + }) + + val errString = err.toByteArray().toString(charset) + val outString = out.toByteArray().toString(charset) + + return ProcessResult(result, outString, errString) + } + + suspend fun execToString( + cmdAndArgs: List, + env: Map = LinkedHashMap(), + charset: Charset = UTF8, + captureError: Boolean = false, + throwOnError: Boolean = true + ): String { + val result = execProcess(cmdAndArgs, env, captureError, charset) + if (throwOnError && result.exitCode != 0) { + throw VfsProcessException("Process not returned 0, but ${result.exitCode}. Error: ${result.stderr}, Output: ${result.stdout}") + } + return result.stdout + } + + suspend fun execProcess( + vararg cmdAndArgs: String, + env: Map = LinkedHashMap(), + captureError: Boolean = false, + charset: Charset = UTF8, + ): ProcessResult = execProcess(cmdAndArgs.toList(), env, captureError, charset) + + suspend fun execToString(vararg cmdAndArgs: String, charset: Charset = UTF8): String = + execToString(cmdAndArgs.toList(), charset = charset) + + suspend fun passthru( + cmdAndArgs: List, + env: Map = LinkedHashMap(), + charset: Charset = UTF8 + ): Int { + return exec(cmdAndArgs.toList(), env, object : VfsProcessHandler() { + override suspend fun onOut(data: ByteArray) = print(data.toString(charset)) + override suspend fun onErr(data: ByteArray) = print(data.toString(charset)) + }).also { + println() + } + } + + suspend fun passthru( + vararg cmdAndArgs: String, + env: Map = LinkedHashMap(), + charset: Charset = UTF8 + ): Int = passthru(cmdAndArgs.toList(), env, charset) + + suspend fun watch(handler: suspend (Vfs.FileEvent) -> Unit): AutoCloseable { + //val cc = coroutineContext + val cc = coroutineContext + return vfs.watch(this.path) { event -> CoroutineScope(cc).launch(cc) { handler(event) } } + } + + suspend fun redirected(pathRedirector: suspend VfsFile.(String) -> String): VfsFile { + val actualFile = this + return VfsFile(object : Vfs.Proxy() { + override suspend fun access(path: String): VfsFile = + actualFile[actualFile.pathRedirector(path)] + + override fun toString(): String = "VfsRedirected" + }, this.path) + } + + suspend fun copyTo(target: AsyncOutputStream): Long = this.openUse { + this.copyTo(target) + } + suspend fun copyTo(target: VfsFile, vararg attributes: Vfs.Attribute): Long = this.openInputStream().use { target.writeStream(it, *attributes) } + + fun jail(): VfsFile = JailVfs(this) + fun jailParent(): VfsFile = JailVfs(parent)[this.baseName] + + override fun toString(): String = "$vfs[${this.path}]" +} + +suspend inline fun VfsFile.setUnixPermission(permissions: Vfs.UnixPermissions): Unit = setAttributes(permissions) +suspend inline fun VfsFile.getUnixPermission(): Vfs.UnixPermissions = getAttribute() ?: Vfs.UnixPermissions(0b111111111) + +/** + * Deletes all the files in this folder recursively. + * If the entry is a file instead of a directory, the file is deleted. + * + * When [includeSelf] is set to false, this function will delete all + * the descendants but the folder itself. + */ +suspend fun VfsFile.deleteRecursively(includeSelf: Boolean = true) { + if (this.isDirectory()) { + this.list().collect { + if (it.isDirectory()) { + it.deleteRecursively() + } else { + it.delete() + } + } + } + if (includeSelf) this.delete() +} + +fun VfsFile.proxied(transform: suspend (VfsFile) -> VfsFile): VfsFile { + val file = this + return object : Vfs.Proxy() { + override suspend fun access(path: String): VfsFile { + return transform(file[path]) + } + }[file.path] +} + +fun VfsFile.withOnce(once: suspend (VfsFile) -> Unit): VfsFile { + val file = this + var completed = false + return proxied { + it.also { + if (!completed) { + completed = true + once(file) + } + } + } +} + +suspend fun VfsFile.useVfs(callback: suspend (VfsFile) -> R): R = vfs.use { callback(this@useVfs) } + +private val LONG_ZERO_TO_MAX_RANGE = 0L..Long.MAX_VALUE +private fun IntRange.toLongRange() = this.start.toLong()..this.endInclusive.toLong() + +fun VfsFile.jail(): VfsFile = JailVfs(this) +fun VfsFile.jailParent(): VfsFile = JailVfs(parent)[this.baseName] +suspend fun VfsFile.readAsSyncStream(): SyncStream = read().openSync() diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/JailVfs.kt b/korlibs-io-vfs/src/korlibs/io/file/std/JailVfs.kt new file mode 100644 index 0000000..bcf9751 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/JailVfs.kt @@ -0,0 +1,26 @@ +package korlibs.io.file.std + +import korlibs.io.file.Vfs +import korlibs.io.file.VfsFile +import korlibs.io.file.normalize +import korlibs.io.file.pathInfo + +class JailVfs private constructor(val jailRoot: VfsFile, dummy: Unit) : Vfs.Proxy() { + companion object { + operator fun invoke(jailRoot: VfsFile): VfsFile = JailVfs(jailRoot, Unit).root + } + + val baseJail = jailRoot.pathInfo.normalize() + + override suspend fun access(path: String): VfsFile = jailRoot[path.pathInfo.normalize().trim('/')] + + override suspend fun VfsFile.transform(): VfsFile { + val outPath = this.path.pathInfo.normalize() + if (!outPath.startsWith(baseJail)) throw UnsupportedOperationException("Jail not base root : ${this.path} | $baseJail") + return file(outPath.substring(baseJail.length)) + } + + override val absolutePath: String get() = jailRoot.absolutePath + + override fun toString(): String = "JailVfs($jailRoot)" +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/LocalVfs.kt b/korlibs-io-vfs/src/korlibs/io/file/std/LocalVfs.kt new file mode 100644 index 0000000..9c8a4d0 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/LocalVfs.kt @@ -0,0 +1,55 @@ +package korlibs.io.file.std + +import korlibs.io.core.SystemFS +import korlibs.io.file.* +import korlibs.io.stream.AsyncInputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext + +//@Deprecated("Replace with localVfs") +//fun nativeLocalVfs(path: String): VfsFile = LocalVfs()[path] + +abstract class LocalVfs(val fs: SystemFS = SystemFS) : Vfs() { + companion object {} + + override suspend fun getAttributes(path: String): List { + val stat = stat(path) + if (!stat.exists) return emptyList() + return listOf(UnixPermissions(stat.mode)) + } + + override suspend fun exec( + path: String, + cmdAndArgs: List, + env: Map, + handler: VfsProcessHandler + ): Int { + val res = fs.exec(cmdAndArgs, env, path) + var completed = false + + val pipeJob = CoroutineScope(coroutineContext).launch { + val temp = ByteArray(1024) + + suspend fun pipeChunk(stream: AsyncInputStream, out: suspend (ByteArray) -> Unit): Boolean { + val read = stream.read(temp, 0, temp.size) + if (read > 0) out(temp.copyOf(read)) + return read > 0 + } + + while (true) { + val stdout = pipeChunk(res.stdout) { handler.onOut(it) } + val stderr = pipeChunk(res.stderr) { handler.onErr(it) } + if (!stdout && !stderr && completed) break + delay(1L) + } + } + return res.exitCode().also { + completed = true + pipeJob.join() + } + } + + override fun toString(): String = "LocalVfs" +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/MemoryNodeTree.kt b/korlibs-io-vfs/src/korlibs/io/file/std/MemoryNodeTree.kt new file mode 100644 index 0000000..a3d682c --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/MemoryNodeTree.kt @@ -0,0 +1,94 @@ +package korlibs.io.file.std + +import korlibs.io.file.* +import korlibs.io.lang.* +import korlibs.io.stream.* + +class MemoryNodeTree(val caseSensitive: Boolean = true) { + val rootNode = Node("", isDirectory = true) + + open inner class Node( + val name: String, + val isDirectory: Boolean = false, + parent: Node? = null + ) : Iterable { + val nameLC = name.toLowerCase() + override fun iterator(): Iterator = children.values.iterator() + + var parent: Node? = null + set(value) { + if (field != null) { + field!!.children.remove(this.name) + field!!.childrenLC.remove(this.nameLC) + } + field = value + field?.children?.set(name, this) + field?.childrenLC?.set(nameLC, this) + } + + init { + this.parent = parent + } + + var bytes: ByteArray? = null + var data: Any? = null + val children = linkedMapOf() + val childrenLC = linkedMapOf() + val root: Node get() = parent?.root ?: this + var stream: AsyncStream? = null + var link: String? = null + + val path: String get() = if (parent == null) "/" else "/" + "${parent?.path ?: ""}/$name".trimStart('/') + + fun child(name: String): Node? = when (name) { + "", "." -> this + ".." -> parent + else -> if (caseSensitive) { + children[name] + } else { + childrenLC[name.lowercase()] + } + } + + fun createChild(name: String, isDirectory: Boolean = false): Node = + Node(name, isDirectory = isDirectory, parent = this) + + operator fun get(path: String): Node = access(path, createFolders = false) + fun getOrNull(path: String): Node? = try { + access(path, createFolders = false) + } catch (e: FileNotFoundException) { + null + } + + fun accessOrNull(path: String): Node? = getOrNull(path) + fun access(path: String, createFolders: Boolean = false): Node { + var node = if (path.startsWith('/')) root else this + path.pathInfo.parts().fastForEach { part -> + var child = node.child(part) + if (child == null && createFolders) child = node.createChild(part, isDirectory = true) + node = child ?: throw FileNotFoundException("Can't find '$part' in $path") + } + return node + } + fun followLinks(): Node = link?.let { accessOrNull(it)?.followLinks() } ?: this + + fun mkdir(name: String): Boolean { + if (child(name) != null) { + return false + } else { + createChild(name, isDirectory = true) + return true + } + } + + fun delete() { + parent?.children?.remove(this.name) + parent?.childrenLC?.remove(this.nameLC) + } + } +} + +private inline fun List.fastForEach(callback: (T) -> Unit) { + var n = 0 + while (n < size) callback(this[n++]) +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/MemoryVfs.kt b/korlibs-io-vfs/src/korlibs/io/file/std/MemoryVfs.kt new file mode 100644 index 0000000..1de0c60 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/MemoryVfs.kt @@ -0,0 +1,45 @@ +@file:Suppress("EXPERIMENTAL_FEATURE_WARNING") + +package korlibs.io.file.std + +import korlibs.io.file.* +import korlibs.io.lang.* +import korlibs.io.stream.* + +fun SingleFileMemoryVfsWithName(data: ByteArray, name: String) = MemoryVfsMix(mapOf(name to data))[name] +fun SingleFileMemoryVfsWithName(data: String, name: String, charset: Charset = Charsets.UTF8) = MemoryVfsMix(mapOf(name to data), charset = charset)[name] + +fun VfsFileFromData(data: ByteArray, ext: String = "bin", basename: String = "file") = SingleFileMemoryVfsWithName(data, "$basename.$ext") +fun VfsFileFromData(data: String, ext: String = "bin", charset: Charset = Charsets.UTF8, basename: String = "file") = SingleFileMemoryVfsWithName(data, "$basename.$ext", charset) + +fun SingleFileMemoryVfs(data: ByteArray, ext: String = "bin", basename: String = "file") = VfsFileFromData(data, ext, basename) +fun SingleFileMemoryVfs(data: String, ext: String = "bin", charset: Charset = Charsets.UTF8, basename: String = "file") = VfsFileFromData(data, ext, charset, basename) + +fun MemoryVfs(items: Map = LinkedHashMap(), caseSensitive: Boolean = true): VfsFile { + val vfs = NodeVfs(caseSensitive) + for ((path, stream) in items) { + val info = PathInfo(path) + val folderNode = vfs.rootNode.access(info.folder, createFolders = true) + val fileNode = folderNode.createChild(info.baseName, isDirectory = false) + fileNode.stream = stream + } + return vfs.root +} + +fun MemoryVfsMix( + items: Map = LinkedHashMap(), + caseSensitive: Boolean = true, + charset: Charset = UTF8 +): VfsFile = MemoryVfs(items.mapValues { (_, v) -> + when (v) { + is SyncStream -> v.toAsync() + is ByteArray -> v.openAsync() + is String -> v.openAsync(charset) + else -> v.toString().toByteArray(charset).openAsync() + } +}, caseSensitive) + +fun MemoryVfsMix(vararg items: Pair, caseSensitive: Boolean = true, charset: Charset = UTF8): VfsFile = MemoryVfsMix(items.toMap(), caseSensitive, charset) + +fun ByteArray.asMemoryVfsFile(name: String = "temp.bin"): VfsFile = MemoryVfs(mapOf(name to openAsync()))[name] +suspend fun VfsFile.cachedToMemory(): VfsFile = this.readAll().asMemoryVfsFile(this.fullName) diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/NodeVfs.kt b/korlibs-io-vfs/src/korlibs/io/file/std/NodeVfs.kt new file mode 100644 index 0000000..1094ca1 --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/NodeVfs.kt @@ -0,0 +1,119 @@ +@file:Suppress("EXPERIMENTAL_FEATURE_WARNING") + +package korlibs.io.file.std + +import korlibs.io.async.* +import korlibs.io.file.* +import korlibs.io.lang.* +import korlibs.io.stream.* +import kotlinx.coroutines.flow.* + +open class NodeVfs(val caseSensitive: Boolean = true) : Vfs() { + private val events = Signal() + + val nodeTree = MemoryNodeTree(caseSensitive) + val rootNode get() = nodeTree.rootNode + + private fun createStream(s: SyncStreamBase, vfsFile: VfsFile) = object : AsyncStreamBase() { + override suspend fun read(position: Long, buffer: ByteArray, offset: Int, len: Int): Int { + return s.read(position, buffer, offset, len) + } + + override suspend fun write(position: Long, buffer: ByteArray, offset: Int, len: Int) { + s.write(position, buffer, offset, len) + events(FileEvent(FileEvent.Kind.MODIFIED, vfsFile)) + } + + override suspend fun setLength(value: Long) { + s.length = value + events(FileEvent(FileEvent.Kind.MODIFIED, vfsFile)) + } + + override suspend fun getLength(): Long = s.length + override suspend fun close() = s.close() + }.toAsyncStream() + + override suspend fun open(path: String, mode: VfsOpenMode): AsyncStream { + //if (mode.truncate) { + // delete(path) + //} + val pathInfo = PathInfo(path) + val folder = rootNode.access(pathInfo.folder) + var node = folder.child(pathInfo.baseName) + val vfsFile = this@NodeVfs[path] + if (node == null && mode.createIfNotExists) { + node = folder.createChild(pathInfo.baseName, isDirectory = false) + node.stream = createStream(MemorySyncStream().base, vfsFile) + } else if (mode.truncate) { + node?.stream = createStream(MemorySyncStream().base, vfsFile) + } + return node?.stream?.duplicate() ?: throw FileNotFoundException(path) + } + + override suspend fun stat(path: String): VfsStat { + return try { + val node = rootNode.access(path) + //createExistsStat(path, isDirectory = node.isDirectory, size = node.stream?.getLength() ?: 0L) // @TODO: Kotlin wrong code generated! + val length = node.stream?.getLength() ?: 0L + createExistsStat(path, isDirectory = node.isDirectory, size = length) + } catch (e: Throwable) { + createNonExistsStat(path) + } + } + + override suspend fun listFlow(path: String): Flow = flow { + val node = rootNode[path] + for ((name, _) in node.children) { + emit(file("$path/$name")) + } + } + + override suspend fun delete(path: String): Boolean { + val node = rootNode.getOrNull(path) + return if (node != null) { + node.parent = null + events(FileEvent(FileEvent.Kind.DELETED, this[path])) + true + } else { + false + } + } + + override suspend fun mkdir(path: String, attributes: List): Boolean { + val pathInfo = PathInfo(path) + val parentFolder = rootNode.accessOrNull(pathInfo.folder) ?: return false + val out = parentFolder.mkdir(pathInfo.baseName) + events(FileEvent(FileEvent.Kind.CREATED, this[path])) + return out + } + + override suspend fun rename(src: String, dst: String): Boolean { + if (src == dst) return false + val dstInfo = PathInfo(dst) + val srcNode = rootNode.access(src) + srcNode.parent = null + + val dstFolder = rootNode.access(dstInfo.folder) + val dstNode = dstFolder.createChild(dstInfo.baseName) + dstNode.data = srcNode.data + dstNode.stream = srcNode.stream + for (child in srcNode.children.values) { + child.parent = dstNode + } + + events( + FileEvent( + FileEvent.Kind.RENAMED, + this[src], + this[dst] + ) + ) + return true + } + + override suspend fun watch(path: String, handler: (FileEvent) -> Unit): AutoCloseable { + return events { handler(it) } + } + + override fun toString(): String = "NodeVfs" +} diff --git a/korlibs-io-vfs/src/korlibs/io/file/std/UrlVfs.kt b/korlibs-io-vfs/src/korlibs/io/file/std/UrlVfs.kt new file mode 100644 index 0000000..a9ceabb --- /dev/null +++ b/korlibs-io-vfs/src/korlibs/io/file/std/UrlVfs.kt @@ -0,0 +1,18 @@ +package korlibs.io.file.std + +import korlibs.io.file.* + +abstract class UrlVfs( + val url: String, + dummy: Unit, + val failFromStatus: Boolean = true +) : Vfs() { + + override val absolutePath: String = url + + fun getFullUrl(path: String): String { + val result = url.trim('/') + '/' + path.trim('/') + //println("UrlVfs.getFullUrl: url=$url, path=$path, result=$result") + return result + } +} \ No newline at end of file diff --git a/korlibs-io-vfs/test/korlibs/io/file/VfsProxyTest.kt b/korlibs-io-vfs/test/korlibs/io/file/VfsProxyTest.kt new file mode 100644 index 0000000..ec70593 --- /dev/null +++ b/korlibs-io-vfs/test/korlibs/io/file/VfsProxyTest.kt @@ -0,0 +1,31 @@ +package korlibs.io.file + +import korlibs.io.async.* +import korlibs.io.file.std.* +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import kotlin.test.* + +class VfsProxyTest { + @Test + fun test() = runTest { + val memory = MemoryVfsMix("hello" to "demo") + val vfs = object : Vfs.Proxy() { + var initialized = false + override suspend fun access(path: String): VfsFile { + check(initialized) + return memory[path] + } + override suspend fun init() { + withContext(Dispatchers.CIO) { + delay(100L) + initialized = true + } + } + } + val job = CoroutineScope(coroutineContext).launch { vfs["hello"].readString() } + delay(1L) + job.cancel() + assertEquals("demo", vfs["hello"].readString()) + } +} \ No newline at end of file diff --git a/korlibs-simple/module.yaml b/korlibs-simple/module.yaml deleted file mode 100644 index 399d46a..0000000 --- a/korlibs-simple/module.yaml +++ /dev/null @@ -1,13 +0,0 @@ -product: - type: lib - platforms: [jvm, js, wasm, android, linuxX64, linuxArm64, tvosArm64, tvosX64, tvosSimulatorArm64, macosX64, macosArm64, iosArm64, iosSimulatorArm64, iosX64, watchosArm64, watchosArm32, watchosDeviceArm64, watchosSimulatorArm64, mingwX64] - -apply: [ ../common.module-template.yaml ] - -aliases: - - jvmAndAndroid: [jvm, android] - -dependencies: - -test-dependencies: - diff --git a/korlibs-simple/src/korlibs/simple/Simple.kt b/korlibs-simple/src/korlibs/simple/Simple.kt deleted file mode 100644 index 8349a68..0000000 --- a/korlibs-simple/src/korlibs/simple/Simple.kt +++ /dev/null @@ -1,4 +0,0 @@ -package korlibs.simple - -class Simple { -} \ No newline at end of file