Skip to content

Commit c59dfb1

Browse files
committed
Support path normalization
Closes #223
1 parent bae3fe3 commit c59dfb1

File tree

10 files changed

+102
-16
lines changed

10 files changed

+102
-16
lines changed

core/api/kotlinx-io-core.api

+1
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ public final class kotlinx/io/files/Path {
231231
public final fun getParent ()Lkotlinx/io/files/Path;
232232
public fun hashCode ()I
233233
public final fun isAbsolute ()Z
234+
public final fun normalized ()Lkotlinx/io/files/Path;
234235
public fun toString ()Ljava/lang/String;
235236
}
236237

core/common/src/files/Paths.kt

+54
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ public expect class Path {
4040
*/
4141
public val isAbsolute: Boolean
4242

43+
/**
44+
* Returns normalized version of this path where all `..` and `.` segments are resolved
45+
* and all sequential path separators are collapsed.
46+
*/
47+
public fun normalized(): Path
48+
4349
/**
4450
* Returns a string representation of this path.
4551
*
@@ -174,3 +180,51 @@ private fun removeTrailingSeparatorsWindows(suffixLength: Int, path: String): St
174180
}
175181
return path.substring(0, idx)
176182
}
183+
184+
internal fun Path.normalizedInternal(preserveDrive: Boolean, vararg separators: Char): String {
185+
var isAbs = isAbsolute
186+
var stringRepresentation = toString()
187+
var drive = ""
188+
if (preserveDrive && stringRepresentation.length >= 2 && stringRepresentation[1] == ':') {
189+
drive = stringRepresentation.substring(0, 2)
190+
stringRepresentation = stringRepresentation.substring(2)
191+
isAbs = stringRepresentation.isNotEmpty() && separators.contains(stringRepresentation.first())
192+
}
193+
val parts = stringRepresentation.split(*separators)
194+
val constructedPath = mutableListOf<String>()
195+
for (idx in parts.indices) {
196+
when (val part = parts[idx]) {
197+
"." -> continue
198+
".." -> if (isAbs) {
199+
constructedPath.removeLastOrNull()
200+
} else {
201+
if (constructedPath.isEmpty() || constructedPath.last() == "..") {
202+
constructedPath.add("..")
203+
} else {
204+
constructedPath.removeLast()
205+
}
206+
}
207+
208+
else -> {
209+
if (part.isNotEmpty()) {
210+
constructedPath.add(part)
211+
}
212+
}
213+
}
214+
}
215+
return buildString {
216+
append(drive)
217+
var skipFirstSeparator = true
218+
if (isAbs) {
219+
append(SystemPathSeparator)
220+
}
221+
for (segment in constructedPath) {
222+
if (skipFirstSeparator) {
223+
skipFirstSeparator = false
224+
} else {
225+
append(SystemPathSeparator)
226+
}
227+
append(segment)
228+
}
229+
}
230+
}

core/common/test/files/SmokeFileTest.kt

+9
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,15 @@ class SmokeFileTest {
443443
source.close() // there should be no error
444444
}
445445

446+
@Test
447+
fun pathNormalize() {
448+
assertEquals(Path(""), Path("").normalized())
449+
assertEquals(Path("/a"), Path("/////////////a/").normalized())
450+
assertEquals(Path("/e"), Path("/a/b/../c/../d/../../../e").normalized())
451+
assertEquals(Path("../../e"), Path("a/b/../c/../d/../../../../e").normalized())
452+
assertEquals(Path("a"), Path("a/././././").normalized())
453+
}
454+
446455
private fun constructAbsolutePath(vararg parts: String): String {
447456
return SystemPathSeparator.toString() + parts.joinToString(SystemPathSeparator.toString())
448457
}

core/common/test/files/SmokeFileTestWindows.kt

+8
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,12 @@ class SmokeFileTestWindows {
4242
// this path could be transformed to use canonical separator on JVM
4343
assertEquals(Path("//").toString(), Path("//").toString())
4444
}
45+
46+
@Test
47+
fun pathNormalize() {
48+
if (!isWindows) return
49+
assertEquals(Path("C:a", "b", "c", "d", "e"), Path("C:a/b\\\\\\//////c/d\\e").normalized())
50+
assertEquals(Path("C:$SystemPathSeparator"), Path("C:\\..\\..\\..\\").normalized())
51+
assertEquals(Path("C:..", "..", ".."), Path("C:..\\..\\..\\").normalized())
52+
}
4553
}

core/common/test/files/UtilsTest.kt

+8
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,12 @@ class UtilsTest {
5252
assertEquals("C:\\", removeTrailingSeparatorsW("C:\\"))
5353
assertEquals("C:\\", removeTrailingSeparatorsW("C:\\/\\"))
5454
}
55+
56+
@Test
57+
fun normalizePathWithDrive() {
58+
assertEquals("C:$SystemPathSeparator",
59+
Path("C:\\..\\..\\..\\").normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator))
60+
assertEquals("C:..$SystemPathSeparator..$SystemPathSeparator..",
61+
Path("C:..\\..\\..\\").normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator))
62+
}
5563
}

core/jvm/src/files/PathsJvm.kt

+8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ public actual class Path internal constructor(internal val file: File) {
1919

2020
public actual override fun toString(): String = file.toString()
2121

22+
// Don't use File.normalize here as it may work incorrectly for absolute paths:
23+
// https://youtrack.jetbrains.com/issue/KT-48354
24+
public actual fun normalized(): Path = Path(path = if (isWindows) {
25+
normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator)
26+
} else {
27+
normalizedInternal(false, UnixPathSeparator)
28+
})
29+
2230
actual override fun equals(other: Any?): Boolean {
2331
if (this === other) return true
2432
if (other !is Path) return false

core/native/src/files/PathsNative.kt

+6
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ public actual class Path internal constructor(
5252
if (path.isEmpty() || path == SystemPathSeparator.toString()) return ""
5353
return basenameImpl(path)
5454
}
55+
56+
public actual fun normalized(): Path = Path(path = if (isWindows) {
57+
normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator)
58+
} else {
59+
normalizedInternal(false, UnixPathSeparator)
60+
})
5561
}
5662

5763
public actual val SystemPathSeparator: Char = UnixPathSeparator

core/nodeFilesystemShared/src/files/PathsNodeJs.kt

+6
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ public actual class Path internal constructor(
6363
actual override fun hashCode(): Int {
6464
return path.hashCode()
6565
}
66+
67+
public actual fun normalized(): Path = Path(path = if (isWindows) {
68+
normalizedInternal(true, WindowsPathSeparator, UnixPathSeparator)
69+
} else {
70+
normalizedInternal(false, UnixPathSeparator)
71+
})
6672
}
6773

6874
public actual val SystemPathSeparator: Char by lazy {

core/wasmWasi/src/files/FileSystemWasm.kt

-16
Original file line numberDiff line numberDiff line change
@@ -272,22 +272,6 @@ internal object WasiFileSystem : SystemFileSystemImpl() {
272272
}
273273
}
274274

275-
private fun Path.normalized(): Path {
276-
require(isAbsolute)
277-
278-
val parts = path.split(UnixPathSeparator)
279-
val constructedPath = mutableListOf<String>()
280-
// parts[0] is always empty
281-
for (idx in 1 until parts.size) {
282-
when (val part = parts[idx]) {
283-
"." -> continue
284-
".." -> constructedPath.removeLastOrNull()
285-
else -> constructedPath.add(part)
286-
}
287-
}
288-
return Path(UnixPathSeparator.toString(), *constructedPath.toTypedArray())
289-
}
290-
291275
public actual open class FileNotFoundException actual constructor(
292276
message: String?,
293277
) : IOException(message)

core/wasmWasi/src/files/PathsWasm.kt

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public actual class Path internal constructor(rawPath: String, @Suppress("UNUSED
4848
}
4949

5050
public actual val isAbsolute: Boolean = path.startsWith(SystemPathSeparator)
51+
52+
public actual fun normalized(): Path = Path(path = normalizedInternal(false, SystemPathSeparator))
5153
}
5254

5355
// The path separator is always '/'.

0 commit comments

Comments
 (0)