diff --git a/Readme.adoc b/Readme.adoc index 5ac96649..381b20a2 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -2025,6 +2025,38 @@ Python, ...) do. Even in cases where it's uncertain, e.g. you're taking user input as a String, you have to either handle both possibilities with BasePath or explicitly choose to convert relative paths to absolute using some base. +==== Roots and filesystems + +If you are using a system that supports different roots of paths, e.g. Windows, +you can use the argument of `os.root` to specify which root you want to use. +If not specified, the default root will be used (usually, C on Windows, / on Unix). + +[source,scala] +---- +val root = os.root('C:\') / "Users" / "me" +assert(root == os.Path("C:\Users\me")) +---- + +Additionally, custom filesystems can be specified by passing a `FileSystem` to +`os.root`. This allows you to use OS-Lib with non-standard filesystems, such as +jar filesystems or in-memory filesystems. + +[source,scala] +---- +val uri = new URI("jar", Paths.get("foo.jar").toURI().toString, null); +val env = new HashMap[String, String](); +env.put("create", "true"); +val fs = FileSystems.newFileSystem(uri, env); +val path = os.root("/", fs) / "dir" +---- + +Note that the jar file system operations suchs as writing to a file are supported +only on JVM 11+. Depending on the filesystem, some operations may not be supported - +for example, running an `os.proc` with pwd in a jar file won't work. You may also +meet limitations imposed by the implementations - in jar file system, the files are +created only after the file system is closed. Until that, the ones created in your +program are kept in memory. + ==== `os.ResourcePath` In addition to manipulating paths on the filesystem, you can also manipulate diff --git a/build.sc b/build.sc index 693a3280..80437aea 100644 --- a/build.sc +++ b/build.sc @@ -5,7 +5,7 @@ import $ivy.`com.github.lolgab::mill-mima::0.0.24` // imports import mill._, scalalib._, scalanativelib._, publish._ import mill.scalalib.api.ZincWorkerUtil -import com.github.lolgab.mill.mima.Mima +import com.github.lolgab.mill.mima._ import de.tobiasroeser.mill.vcs.version.VcsVersion val communityBuildDottyVersion = sys.props.get("dottyVersion").toList @@ -53,6 +53,9 @@ trait SafeDeps extends ScalaModule { trait MiMaChecks extends Mima { def mimaPreviousVersions = Seq("0.9.0", "0.9.1") + override def mimaBinaryIssueFilters: T[Seq[ProblemFilter]] = Seq( + ProblemFilter.exclude[ReversedMissingMethodProblem]("os.PathConvertible.isCustomFs") + ) } trait OsLibModule diff --git a/os/src-jvm/package.scala b/os/src-jvm/package.scala index 1774cb37..300c1546 100644 --- a/os/src-jvm/package.scala +++ b/os/src-jvm/package.scala @@ -1,4 +1,7 @@ import scala.language.implicitConversions +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Paths package object os { type Generator[+T] = geny.Generator[T] @@ -10,6 +13,12 @@ package object os { */ val root: Path = Path(java.nio.file.Paths.get(".").toAbsolutePath.getRoot) + def root(root: String, fileSystem: FileSystem = FileSystems.getDefault()): Path = { + val path = Path(fileSystem.getPath(root)) + assert(path.root == root, s"$root is not a root path") + path + } + def resource(implicit resRoot: ResourceRoot = Thread.currentThread().getContextClassLoader) = { os.ResourcePath.resource(resRoot) } diff --git a/os/src-native/package.scala b/os/src-native/package.scala index 0edf9f2d..9f6121f3 100644 --- a/os/src-native/package.scala +++ b/os/src-native/package.scala @@ -1,3 +1,5 @@ +import java.nio.file.FileSystem +import java.nio.file.FileSystems package object os { type Generator[+T] = geny.Generator[T] val Generator = geny.Generator @@ -8,6 +10,12 @@ package object os { */ val root: Path = Path(java.nio.file.Paths.get(".").toAbsolutePath.getRoot) + def root(root: String, fileSystem: FileSystem = FileSystems.getDefault()): Path = { + val path = Path(fileSystem.getPath(root)) + assert(path.root == root, s"$root is not a root path") + path + } + /** * The user's home directory */ diff --git a/os/src/Path.scala b/os/src/Path.scala index b6696de8..d9fb0554 100644 --- a/os/src/Path.scala +++ b/os/src/Path.scala @@ -5,6 +5,7 @@ import java.nio.file.Paths import collection.JavaConverters._ import scala.language.implicitConversions +import java.nio.file trait PathChunk { def segments: Seq[String] @@ -400,11 +401,12 @@ object Path { def apply[T: PathConvertible](f: T, base: Path): Path = apply(FilePath(f), base) def apply[T: PathConvertible](f0: T): Path = { + val pathConvertible = implicitly[PathConvertible[T]] // drive letter prefix is empty unless running in Windows. - val f = if (driveRelative(f0)) { + val f = if (!pathConvertible.isCustomFs(f0) && driveRelative(f0)) { Paths.get(s"$driveRoot$f0") } else { - implicitly[PathConvertible[T]].apply(f0) + pathConvertible.apply(f0) } if (f.iterator.asScala.count(_.startsWith("..")) > f.getNameCount / 2) { throw PathError.AbsolutePathOutsideRoot @@ -484,6 +486,9 @@ class Path private[os] (val wrapped: java.nio.file.Path) new SeekableSource.ChannelSource(java.nio.file.Files.newByteChannel(wrapped)) require(wrapped.isAbsolute || Path.driveRelative(wrapped), s"$wrapped is not an absolute path") + def root = Option(wrapped.getRoot).map(_.toString).getOrElse("") + def fileSystem = wrapped.getFileSystem() + def segments: Iterator[String] = wrapped.iterator().asScala.map(_.toString) def getSegment(i: Int): String = wrapped.getName(i).toString def segmentCount = wrapped.getNameCount @@ -509,7 +514,11 @@ class Path private[os] (val wrapped: java.nio.file.Path) def endsWith(target: RelPath) = wrapped.endsWith(target.toString) def relativeTo(base: Path): RelPath = { - + if (fileSystem != base.fileSystem) { + throw new IllegalArgumentException( + s"Paths $wrapped and $base are on different filesystems" + ) + } val nioRel = base.wrapped.relativize(wrapped) val segments = nioRel.iterator().asScala.map(_.toString).toArray match { case Array("") => Internals.emptyStringArray @@ -533,6 +542,7 @@ class Path private[os] (val wrapped: java.nio.file.Path) sealed trait PathConvertible[T] { def apply(t: T): java.nio.file.Path + def isCustomFs(t: T): Boolean = false } object PathConvertible { @@ -544,6 +554,8 @@ object PathConvertible { } implicit object NioPathConvertible extends PathConvertible[java.nio.file.Path] { def apply(t: java.nio.file.Path) = t + override def isCustomFs(t: java.nio.file.Path): Boolean = + t.getFileSystem() != java.nio.file.FileSystems.getDefault() } implicit object UriPathConvertible extends PathConvertible[URI] { def apply(uri: URI) = uri.getScheme() match { diff --git a/os/test/src-jvm/PathTestsCustomFilesystem.scala b/os/test/src-jvm/PathTestsCustomFilesystem.scala new file mode 100644 index 00000000..6ffd21a9 --- /dev/null +++ b/os/test/src-jvm/PathTestsCustomFilesystem.scala @@ -0,0 +1,247 @@ +package test.os + +import utest._ +import os._ +import java.util.HashMap +import java.nio.file.FileSystems +import java.net.URI +import java.nio.file.FileSystem +import java.nio.file.Paths + +object PathTestsCustomFilesystem extends TestSuite { + + def customFsUri(jarName: String = "foo.jar") = { + val path = java.nio.file.Paths.get(jarName); + path.toUri() + } + + def withCustomFs(f: FileSystem => Unit, fsUri: URI = customFsUri()): Unit = { + val uri = new URI("jar", fsUri.toString(), null); + val env = new HashMap[String, String](); + env.put("create", "true"); + val fs = FileSystems.newFileSystem(uri, env); + val p = os.root("/", fs) + try { + os.makeDir(p / "test") + os.makeDir(p / "test" / "dir") + f(fs) + } finally { + cleanUpFs(fs, fsUri) + } + } + + def cleanUpFs(fs: FileSystem, fsUri: URI): Unit = { + fs.close() + os.remove(Path(fsUri)) + } + + val testsCommon = Tests { // native doesnt support custom fs yet + test("customFilesystem") { + test("createPath") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + assert(p.root == "/") + assert(p.fileSystem == fileSystem) + } + } + test("list") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" + os.makeDir(p / "dir2") + os.makeDir(p / "dir3") + assert(os.list(p).size == 3) + } + } + test("removeDir") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" / "dir2" + os.makeDir.all(p) + assert(os.exists(p)) + os.remove.all(p) + assert(!os.exists(p)) + } + } + test("failTemp") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + intercept[UnsupportedOperationException] { + os.temp.dir(dir = p) + } + } + } + test("failProcCall") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + intercept[UnsupportedOperationException] { + os.proc("echo", "hello").call(cwd = p) + } + } + } + test("up") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + assert((p / os.up) == os.root("/", fileSystem) / "test") + } + } + test("withRelPath") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + val rel = os.rel / os.up / "file.txt" + assert((p / rel) == os.root("/", fileSystem) / "test" / "file.txt") + } + } + test("withSubPath") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + val sub = os.sub / "file.txt" + assert((p / sub) == os.root("/", fileSystem) / "test" / "dir" / "file.txt") + } + } + test("differentFsCompare") { + withCustomFs { fs1 => + withCustomFs( + { fs2 => + val p1 = os.root("/", fs1) / "test" / "dir" + val p2 = os.root("/", fs2) / "test" / "dir" + assert(p1 != p2) + }, + fsUri = customFsUri("bar.jar") + ) + } + } + test("failRelativeToDifferentFs") { + withCustomFs { fs1 => + withCustomFs( + { fs2 => + val p1 = os.root("/", fs1) / "test" / "dir" + val p2 = os.root("/", fs2) / "test" / "dir" + intercept[IllegalArgumentException] { + p1.relativeTo(p2) + } + }, + fsUri = customFsUri("bar.jar") + ) + } + } + test("failSubRelativeToDifferentFs") { + withCustomFs { fs1 => + withCustomFs( + { fs2 => + val p1 = os.root("/", fs1) / "test" / "dir" + val p2 = os.root("/", fs2) / "test" / "dir" + intercept[IllegalArgumentException] { + p1.subRelativeTo(p2) + } + }, + fsUri = customFsUri("bar.jar") + ) + } + } + } + } + + val testsJava11 = Tests { + test("customFilesystem") { + test("writeAndRead") { + withCustomFs { fileSystem => + val p = root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello") + assert(os.read(p / "file.txt") == "Hello") + } + } + test("writeOver") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + os.write.over(p / "file.txt", "Hello World2") + assert(os.read(p / "file.txt") == "Hello World2") + } + } + test("move") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + os.move(p / "file.txt", p / "file2.txt") + assert(os.read(p / "file2.txt") == "Hello World") + assert(!os.exists(p / "file.txt")) + } + } + test("copy") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + os.copy(p / "file.txt", p / "file2.txt") + assert(os.read(p / "file2.txt") == "Hello World") + assert(os.exists(p / "file.txt")) + } + } + test("remove") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + assert(os.exists(p / "file.txt")) + os.remove(p / "file.txt") + assert(!os.exists(p / "file.txt")) + } + } + test("removeAll") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + os.write(p / "file2.txt", "Hello World") + os.remove.all(p) + assert(!os.exists(p / "file.txt")) + assert(!os.exists(p / "file2.txt")) + } + } + test("failSymlink") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + intercept[UnsupportedOperationException] { + os.symlink(p / "link", p / "file.txt") + } + } + } + test("walk") { + withCustomFs { fileSystem => + val p = os.root("/", fileSystem) / "test" / "dir" + os.write(p / "file.txt", "Hello World") + os.write(p / "file2.txt", "Hello World") + os.write(p / "file3.txt", "Hello World") + os.makeDir(p / "dir2") + os.write(p / "dir2" / "file.txt", "Hello World") + assert(os.walk(p).map(_.relativeTo(p)).toSet == + Set( + RelPath("file.txt"), + RelPath("file2.txt"), + RelPath("file3.txt"), + RelPath("dir2"), + RelPath("dir2/file.txt") + )) + } + } + } + } + + val testWindows = Tests { + test("cRootPath") { + val p = os.root("C:\\") / "Users" + assert(p.toString == "C:\\Users") + } + } + + private lazy val isWindows: Boolean = { + sys.props("os.name").toLowerCase().contains("windows") + } + + private lazy val isJava11OrAbove: Boolean = { + val version = System.getProperty("java.version") + val major = version.split("\\.")(0).toInt + major >= 11 + } + + override val tests: Tests = + testsCommon ++ (if (isJava11OrAbove) testsJava11 else Tests {}) ++ (if (isWindows) testWindows + else Tests {}) +} diff --git a/os/test/src-jvm/PathTestsJvmOnly.scala b/os/test/src-jvm/PathTestsJvmOnly.scala index 8028e063..fbb23ad7 100644 --- a/os/test/src-jvm/PathTestsJvmOnly.scala +++ b/os/test/src-jvm/PathTestsJvmOnly.scala @@ -4,6 +4,10 @@ import java.nio.file.Paths import os._ import utest._ +import java.util.HashMap +import java.nio.file.FileSystems +import java.net.URI + object PathTestsJvmOnly extends TestSuite { val tests = Tests { test("construction") { diff --git a/os/test/src/PathTests.scala b/os/test/src/PathTests.scala index 4c696671..98aa6ae4 100644 --- a/os/test/src/PathTests.scala +++ b/os/test/src/PathTests.scala @@ -1,6 +1,7 @@ package test.os import java.nio.file.Paths +import java.io.File import os._ import os.Path.{driveRoot} @@ -420,6 +421,14 @@ object PathTests extends TestSuite { assert(result1 == expected) assert(result2 == expected) } + test("custom root") { + assert(os.root == os.root(os.root.root)) + File.listRoots().foreach { root => + val path = os.root(root.toPath().toString) / "test" / "dir" + assert(path.root == root.toString) + assert(path.relativeTo(os.root(root.toPath().toString)) == rel / "test" / "dir") + } + } test("issue201") { val p = Path("/omg") // driveRelative path does not throw exception. System.err.printf("p[%s]\n", posix(p))