Skip to content

Commit 885715a

Browse files
committed
feat: add yogurt-fs layer and replace direct kotlinx.io calls
1 parent 6fe6bfd commit 885715a

22 files changed

Lines changed: 824 additions & 201 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- `yogurt` - 基于 `acidify-core` 的 QQ 协议端 [![GitHub Release](https://img.shields.io/github/v/release/SaltifyDev/yogurt-releases?label=GitHub%20release)](https://github.com/SaltifyDev/yogurt-releases)
2222
- `@acidify/yogurt` - Yogurt 的预编译二进制包 [![npm](https://img.shields.io/npm/v/%40acidify%2Fyogurt)](https://www.npmjs.com/package/@acidify/yogurt)
2323
- `yogurt-jvm` - Yogurt 的 JVM 平台适配 (Workaround for Ktor plugin's incompatibility issue)
24+
- `yogurt-fs` - Yogurt 的文件系统适配层(在 Windows 上重新实现文件系统,在其他平台上包装 `kotlinx-io` 的实现)
2425

2526
## 支持平台
2627

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ include(
2222
":acidify-milky",
2323
":yogurt",
2424
":yogurt-jvm",
25+
":yogurt-fs",
2526
)
2627

2728
rootProject.name = "acidify"

yogurt-fs/build.gradle.kts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
plugins {
2+
id("buildsrc.convention.kotlin-multiplatform")
3+
}
4+
5+
kotlin {
6+
applyDefaultHierarchyTemplate()
7+
8+
sourceSets {
9+
commonMain.dependencies {
10+
implementation(libs.kotlinx.io)
11+
}
12+
13+
val wrappedMain by creating {
14+
dependsOn(commonMain.get())
15+
}
16+
17+
jvmMain.get().dependsOn(wrappedMain)
18+
macosArm64Main.get().dependsOn(wrappedMain)
19+
linuxX64Main.get().dependsOn(wrappedMain)
20+
linuxArm64Main.get().dependsOn(wrappedMain)
21+
22+
commonTest.dependencies {
23+
implementation(kotlin("test"))
24+
}
25+
}
26+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.ntqqrev.yogurt.fs
2+
3+
import kotlinx.io.RawSink
4+
import kotlinx.io.RawSource
5+
import kotlinx.io.buffered
6+
import kotlinx.io.files.FileMetadata
7+
import kotlinx.io.files.Path
8+
import kotlinx.io.readByteArray
9+
import kotlinx.io.readString
10+
import kotlinx.io.writeString
11+
12+
interface FileSystem {
13+
fun exists(path: Path): Boolean
14+
fun delete(path: Path, mustExist: Boolean = true)
15+
fun createDirectories(path: Path, mustCreate: Boolean = false)
16+
fun atomicMove(source: Path, destination: Path)
17+
fun source(path: Path): RawSource
18+
fun sink(path: Path, append: Boolean = false): RawSink
19+
fun metadataOrNull(path: Path): FileMetadata?
20+
fun resolve(path: Path): Path
21+
fun list(directory: Path): Collection<Path>
22+
23+
fun Path.readText(): String {
24+
source(this@readText).buffered().use {
25+
return it.readString()
26+
}
27+
}
28+
29+
fun Path.write(text: String, append: Boolean = false) {
30+
sink(this@write, append).buffered().use {
31+
it.writeString(text)
32+
}
33+
}
34+
35+
fun Path.readBytes(): ByteArray {
36+
source(this@readBytes).buffered().use {
37+
return it.readByteArray()
38+
}
39+
}
40+
41+
fun Path.write(bytes: ByteArray, append: Boolean = false) {
42+
sink(this@write, append).buffered().use {
43+
it.write(bytes)
44+
}
45+
}
46+
}
47+
48+
expect val defaultFileSystem: FileSystem
49+
50+
inline fun <R> withFs(fs: FileSystem = defaultFileSystem, block: FileSystem.() -> R): R {
51+
return fs.block()
52+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package org.ntqqrev.yogurt.fs
2+
3+
import kotlinx.io.buffered
4+
import kotlinx.io.files.Path
5+
import kotlinx.io.files.SystemTemporaryDirectory
6+
import kotlinx.io.readByteArray
7+
import kotlinx.io.readString
8+
import kotlinx.io.writeString
9+
import kotlin.random.Random
10+
import kotlin.test.*
11+
12+
class FileSystemTest {
13+
private val fs = defaultFileSystem
14+
15+
@Test
16+
fun cjkDirectoryAndTextRoundTrip() = withTemporaryRoot("目录读写") { root ->
17+
val topLevel = Path(root, "中文目录")
18+
val nested = Path(Path(topLevel, "二级_日本語"), "세번째_한글")
19+
val textFile = Path(nested, "数据-混合.txt")
20+
val content = """
21+
第一行:你好,世界
22+
第二行:日本語の文章
23+
第三行:한글 문장
24+
""".trimIndent()
25+
26+
fs.createDirectories(nested)
27+
fs.sink(textFile).buffered().use { it.writeString(content) }
28+
29+
assertTrue(fs.exists(topLevel))
30+
assertTrue(fs.exists(nested))
31+
assertTrue(fs.exists(textFile))
32+
33+
val metadata = fs.metadataOrNull(textFile)
34+
assertNotNull(metadata)
35+
assertTrue(metadata.isRegularFile)
36+
assertFalse(metadata.isDirectory)
37+
assertEquals(content.encodeToByteArray().size.toLong(), metadata.size)
38+
39+
val listedEntries = fs.list(topLevel)
40+
val listedNames = listedEntries.map(::semanticLeafName).toSet()
41+
assertEquals(setOf("二级_日本語"), listedNames)
42+
val listedPath = listedEntries.single()
43+
assertEquals(normalizedPath(Path(topLevel, "二级_日本語")), normalizedPath(listedPath))
44+
assertEquals(listOf("中文目录", "二级_日本語"), semanticTail(listedPath, 2))
45+
46+
val readBack = fs.source(textFile).buffered().use { it.readString() }
47+
assertEquals(content, readBack)
48+
49+
val resolved = fs.resolve(textFile)
50+
assertTrue(resolved.isAbsolute)
51+
assertEquals(listOf("二级_日本語", "세번째_한글", "数据-混合.txt"), semanticTail(resolved, 3))
52+
}
53+
54+
@Test
55+
fun appendAndBinaryRoundTripOnCjkPaths() = withTemporaryRoot("追加与二进制") { root ->
56+
val dataDir = Path(root, "附件_资料")
57+
val textFile = Path(dataDir, "日志.txt")
58+
val binaryFile = Path(dataDir, "图像_样本.bin")
59+
val expectedText = "第一行\n第二行_追加\n第三行_終わり\n"
60+
val expectedBinary = byteArrayOf(0, 1, 2, 3, 127, (-1).toByte()) +
61+
"中文ABCかな한글".encodeToByteArray()
62+
63+
fs.createDirectories(dataDir)
64+
65+
fs.sink(textFile).buffered().use { it.writeString("第一行\n") }
66+
fs.sink(textFile, append = true).buffered().use { it.writeString("第二行_追加\n第三行_終わり\n") }
67+
fs.sink(binaryFile).buffered().use { it.write(expectedBinary) }
68+
69+
val actualText = fs.source(textFile).buffered().use { it.readString() }
70+
val actualBinary = fs.source(binaryFile).buffered().use { it.readByteArray() }
71+
72+
assertEquals(expectedText, actualText)
73+
assertContentEquals(expectedBinary, actualBinary)
74+
}
75+
76+
@Test
77+
fun atomicMoveKeepsCjkNamesAndContent() = withTemporaryRoot("原子移动") { root ->
78+
val sourceDir = Path(root, "原目录")
79+
val destinationDir = Path(root, "目标目录")
80+
val sourceFile = Path(sourceDir, "待移动_文件.txt")
81+
val destinationFile = Path(destinationDir, "已移动_结果.txt")
82+
val content = "迁移内容_日本語_한글"
83+
84+
fs.createDirectories(sourceDir)
85+
fs.createDirectories(destinationDir)
86+
fs.sink(sourceFile).buffered().use { it.writeString(content) }
87+
88+
fs.atomicMove(sourceFile, destinationFile)
89+
90+
assertFalse(fs.exists(sourceFile))
91+
assertTrue(fs.exists(destinationFile))
92+
assertEquals(content, fs.source(destinationFile).buffered().use { it.readString() })
93+
94+
val destinationEntries = fs.list(destinationDir)
95+
val destinationListing = destinationEntries.map(::semanticLeafName).toSet()
96+
assertEquals(setOf(destinationFile.name), destinationListing)
97+
assertEquals(normalizedPath(destinationFile), normalizedPath(destinationEntries.single()))
98+
}
99+
100+
private inline fun withTemporaryRoot(label: String, block: (Path) -> Unit) {
101+
val root = Path(
102+
SystemTemporaryDirectory,
103+
"ktfs-${Random.nextLong().toString(16)}-$label-中文_日本語_한글",
104+
)
105+
fs.createDirectories(root, mustCreate = true)
106+
try {
107+
block(root)
108+
} finally {
109+
deleteRecursively(root)
110+
}
111+
}
112+
113+
private fun deleteRecursively(path: Path) {
114+
val metadata = fs.metadataOrNull(path) ?: return
115+
if (metadata.isDirectory) {
116+
fs.list(path).forEach(::deleteRecursively)
117+
}
118+
fs.delete(path, mustExist = true)
119+
}
120+
121+
private fun semanticLeafName(path: Path): String {
122+
return semanticSegments(path).last()
123+
}
124+
125+
private fun semanticTail(path: Path, count: Int): List<String> {
126+
return semanticSegments(path).takeLast(count)
127+
}
128+
129+
private fun normalizedPath(path: Path): String {
130+
return path.toString().replace('\\', '/').removeSuffix("/.")
131+
}
132+
133+
private fun semanticSegments(path: Path): List<String> {
134+
return normalizedPath(path)
135+
.split('/')
136+
.filter { it.isNotEmpty() && it != "." }
137+
}
138+
}

0 commit comments

Comments
 (0)