From a98f9bbb36a8860c7454db4ff3f032b6af3a98a2 Mon Sep 17 00:00:00 2001 From: Phil Date: Fri, 1 Sep 2023 16:21:14 -0600 Subject: [PATCH] initial code checkin rename PlatformInfo Create scala.yml cleanup tests, remove duplication Merge branch 'main' of https://github.com/philwalk/pallet --- .github/workflows/scala.yml | 34 + .gitignore | 38 + .jvmopts | 2 + .sbtopts | 2 + README.md | 24 + build.sbt | 71 ++ dev.env | 1 + jsrc/demo.sc | 15 + jsrc/dirlist.sc | 6 + jsrc/pathsdemo.sc | 17 + jsrc/platform.sc | 10 + jsrc/postPublish.sc | 8 + jsrc/testversions.sc | 13 + project/build.properties | 1 + project/plugins.sbt | 2 + src/main/scala-2.13/vastblue/pathextend.scala | 682 ++++++++++++++++ .../vastblue/time/TimeExtensions.scala | 113 +++ src/main/scala-3/vastblue/pathextend.scala | 687 ++++++++++++++++ .../vastblue/time/TimeExtensions.scala | 139 ++++ src/main/scala/vastblue/Platform.scala | 735 ++++++++++++++++++ src/main/scala/vastblue/file/EzPath.scala | 115 +++ src/main/scala/vastblue/file/Files.scala | 137 ++++ src/main/scala/vastblue/file/Paths.scala | 686 ++++++++++++++++ src/main/scala/vastblue/time/FileTime.scala | 552 +++++++++++++ src/test/scala/TestUniPath.scala | 44 ++ src/test/scala/vastblue/ParseDateSpec.scala | 77 ++ src/test/scala/vastblue/TimeSpec.scala | 35 + src/test/scala/vastblue/file/EzPathTest.scala | 142 ++++ src/test/scala/vastblue/file/FileSpec.scala | 290 +++++++ .../scala/vastblue/file/FilenameTest.scala | 19 + src/test/scala/vastblue/file/PathSpec.scala | 263 +++++++ .../scala/vastblue/file/PathnameTest.scala | 55 ++ .../vastblue/file/RootRelativeTest.scala | 79 ++ 33 files changed, 5094 insertions(+) create mode 100644 .github/workflows/scala.yml create mode 100644 .gitignore create mode 100644 .jvmopts create mode 100644 .sbtopts create mode 100644 build.sbt create mode 100644 dev.env create mode 100644 jsrc/demo.sc create mode 100644 jsrc/dirlist.sc create mode 100644 jsrc/pathsdemo.sc create mode 100644 jsrc/platform.sc create mode 100644 jsrc/postPublish.sc create mode 100644 jsrc/testversions.sc create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 src/main/scala-2.13/vastblue/pathextend.scala create mode 100644 src/main/scala-2.13/vastblue/time/TimeExtensions.scala create mode 100644 src/main/scala-3/vastblue/pathextend.scala create mode 100644 src/main/scala-3/vastblue/time/TimeExtensions.scala create mode 100644 src/main/scala/vastblue/Platform.scala create mode 100644 src/main/scala/vastblue/file/EzPath.scala create mode 100644 src/main/scala/vastblue/file/Files.scala create mode 100644 src/main/scala/vastblue/file/Paths.scala create mode 100644 src/main/scala/vastblue/time/FileTime.scala create mode 100644 src/test/scala/TestUniPath.scala create mode 100644 src/test/scala/vastblue/ParseDateSpec.scala create mode 100644 src/test/scala/vastblue/TimeSpec.scala create mode 100644 src/test/scala/vastblue/file/EzPathTest.scala create mode 100644 src/test/scala/vastblue/file/FileSpec.scala create mode 100644 src/test/scala/vastblue/file/FilenameTest.scala create mode 100644 src/test/scala/vastblue/file/PathSpec.scala create mode 100644 src/test/scala/vastblue/file/PathnameTest.scala create mode 100644 src/test/scala/vastblue/file/RootRelativeTest.scala diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml new file mode 100644 index 0000000..e8f735a --- /dev/null +++ b/.github/workflows/scala.yml @@ -0,0 +1,34 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Scala CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + cache: 'sbt' + - name: Run tests + run: sbt test + # Optional: This step uploads information to the GitHub dependency graph and unblocking Dependabot alerts for the repository + - name: Upload dependency graph + uses: scalacenter/sbt-dependency-submission@ab086b50c947c9774b70f39fc7f6e20ca2706c91 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..878b992 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +../ +*.class +*.log +sall +s[0-9]* +_vb +tags + +.cache +.env +.envrc +.history +.sdkmanrc +.lib/ +dist/* +target/ +lib_managed/ +local.conf +src_managed/ +project/boot/ +project/plugins/project/ +project/scalafix/* +project/.sbtserver +project/.sbtserver.lock +project/project/ +*.swp +*.sw? + +jsrc/*.jar + +.idea* + +# Metals +.metals/ +.bsp/ +.bloop/ +metals.sbt +.vscode diff --git a/.jvmopts b/.jvmopts new file mode 100644 index 0000000..56300ab --- /dev/null +++ b/.jvmopts @@ -0,0 +1,2 @@ +--add-opens=java.base/java.util=ALL-UNNAMED +--add-opens=java.base/java.lang=ALL-UNNAMED diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..f08151e --- /dev/null +++ b/.sbtopts @@ -0,0 +1,2 @@ +-J--add-opens=java.base/java.lang=ALL-UNNAMED +-J--add-opens=java.base/java.util=ALL-UNNAMED diff --git a/README.md b/README.md index a275a42..791baf1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ # pallet Platform Independent Tooling + +Provides support for various expressive idioms typical of scripting languages, +with a goal of supporting portable code runnable in many environments with little or no customization. + +Target environments include Linux, OSX, Cygwin, Msys2, Mingw, WSL, Windows. + +Example script: +```scala +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes +// hashbang line above is sufficient after 'sbt compile' +import vastblue.pathextend.* + +def main(args: Array[String]): Unit = { + // show system memory info + for (line <- "/proc/meminfo".path.lines) { + printf("%s\n", line) + } + // list child directories of the current working directory + val cwd: Path = ".".path + for ( (p: Path) <- cwd.paths.filter { _.isDirectory }){ + printf("%s\n", p.norm) + } +} +``` diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..333bc10 --- /dev/null +++ b/build.sbt @@ -0,0 +1,71 @@ +lazy val scala213 = "2.13.11" +lazy val scala330 = "3.3.0" +//lazy val supportedScalaVersions = List(scala213, scala330) +lazy val supportedScalaVersions = List(scala330) + +//ThisBuild / envFileName := "dev.env" // sbt-dotenv plugin gets build environment here +ThisBuild / organization := "org.vastblue" +ThisBuild / scalaVersion := "3.3.0" +ThisBuild / version := "0.8.1-SNAPSHOT" + +ThisBuild / crossScalaVersions := supportedScalaVersions + +lazy val root = (project in file(".")) + .settings( +// crossScalaVersions := supportedScalaVersions, + name := "pallet" + ) + +libraryDependencies ++= Seq( + "org.scalacheck" %% "scalacheck" % "1.17.0" % Test, + "org.scalatest" %% "scalatest" % "3.2.16" % Test, + "com.github.sbt" % "junit-interface" % "0.13.3" % Test, +) +//def rtp: String = { +// val psep = java.io.File.pathSeparator +// val syspath: List[String] = Option(System.getenv("PATH")).getOrElse("").split(psep).map { _.toString }.toList +// val javaLibraryPath: List[String] = sys.props("java.library.path").split(psep).map { _.toString }.toList +// val entries: List[String] = (javaLibraryPath ::: syspath) +// val path: String = entries.map { _.replace('\\', '/').toLowerCase }.distinct.mkString(";") +// System.setProperty("java.library.path", path) +// path +//} +//lazy val runtimePath = settingKey[String]("runtime path") +// +//runtimePath := rtp + +scalacOptions := Seq( +//"-Xmaxerrs", "10", +// Warnings as errors! +//"-Xfatal-warnings", // must be commented out for scalafix actions, pre-2.13 +//"-Wconf:any:error", // must be commented out for scalafix actions, 2.13+ + +//"-Wvalue-discard", + + "-encoding", "utf-8", + "-explaintypes", + "-language:existentials", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + + // Linting options + "-unchecked", + + "-Wunused:implicits", + "-Wunused:imports", + "-Wunused:locals", + "-Wunused:params", + "-Wunused:privates", +) +scalacOptions ++= (CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => Seq( + "-Xsource:3", + "-Xmaxerrs", "10", + "-Yscala3-implicit-resolution", + "-language:implicitConversions", + ) + case _ => Nil +}) + + diff --git a/dev.env b/dev.env new file mode 100644 index 0000000..3160840 --- /dev/null +++ b/dev.env @@ -0,0 +1 @@ +JAVA_OPTS='--add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED' diff --git a/jsrc/demo.sc b/jsrc/demo.sc new file mode 100644 index 0000000..0d125e2 --- /dev/null +++ b/jsrc/demo.sc @@ -0,0 +1,15 @@ +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes +// the above hashbang line works after successful sbt compile +import vastblue.pathextend._ + +def main(args: Array[String]): Unit = { + // show system memory info + for (line <- "/proc/meminfo".path.lines) { + printf("%s\n", line) + } + // list child directories of the current working directory + val cwd: Path = ".".path + for ( (p: Path) <- cwd.paths.filter { _.isDirectory }){ + printf("%s\n", p.norm) + } +} diff --git a/jsrc/dirlist.sc b/jsrc/dirlist.sc new file mode 100644 index 0000000..64293e4 --- /dev/null +++ b/jsrc/dirlist.sc @@ -0,0 +1,6 @@ +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes +import vastblue.pathextend._ + +def main(args: Array[String]): Unit = { + ".".path.paths.filter { _.isDirectory }.foreach { (p: Path) => printf("%s\n", p.norm) } +} diff --git a/jsrc/pathsdemo.sc b/jsrc/pathsdemo.sc new file mode 100644 index 0000000..035e121 --- /dev/null +++ b/jsrc/pathsdemo.sc @@ -0,0 +1,17 @@ +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes +import vastblue.pathextend._ + +object PathsDemo { + def main(args: Array[String]): Unit = { + if (args.isEmpty) { + printf("usage: %s [ ...]\n", scriptPath.path.name) + } else { + for (a <- args) { + printf("========== arg[%s]\n", a) + printf("stdpath [%s]\n", Paths.get(a).stdpath) + printf("normpath [%s]\n", Paths.get(a).norm) + printf("dospath [%s]\n", Paths.get(a).dospath) + } + } + } +} diff --git a/jsrc/platform.sc b/jsrc/platform.sc new file mode 100644 index 0000000..2a5f37e --- /dev/null +++ b/jsrc/platform.sc @@ -0,0 +1,10 @@ +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes +// the above hashbang line works after successful sbt compile + +import vastblue.Platform + +def main(args: Array[String]): Unit = + Platform.main(args) + for ((k,v) <- Platform.mountMap){ + printf("%-22s: %s\n", k, v) + } diff --git a/jsrc/postPublish.sc b/jsrc/postPublish.sc new file mode 100644 index 0000000..d868292 --- /dev/null +++ b/jsrc/postPublish.sc @@ -0,0 +1,8 @@ +#!/usr/bin/env -S scala -cp /Users/philwalk/.ivy2/local/org.vastblue/pallet_3/0.8.0-SNAPSHOT/jars/pallet_3.jar + +import vastblue.pathextend.* + +def main(args: Array[String]): Unit = { + // show various info relevant to the current runtime environment + vastblue.Platform.main(args) +} diff --git a/jsrc/testversions.sc b/jsrc/testversions.sc new file mode 100644 index 0000000..4f45692 --- /dev/null +++ b/jsrc/testversions.sc @@ -0,0 +1,13 @@ +#!/usr/bin/env -S scala -cp target/scala-3.3.0/classes + +import vastblue.pathextend._ + +def main(args: Array[String]): Unit = + // show runtime scala VERSION + val scalaHome = sys.props("scala.home") + val version = Paths.get(s"$scalaHome/VERSION").contentAsString.trim + printf("%s\n",version) + + // display output of uname -a + import scala.sys.process._ + printf("%s\n",Seq("uname","-a").lazyLines_!.toList.mkString("")) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..875b706 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.2 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..4736d95 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +//addSbtPlugin("nl.gn0s1s" % "sbt-dotenv" % "3.0.0") +addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.10") diff --git a/src/main/scala-2.13/vastblue/pathextend.scala b/src/main/scala-2.13/vastblue/pathextend.scala new file mode 100644 index 0000000..86536df --- /dev/null +++ b/src/main/scala-2.13/vastblue/pathextend.scala @@ -0,0 +1,682 @@ +package vastblue + +import java.nio.file.{Files => JFiles} +//import java.nio.file.{Paths => JPaths} +import java.nio.charset.Charset +//import java.nio.charset.Charset.* +import java.io.{ByteArrayInputStream, InputStream} +//import java.io.{File => JFile} +//import java.io.{BufferedWriter, FileWriter} +import java.io.{FileOutputStream, OutputStreamWriter} +import java.security.{DigestInputStream, MessageDigest} +import scala.jdk.CollectionConverters.* +import vastblue.Platform.* +//import vastblue.file.Paths +//import vastblue.file.Paths.* +import vastblue.time.FileTime +import vastblue.time.FileTime.* +//import vastblue.file.QuickCsv +//import vastblue.file.QuickCsv.* + +object pathextend { + def Paths = vastblue.file.Paths + def Files = vastblue.file.Files + type Path = java.nio.file.Path + type PrintWriter = java.io.PrintWriter + type JFile = java.io.File + var hook = 0 + lazy val DefaultEncoding = DefaultCodec.toString + lazy val DefaultCharset = Charset.forName(DefaultEncoding) + lazy val Utf8 = Charset.forName("UTF-8") + lazy val Latin1 = Charset.forName("ISO-8859-1") + lazy val userHome = sys.props("user.home").replace('\\', '/') + lazy val userDir = sys.props("user.dir").replace('\\', '/') + lazy val ymd = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + lazy val CygdrivePattern = "/([a-z])(/.*)?".r + lazy val DriveLetterPattern = "([a-z]):(/.*)?".r + private def cwd: Path = userDir.path.toAbsolutePath.normalize + + def scriptPath = Option(sys.props("script.path")) match { + case None => "" + case Some(path) => path + } + + def fixHome(s: String): String = { + s.startsWith("~") match { + case false => s + case true => s.replaceFirst("~",userHome).replace('\\', '/') + } + } + + implicit class ExtendString(s: String) { + def path: Path = vastblue.file.Paths.get(s) // .toAbsolutePath + def toPath: Path = path + def absPath: Path = s.path.toAbsolutePath.normalize // alias + def toFile: JFile = toPath.toFile + def file: JFile = toFile + //def norm: String = path.norm + def norm: String = s.replace('\\', '/') + def dropSuffix: String = s.reverse.dropWhile(_ != '.').drop(1).reverse + //def problemPath: Boolean = vastblue.fileutils.problemPath(s) + } + + implicit class ExtendPath(p: Path) { + def toFile: JFile = p.toFile + def length: Long = p.toFile.length + def file: JFile = p.toFile + def realpath: Path = if (p.isSymbolicLink) p.toRealPath() else p // toRealPath(p) + def getParentFile: JFile = p.toFile.getParentFile + def parentFile: JFile = getParentFile // alias + def parentPath: Path = parentFile.toPath + def parent: Path = parentPath // alias + def exists: Boolean = Files.exists(p) // p.toFile.exists + def listFiles: Seq[JFile] = p.toFile.listFiles.toList + def localpath: String = cygpath2driveletter(p.normalize.toString) + def dospath: String = localpath.replace('/', '\\') + def isDirectory: Boolean = p.toFile.isDirectory + def isFile: Boolean = p.toFile.isFile + def isRegularFile: Boolean = isFile // alias + def relpath: Path = if (p.norm.startsWith(cwd.norm)) cwd.relativize(p) else p + def relativePath: String = relpath.norm + def getName: String = p.toFile.getName() + def name: String = p.toFile.getName // alias + def lcname: String = name.toLowerCase + def basename: String = p.toFile.basename + def lcbasename: String = basename.toLowerCase + def suffix: String = dotsuffix.dropWhile( (c:Char) => c == '.' ) + def lcsuffix: String = suffix.toLowerCase + def dotsuffix: String = p.toFile.dotsuffix + def noDrive: String = p.norm.replaceAll("^/?[A-Za-z]:?/","/") // toss Windows drive letter, if present + + def text: String = p.toFile.contentAsString // alias + def extension: Option[String] = p.toFile.extension + def pathFields = p.iterator.asScala.toList + def reversePath: String = pathFields.reverse.mkString("/") + def lastModified: Long = p.toFile.lastModified + def lastModifiedTime = whenModified(p.toFile) + def lastModSeconds:Double = { + secondsBetween(lastModifiedTime, now).toDouble + } + def lastModMinutes: Double = lastModSeconds / 60.0 + def lastModHours: Double = lastModMinutes / 60.0 + def lastModDays: Double = round(lastModHours / 24.0) + def weekDay: java.time.DayOfWeek = { + p.lastModifiedTime.getDayOfWeek + } + def round(number:Double,scale:Int=6):Double = { + BigDecimal(number).setScale(scale, BigDecimal.RoundingMode.HALF_UP).toDouble + } + def age: String = { // readable description of lastModified + if( lastModMinutes <= 60.0 ){ + "%1.2f minutes".format(lastModMinutes) + } else + if( lastModHours <= 24.0 ){ + "%1.2f hours".format(lastModHours) + } else + if( lastModDays <= 365.25 ){ + "%1.2f days".format(lastModDays) + } else { + "%1.2f years".format(lastModDays/365.25) + } + } + + def files: Seq[JFile] = p.toFile match { + case f if f.isDirectory => f.files + case _ => Nil + } + def paths: Seq[Path] = files.map( _.toPath ) + def dirs: Seq[Path] = paths.filter { _.isDirectory } + def filesTree: Seq[JFile] = p.toFile.filesTree + def pathsTree: Seq[Path] = p.toFile.pathsTree + def lines: Seq[String] = linesCharset(DefaultCharset) + def lines(encoding: String): Seq[String] = linesCharset(Charset.forName(encoding)) + def linesCharset(charset: Charset): Seq[String] = { + if (p.norm.startsWith("/proc/")) { + execBinary("cat", p.norm) + } else { + try { + Files.readAllLines(p, charset).asScala.toSeq + } catch { + case mie:java.nio.charset.MalformedInputException => + sys.error(s"malformed input reading file [$p] with charset [$charset]") + } + } + } + def linesAnyEncoding: Seq[String] = getLinesAnyEncoding(p) + def linesWithEncoding(encoding: String): Seq[String] = getLinesAnyEncoding(p, encoding) + def firstline = p.linesAnyEncoding.take(1).mkString("") + def getLinesIgnoreEncodingErrors(): Seq[String] = linesAnyEncoding + //def getLinesIgnoreEncodingErrors(encoding: String): Seq[String] = linesWithEncoding(encoding) + def contentAsString: String = p.toFile.contentAsString() + def contentWithEncoding(encoding: String): String = p.linesWithEncoding(encoding).mkString("\n") + def contains(s: String): Boolean = p.toFile.contentAsString().contains(s) + def contentAnyEncoding: String = p.toFile.contentAnyEncoding + def bytes: Array[Byte] = JFiles.readAllBytes(p) + def byteArray: Array[Byte] = bytes // alias + //def cksum: Long = { val (sum, _) = vastblue.math.Cksum.gnuCksum(bytes.iterator); sum } + //def cksumNe: Long = vastblue.fileutils.cksumNe(p)._1 + //def md5: String = p.toFile.md5 + //def sha256: String = p.toFile.sha256 + def ageInDays: Double = FileTime.ageInDays(p.toFile) + /* + private def quickCsv(delimiter: String): QuickCsv = p.toFile.quickCsv(delimiter) + def csvRows: Seq[Seq[String]] = quickCsv("").rows.filter { (row: Seq[String]) => !row.take(1).mkString("").startsWith("#") } + def csvRows(charset: Charset): Seq[Seq[String]] = QuickCsv(p, columnDelimiter, charset).rows + def csvColnamesAndRows: (Seq[String], Seq[Seq[String]]) = csvRows.toList match { + case cnames :: tail => (cnames, tail) + case _ => (Nil, Nil) + } + def headingsAndRows: (Seq[String], Seq[Seq[String]]) = csvColnamesAndRows // alias + def csvRows(delimiter: String): Seq[Seq[String]] = p.toFile.quickCsv(delimiter).rows + + def csvMainRows: Seq[Seq[String]] = { + // if only interested in rows with the most common column count + vastblue.FastCsvParser.Stats(p).mainGroup.rowlist + } + + def delim: String = toFile.guessDelimiter() + def columnDelimiter: String = toFile.guessDelimiter() // alias + //def guessEncoding: String = p.toFile.guessEncoding + //def guessCharset: Charset = Charset.forName(p.guessEncoding) + */ + + def trimmedLines: Seq[String] = linesCharset(DefaultCharset).map { _.trim } + def trimmedSql: Seq[String] = lines.map { _.replaceAll("\\s*--.*","") }.filter{ _.trim.length > 0 } + // def copyTo(destFile: Path): Int = vastblue.fileutils.copy(p.file, destFile.file) + + def isSymbolicLink: Boolean = JFiles.isSymbolicLink(p) + def mkdirs: Boolean = { + val dir = Files.createDirectories(p) + dir.toFile.isDirectory + } + def realpathLs: Path = { // ask ls what symlink references + exec("ls","-l", p.norm).split("\\s+->\\s+").toList match { + case a :: b :: Nil => b.path + case _ => p + } + } + def lastModifiedYMD: String = { + def lastModified = p.toFile.lastModified + val date = new java.util.Date(lastModified) + ymd.format(date) + } + def norm: String = { + val s1 = p.toString + val s2 = s1 match { + case "." => s1 + case ".." => p.parentPath.normalize.toString + case _ => p.normalize.toString + } + s2.replace('\\','/') match { + case CygdrivePattern(dr,p) if isWindows => + s"$dr:$p" // this can never happen, because cygdrive prefix never reproduced by Path.toString + case DriveLetterPattern(dr,p) if isWindows => + s"$dr:$p" // not strictly needed, but useful in IDE + case s => + s + } + } + + def abspath: String = norm // alias + + // output string should be posix format, either because: + // A. non-Windows os + // B. C: matching default drive is dropped + // C. D: (not matching default drive) is converted to /d + def stdpath: String = { // alias + // drop drive letter, if present + val rawString = p.toString + val posix = if (notWindows){ + rawString // case A + } else { + val nm = norm +// val prefix4 = nm.take(4).toLowerCase +// val dd = defaultDrive +// val defdrive = prefix4.startsWith(defaultDrive) +// if (prefix4.length > 3 && prefix4(1) == ':' && defdrive) +// nm.drop(2) // case B +// else + posixDriveLetter(nm) // case C + } + posix + } + def posixpath: String = stdpath // alias + def delete(): Boolean = toFile.delete() + def withWriter(charsetName: String=DefaultEncoding, append: Boolean = false)(func: PrintWriter => Any): Unit = { + p.toFile.withWriter(charsetName, append)(func) + } + + /* + def overwrite(text: String): Unit = p.toFile.overwrite(text) + */ + + def dateSuffix: String = { + lcbasename match { + case DatePattern1(_,yyyymmdd,_) => + yyyymmdd + case DatePattern2(_,yyyymmdd) => + yyyymmdd + case _ => + "" + } + } + /* + def renameViaCopy(newfile: Path, overwrite:Boolean = false) = fileutils.renameViaCopy(p.toFile, newfile.toFile, overwrite) + def renameToUnique(newpath: Path, backupDir: String = "/tmp"): (Boolean, Option[Path]) = { + val bakDir = Paths.get(backupDir) + renameFileUnique(p, newpath, bakDir) + } + */ + def renameTo(s: String): Boolean = renameTo(s.path) + def renameTo(alt: Path): Boolean = { + p.toFile.renameTo(alt) + } + def isEmpty: Boolean = p.toFile.isEmpty + def nonEmpty: Boolean = p.toFile.nonEmpty + def canRead: Boolean = p.toFile.canRead + def canExecute: Boolean = p.toFile.canExecute + } + + implicit class ExtendFile(f: JFile) { + def path = f.toPath + def realfile: JFile = path.realpath.toFile + def name: String = f.getName // alias + def lcname = f.getName.toLowerCase + def norm: String = f.path.norm + def abspath: String = norm // alias + def stdpath: String = norm // alias + def posixpath: String = stdpath // alias + def lastModifiedYMD: String = f.path.lastModifiedYMD + def basename: String = dropDotSuffix(name) // TODO: verify same as below rendition + /* + def basename: String = { + val fname = f.name + val ff = fname.split("\\.").toSeq + if (ff.take(2).size > 1 ) { + ff.reverse.drop(1).reverse.mkString(".") + } else { + fname + } + } + */ + def lcbasename: String = basename.toLowerCase + def dotsuffix: String = f.name.drop(f.basename.length) // .txt, etc. + def suffix: String = dotsuffix.dropWhile( (c:Char) => c == '.' ) + def lcsuffix: String = suffix.toLowerCase + def extension: Option[String] = f.dotsuffix match { case "" => None ; case str => Some(str) } + def parentFile: JFile = f.getParentFile + def parentPath: Path = parentFile.toPath + def parent: Path = parentPath // alias + def isFile: Boolean = f.isFile + def isRegularFile: Boolean = isFile // alias + def filesTree: Seq[JFile] = { + assert(f.isDirectory,s"not a directory [$f]") + pathextend.filesTree(f)() + } + def pathsTree: Seq[Path] = filesTree.map { _.path } + def files: Seq[JFile] = { + assert(f.isDirectory,s"not a directory [$f]") + f.listFiles.toList + } + def contentAsString: String = contentAsString(DefaultCharset) + def contentAsString(charset: Charset = DefaultCharset): String = f.lines(charset).mkString("\n") + def contentAnyEncoding: String = f.linesAnyEncoding.mkString("\n") + def bytes: Array[Byte] = f.getBytes("UTF-8") // JFiles.readAllBytes(path) + def byteArray: Array[Byte] = bytes // alias + def getBytes(encoding: String="utf-8"): Array[Byte] = contentAsString.getBytes(Charset.forName(encoding)) + def lines: Seq[String] = lines(DefaultCharset) + def lines(charset: Charset): Seq[String] = path.linesCharset(charset) + def linesAnyEncoding: Seq[String] = getLinesAnyEncoding(f.toPath) + def contentWithEncoding(encoding: String): String = f.path.linesWithEncoding(encoding).mkString("\n") +// def cksum: Long = path.cksum +// def md5: String = fileChecksum(f, algorithm="MD5") +// def sha256: String = fileChecksum(f, algorithm="SHA-256") + + /* + def guessDelimiter(count: Int=50): String = { + val charset = f.detectEncoding match { + case None => Latin1 + case Some(encoding) => Charset.forName(encoding) + } + autoDetectDelimiter(lines(charset).take(count).mkString("\n"), norm) + } + def delim: String = guessDelimiter() + def columnDelimiter: String = guessDelimiter() // alias + + def detectEncoding: Option[String] = Option(UniversalDetector.detectCharset(f)) + def guessEncoding: String = detectEncoding match { + case None => if (f.length == 0) "UTF-8" else "unknown" + case Some(str) => str + } + + def DefaultEncoding = sys.props("file.encoding") + def quickCsv(delimiter: String, encstr: String = DefaultEncoding): QuickCsv = { + val charset:Charset = Charset.forName(encstr) + val delim: String = if (delimiter.isEmpty) guessDelimiter() else delimiter + QuickCsv(f.path, delim, charset) + } + def csvRows: Seq[Seq[String]] = quickCsv("").rows + def csvRows(charset: Charset = DefaultCharset): Seq[Seq[String]] = QuickCsv(f.path, columnDelimiter, charset).rows + */ + + def withWriter(charsetName: String, append: Boolean)(func: PrintWriter => Any): Unit = { + def lcname = name.toLowerCase + if( lcname != "stdout" ){ + Option(parentFile) match { + case Some(parent) if parent.isDirectory => + // ok + case Some(parent) => + throw new IllegalArgumentException(s"parent directory not found [${parent}]") + case None => + throw new IllegalArgumentException(s"no parent directory") + } + } + val writer = lcname match { + case "stdout" => + new PrintWriter(new OutputStreamWriter(System.out, charsetName), true) + case _ => + val charset = Charset.forName(charsetName) + new PrintWriter(new OutputStreamWriter(new FileOutputStream(f, append), charset)) +// new PrintWriter(new FileWriter(f, charset, append)) + } + var junk: Any = 0 + try { + junk = func(writer) // suppressWarnings:discarded-value + } finally { + writer.flush() + if( lcname != "stdout" ){ + // don't close stdout! + writer.close() + } + } + } + /* + def overwrite(text: String): Unit = + withWriter(charsetName="utf-8", append=false){ w => w.write(text) } + */ + + def renameTo(s: String): Boolean = renameTo(s.path) + def renameTo(alt: Path): Boolean = { + f.renameTo(alt.file) + } + /* + def renameViaCopy(newfile: JFile, overwrite:Boolean) = fileutils.renameViaCopy(f, newfile, overwrite) + def renameViaCopy(p: Path, overwrite:Boolean): Int = renameViaCopy(p.toFile, overwrite) + */ + // def diff(other: JFile): Seq[String] = vastblue.util.Exec.diffExec(f, other) + + def isEmpty: Boolean = f.length == 0 + def nonEmpty: Boolean = f.length != 0 + } + +//import scala.jdk.StreamConverters.* +//import scala.util.Using +//import scala.util.{Try,Success,Failure} +//import java.io.{BufferedReader, FileReader} +//import scala.io.Source + + def aFile(s: String): Path = Paths.get(s) + def aFile(dir: Path, s: String): Path = Paths.get(s"$dir/$s") + def _chmod(p: Path, permissions: String, allusers:Boolean): Boolean = Paths._chmod(p, permissions, allusers) + + def newEx: RuntimeException = new RuntimeException("LimitedStackTrace") + def showLimitedStack(e: Throwable = newEx): Unit = { System.err.println(getLimitedStackTrace(e)) } + def getLimitedStackTrace(implicit ee: Throwable=newEx): String = { + getLimitedStackList.mkString("\n") + } + /** default filtering of stack trace removes known debris */ + def getLimitedStackList(implicit ee: Throwable=new RuntimeException("getLimitedStackTrace")): List[String] = { + currentStackList(ee).filter { entry => + entry.charAt(0) == ' ' || // keep lines starting with non-space + ( !entry.contains("at scala.") + && !entry.contains("at oracle.") + && !entry.contains("at org.") + && !entry.contains("at codehaus.") + && !entry.contains("at sun.") + && !entry.contains("at java") + && !entry.contains("at scalikejdbc") + && !entry.contains("at net.") + && !entry.contains("at dotty.") + && !entry.toLowerCase.contains("(unknown source)") + ) + } + } + def currentStack(ee: Throwable=new RuntimeException("currentStack")): String = { + import java.io.{StringWriter} + val result = new StringWriter() + val printWriter = new PrintWriter(result) + ee.printStackTrace(printWriter) + result.toString + } + def currentStackList(ee: Throwable=new RuntimeException("currentStackList")): List[String] = { + currentStack(ee).split("[\r\n]+").toList + } + def notWindows: Boolean = java.io.File.separator == "/" + def isWindows: Boolean = !notWindows + + // set initial codec value, affecting default usage. + import scala.io.Codec + def writeCodec: Codec = { + def osDefault = if (isWindows) { + Codec.ISO8859 + } else { + Codec.UTF8 + } + val lcAll: String = Option(System.getenv("LC_ALL")).getOrElse(osDefault.toString) + lcAll match { + case "UTF-8" | "utf-8" | "en_US.UTF-8" | "en_US.utf8" => + Codec.UTF8 // "mac" | "linux" + case s if s.toLowerCase.replaceAll("[^a-zA-Z0-9]", "").contains("utf8") => + Codec.UTF8 // "mac" | "linux" + case "ISO-8859-1" | "latin1" => + Codec(lcAll) + case encodingName => + //System.err.printf("warning : unrecognized charset encoding: LC_ALL==[%s]\n",encodingName) + Codec(encodingName) + } + } + lazy val DefaultCodec = writeCodec + object JFile { + def apply(dir: String, fname: String): JFile = new JFile(dir,fname) + def apply(dir: JFile, fname: String): JFile = new JFile(dir,fname) + def apply(fpath: String): JFile = new JFile(fpath) + } + + /** + * Recursive list of all files below rootfile. + * Filter for directories to be descended and/or + * files to be retained. + */ + def dummyFilter(f:JFile): Boolean = f.canRead() + + import scala.annotation.tailrec + def filesTree(dir: JFile)(func: JFile => Boolean = dummyFilter) : Seq[JFile] = { + assert(dir.isDirectory,s"error: not a directory [$dir]") + @tailrec + def filesTree(files: List[JFile], result: List[JFile]): List[JFile] = files match { + case Nil => result + case head :: tail if Option(head).isEmpty => + Nil + case head :: tail if head.isDirectory => + // filtered directories are pruned + if ( head.canRead() ){ + val subs: List[JFile] = head.listFiles.toList.filter { func(_) } + filesTree(subs ::: tail, result) // depth-first + } else { + Nil + } + //filesTree(tail ::: subs, result) // width-first + case head :: tail => // if head.isFile => + val newResult = func(head) match { + case true => head :: result // accepted + case false => result // rejected + } + filesTree(tail, newResult) + } + filesTree(List(dir), Nil).toSeq + } + def autoDetectDelimiter(sampleText:String,fname:String,ignoreErrors:Boolean=true):String = { + var (tabs,commas,semis,pipes) = (0,0,0,0) + sampleText.toCharArray.foreach { + case '\t' => + tabs += 1 + case ',' => + commas += 1 + case ';' => + semis += 1 + case '|' => + pipes += 1 + case _ => + } + // This approach provides a reasonably fast guess, but sometimes fails: + // Premise: + // tab-delimited files usually contain more tabs than commas, + // while comma-delimited files contain more commas than tabs. + // + // A much slower but more thorough approach would be: + // 1. replaceAll("""(?m)"[^"]*","") // remove quoted strings + // 2. split("[\r\n]+") // extract multiple lines + // 3. count columns-per-row tallies using various delimiters + // 4. the tally with the most consistency is the "winner" + (commas,tabs,pipes,semis) match { + case (cms,tbs,pps,sms) if cms > tbs && cms >= pps && cms >= sms => + "," + case (cms,tbs,pps,sms) if tbs >= cms && tbs >= pps && tbs >= sms => + "\t" + case (cms,tbs,pps,sms) if pps > cms && pps > tbs && pps > sms => + "|" + case (cms,tbs,pps,sms) if sms > cms && sms > tbs && sms > pps => + ";" + case _ if ignoreErrors => + "" + case _ => + sys.error(s"unable to choose delimiter: tabs[$tabs], commas[$commas], semis[$semis], pipes[$pipes] for file:\n[${fname}]") + } + } + + def toRealPath(p: Path): Path = { + exec(realpathExe,p.norm).path + } + lazy val realpathExe = { + val rp = where(s"realpath${exeSuffix}") + rp + } + + def getLinesAnyEncoding(p: Path, encoding:String="utf-8"): Seq[String] = { + getLinesIgnoreEncodingErrors(p, encoding).toSeq + } + def getLinesIgnoreEncodingErrors(p: Path, encoding:String=DefaultEncoding): Seq[String] = { + import java.nio.charset.CodingErrorAction + implicit val codec = Codec(encoding) + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + try { + Files.readAllLines(p, codec.charSet).asScala.toSeq + } catch { + case _:Exception => + encoding match { + case "utf-8" => + implicit val codec = Codec("latin1") + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + Files.readAllLines(p, codec.charSet).asScala.toSeq + case _ => + implicit val codec = Codec("utf-8") + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + Files.readAllLines(p, codec.charSet).asScala.toSeq + } + } + } +// def withPathWriter(p: Path, charsetName: String = DefaultEncoding, append: Boolean = false)(func: PrintWriter => Any): Unit = { +// p.withWriter(charsetName, append)(func) +// } + + import scala.util.matching.Regex + lazy val DatePattern1: Regex = """(.+)(\d\d\d\d\d\d\d\d)(\D.*)?""".r + lazy val DatePattern2: Regex = """(.+)(\d\d\d\d\d\d\d\d)""".r + + lazy val bintools = true // faster than MessageDigest + def fileChecksum(file: JFile, algorithm: String): String = { + val toolName = algorithm match { + case "SHA-256" => "sha256sum" + case "MD5" => "md5sum" + case _ => "" + } + val toolPath = localPath(toolName) + val sum = if (bintools && toolPath.nonEmpty && toolPath.path.isFile){ + // very fast + val binstr = execBinary(toolPath, file.norm).take(1).mkString("") + binstr.replaceAll(" .*","") + } else { + // very slow + val is = JFiles.newInputStream(file.path) + checkSum(is, algorithm) + } + sum + } + lazy val PosixDriveLetterPrefix = "(?i)/([a-z])(/.*)".r + lazy val WindowsDriveLetterPrefix = "(?i)([a-z]):(/.*)".r + + def cygpath2driveletter(str: String): String = { + val strtmp = str.replace('\\', '/') + strtmp match { + case PosixDriveLetterPrefix(dl,tail) => + val tailstr = Option(tail).getOrElse("/") + s"$dl:$tailstr" + case WindowsDriveLetterPrefix(dl,tail) => + val tailstr = Option(tail).getOrElse("/") + s"$dl:$tailstr" + case _ => + s"$defaultDrive:$strtmp" + } + } + def cygpath2driveletter(p: Path): String = { + cygpath2driveletter(p.stdpath) + } + // return a posix version of path string; include drive letter, if not the default drive + def posixDriveLetter(str: String) = { + val posix = if( str.drop(1).startsWith(":") ){ + val letter = str.take(1).toLowerCase + val tail = str.drop(2) + tail match { + case "/" => + s"/$letter" + case s if s.startsWith("/") => + if (letter == defaultDrive.take(1).toLowerCase){ + tail + } else { + s"/$letter$tail" + } + case _ => + s"/$letter/$tail" + } + } else { + str + } + posix + } + def dropDotSuffix(s: String): String = if (!s.contains(".")) s else s.reverse.dropWhile(_ != '.').drop(1).reverse + def commonLines(f1: Path, f2: Path): Map[String,List[String]] = { + val items = (f1.trimmedLines ++ f2.trimmedLines).groupBy { line => + line.replaceAll("""[^a-zA-Z_0-9]+""","") // remove whitespace and punctuation + } + items.map { case (key,items) => (key,items.toList) } + } + // supported algorithms: "MD5" and "SHA-256" + def checkSum(bytes: Array[Byte], algorithm: String): String = { + val is: InputStream = new ByteArrayInputStream(bytes) + checkSum(is, algorithm) + } + def checkSum(is: InputStream, algorithm: String): String = { + val md = MessageDigest.getInstance(algorithm) + val dis = new DigestInputStream(is, md) + var num = 0 + while (dis.available > 0) { + num += dis.read + } + dis.close + val sum = md.digest.map(b => String.format("%02x", Byte.box(b))).mkString + sum + } +} diff --git a/src/main/scala-2.13/vastblue/time/TimeExtensions.scala b/src/main/scala-2.13/vastblue/time/TimeExtensions.scala new file mode 100644 index 0000000..6e55f8d --- /dev/null +++ b/src/main/scala-2.13/vastblue/time/TimeExtensions.scala @@ -0,0 +1,113 @@ +package vastblue.time + +import java.time.* +//import java.time.format.* +import java.time.temporal.TemporalAdjusters +import scala.runtime.RichInt + +//import io.github.chronoscala.Imports.* +//import io.github.chronoscala.* +import vastblue.time.FileTime.* +import java.time.DayOfWeek +//import java.time.DayOfWeek.* +//import scala.language.implicitConversions + +trait TimeExtensions { + //implicit def ld2zdt(ld:LocalDate):ZonedDateTime = { ld.atStartOfDay.withZoneSameLocal(zoneid) } + implicit def date2option(date:LocalDateTime):Option[LocalDateTime] = Some(date) + + implicit def ldt2zdt(ldt:LocalDateTime):ZonedDateTime = { + ldt.atZone(UTC) + } + implicit def str2richStr(s:String):RichString = new RichString(s) + implicit def ta2zdt(ta:java.time.temporal.TemporalAccessor):ZonedDateTime = { + try { + ta match { + case ld:LocalDateTime => + ld.atZone(zoneid) + } + } catch { + case _:java.time.DateTimeException => + sys.error(s"cannot convert to LocalDateTime from: ${ta.getClass.getName}") + } + } + implicit def dateTimeOrdering: Ordering[LocalDateTime] = Ordering.fromLessThan(_ isBefore _) + + //implicit def sqlDate2LocalDateTime(sd:java.sql.Date):LocalDateTime = sd.toLocalDate.atStartOfDay() + //implicit def sqlDate2LocalDate(sd:java.sql.Date):LocalDate = sd.toLocalDate + implicit def int2richInt(i:Int):RichInt = new RichInt(i) + implicit def int2Period(i:Int):java.time.Period = java.time.Period.ofWeeks(i) + + import java.time.Duration + //implicit class aInterval(val i:Interval) { def toDuration = i.duration } + /* + implicit class aDayOfWeek(val d:java.time.DayOfWeek){ + def >=(other:java.time.DayOfWeek) = { d.compareTo(other) >= 0 } + def > (other:java.time.DayOfWeek) = { d.compareTo(other) > 0 } + def <=(other:java.time.DayOfWeek) = { d.compareTo(other) <= 0 } + def < (other:java.time.DayOfWeek) = { d.compareTo(other) < 0 } + } + implicit class aDuration(val pd:java.time.Duration) { + def getStandardSeconds:Long = pd.seconds + def getStandardMinutes: Long = getStandardSeconds / 60 + def getStandardHours: Long = getStandardMinutes / 60 + def getStandardDays: Long = getStandardHours / 24 + } + */ + def between(d1:LocalDateTime,d2:LocalDateTime) = Duration.between(d1,d2) + + implicit class aDateTime(val d:LocalDateTime) extends Ordered[aDateTime] { + override def compare(that: aDateTime): Int = { + val (a,b) = (getMillis(),that.getMillis()) + if( a < b ) -1 + else if (a > b ) +1 + else 0 + } + def ymd: String = d.format(dateTimeFormatPattern(dateonlyFmt)) + + def ymdhms: String = d.format(dateTimeFormatPattern(datetimeFmt7)) + + def startsWith(str:String):Boolean = d.toString(ymdhms).startsWith(str) + + def toString(fmt:String):String = { + d.format(dateTimeFormatPattern(fmt)) + } + def getMillis():Long = { + d.atZone(zoneid).toInstant().toEpochMilli() + } + def >(other:LocalDateTime):Boolean = { + d.compareTo(other) > 0 + } + def >=(other:LocalDateTime):Boolean = { + d.compareTo(other) >= 0 + } + def to(other:LocalDateTime):Duration = { + between(d,other) + } +// def to(other:LocalDateTime):Duration = { +// Duration.between(d,other) +// } + def +(p:java.time.Period) = d.plus(p) + def -(p:java.time.Period) = d.minus(p) + + def minute = d.getMinute + def second = d.getSecond + def hour = d.getHour + def day = d.getDayOfMonth + def month = d.getMonth + def year = d.getYear + + def setHour(h:Int): LocalDateTime = d.plusHours((d.getHour + h).toLong) + def setMinute(m:Int): LocalDateTime = d.plusMinutes((d.getMinute + m).toLong) + + def compare(that: LocalDateTime): Int = d.getMillis() compare that.getMillis() + def dayOfYear = d.getDayOfYear + def getDayOfYear = d.getDayOfYear + def dayOfMonth = d.getDayOfMonth + def getDayOfMonth = d.getDayOfMonth + def dayOfWeek: DayOfWeek = d.getDayOfWeek // .getValue + def getDayOfWeek: DayOfWeek = d.getDayOfWeek // .getValue + def withDayOfWeek(dow:java.time.DayOfWeek):LocalDateTime = d.`with`(TemporalAdjusters.next(dow)) + def lastDayOfMonth: LocalDateTime = d.`with`(LastDayAdjuster) + } +} diff --git a/src/main/scala-3/vastblue/pathextend.scala b/src/main/scala-3/vastblue/pathextend.scala new file mode 100644 index 0000000..02f0545 --- /dev/null +++ b/src/main/scala-3/vastblue/pathextend.scala @@ -0,0 +1,687 @@ +package vastblue + +import java.nio.file.{Files as JFiles} +//import java.nio.file.{Paths as JPaths} +import java.nio.charset.Charset +//import java.nio.charset.Charset.* +import java.io.{ByteArrayInputStream, InputStream} +//import java.io.{File => JFile} +//import java.io.{BufferedWriter, FileWriter} +import java.io.{FileOutputStream, OutputStreamWriter} +import java.security.{DigestInputStream, MessageDigest} +import scala.jdk.CollectionConverters.* +import vastblue.Platform.* +//import vastblue.file.Paths +//import vastblue.file.Paths.* +import vastblue.time.FileTime +import vastblue.time.FileTime.* +//import vastblue.file.QuickCsv +//import vastblue.file.QuickCsv.* + +object pathextend { + def Paths = vastblue.file.Paths + def Files = vastblue.file.Files + type Path = java.nio.file.Path + type PrintWriter = java.io.PrintWriter + type JFile = java.io.File + var hook = 0 + lazy val DefaultEncoding = DefaultCodec.toString + lazy val DefaultCharset = Charset.forName(DefaultEncoding) + lazy val Utf8 = Charset.forName("UTF-8") + lazy val Latin1 = Charset.forName("ISO-8859-1") + lazy val userHome = sys.props("user.home").replace('\\', '/') + lazy val userDir = sys.props("user.dir").replace('\\', '/') + lazy val ymd = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss") + lazy val CygdrivePattern = "/([a-z])(/.*)?".r + lazy val DriveLetterPattern = "([a-z]):(/.*)?".r + private def cwd: Path = userDir.path.toAbsolutePath.normalize + + def scriptPath = Option(sys.props("script.path")) match { + case None => "" + case Some(path) => path + } + + def fixHome(s: String): String = { + s.startsWith("~") match { + case false => s + case true => s.replaceFirst("~",userHome).replace('\\', '/') + } + } + + extension(s: String) { + def path: Path = vastblue.file.Paths.get(s) // .toAbsolutePath + def toPath: Path = path + def absPath: Path = s.path.toAbsolutePath.normalize // alias + def toFile: JFile = toPath.toFile + def file: JFile = toFile + //def norm: String = path.norm + def norm: String = s.replace('\\', '/') + def dropSuffix: String = s.reverse.dropWhile(_ != '.').drop(1).reverse + //def problemPath: Boolean = vastblue.fileutils.problemPath(s) + } + + extension(p: Path) { + def toFile: JFile = p.toFile + def length: Long = p.toFile.length + def file: JFile = p.toFile + def realpath: Path = if (p.isSymbolicLink) p.toRealPath() else p // toRealPath(p) + def getParentFile: JFile = p.toFile.getParentFile + def parentFile: JFile = getParentFile // alias + def parentPath: Path = parentFile.toPath + def parent: Path = parentPath // alias + def exists: Boolean = Files.exists(p) // p.toFile.exists + def listFiles: Seq[JFile] = p.toFile.listFiles.toList + def localpath: String = cygpath2driveletter(p.normalize.toString) + def dospath: String = localpath.replace('/', '\\') + def isDirectory: Boolean = p.toFile.isDirectory + def isFile: Boolean = p.toFile.isFile + def isRegularFile: Boolean = isFile // alias + def relpath: Path = if (p.norm.startsWith(cwd.norm)) cwd.relativize(p) else p + def relativePath: String = relpath.norm + def getName: String = p.toFile.getName() + def name: String = p.toFile.getName // alias + def lcname: String = name.toLowerCase + def basename: String = p.toFile.basename + def lcbasename: String = basename.toLowerCase + def suffix: String = dotsuffix.dropWhile( (c:Char) => c == '.' ) + def lcsuffix: String = suffix.toLowerCase + def dotsuffix: String = p.toFile.dotsuffix + def noDrive: String = p.norm.replaceAll("^/?[A-Za-z]:?/","/") // toss Windows drive letter, if present + + def text: String = p.toFile.contentAsString // alias + def extension: Option[String] = p.toFile.extension + def pathFields = p.iterator.asScala.toList + def reversePath: String = pathFields.reverse.mkString("/") + def lastModified: Long = p.toFile.lastModified + def lastModifiedTime = whenModified(p.toFile) + def lastModSeconds:Double = { + secondsBetween(lastModifiedTime, now).toDouble + } + def lastModMinutes: Double = lastModSeconds / 60.0 + def lastModHours: Double = lastModMinutes / 60.0 + def lastModDays: Double = round(lastModHours / 24.0) + def weekDay: java.time.DayOfWeek = { + p.lastModifiedTime.getDayOfWeek + } + def round(number:Double,scale:Int=6):Double = { + BigDecimal(number).setScale(scale, BigDecimal.RoundingMode.HALF_UP).toDouble + } + def age: String = { // readable description of lastModified + if( lastModMinutes <= 60.0 ){ + "%1.2f minutes".format(lastModMinutes) + } else + if( lastModHours <= 24.0 ){ + "%1.2f hours".format(lastModHours) + } else + if( lastModDays <= 365.25 ){ + "%1.2f days".format(lastModDays) + } else { + "%1.2f years".format(lastModDays/365.25) + } + } + + def files: Seq[JFile] = p.toFile match { + case f if f.isDirectory => f.files + case _ => Nil + } + def paths: Seq[Path] = files.map( _.toPath ) + def dirs: Seq[Path] = paths.filter { _.isDirectory } + def filesTree: Seq[JFile] = p.toFile.filesTree + def pathsTree: Seq[Path] = p.toFile.pathsTree + def lines: Seq[String] = linesCharset(DefaultCharset) + def lines(encoding: String): Seq[String] = linesCharset(Charset.forName(encoding)) + def linesCharset(charset: Charset): Seq[String] = { + if (p.norm.startsWith("/proc/")) { + execBinary("cat", p.norm) + } else { + try { + Files.readAllLines(p, charset).asScala.toSeq + } catch { + case mie:java.nio.charset.MalformedInputException => + sys.error(s"malformed input reading file [$p] with charset [$charset]") + } + } + } + def linesAnyEncoding: Seq[String] = getLinesAnyEncoding(p) + def linesWithEncoding(encoding: String): Seq[String] = getLinesAnyEncoding(p, encoding) + def firstline = p.linesAnyEncoding.take(1).mkString("") + def getLinesIgnoreEncodingErrors(): Seq[String] = linesAnyEncoding + //def getLinesIgnoreEncodingErrors(encoding: String): Seq[String] = linesWithEncoding(encoding) + def contentAsString: String = p.toFile.contentAsString() + def contentWithEncoding(encoding: String): String = p.linesWithEncoding(encoding).mkString("\n") + def contains(s: String): Boolean = p.toFile.contentAsString().contains(s) + def contentAnyEncoding: String = p.toFile.contentAnyEncoding + def bytes: Array[Byte] = JFiles.readAllBytes(p) + def byteArray: Array[Byte] = bytes // alias + //def cksum: Long = { val (sum, _) = vastblue.math.Cksum.gnuCksum(bytes.iterator); sum } + //def cksumNe: Long = vastblue.fileutils.cksumNe(p)._1 + //def md5: String = p.toFile.md5 + //def sha256: String = p.toFile.sha256 + def ageInDays: Double = FileTime.ageInDays(p.toFile) + /* + private def quickCsv(delimiter: String): QuickCsv = p.toFile.quickCsv(delimiter) + def csvRows: Seq[Seq[String]] = quickCsv("").rows.filter { (row: Seq[String]) => !row.take(1).mkString("").startsWith("#") } + def csvRows(charset: Charset): Seq[Seq[String]] = QuickCsv(p, columnDelimiter, charset).rows + def csvColnamesAndRows: (Seq[String], Seq[Seq[String]]) = csvRows.toList match { + case cnames :: tail => (cnames, tail) + case _ => (Nil, Nil) + } + def headingsAndRows: (Seq[String], Seq[Seq[String]]) = csvColnamesAndRows // alias + def csvRows(delimiter: String): Seq[Seq[String]] = p.toFile.quickCsv(delimiter).rows + + def csvMainRows: Seq[Seq[String]] = { + // if only interested in rows with the most common column count + vastblue.FastCsvParser.Stats(p).mainGroup.rowlist + } + + def delim: String = toFile.guessDelimiter() + def columnDelimiter: String = toFile.guessDelimiter() // alias + //def guessEncoding: String = p.toFile.guessEncoding + //def guessCharset: Charset = Charset.forName(p.guessEncoding) + */ + + def trimmedLines: Seq[String] = linesCharset(DefaultCharset).map { _.trim } + def trimmedSql: Seq[String] = lines.map { _.replaceAll("\\s*--.*","") }.filter{ _.trim.length > 0 } + // def copyTo(destFile: Path): Int = vastblue.fileutils.copy(p.file, destFile.file) + + def isSymbolicLink: Boolean = JFiles.isSymbolicLink(p) + def mkdirs: Boolean = { + val dir = Files.createDirectories(p) + dir.toFile.isDirectory + } + def realpathLs: Path = { // ask ls what symlink references + exec("ls","-l", p.norm).split("\\s+->\\s+").toList match { + case a :: b :: Nil => b.path + case _ => p + } + } + def lastModifiedYMD: String = { + def lastModified = p.toFile.lastModified + val date = new java.util.Date(lastModified) + ymd.format(date) + } + def norm: String = { + val s1 = p.toString + val s2 = s1 match { + case "." => s1 + case ".." => p.parentPath.normalize.toString + case _ => p.normalize.toString + } + s2.replace('\\','/') match { + case CygdrivePattern(dr,p) if isWindows => + s"$dr:$p" // this can never happen, because cygdrive prefix never reproduced by Path.toString + case DriveLetterPattern(dr,p) if isWindows => + s"$dr:$p" // not strictly needed, but useful in IDE + case s => + s + } + } + + def abspath: String = norm // alias + + // output string should be posix format, either because: + // A. non-Windows os + // B. C: matching default drive is dropped + // C. D: (not matching default drive) is converted to /d + def stdpath: String = { // alias + // drop drive letter, if present + val rawString = p.toString + val posix = if (notWindows){ + rawString // case A + } else { + val nm = norm +// val prefix4 = nm.take(4).toLowerCase +// val dd = defaultDrive +// val defdrive = prefix4.startsWith(defaultDrive) +// if (prefix4.length > 3 && prefix4(1) == ':' && defdrive) +// nm.drop(2) // case B +// else + posixDriveLetter(nm) // case C + } + posix + } + def posixpath: String = stdpath // alias + def delete(): Boolean = toFile.delete() + def withWriter(charsetName: String=DefaultEncoding, append: Boolean = false)(func: PrintWriter => Any): Unit = { + p.toFile.withWriter(charsetName, append)(func) + } + + /* + def overwrite(text: String): Unit = p.toFile.overwrite(text) + */ + + def dateSuffix: String = { + lcbasename match { + case DatePattern1(_,yyyymmdd,_) => + yyyymmdd + case DatePattern2(_,yyyymmdd) => + yyyymmdd + case _ => + "" + } + } + /* + def renameViaCopy(newfile: Path, overwrite:Boolean = false) = fileutils.renameViaCopy(p.toFile, newfile.toFile, overwrite) + def renameToUnique(newpath: Path, backupDir: String = "/tmp"): (Boolean, Option[Path]) = { + val bakDir = Paths.get(backupDir) + renameFileUnique(p, newpath, bakDir) + } + */ + def renameTo(s: String): Boolean = renameTo(s.path) + def renameTo(alt: Path): Boolean = { + p.toFile.renameTo(alt) + } + def isEmpty: Boolean = p.toFile.isEmpty + def nonEmpty: Boolean = p.toFile.nonEmpty + def canRead: Boolean = p.toFile.canRead + def canExecute: Boolean = p.toFile.canExecute + } + + extension(f: JFile) { + def path = f.toPath + def realfile: JFile = path.realpath.toFile + def name: String = f.getName // alias + def lcname = f.getName.toLowerCase + def norm: String = f.path.norm + def abspath: String = norm // alias + def stdpath: String = norm // alias + def posixpath: String = stdpath // alias + def lastModifiedYMD: String = f.path.lastModifiedYMD + def basename: String = dropDotSuffix(name) // TODO: verify same as below rendition + /* + def basename: String = { + val fname = f.name + val ff = fname.split("\\.").toSeq + if (ff.take(2).size > 1 ) { + ff.reverse.drop(1).reverse.mkString(".") + } else { + fname + } + } + */ + def lcbasename: String = basename.toLowerCase + def dotsuffix: String = f.name.drop(f.basename.length) // .txt, etc. + def suffix: String = dotsuffix.dropWhile( (c:Char) => c == '.' ) + def lcsuffix: String = suffix.toLowerCase + def extension: Option[String] = f.dotsuffix match { case "" => None ; case str => Some(str) } + def parentFile: JFile = f.getParentFile + def parentPath: Path = parentFile.toPath + def parent: Path = parentPath // alias + def isFile: Boolean = f.isFile + def isRegularFile: Boolean = isFile // alias + def filesTree: Seq[JFile] = { + assert(f.isDirectory,s"not a directory [$f]") + pathextend.filesTree(f)() + } + def pathsTree: Seq[Path] = filesTree.map { _.path } + def files: Seq[JFile] = { + assert(f.isDirectory,s"not a directory [$f]") + f.listFiles.toList + } + def contentAsString: String = contentAsString(DefaultCharset) + def contentAsString(charset: Charset = DefaultCharset): String = f.lines(charset).mkString("\n") + def contentAnyEncoding: String = f.linesAnyEncoding.mkString("\n") + def bytes: Array[Byte] = f.getBytes("UTF-8") // JFiles.readAllBytes(path) + def byteArray: Array[Byte] = bytes // alias + def getBytes(encoding: String="utf-8"): Array[Byte] = contentAsString.getBytes(Charset.forName(encoding)) + def lines: Seq[String] = lines(DefaultCharset) + def lines(charset: Charset): Seq[String] = path.linesCharset(charset) + def linesAnyEncoding: Seq[String] = getLinesAnyEncoding(f.toPath) + def contentWithEncoding(encoding: String): String = f.path.linesWithEncoding(encoding).mkString("\n") +// def cksum: Long = path.cksum +// def md5: String = fileChecksum(f, algorithm="MD5") +// def sha256: String = fileChecksum(f, algorithm="SHA-256") + + /* + def guessDelimiter(count: Int=50): String = { + val charset = f.detectEncoding match { + case None => Latin1 + case Some(encoding) => Charset.forName(encoding) + } + autoDetectDelimiter(lines(charset).take(count).mkString("\n"), norm) + } + def delim: String = guessDelimiter() + def columnDelimiter: String = guessDelimiter() // alias + + def detectEncoding: Option[String] = Option(UniversalDetector.detectCharset(f)) + def guessEncoding: String = detectEncoding match { + case None => if (f.length == 0) "UTF-8" else "unknown" + case Some(str) => str + } + + def DefaultEncoding = sys.props("file.encoding") + def quickCsv(delimiter: String, encstr: String = DefaultEncoding): QuickCsv = { + val charset:Charset = Charset.forName(encstr) + val delim: String = if (delimiter.isEmpty) guessDelimiter() else delimiter + QuickCsv(f.path, delim, charset) + } + def csvRows: Seq[Seq[String]] = quickCsv("").rows + def csvRows(charset: Charset = DefaultCharset): Seq[Seq[String]] = QuickCsv(f.path, columnDelimiter, charset).rows + */ + + def withWriter(charsetName: String, append: Boolean)(func: PrintWriter => Any): Unit = { + def lcname = name.toLowerCase + if( lcname != "stdout" ){ + Option(parentFile) match { + case Some(parent) if parent.isDirectory => + // ok + case Some(parent) => + throw new IllegalArgumentException(s"parent directory not found [${parent}]") + case None => + throw new IllegalArgumentException(s"no parent directory") + } + } + val writer = lcname match { + case "stdout" => + new PrintWriter(new OutputStreamWriter(System.out, charsetName), true) + case _ => + val charset = Charset.forName(charsetName) + new PrintWriter(new OutputStreamWriter(new FileOutputStream(f, append), charset)) +// new PrintWriter(new FileWriter(f, charset, append)) + } + var junk: Any = 0 + try { + junk = func(writer) // suppressWarnings:discarded-value + } finally { + writer.flush() + if( lcname != "stdout" ){ + // don't close stdout! + writer.close() + } + } + } + /* + def overwrite(text: String): Unit = + withWriter(charsetName="utf-8", append=false){ w => w.write(text) } + */ + + def renameTo(s: String): Boolean = renameTo(s.path) + def renameTo(alt: Path): Boolean = { + f.renameTo(alt.file) + } + /* + def renameViaCopy(newfile: JFile, overwrite:Boolean) = fileutils.renameViaCopy(f, newfile, overwrite) + def renameViaCopy(p: Path, overwrite:Boolean): Int = renameViaCopy(p.toFile, overwrite) + */ + // def diff(other: JFile): Seq[String] = vastblue.util.Exec.diffExec(f, other) + + def isEmpty: Boolean = f.length == 0 + def nonEmpty: Boolean = f.length != 0 + } + +//import scala.jdk.StreamConverters.* +//import scala.util.Using +//import scala.util.{Try,Success,Failure} +//import java.io.{BufferedReader, FileReader} +//import scala.io.Source + + def aFile(s: String): Path = Paths.get(s) + def aFile(dir: Path, s: String): Path = Paths.get(s"$dir/$s") + def _chmod(p: Path, permissions: String, allusers:Boolean): Boolean = Paths._chmod(p, permissions, allusers) + + def newEx: RuntimeException = new RuntimeException("LimitedStackTrace") + def showLimitedStack(e: Throwable = newEx): Unit = { System.err.println(getLimitedStackTrace(e)) } + def getLimitedStackTrace(implicit ee: Throwable=newEx): String = { + getLimitedStackList.mkString("\n") + } + /** default filtering of stack trace removes known debris */ + def getLimitedStackList(implicit ee: Throwable=new RuntimeException("getLimitedStackTrace")): List[String] = { + currentStackList(ee).filter { entry => + entry.charAt(0) == ' ' || // keep lines starting with non-space + ( !entry.contains("at scala.") + && !entry.contains("at oracle.") + && !entry.contains("at org.") + && !entry.contains("at codehaus.") + && !entry.contains("at sun.") + && !entry.contains("at java") + && !entry.contains("at scalikejdbc") + && !entry.contains("at net.") + && !entry.contains("at dotty.") + && !entry.toLowerCase.contains("(unknown source)") + ) + } + } + def currentStack(ee: Throwable=new RuntimeException("currentStack")): String = { + import java.io.{StringWriter} + val result = new StringWriter() + val printWriter = new PrintWriter(result) + ee.printStackTrace(printWriter) + result.toString + } + def currentStackList(ee: Throwable=new RuntimeException("currentStackList")): List[String] = { + currentStack(ee).split("[\r\n]+").toList + } + def notWindows: Boolean = java.io.File.separator == "/" + def isWindows: Boolean = !notWindows + + // set initial codec value, affecting default usage. + import scala.io.Codec + def writeCodec: Codec = { + def osDefault = if (isWindows) { + Codec.ISO8859 + } else { + Codec.UTF8 + } + val lcAll: String = Option(System.getenv("LC_ALL")).getOrElse(osDefault.toString) + lcAll match { + case "UTF-8" | "utf-8" | "en_US.UTF-8" | "en_US.utf8" => + Codec.UTF8 // "mac" | "linux" + case s if s.toLowerCase.replaceAll("[^a-zA-Z0-9]", "").contains("utf8") => + Codec.UTF8 // "mac" | "linux" + case "ISO-8859-1" | "latin1" => + Codec(lcAll) + case encodingName => + //System.err.printf("warning : unrecognized charset encoding: LC_ALL==[%s]\n",encodingName) + Codec(encodingName) + } + } + lazy val DefaultCodec = writeCodec + object JFile { + def apply(dir: String, fname: String): JFile = new JFile(dir,fname) + def apply(dir: JFile, fname: String): JFile = new JFile(dir,fname) + def apply(fpath: String): JFile = new JFile(fpath) + } + + /** + * Recursive list of all files below rootfile. + * Filter for directories to be descended and/or + * files to be retained. + */ + def dummyFilter(f:JFile): Boolean = f.canRead() + + import scala.annotation.tailrec + def filesTree(dir: JFile)(func: JFile => Boolean = dummyFilter) : Seq[JFile] = { + assert(dir.isDirectory,s"error: not a directory [$dir]") + @tailrec + def filesTree(files: List[JFile], result: List[JFile]): List[JFile] = files match { + case Nil => result + case head :: tail if Option(head).isEmpty => + Nil + case head :: tail if head.isDirectory => + // filtered directories are pruned + if ( head.canRead() ){ + val subs: List[JFile] = head.listFiles.toList.filter { func(_) } + filesTree(subs ::: tail, result) // depth-first + } else { + Nil + } + //filesTree(tail ::: subs, result) // width-first + case head :: tail => // if head.isFile => + val newResult = func(head) match { + case true => head :: result // accepted + case false => result // rejected + } + filesTree(tail, newResult) + } + filesTree(List(dir), Nil).toSeq + } + def autoDetectDelimiter(sampleText:String,fname:String,ignoreErrors:Boolean=true):String = { + var (tabs,commas,semis,pipes) = (0,0,0,0) + sampleText.toCharArray.foreach { + case '\t' => + tabs += 1 + case ',' => + commas += 1 + case ';' => + semis += 1 + case '|' => + pipes += 1 + case _ => + } + // This approach provides a reasonably fast guess, but sometimes fails: + // Premise: + // tab-delimited files usually contain more tabs than commas, + // while comma-delimited files contain more commas than tabs. + // + // A much slower but more thorough approach would be: + // 1. replaceAll("""(?m)"[^"]*","") // remove quoted strings + // 2. split("[\r\n]+") // extract multiple lines + // 3. count columns-per-row tallies using various delimiters + // 4. the tally with the most consistency is the "winner" + (commas,tabs,pipes,semis) match { + case (cms,tbs,pps,sms) if cms > tbs && cms >= pps && cms >= sms => + "," + case (cms,tbs,pps,sms) if tbs >= cms && tbs >= pps && tbs >= sms => + "\t" + case (cms,tbs,pps,sms) if pps > cms && pps > tbs && pps > sms => + "|" + case (cms,tbs,pps,sms) if sms > cms && sms > tbs && sms > pps => + ";" + case _ if ignoreErrors => + "" + case _ => + sys.error(s"unable to choose delimiter: tabs[$tabs], commas[$commas], semis[$semis], pipes[$pipes] for file:\n[${fname}]") + } + } + + def toRealPath(p: Path): Path = { + exec(realpathExe,p.norm).path + } + lazy val realpathExe = { + val rp = where(s"realpath${exeSuffix}") + rp + } + + def getLinesAnyEncoding(p: Path, encoding:String="utf-8"): Seq[String] = { + getLinesIgnoreEncodingErrors(p, encoding).toSeq + } + def getLinesIgnoreEncodingErrors(p: Path, encoding:String=DefaultEncoding): Seq[String] = { + import java.nio.charset.CodingErrorAction + implicit val codec = Codec(encoding) + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + try { + Files.readAllLines(p, codec.charSet).asScala.toSeq + } catch { + case _:Exception => + encoding match { + case "utf-8" => + implicit val codec = Codec("latin1") + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + Files.readAllLines(p, codec.charSet).asScala.toSeq + case _ => + implicit val codec = Codec("utf-8") + codec.onMalformedInput(CodingErrorAction.REPLACE) + codec.onUnmappableCharacter(CodingErrorAction.REPLACE) + Files.readAllLines(p, codec.charSet).asScala.toSeq + } + } + } +// def withPathWriter(p: Path, charsetName: String = DefaultEncoding, append: Boolean = false)(func: PrintWriter => Any): Unit = { +// p.withWriter(charsetName, append)(func) +// } + + import scala.util.matching.Regex + lazy val DatePattern1: Regex = """(.+)(\d\d\d\d\d\d\d\d)(\D.*)?""".r + lazy val DatePattern2: Regex = """(.+)(\d\d\d\d\d\d\d\d)""".r + + lazy val bintools = true // faster than MessageDigest + def fileChecksum(file: JFile, algorithm: String): String = { + val toolName = algorithm match { + case "SHA-256" => "sha256sum" + case "MD5" => "md5sum" + case _ => "" + } + val toolPath = localPath(toolName) + val sum = if (bintools && toolPath.nonEmpty && toolPath.path.isFile){ + // very fast + val binstr = execBinary(toolPath, file.norm).take(1).mkString("") + binstr.replaceAll(" .*","") + } else { + // very slow + val is = JFiles.newInputStream(file.path) + checkSum(is, algorithm) + } + sum + } + lazy val PosixDriveLetterPrefix = "(?i)/([a-z])(/.*)".r + lazy val WindowsDriveLetterPrefix = "(?i)([a-z]):(/.*)".r + + def cygpath2driveletter(str: String): String = { + val strtmp = str.replace('\\', '/') + strtmp match { + case PosixDriveLetterPrefix(dl,tail) => + val tailstr = Option(tail).getOrElse("/") + s"$dl:$tailstr" + case WindowsDriveLetterPrefix(dl,tail) => + val tailstr = Option(tail).getOrElse("/") + s"$dl:$tailstr" + case _ => + s"$defaultDrive:$strtmp" + } + } + def cygpath2driveletter(p: Path): String = { + cygpath2driveletter(p.stdpath) + } + // return a posix version of path string; include drive letter, if not the default drive + def posixDriveLetter(str: String) = { + val dd = defaultDrive + val posix = if( str.drop(1).startsWith(":") ){ + val letter = str.take(1).toLowerCase + val tail = str.drop(2) + tail match { + case "/" => + s"/$letter" + case s if s.startsWith("/") => + if (letter == defaultDrive.take(1).toLowerCase){ + tail + } else { + s"/$letter$tail" + } + case _ => + s"/$letter/$tail" + } + } else { + if (defaultDrive.nonEmpty && str.startsWith(s"/$defaultDrive")) { + str.drop(2) // drop default drive + } else { + str + } + } + posix + } + def dropDotSuffix(s: String): String = if (!s.contains(".")) s else s.reverse.dropWhile(_ != '.').drop(1).reverse + def commonLines(f1: Path, f2: Path): Map[String,List[String]] = { + val items = (f1.trimmedLines ++ f2.trimmedLines).groupBy { line => + line.replaceAll("""[^a-zA-Z_0-9]+""","") // remove whitespace and punctuation + } + items.map { case (key,items) => (key,items.toList) } + } + // supported algorithms: "MD5" and "SHA-256" + def checkSum(bytes: Array[Byte], algorithm: String): String = { + val is: InputStream = new ByteArrayInputStream(bytes) + checkSum(is, algorithm) + } + def checkSum(is: InputStream, algorithm: String): String = { + val md = MessageDigest.getInstance(algorithm) + val dis = new DigestInputStream(is, md) + var num = 0 + while (dis.available > 0) { + num += dis.read + } + dis.close + val sum = md.digest.map(b => String.format("%02x", Byte.box(b))).mkString + sum + } +} diff --git a/src/main/scala-3/vastblue/time/TimeExtensions.scala b/src/main/scala-3/vastblue/time/TimeExtensions.scala new file mode 100644 index 0000000..8817764 --- /dev/null +++ b/src/main/scala-3/vastblue/time/TimeExtensions.scala @@ -0,0 +1,139 @@ +package vastblue.time + +import java.time.* +//import java.time.format.* +import java.time.temporal.TemporalAdjusters +import scala.runtime.RichInt + +//import io.github.chronoscala.Imports.* +//import io.github.chronoscala.* +import vastblue.time.FileTime.* +import java.time.DayOfWeek +//import java.time.DayOfWeek.* +//import scala.language.implicitConversions + +trait TimeExtensions { + //implicit def ld2zdt(ld:LocalDate):ZonedDateTime = { ld.atStartOfDay.withZoneSameLocal(zoneid) } + implicit def date2option(date:LocalDateTime):Option[LocalDateTime] = Some(date) + + implicit def ldt2zdt(ldt:LocalDateTime):ZonedDateTime = { + ldt.atZone(UTC) + } + implicit def str2richStr(s:String):RichString = new RichString(s) + implicit def ta2zdt(ta:java.time.temporal.TemporalAccessor):ZonedDateTime = { + try { + ta match { + case ld:LocalDateTime => + ld.atZone(zoneid) + } + } catch { + case _:java.time.DateTimeException => + sys.error(s"cannot convert to LocalDateTime from: ${ta.getClass.getName}") + } + } + implicit def dateTimeOrdering: Ordering[LocalDateTime] = Ordering.fromLessThan(_ isBefore _) + + //implicit def sqlDate2LocalDateTime(sd:java.sql.Date):LocalDateTime = sd.toLocalDate.atStartOfDay() + //implicit def sqlDate2LocalDate(sd:java.sql.Date):LocalDate = sd.toLocalDate + implicit def int2richInt(i:Int):RichInt = new RichInt(i) + implicit def int2Period(i:Int):java.time.Period = java.time.Period.ofWeeks(i) + + import java.time.Duration + //extension(i: Interval) { def toDuration = i.duration } + /* + implicit class aDayOfWeek(private[Time] val d:java.time.DayOfWeek){ + def >=(other:java.time.DayOfWeek) = { d.compareTo(other) >= 0 } + def > (other:java.time.DayOfWeek) = { d.compareTo(other) > 0 } + def <=(other:java.time.DayOfWeek) = { d.compareTo(other) <= 0 } + def < (other:java.time.DayOfWeek) = { d.compareTo(other) < 0 } + } + implicit class aDuration(private[Time] val pd:java.time.Duration) { + def getStandardSeconds:Long = pd.seconds + def getStandardMinutes: Long = getStandardSeconds / 60 + def getStandardHours: Long = getStandardMinutes / 60 + def getStandardDays: Long = getStandardHours / 24 + } + */ + def between(d1:LocalDateTime,d2:LocalDateTime) = Duration.between(d1,d2) + + extension(zdt:ZonedDateTime){ + def toDateTime:LocalDate = zdt.toLocalDate + } + extension(format:String){ + def toDateTime = parseDateStr(format) + } + extension(ldt:java.time.LocalDateTime){ + def atStartOfDay():LocalDateTime = ldt.withHour(0).withMinute(0).withSecond(0).withNano(0) // atStartOfDay(zoneid) + def atStartOfDay(zone:ZoneId):ZonedDateTime = ldt.atStartOfDay().atZone(zone) + // def zonedDateTime = { atStartOfDay.withZoneSameLocal(zoneid) } + } + extension(d:java.time.DayOfWeek){ + def >=(other:java.time.DayOfWeek) = { d.compareTo(other) >= 0 } + def > (other:java.time.DayOfWeek) = { d.compareTo(other) > 0 } + def <=(other:java.time.DayOfWeek) = { d.compareTo(other) <= 0 } + def < (other:java.time.DayOfWeek) = { d.compareTo(other) < 0 } + } + extension(pd:java.time.Duration){ + def getStandardSeconds:Long = pd.getSeconds.toLong + def getStandardMinutes: Long = getStandardSeconds / 60 + def getStandardHours: Long = getStandardMinutes / 60 + def getStandardDays: Long = getStandardHours / 24 + } + extension(d:LocalDateTime) { + def ymd: String = d.format(dateTimeFormatPattern(dateonlyFmt)) + + def ymdhms: String = d.format(dateTimeFormatPattern(datetimeFmt7)) + + def startsWith(str:String):Boolean = d.toString(ymdhms).startsWith(str) + + def fmt(fmt:String):String = { + d.format(dateTimeFormatPattern(fmt)) + } + def toString(fmt:String):String = { + d.format(dateTimeFormatPattern(fmt)) + } + def getMillis():Long = { + d.atZone(zoneid).toInstant().toEpochMilli() + } + def >(other:LocalDateTime):Boolean = { + d.compareTo(other) > 0 + } + def >=(other:LocalDateTime):Boolean = { + d.compareTo(other) >= 0 + } + def <(other:LocalDateTime):Boolean = { + d.compareTo(other) < 0 + } + def <=(other:LocalDateTime):Boolean = { + d.compareTo(other) <= 0 + } + def to(other:LocalDateTime):Duration = { + Duration.between(d,other) + } +// def to(other:LocalDateTime):Duration = { +// Duration.between(d,other) +// } + def +(p:java.time.Period) = d.plus(p) + def -(p:java.time.Period) = d.minus(p) + + def minute = d.getMinute + def second = d.getSecond + def hour = d.getHour + def day = d.getDayOfMonth + def month = d.getMonth + def year = d.getYear + + def setHour(h:Int): LocalDateTime = d.plusHours((d.getHour + h).toLong) + def setMinute(m:Int): LocalDateTime = d.plusMinutes((d.getMinute + m).toLong) + + def compare(that: LocalDateTime): Int = d.getMillis() compare that.getMillis() + def dayOfYear = d.getDayOfYear + def getDayOfYear = d.getDayOfYear + def dayOfMonth = d.getDayOfMonth + def getDayOfMonth = d.getDayOfMonth + def dayOfWeek: DayOfWeek = d.getDayOfWeek // .getValue + def getDayOfWeek: DayOfWeek = d.getDayOfWeek // .getValue + def withDayOfWeek(dow:java.time.DayOfWeek):LocalDateTime = d.`with`(TemporalAdjusters.next(dow)) + def lastDayOfMonth: LocalDateTime = d.`with`(LastDayAdjuster) + } +} diff --git a/src/main/scala/vastblue/Platform.scala b/src/main/scala/vastblue/Platform.scala new file mode 100644 index 0000000..4d86ccb --- /dev/null +++ b/src/main/scala/vastblue/Platform.scala @@ -0,0 +1,735 @@ +//#!/usr/bin/env scala3 +package vastblue + +//import vastblue.ExtUtils.getPath +import vastblue.pathextend.* + +import java.io.{File => JFile} +import java.nio.file.{Files, Path, Paths} +import scala.collection.immutable.ListMap +import scala.util.control.Breaks.* +import java.io.{BufferedReader, FileReader} +import scala.util.Using +import scala.sys.process.* + +/* + * Low level support for scala in Windows SHELL environments. + * Makes it easy to write scala scripts that are portable between + * Linux, Osx and cygwin64/mingw64/msys2 in Windows. + * Everything should work as expected in all environments. + * + * Treats the default drive as the filesystem root. (typically C:/) + * To make other drives available, symlink them off of the default drive. + * Assumes shell environment (/cygwin64, /msys64, etc.) is on the default drive. + * + * The following are available to navigate the synthetic winshell filesystem. + * bashPath: String : valid path to the bash executable + * realroot: String : root directory of the synthetic filesystem + * unamefull: String : value reported by `uname -a` + * To identify the environment: + * isCygwin: Boolean : true if running cygwin64 + * isMsys64: Boolean : true if running msys64 + * isMingw64: Boolean : true if running mingw64 + * isGitSdk64: Boolean : true if running gitsdk + * isMingw64: Boolean : true if running mingw64 + * isWinshell + * wsl: Boolean + * + * The preferred way to find an executable on the PATH (very fast): + * val p: Path = findInPath(binaryName) + * + * A Fallback method (much slower): + * val path: String = whichPath(binaryName) + * + * + * How to determine where msys2/ mingw64 / cygwin64 is installed? + * best answer: norm(where(s"bash${exeSuffix}")).replaceFirst("/bin/bash.*","") + */ +object Platform { + + def main(args:Array[String]):Unit = { + for (arg <- args) { + val list = findAllInPath(arg) + printf("found %d [%s] in PATH:\n", list.size, arg) + for (path <- list) { + printf(" [%s] found at [%s]\n",arg, path) + printf("--version: [%s]\n", exec(path.toString, "--version").takeWhile(_ != '(')) + } + } + val cwd = ".".path + for ((p: Path) <- cwd.paths if p.isDirectory){ + printf("%s\n", p.norm) + } + for (line <- "/proc/meminfo".path.lines) { + printf("%s\n", line) + } + + val prognames = Seq( + "basename", + "bash", + "cat", + "chgrp", + "chmod", + "chown", + "cksum", + "cp", + "curl", + "date", + "diff", + "env", + "file", + "find", + "git", + "gzip", + "head", + "hostname", + "ln", + "ls", + "md5sum", + "mkdir", + "nohup", + "uname", + ) + for (progname <- prognames){ + val prog = where(progname) + printf("%-12s: %s\n", progname, prog) + } + + printf("bashPath [%s]\n",bashPath) + printf("cygPath [%s]\n",cygPath) + printf("realroot [%s]\n",realroot) + printf("realrootbare [%s]\n",realrootbare) + printf("realrootfull [%s]\n",realrootfull) + printf("osName [%s]\n",osName) + printf("unamefull [%s]\n",unamefull) + printf("unameshort [%s]\n",unameshort) + printf("isCygwin [%s]\n",isCygwin) + printf("isMsys64 [%s]\n",isMsys64) + printf("isMingw64 [%s]\n",isMingw64) + printf("isGitSdk64 [%s]\n",isGitSdk64) + printf("isWinshell [%s]\n",isWinshell) + printf("bash in path [%s]\n",findInPath("bash").getOrElse("")) + printf("cygdrive2root[%s]\n",cygdrive2root) + printf("wsl [%s]\n",wsl) + printf("javaHome [%s]\n",javaHome) + printf("etcdir [%s]\n",etcdir) + + printf("\n") + printf("all bash in path:\n") + val bashlist = findAllInPath("bash") + for (path <- bashlist) { + printf(" found at %-36s : ", s"[$path]") + printf("--version: [%s]\n", exec(path.toString, "--version").takeWhile(_ != '(')) + } + if (possibleWinshellRootDirs.nonEmpty) { + printf("\nfound %d windows shell root dirs:\n", possibleWinshellRootDirs.size) + for (root <- possibleWinshellRootDirs){ + printf(" %s\n", root) + } + } + } + + def getPath(s: String): Path = Paths.get(s) + + def getPath(dir: Path, s: String): Path = { + Paths.get(s"$dir/$s") + } + def getPath(dir: String, s: String=""): Path = { + Paths.get(s"$dir/$s") + } + + def setSuffix(exeName: String): String = { + if (!exeName.endsWith(".exe")) { + s"$exeName$exeSuffix" + } else { + exeName + } + } + + def localPath(exeName: String): String = where(exeName) + + def bashPath: String = localPath("bash") + def cygPath: String = localPath("cygpath") + def notWindows: Boolean = java.io.File.separator == "/" + def isWindows: Boolean = !notWindows + def exeSuffix: String = if (isWindows) ".exe" else "" + + // get path to binaryName via 'which.exe' or 'where' + def where(binaryName: String): String = { + if (isWindows) { + val binName = setSuffix(binaryName) + // exec3 hides stderr: INFO: Could not find files for the given pattern(s) + exec3("c:/Windows/System32/where.exe", binName).replace('\\', '/') + } else { + exec("which", binaryName) + } + } + + // a "binary" is a standalone executable + // counterexamples: + // shell builtins (e.g., "echo") + // script with a hashbang line + def execBinary(args: String *): Seq[String] = { + import scala.sys.process.* + Process(Array(args:_*)).lazyLines_! + } + +// private def exec2(prog: String, arg: String): String = { +// val lines = Process(Seq(prog, arg)).lazyLines_! +// lines.take(1).mkString("") +// } + def spawnCmd(cmd: Seq[String], verbose: Boolean = false): (Int, List[String], List[String]) = { + var (out, err) = (List[String](), List[String]()) + + def toOut(str: String): Unit = { + if (verbose) printf("stdout[%s]\n", str) + out ::= str + } + + def toErr(str: String): Unit = { + err ::= str + if (verbose) System.err.printf("stderr[%s]\n", str).asInstanceOf[Unit] + } + import scala.sys.process._ + val exit = cmd ! ProcessLogger((o) => toOut(o), (e) => toErr(e)) + (exit, out.reverse, err.reverse) + } + private def exec3(prog: String, arg: String): String = { + val cmd = Seq(prog, arg) + val (exit, out, err) = spawnCmd(cmd, verbose) + out.map { _.replace('\\', '/') }.filter{ s => !ignoreList.contains(s) }.take(1).mkString("") + } + + def ignoreList = Set( + "C:/ProgramData/anaconda3/Library/usr/bin/cygpath.exe", + "C:/Windows/System32/bash.exe", + "C:/Windows/System32/find.exe", + ) + def exec(args: String *): String = { + execBinary(args:_*).toList.mkString("") + } + def execShell(args: String *): Seq[String] = { + val cmd = bashPath.norm :: "-c" :: args.toList + execBinary(cmd:_*) + } + + // get first path to prog by searching the PATH + def findInPath(binaryName: String): Option[Path] = { + findAllInPath(binaryName, findAll = false) match { + case Nil => None + case head :: tail => Some(head) + } + } + + // get all occurences of binaryName int the PATH + def findAllInPath(prog: String, findAll: Boolean = true): Seq[Path] = { + // val path = Paths.get(prog) + val progname = prog.replace('\\','/').split("/").last // remove path, if present + var found = List.empty[Path] + breakable { + for (dir <- envPath){ + // sort .exe suffix ahead of no .exe suffix + for (name <- Seq(s"$dir$fsep$progname$exeSuffix", s"$dir$fsep$progname").distinct){ + val p = Paths.get(name) + if (p.toFile.isFile){ + found ::= p.normalize + if (! findAll){ + break() // quit on first one + } + } + } + } + } + found.reverse.distinct + } + + // this is quite slow, you probably should use `where(binaryName)` instead. + def whichPath(binaryName: String): String = { + if (isWindows) { + def exeName = if (binaryName.endsWith(".exe")) { + binaryName + } else { + s"$binaryName.exe" + } + def findFirst(binName: String): String = { + execBinary("where", binName).headOption.getOrElse("") + } + findFirst(binaryName) match { + case "" => findFirst(exeName) + case pathstr => pathstr + } + } else { + exec("which",binaryName) + } + } + + def altJavaHome = envOrElse("JAVA_HOME", "") + + def javaHome = Option(sys.props("java.home")) match { + case None => altJavaHome + case Some(path) => path + } + lazy val osName = sys.props("os.name") + lazy val osType: String = osName.takeWhile(_!=' ').toLowerCase match { + case "windows" => "windows" + case "linux" => "linux" + case "mac os x" => "darwin" + case other => + sys.error(s"osType is [$other]") + } + + lazy val winshellFlag = { + isDirectory(realroot) && (isCygwin || isMsys64 || isMingw64 || isGitSdk64) + } + def isDirectory(path: String): Boolean = { + Paths.get(path).toFile.isDirectory + } + + lazy val programFilesX86: String = System.getenv("ProgramFiles(x86)") match { + case other:String => other + case null => "c:/Program Files (x86)" + } + + lazy val home: Path = Paths.get(sys.props("user.home")) + lazy val debug: Boolean = Paths.get(".debug").toFile.exists + lazy val verbose = Option(System.getenv("VERBY")) != None + +// def str2ascii(a:String): String = vastblue.util.StringExtras.str2ascii(a) + lazy val _debug: Boolean = Option(System.getenv("DEBUG")) != None + + def driveLetterColon = Paths.get(".").toAbsolutePath.normalize.toString.take(2) + + // these must all be lowercase + lazy val defaultCygroot = "/cygwin64" + lazy val defaultMsysroot = "/msys64" + lazy val defaultMingwroot = "/mingw" + lazy val defaultGitsdkroot = "/git-sdk-64" + lazy val defaultGitbashroot = "/gitbash" + def isWinshell = isCygwin | isMsys64 | isMingw64 | isGitSdk64 | isGitbash + + def uname(arg: String) = { + val unamepath = where("uname") match { + case "" => "uname" + case str => str + } + val ostype = try { + Process(Seq(unamepath, arg)).lazyLines_!.mkString("") + } catch { + case _:Exception => + "" + } + ostype + } + lazy val unamefull = uname("-a") + lazy val unameshort = ostype.toLowerCase.replaceAll("[^a-z0-9].*","") + + lazy val isCygwin = unameshort.toLowerCase.startsWith("cygwin") + lazy val isMsys64 = unameshort.toLowerCase.startsWith("msys") + lazy val isMingw64 = unameshort.toLowerCase.startsWith("mingw") + lazy val isGitSdk64 = unameshort.toLowerCase.startsWith("git-sdk") + lazy val isGitbash = unameshort.toLowerCase.startsWith("gitbash") + + def listPossibleRootDirs(startDir: String): Seq[JFile] = { + Paths.get(startDir).toAbsolutePath.toFile match { + case dir if dir.isDirectory => + // NOTE: /opt/gitbash is excluded by this approach: + def defaultRootNames = Seq( + "cygwin64", + "msys64", + "git-sdk-64", + "gitbash", + "MinGW", + ) + dir.listFiles.toList.filter { f => + f.isDirectory && defaultRootNames.exists { name => + f.getName.contains(name) + } + } + case path => + Nil + } + } + def possibleWinshellRootDirs = { + listPossibleRootDirs("/") ++ listPossibleRootDirs("/opt") + } + + // this is less than ideal, better to use `cygpath -m /` + lazy val rootFromPath = { + val javalibpath = sys.props("java.library.path").split(psep).toArray.toSeq + val fromPathTest = javalibpath.map { asPosixPath(_) }.map { str => + str match { + case str if str.startsWith("/cygwin") => + s"/${str.drop(1).replaceAll("/.*","")}" + case str if str.startsWith("/msys64") => + s"/${str.drop(1).replaceAll("/.*","")}" + case _ => + "" + } + } + fromPathTest.filter { _.nonEmpty }.take(1).toList match { + case Nil => + "" + case str :: _ => + s"${driveLetterColon}${str}" + } + } + + def rootFromBashPath: String = { + val nb = norm(bashPath) + val guess = nb.replaceFirst("/bin/bash.*","") match { + case str if str.endsWith("/usr") => + str.substring(0, str.length - 4) + case str => str + } + val guessPath = Paths.get(guess) + if ( Files.isDirectory(guessPath) ){ + guess + } else { + sys.error(s"unable to determine winshell root dir in $osName") + } + if ( Files.isDirectory(guessPath) ){ + guess + } else { + sys.error(s"unable to determine winshell root dir in $osName") + } + } + + def realrootfull: String = realroot + + lazy val mountMap = { + val rr: String = realroot + val etcFstab = s"$rr/etc/fstab".replaceAll("[\\/]+", "/") + val f = java.nio.file.Paths.get(etcFstab) + if (!f.isFile) { + Map.empty[String, String] + } else { + { + for { + line <- f.lines + trimmed = line.trim.replaceAll("\\s*#.*", "") + if trimmed.nonEmpty + ff = trimmed.replaceAll("\\s*#.*", "").split("\\s+", -1) + if ff.size >= 3 + Array(local, _posix, tag) = ff.take(3) + posix = if (tag == "cygdrive") "/cygdrive" else _posix + } yield (posix, local) + }.toMap + } + } + + def cwdstr = java.nio.file.Paths.get(".").toAbsolutePath.toString.replace('\\', '/') + + lazy val defaultDrive = if (isWindows) { + cwdstr.replaceAll(":.*", ":") + } else { + "" + } + lazy val cygpathExes = Seq( + "c:/msys64/usr/bin/cygpath.exe", + "c:/cygwin64/bin/cygpath.exe", + ) + lazy val cygpathExe: String = { + val cpexe = where("cygpath.exe") + val cp = cpexe match { + case "" => + cygpathExes.find { s => + java.nio.file.Paths.get(s).toFile.isFile + }.getOrElse(cpexe) + case f => + f + } + cp + } + lazy val realroot: String = { + if (!isWindows) { + "/" + } else { + val cpe: String = cygpathExe + if (cpe.isEmpty){ + "/" + } else { + exec(cygpathExe, "-m", "/").mkString("") + } + } + } + def realrootbare = realroot.replaceFirst(s"^(?i)${defaultDrive}:","") match { + case "" => + "/" + case str => + str + } + + lazy val binDir = { + val binDirString = s"${realroot}/bin" + val binDirPath = Paths.get(binDirString) + binDirPath.toFile.isDirectory match { + case true => + binDirString + case false => + sys.error(s"unable to find binDir at [${binDirString}]") + } + } + + def dumpPath():Unit = { + envPath.foreach { println } + } + + def ostype = uname("-s") + + lazy val LetterPath = """([a-zA-Z]):([$/a-zA-Z_0-9]*)""".r + + def driveAndPath(filepath:String) = { + filepath match { + case LetterPath(letter,path) => + (letter.toLowerCase, path) + case _ => + ("",realrootfull) + } + } + + lazy val envPath: Seq[String] = Option(System.getenv("PATH")) match { + case None => Nil + case Some(str) => str.split(psep).toList.map { canonical(_) }.distinct + } + def canonical(str: String): String = { + Paths.get(str) match { + case p if p.toFile.exists => p.normalize.toString + case p => p.toString + } + } + + def norm(str:String) = Paths.get(str).normalize.toString match { + case "." => "." + case p => p.replace('\\','/') + } + + def checkPath(dirs:Seq[String],prog:String): String = { + dirs.map { dir => + Paths.get(s"$dir/$prog") + }.find { (p:Path) => + p.toFile.isFile + } match { + case None => "" + case Some(p) => p.normalize.toString.replace('\\','/') + } + } + + def whichInPath(prog:String):String = { + checkPath(envPath,prog) + } + def which(cmdname:String) = { + val cname = if (exeSuffix.nonEmpty && ! cmdname.endsWith(exeSuffix)) { + s"${cmdname}${exeSuffix}" + } else { + cmdname + } + whichInPath(cname) + } + + def verbyshow(str: String): Unit = if( verbose ) eprintf("verby[%s]\n",str) + + def dirExists(pathstr:String):Boolean = { + dirExists(Paths.get(pathstr)) + } + def dirExists(path:Path):Boolean = { + canExist(path) && Files.isDirectory(path) + } + + def pathDriveletter(ps:String):String = { + ps.take(2) match { + case str if str.drop(1) == ":" => + str.take(2).toLowerCase + case _ => + "" + } + } + def pathDriveletter(p:Path):String = { + pathDriveletter(p.toAbsolutePath.toFile.toString) + } + + def canExist(p:Path):Boolean = { + // val letters = driveLettersLc.toArray + val pathdrive = pathDriveletter(p) + pathdrive match { + case "" => + true + case letter => + driveLettersLc.contains(letter) + } + } + + // fileExists() solves the Windows jvm problem that path.toFile.exists + // is VEEERRRY slow for files on a non-existent drive (e.g., q:/). + def fileExists(p:Path):Boolean = { + canExist(p) && + p.toFile.exists + } + def exists(path:String): Boolean = { + exists(Paths.get(path)) + } + def exists(p:Path): Boolean = { + canExist(p) && { + p.toFile match { + case f if f.isDirectory => true + case f => f.exists + } + } + } + + // drop drive letter and normalize backslash + def dropDefaultDrive(str: String) = str.replaceFirst(s"^${defaultDrive}:","") + def dropDriveLetter(str: String) = str.replaceFirst("^[a-zA-Z]:","") + def asPosixPath(str:String) = dropDriveLetter(str).replace('\\','/') + def stdpath(path:Path):String = path.toString.replace('\\','/') + def stdpath(str:String) = str.replace('\\','/') + def norm(p: Path): String = p.toString.replace('\\', '/') + + def etcdir = getPath(realrootfull,"etc") match { + case p if Files.isSymbolicLink(p) => + p.toRealPath() + case p => + p + } + def defaultCygdrivePrefix = unamefull match { + case "cygwin" => "/cygdrive" + case _ => "" + } + lazy val (_mountMap, cygdrive2root) = { + if( verbose ) printf("etcdir[%s]\n",etcdir) + val fpath = Paths.get(s"$etcdir/fstab") + //printf("fpath[%s]\n",fpath) + val lines: Seq[String] = if (fpath.toFile.isFile) { + val src = scala.io.Source.fromFile(fpath.toFile,"UTF-8") + src.getLines().toList.map { _.replaceAll("#.*$","").trim }.filter { _.nonEmpty } + } else { + Nil + } + + //printf("fpath.lines[%s]\n",lines.toSeq.mkString("\n")) + var (cygdrive,_usertemp) = ("","") + // map order prohibits any key to contain an earlier key as a prefix. + // this implies the use of an ordered Map, and is necessary so that + // when converting posix-to-windows paths, the first matching prefix terminates the search. + var localMountMap = ListMap.empty[String,String] + var cd2r = true // by default /c should mount to c:/ in windows + if (isWindows) { + // cygwin provides default values, potentially overridden in fstab + val bareRoot = realrootfull + localMountMap += "/usr/bin" -> s"$bareRoot/bin" + localMountMap += "/usr/lib" -> s"$bareRoot/lib" + // next 2 are convenient, but MUST be added before reading fstab + localMountMap += "/bin" -> s"$bareRoot/bin" + localMountMap += "/lib" -> s"$bareRoot/lib" + for( line <- lines ){ + //printf("line[%s]\n",line) + val cols = line.split("\\s+",-1).toList + val List(winpath, _mountpoint, fstype) = cols match { + case a :: b :: Nil => a :: b :: "" :: Nil + case a :: b :: c :: tail => a :: b :: c :: Nil + case list => sys.error(s"bad line in ${fpath}: ${list.mkString("|")}") + } + val mountpoint = _mountpoint.replaceAll("\\040"," ") + fstype match { + case "cygdrive" => + cygdrive = mountpoint + case "usertemp" => + _usertemp = mountpoint // need to parse it, but unused here + case _ => + // fstype ignored + localMountMap += mountpoint -> winpath + } + //printf("%s\n",cols.size) + // printf("%s -> %s\n",cols(1),cols(0)) + } + cd2r = cygdrive == "/" // cygdrive2root (the cygwin default mapping) + if (cygdrive.isEmpty) { + cygdrive =defaultCygdrivePrefix + } + localMountMap += "/cygdrive" -> cygdrive + + val driveLetters:Array[JFile] = { + if (false) { + java.io.File.listRoots() // veeery slow (potentially) + } else { + // 1000 times faster + val dlfiles = for { + locl <- localMountMap.values.toList + dl = locl.take(2) + if dl.drop(1) == ":" + ff = new JFile(s"$dl/") + } yield ff + dlfiles.distinct.toArray + } + } + + for( drive <- driveLetters ){ + val letter = drive.getAbsolutePath.take(1).toLowerCase // lowercase is typical user expectation + val winpath = stdpath(drive.getCanonicalPath) // retain uppercase, to match cygpath.exe behavior + //printf("letter[%s], path[%s]\n",letter,winpath) + localMountMap += s"/$letter" -> winpath + } + //printf("bareRoot[%s]\n",bareRoot) + } + localMountMap += "/" -> realrootfull // this must be last + (localMountMap,cd2r) + } + + lazy val driveLettersLc:List[String] = { + val values = mountMap.values.toList + val letters = { + for { + dl <- values.map { _.take(2) } + if dl.drop(1) == ":" + } yield dl.toLowerCase + }.distinct + letters + } + + def eprint(xs: Any*):Unit = { + System.err.print("%s".format(xs:_*)) + } + def eprintf(fmt: String, xs: Any*):Unit = { + System.err.print(fmt.format(xs:_*)) + } + + def userhome: String = sys.props("user.home") + + def fileLines(f: JFile): Seq[String] = { + Using.resource(new BufferedReader(new FileReader(f))) { reader => + Iterator.continually(reader.readLine()).takeWhile(_ != null).toSeq + } + } + + def envOrElse(varname: String, elseValue: String = ""): String = Option(System.getenv(varname)) match { + case None => elseValue + case Some(str) => str + } + + lazy val wsl: Boolean = { + val f = Paths.get("/proc/version").toFile + def lines: Seq[String] = fileLines(f) + def contentAsString: String = lines.mkString("\n") + val test0 = f.isFile && contentAsString.contains("Microsoft") + val test1 = unamefull.contains("microsoft") + test0 || test1 + } + + def cwd = Paths.get(".").toAbsolutePath + def fsep = java.io.File.separator + def psep = sys.props("path.separator") + + // useful for benchmarking + /* + def time(n: Int, func : (String) => Any): Unit = { + val t0 = System.currentTimeMillis + for (i <- 0 until n) { + func("bash.exe") + } + for (i <- 0 until n) { + func("bash") + } + val elapsed = System.currentTimeMillis - t0 + printf("%d iterations in %9.6f seconds\n", n * 2, elapsed.toDouble/1000.0) + } + */ +} diff --git a/src/main/scala/vastblue/file/EzPath.scala b/src/main/scala/vastblue/file/EzPath.scala new file mode 100644 index 0000000..7ebda11 --- /dev/null +++ b/src/main/scala/vastblue/file/EzPath.scala @@ -0,0 +1,115 @@ +package vastblue.file + +import java.nio.file.Path + +enum Slash(s: String){ + case Unx extends Slash("/") + case Win extends Slash("\\") + override def toString = s +} +object Slash { + def unx = '/' + def win = '\\' +} + +import Slash.* + +trait EzPath(val initstring: String, val sl: Slash){ + val p: Path = { + def str: String = if notWindows then initstring.norm else initstring + Paths.get(str) + } + def ab: Path = p.toAbsolutePath.normalize + def abs: String = ab.toString.slash(sl) + def norm: String = { + if (sl == Win){ + initstring + } else { + initstring.norm + } + } + def slash: String = { + if (sl == Win){ + initstring.replace('/', '\\') + } else { + initstring.norm + } + } +} +object EzPath { + // val winu = EzPath("c:\\opt", Unx) // valid + // val winw = EzPath("c:\\opt", Win) // valid + def apply(p: Path, sl: Slash) = { + val pstr: String = if notWindows then p.toString.norm else p.toString + sl match { + case Unx => new PathUnx(pstr) + case Win => new PathWin(pstr) + } + } + def apply(s: String, sl: Slash): EzPath = { + def str: String = if notWindows then s.norm else s + if (sl == Unx){ + new PathUnx(str) + } else { + new PathWin(str) + } + } + def apply(s: String): EzPath = { + def str: String = if notWindows then s.norm else s + if (notWindows){ + new PathUnx(str) + } else { + new PathWin(str) + } + } +} + +def notWindows = java.io.File.separatorChar == '/' +def isWindows = !notWindows +def defaultSlash = if (isWindows) Slash.Win else Slash.Unx + +def platformPrefix: String = Paths.get(".").toAbsolutePath.getRoot.toString match { + case "/" => "" + case s => s.take(2) +} + +def winlikePathstr(s: String): Boolean = { + s.contains(':') || s.contains('\\') +} +def defaultSlash(s: String): Slash = { + if (winlikePathstr(s)) Slash.Win else Slash.Unx +} +object PathUnx: + def apply(s: String): PathUnx = new PathUnx(s) + +object PathWin: + def apply(s: String): PathWin = new PathWin(s) + +class PathUnx(s: String) extends EzPath(s, Slash.Unx){ + override def toString = abs +} +class PathWin(s: String) extends EzPath(s, Slash.Win){ + override def toString = abs +} + +extension(p: Path){ + def slash(sl: Slash): String = { + if (sl == Win){ + p.toString.replace('/', '\\') + } else { + p.toString.replace('\\', '/') + } + } +} +extension(s: String){ + def norm: String = { + s.replace('\\', '/') + } + def slash(sl: Slash): String = { + if (sl == Win){ + s.replace('/', '\\') + } else { + s.norm + } + } +} diff --git a/src/main/scala/vastblue/file/Files.scala b/src/main/scala/vastblue/file/Files.scala new file mode 100644 index 0000000..ff32396 --- /dev/null +++ b/src/main/scala/vastblue/file/Files.scala @@ -0,0 +1,137 @@ +//#!/bin/scala +package vastblue.file + +import java.nio.file.{ + Files => JFiles, + Path, +//Paths => JPaths, + CopyOption, LinkOption, FileStore, OpenOption, DirectoryStream, FileVisitor, FileVisitOption +} +import java.util.{Set, List} +import java.nio.file.attribute.* +import java.nio.channels.* +import java.nio.charset.* +import java.io.* // InputStream + +object Files { + //Copies all bytes from an input stream to a file. + def copy(in: InputStream, target: Path, options: CopyOption *): Long = JFiles.copy(in, target, options:_*) + //Copies all bytes from a file to an output stream. + def copy(source: Path, out: OutputStream): Long = JFiles.copy(source, out) + //Copy a file to a target file. + def copy(source: Path, target: Path, options: CopyOption *): Path = JFiles.copy(source, target, options:_*) + //Creates a directory by creating all nonexistent parent directories first. + def createDirectories(dir: Path, attrs: FileAttribute[_] *): Path = JFiles.createDirectories(dir, attrs:_*) + //Creates a new directory. + def createDirectory(dir: Path, attrs: FileAttribute[_] *): Path = JFiles.createDirectory(dir, attrs:_*) + //Creates a new and empty file, failing if the file already exists. + def createFile(path: Path, attrs: FileAttribute[_] *): Path = JFiles.createFile(path, attrs:_*) + //Creates a new link (directory entry) for an existing file (optional operation). + def createLink(link: Path, existing: Path): Path = JFiles.createLink(link, existing) + //Creates a symbolic link to a target (optional operation). + def createSymbolicLink(link: Path, target: Path, attrs: FileAttribute[_] *): Path = JFiles.createSymbolicLink(link, target, attrs:_*) + //Creates a new directory in the specified directory, using the given prefix to generate its name. + def createTempDirectory(dir: Path, prefix: String, attrs: FileAttribute[_] *): Path = JFiles.createTempDirectory(dir, prefix, attrs:_*) + //Creates a new directory in the default temporary-file directory, using the given prefix to generate its name. + def createTempDirectory(prefix: String, attrs: FileAttribute[_] *): Path = JFiles.createTempDirectory(prefix, attrs:_*) + //Creates a new empty file in the specified directory, using the given prefix and suffix strings to generate its name. + def createTempFile(dir: Path, prefix: String, suffix: String, attrs: FileAttribute[_] *): Path = JFiles.createTempFile(dir, prefix, suffix, attrs:_*) + //Creates an empty file in the default temporary-file directory, using the given prefix and suffix to generate its name. + def createTempFile(prefix: String, suffix: String, attrs: FileAttribute[_] *): Path = JFiles.createTempFile(prefix, suffix, attrs:_*) + //Deletes a file. + def delete(path: Path): Unit = JFiles.delete(path) + //Deletes a file if it exists. + def deleteIfExists(path: Path): Boolean = JFiles.deleteIfExists(path) + //Tests whether a file exists. + def exists(path: Path, options: LinkOption *): Boolean = JFiles.exists(path, options:_*) + //Reads the value of a file attribute. + def getAttribute(path: Path, attribute: String, options: LinkOption *): AnyRef = JFiles.getAttribute(path, attribute, options:_*) + //Returns a file attribute view of a given type. + def getFileAttributeView[V <: FileAttributeView](path: Path, `type`: Class[V], options: LinkOption *): V = JFiles.getFileAttributeView(path,`type`,options:_*) + //Returns the FileStore representing the file store where a file is located. + def getFileStore(path: Path): FileStore = JFiles.getFileStore(path) + //Returns a file's last modified time. + def getLastModifiedTime(path: Path, options: LinkOption *): FileTime = JFiles.getLastModifiedTime(path, options:_*) + //Returns the owner of a file. + def getOwner(path: Path, options: LinkOption *): UserPrincipal = JFiles.getOwner(path, options:_*) + //Returns a file's POSIX file permissions. + def getPosixFilePermissions(path: Path, options: LinkOption *): Set[PosixFilePermission] = JFiles.getPosixFilePermissions(path, options:_*) + //Tests whether a file is a directory. + def isDirectory(path: Path, options: LinkOption *): Boolean = JFiles.isDirectory(path, options:_*) + //Tests whether a file is executable. + def isExecutable(path: Path): Boolean = JFiles.isExecutable(path) + //Tells whether or not a file is considered hidden. + def isHidden(path: Path): Boolean = JFiles.isHidden(path) + //Tests whether a file is readable. + def isReadable(path: Path): Boolean = JFiles.isReadable(path) + //Tests whether a file is a regular file with opaque content. + def isRegularFile(path: Path, options: LinkOption *): Boolean = JFiles.isRegularFile(path, options:_*) + //Tests if two paths locate the same file. + def isSameFile(path1: Path, path2: Path): Boolean = { + JFiles.isSameFile(path1, path2) match { + case true => + true + case _ => + Paths.isSameFile(path1, path2) + } + } + //Tests whether a file is a symbolic link. + def isSymbolicLink(path: Path): Boolean = JFiles.isSymbolicLink(path) + //Tests whether a file is writable. + def isWritable(path: Path): Boolean = JFiles.isWritable(path) + //Move or rename a file to a target file. + def move(source: Path, target: Path, options:CopyOption *): Path = JFiles.move(source, target, options:_*) + //Opens a file for reading, returning a BufferedReader that may be used to read text from the file in an efficient manner. + def newBufferedReader(path: Path, cs: Charset): BufferedReader = JFiles.newBufferedReader(path, cs) + //Opens or creates a file for writing, returning a BufferedWriter that may be used to write text to the file in an efficient manner. + def newBufferedWriter(path: Path, cs: Charset, options: OpenOption *): BufferedWriter = JFiles.newBufferedWriter(path, cs, options:_*) + //Opens or creates a file, returning a seekable byte channel to access the file. + def newByteChannel(path: Path, options: OpenOption *): SeekableByteChannel = JFiles.newByteChannel(path, options:_*) + //Opens or creates a file, returning a seekable byte channel to access the file. + def newByteChannel(path: Path, options: Set[_ <: OpenOption], attrs: FileAttribute[_] *): SeekableByteChannel = JFiles.newByteChannel(path, options, attrs:_*) + //Opens a directory, returning a DirectoryStream to iterate over all entries in the directory. + def newDirectoryStream(dir: Path): DirectoryStream[Path] = JFiles.newDirectoryStream(dir) + //Opens a directory, returning a DirectoryStream to iterate over the entries in the directory. + def newDirectoryStream(dir: Path, filter: DirectoryStream.Filter[_ >: Path]): DirectoryStream[Path] = JFiles.newDirectoryStream(dir, filter) + //Opens a directory, returning a DirectoryStream to iterate over the entries in the directory. + def newDirectoryStream(dir: Path, glob: String): DirectoryStream[Path] = JFiles.newDirectoryStream(dir, glob) + //Opens a file, returning an input stream to read from the file. + def newInputStream(path: Path, options: OpenOption *): InputStream = JFiles.newInputStream(path, options:_*) + //Opens or creates a file, returning an output stream that may be used to write bytes to the file. + def newOutputStream(path: Path, options: OpenOption *): OutputStream = JFiles.newOutputStream(path, options:_*) + //Tests whether the file located by this path does not exist. + def notExists(path: Path, options: LinkOption *): Boolean = JFiles.notExists(path, options:_*) + //Probes the content type of a file. + def probeContentType(path: Path): String = JFiles.probeContentType(path) + //Reads all the bytes from a file. + def readAllBytes(path: Path): Array[Byte] = JFiles.readAllBytes(path) + //Read all lines from a file. + def readAllLines(path: Path, cs:Charset): List[String] = JFiles.readAllLines(path, cs) + + //Reads a file's attributes as a bulk operation. +//def readAttributes[A <: BasicFileAttributes](path: Path, `type`: Class[_ <: A], options: LinkOption *): A = JFiles.readAttributes(path, `type`, options:_*) + + //Reads a set of file attributes as a bulk operation. +//def readAttributes(path: Path, attributes: String, options: LinkOption *): Map[String,AnyRef] = JFiles.readAttributes(path, attributes, options:_*) + + //Reads the target of a symbolic link (optional operation). + def readSymbolicLink(link: Path): Path = JFiles.readSymbolicLink(link) + //Sets the value of a file attribute. + def setAttribute(path: Path, attribute: String, value: AnyRef, options: LinkOption *): Path = JFiles.setAttribute(path, attribute, value, options:_*) + //Updates a file's last modified time attribute. + def setLastModifiedTime(path: Path, time: FileTime): Path = JFiles.setLastModifiedTime(path, time) + //Updates the file owner. + def setOwner(path: Path, owner: UserPrincipal): Path = JFiles.setOwner(path, owner) + //Sets a file's POSIX permissions. + def setPosixFilePermissions(path: Path, perms: Set[PosixFilePermission]): Path = JFiles.setPosixFilePermissions(path, perms) + //Returns the size of a file (in bytes). + def size(path: Path): Long = JFiles.size(path) + //Walks a file tree. + def walkFileTree(start: Path, visitor: FileVisitor[_ >: Path]): Path = JFiles.walkFileTree(start, visitor) + //Walks a file tree. + def walkFileTree(start: Path, options: Set[FileVisitOption], maxDepth: Int, visitor: FileVisitor[_ >: Path]): Path = JFiles.walkFileTree(start, options, maxDepth, visitor) + //Writes bytes to a file. + def write(path: Path, bytes: Array[Byte], options: OpenOption *): Path = JFiles.write(path, bytes.asInstanceOf[Array[Byte]], options:_*) + //Write lines of text to a file. + // def write(path: Path, lines: Iterable[_ <: CharSequence], cs: Charset, options: OpenOption *): Path = JFiles.write(path, lines, cs, options:_*) +} diff --git a/src/main/scala/vastblue/file/Paths.scala b/src/main/scala/vastblue/file/Paths.scala new file mode 100644 index 0000000..7acf368 --- /dev/null +++ b/src/main/scala/vastblue/file/Paths.scala @@ -0,0 +1,686 @@ +//#!/usr/bin/env -S scala3 +package vastblue.file + +//import vastblue.Platform.mountMap +//import vastblue.pathextend.PosixDriveLetterPrefix +//import vastblue.file.Files.* + +import java.io.{File => JFile} +import java.nio.file.{Files => JFiles, Paths => JPaths} +import java.nio.file.{Path => JPath} +import scala.collection.immutable.ListMap +import scala.util.control.Breaks.* +import java.io.{BufferedReader, FileReader} +import scala.util.Using +import scala.sys.process.* + +/* + * Enable access to the synthetic winshell filesystem provided by + * Cygwin64, MinGW64, Msys2, Gitbash, etc. + * + * Permits writing of scripts that are portable portable between + * Linux, Osx, and windows shell environments. + * + * To create a winshell-friendly client script: + * +. import vastblue.file.Paths rather than java.nio.file.Paths + * +. call `findInPath(binaryPath)` or `where(binaryPath)` to find executable + * + * The following are used to navigate the synthetic winshell filesystem. + * + * bashPath: String : valid path to the bash executable + * shellBaseDir: String: root directory of the synthetic filesystem + * unamefull: String : value reported by `uname -a` + * To identify the environment: + * isCygwin: Boolean : true if running cygwin64 + * isMsys64: Boolean : true if running msys64 + * isMingw64: Boolean : true if running mingw64 + * isGitSdk64: Boolean : true if running gitsdk + * isMingw64: Boolean : true if running mingw64 + * isWinshell + * wsl: Boolean + * + * NOTES: + * Treats the Windows default drive root (typically C:\) as the filesystem root. + * Other drives may be made available by symlink off of the filesystem root. + * Shell environment (/cygwin64, /msys64, etc.) must be on the default drive. + * + * The preferred way to find an executable on the PATH (very fast): + * val p: Path = findInPath(binaryName) + * + * A Fallback method (much slower): + * val path: String = whichPath(binaryName) + * + * Most of the magic is available via Paths.get(), defined below. + * + * How to determine where msys2/ mingw64 / cygwin64 is installed? + * best answer: norm(where(s"bash${exeSuffix}")).replaceFirst("/bin/bash.*","") + */ + +object Paths { + type Path = java.nio.file.Path + + def get(dirpathstr: String, subpath: String): Path = { + val dirpath = derefTilde(dirpathstr) // replace leading tilde with sys.props("user.home") + val subtest = JPaths.get(subpath) + if (subtest.isAbsolute){ + sys.error(s"dirpath[$dirpath], subpath[$subpath]") + } + get(s"$dirpath/$subpath") + } + + lazy val DriveLetterColonPattern = "([a-zA-Z]):(.*)?".r + lazy val CygdrivePattern = "/([a-zA-Z])(/.*)?".r + // there are three supported filename patterns: + // non-windows (posix by default; the easy case) + // windows semi-absolute path, with default drive, e.g., /Windows/system32 + // windows absolute path, e.g., c:/Windows/system32 + // Windows paths are normalized to forward slash + def get(fnamestr: String): Path = { + val pathstr = derefTilde(fnamestr) // replace leading tilde with sys.props("user.home") + if (notWindows){ + JPaths.get(pathstr) + } else if (pathstr == ".") { + herepath // based on sys.props("user.dir") + } else { + val _normpath = pathstr.replace('\\', '/') + val normpath = applyMountMap(_normpath) // if mounted, not pathRelative any longer + val dd = defaultDrive.toUpperCase + val (literalDrive, impliedDrive, pathtail) = normpath match { + case DriveLetterColonPattern(dl, tail) => // windows drive letter + (dl, dl, tail) + case CygdrivePattern(dl, tail) => // cygpath drive letter + (dl, dl, tail) + case pstr if pstr.matches("/proc(/.*)?") => // /proc file system + ("", "", pstr) // must not specify a drive letter! + case pstr if pstr.startsWith("/") => // semi-absolute path, with no drive letter + // semi-absolute paths are interpreted as being on the current-working-drive, + ("", dd, pstr) + case pstr => // relative path, implies default drive + (dd, "", pstr) + } + val semipath = Option(pathtail).getOrElse("/") + val neededDriveLetter = if (impliedDrive.nonEmpty) s"$impliedDrive:" else "" + val fpstr = s"${neededDriveLetter}$semipath" // derefTilde(pathstr) + if (literalDrive.nonEmpty) { + // no need for cygpathM, if drive is unambiguous(?? is that correct? ; yes, if symlinks (and mounts?) are set correctly + val fpath = if (fpstr.endsWith(":") && fpstr.take(3).length == 2) { + cwd + } else { + jget(fpstr) + } + normPath(fpath) + } else { + // TODO: fast enhanced isAbsolute that recognizes a bare drive letter as ambiguous (isAbsolute can be very slow) + val cstr = fpstr // cygpathM(fpstr) + jget(cstr) + } + } + } + + // if mountMap has entries, apply it to `path`. + def applyMountMap(path: String): String = { + if (path.startsWith("q:")){ + vastblue.pathextend.hook += 1 + } + var nup = path + val (dl, segments) = pathSegments(path) + if (segments.nonEmpty) { + var firstSeg = s"/${segments.head}" + + val mounts = mountMap.keySet.toArray + val mounted = mounts.find { (s: String) => + sameFile(s, firstSeg) + } + if (mounted.nonEmpty) { + firstSeg = mountMap(firstSeg) + nup = (firstSeg :: segments.tail.toList).mkString("/") + } + } + nup + } + def getDriveLetter(str: String): String = { + str.take(2).toList match { + case letter :: ':' :: Nil => + s"$letter" // letter is a char, convert to String + case _ => + "" // false + } + } + def dropTrailingSlash(s: String): String = { + s.reverse.dropWhile( (c:Char) => c =='/' || c == '\\').reverse + } + def jget(fnamestr : String): Path = { + if (isWindows && fnamestr.contains(";")) { + sys.error(s"internal error: called JPaths.get with a filename containing a semicolon") + } + JPaths.get(fnamestr) + } + def derefTilde(str: String): String = if (str.startsWith("~")) { + //val uh = userhome + s"${userhome}${str.drop(1)}".replace('\\','/') + } else { + str + } + + def expandTilde(s: String): String = { + s.startsWith("~") match { + case false => s + case true => + s.replaceFirst("~", userHome).replace('\\', '/') + } + } + def userHome = sys.props("user.home").replace('\\', '/') + + def isSameFile(p1: Path, p2: Path): Boolean ={ + JFiles.isSameFile(p1, p2) + } + + // get path to binaryName via 'which' or 'where' + def where(binaryName: String): String = { + if (isWindows) { + // some winshell paths are invisible to java + // where.exe provides paths that java can see + // prefer binary with .exe extension, ceteris paribus + val head = execBinary("where", binaryName).partition( _.endsWith(".exe")) match { + case (exe,nonexe) if exe.nonEmpty => + exe.headOption.getOrElse("") + case (_, nonexe) => + nonexe.headOption.getOrElse("") + } + if (head.isEmpty){ + "" + } else { + norm(head) + } + } else { + exec("which", binaryName) + } + } + + def notWindows = java.io.File.separator == "/" + def isWindows = !notWindows + def exeSuffix = if (isWindows) ".exe" else "" + def osName = sys.props("os.name") + def systemDrive = if (notWindows) "" else envOrElse("SYSTEMDRIVE", javaHome.take(2)) + + lazy val winshellBashExes = Seq( + "c:/msys64/usr/bin/bash.exe", + "c:/cygwin64/bin/bash.exe", + ) + def bashPath: String = { + val inPath = where(s"bash${exeSuffix}") + if (!isWindows) { inPath } else { + inPath match { + case "" | "C:/Windows/System32/bash.exe" => + winshellBashExes.find { (s: String) => + JPaths.get(s).toFile.isFile + }.getOrElse(inPath) + case s => + s + } + } + } + + // root from the perspective of shell environment + lazy val shellRoot: String = { + if (notWindows) "/" else { + val guess = bashPath.replaceFirst("(/usr)?/bin/bash.*","") + val guessPath = JPaths.get(guess) // call JPaths.get here to avoid circular reference + if ( JFiles.isDirectory(guessPath) ){ + guess + } else { + //sys.error(s"unable to determine winshell root dir in $osName") + "" // no path prefix applicable + } + } + } + lazy val shellRootPath = Paths.get(shellRoot) + lazy val (shellDrive,shellBaseDir) = driveAndPath(shellRoot) + + lazy val herepath: Path = normPath(sys.props("user.dir")) + + def here = herepath.toString.replace('\\', '/') + def defaultDrive: String = { + if (notWindows) "" else { + val h2 = here.take(2) + val dl = h2.replaceAll("/", "").take(1) + dl.toLowerCase + } + } + + lazy val LetterPath = """([a-zA-Z]):([$\\/a-zA-Z_0-9]*)""".r + + def driveAndPath(filepath:String) = { + filepath match { + case LetterPath(letter,path) => + (s"$letter:",path) + case _ => + ("",shellRoot) + } + } + + // get first path to prog by searching the PATH + def findInPath(binaryName: String): Option[Path] = { + findAllInPath(binaryName, findAll = false) match { + case Nil => None + case head :: tail => Some(head) + } + } + + // get all occurences of binaryName int the PATH + def findAllInPath(prog: String, findAll: Boolean = true): Seq[Path] = { + val progname = prog.replace('\\','/') match { + case "/" => "/" + case str => str.split("/").last // remove path from program, if present + } + //val path = Paths.get(prog) + var found = List.empty[Path] + breakable { + for (dir <- envPath){ + // sort .exe suffix ahead of no .exe suffix + for (name <- Seq(s"$dir$fsep$progname$exeSuffix", s"$dir$fsep$progname").distinct){ + val p = Paths.get(name) + if (p.toFile.isFile){ + found ::= p.normalize + if (! findAll){ + break() // quit on first one + } + } + } + } + } + found.reverse.distinct + } + + def execBinary(args: String *): Seq[String] = { + import scala.sys.process.* + Process(Array(args:_*)).lazyLines_! + } + + def exec(args: String *): String = { + execBinary(args:_*).toList.mkString("") + } + + def shellExec(cmd: String): Seq[String] = { + execBinary(bashPath, "-c", cmd) + } + + def javaHome = Option(sys.props("java.home")) match { + case None => envOrElse("JAVA_HOME", "") + case Some(path) => path + } + + def osType: String = osName.takeWhile(_!=' ').toLowerCase + + lazy val winshellFlag = { + isDirectory(shellBaseDir) && (isCygwin || isMsys64 || isGitSdk64) + } + def isDirectory(path: String): Boolean = { + Paths.get(path).toFile.isDirectory + } + + lazy val programFilesX86: String = Option(System.getenv("ProgramFiles(x86)")) match { + case Some(valu) => valu + case None => "c:/Program Files (x86)" + } + + def userhome: String = sys.props("user.home").replace('\\','/') + lazy val home: Path = Paths.get(userhome) + + def isWinshell = isCygwin | isMsys64 | isMingw64 | isGitSdk64 | isGitbash + + def isCygwin = unameshort.toLowerCase.startsWith("cygwin") + def isMsys64 = unameshort.toLowerCase.startsWith("msys") + def isMingw64 = unameshort.toLowerCase.startsWith("mingw") + def isGitSdk64 = unameshort.toLowerCase.startsWith("git-sdk") + def isGitbash = unameshort.toLowerCase.startsWith("gitbash") + + def uname(arg:String) = { + val unamepath = where("uname") match { + case "" => "uname" + case str => str + } + val ostype = try { + Process(Seq(unamepath,arg)).lazyLines_!.mkString("") + } catch { + case _:Exception => + "" + } + ostype + } + + lazy val unamefull = uname("-a") + + def unameshort = unamefull.toLowerCase.replaceAll("[^a-z0-9].*","") + + lazy val envPath: Seq[String] = Option(System.getenv("PATH")) match { + case None => Nil + case Some(str) => str.split(psep).toList.map { canonical(_) }.distinct + } + def canonical(str: String): String = { + Paths.get(str) match { + case p if p.toFile.exists => p.normalize.toString + case p => p.toString + } + } + + def findPath(prog:String, dirs:Seq[String] = envPath): String = { + dirs.map { dir => + Paths.get(s"$dir/$prog") + }.find { (p:Path) => + p.toFile.isFile + } match { + case None => "" + case Some(p) => p.normalize.toString.replace('\\','/') + } + } + + def which(cmdname:String) = { + val cname = if (exeSuffix.nonEmpty && ! cmdname.endsWith(exeSuffix)) { + s"${cmdname}${exeSuffix}" + } else { + cmdname + } + findPath(cname) + } + + def canExist(p:Path):Boolean = { + // val letters = driveLettersLc.toArray + val pathdrive = pathDriveletter(p) + pathdrive match { + case "" => + true + case letter => + driveLettersLc.contains(letter) + } + } + + def dirExists(pathstr:String):Boolean = { + dirExists(Paths.get(pathstr)) + } + def dirExists(path:Path):Boolean = { + canExist(path) && JFiles.isDirectory(path) + } + + def pathDriveletter(ps:String):String = { + ps.take(2) match { + case str if str.drop(1) == ":" => + str.take(2).toLowerCase + case _ => + "" + } + } + def pathDriveletter(p:Path):String = { + pathDriveletter(p.toAbsolutePath.toFile.toString) + } + + // fileExists() solves the Windows jvm problem that path.toFile.exists + // is VEEERRRY slow for files on a non-existent drive (e.g., q:/). + def fileExists(p:Path):Boolean = { + canExist(p) && + p.toFile.exists + } + def exists(path:String): Boolean = { + exists(Paths.get(path)) + } + def exists(p:Path): Boolean = { + canExist(p) && { + p.toFile match { + case f if f.isDirectory => true + case f => f.exists + } + } + } + + // drop drive letter and normalize backslash + def dropshellDrive(str: String) = str.replaceFirst(s"^${shellDrive}:","") + def dropDriveLetter(str: String) = str.replaceFirst("^[a-zA-Z]:","") + def asPosixPath(str:String) = dropDriveLetter(str).replace('\\','/') + def asLocalPath(str:String) = if (notWindows) str else str match { + case PosixCygdrive(dl,tail) => s"$dl:/$tail" + case _ => str + } + lazy val PosixCygdrive = "[\\/]([a-z])([\\/].*)?".r + + def stdpath(path:Path):String = path.toString.replace('\\','/') + def stdpath(str:String) = str.replace('\\','/') + def norm(p: Path): String = p.toString.replace('\\', '/') + def norm(str:String) = str.replace('\\', '/') // Paths.get(str).normalize.toString.replace('\\', '/') + + lazy val mountMap: Map[String, String] = { +// printf("shellRoot[%s]\n", shellRoot) + if (notWindows || shellRoot.isEmpty ) { + Map.empty[String, String] + } else { + vastblue.Platform.mountMap + // readWinshellMounts + } + } + lazy val cygdrive = mountMap.get("/cygdrive").getOrElse("/cygdrive") + + def cygpathM(path: Path): Path = { + val cygstr = cygpathM(path.normalize.toString) + JPaths.get(cygstr) + } + def cygpathM(pathstr: String): String = { + val normed = pathstr.replace('\\', '/') + val tupes: Option[(String, String)] = mountMap.find { case (k,v) => + val normtail = normed.drop(k.length) + // detect whether a fstab prefix is an exactly match of a normed path string. + normed.startsWith(k) && (normtail.isEmpty || normtail.startsWith("/")) + } + val cygMstr: String = tupes match { + case Some((k,v)) => + val normtail = normed.drop(k.length) + s"$v$normtail" + case None => + // apply the convention that single letter paths below / are cygdrive references + if (normed.take(3).matches("/./?")){ + val dl: String = normed.drop(1).take(1)+":" + normed.drop(2) match { + case "" => + s"$dl/" // trailing slash is required here + case str => + s"$dl$str" + } + } else { + normed + } + } + // replace multiple slashes with single slash + cygMstr.replaceAll("//+","/") + } + + def readWinshellMounts: Map[String, String] = { + // Map must be ordered: no key may contain an earlier key as a prefix. + // With an ordered Map, the first match terminates the search. + var localMountMap = ListMap.empty[String,String] + + // default mounts for cygwin, potentially overridden in fstab + val bareRoot = shellRoot + localMountMap += "/usr/bin" -> s"$bareRoot/bin" + localMountMap += "/usr/lib" -> s"$bareRoot/lib" + // next 2 are convenient, but MUST be added before reading fstab + localMountMap += "/bin" -> s"$bareRoot/bin" + localMountMap += "/lib" -> s"$bareRoot/lib" + + var (cygdrive,usertemp) = ("","") + val fpath = "/proc/mounts" + val lines: Seq[String] = { + if (notWindows || shellRoot.isEmpty ) { + Nil + } else { + execBinary("cat.exe","/proc/mounts") + } + } + + for( line <- lines ){ + val cols = line.split("\\s+",-1).toList + val List(winpath, _mountpoint, fstype) = cols match { + case a :: b :: Nil => a :: b :: "" :: Nil + case a :: b :: c :: tail => a :: b :: c :: Nil + case list => sys.error(s"bad line in ${fpath}: ${list.mkString("|")}") + } + val mountpoint = _mountpoint.replaceAll("\\040"," ") + fstype match { + case "cygdrive" => + cygdrive = mountpoint + case "usertemp" => + usertemp = mountpoint // need to parse it, but unused here + case _ => + // fstype ignored + localMountMap += mountpoint -> winpath + } + } + + if (cygdrive.isEmpty) { + cygdrive = "/cygdrive" + } + localMountMap += "/cygdrive" -> cygdrive + + val driveLetters:Array[JFile] = { + if (false) { + java.io.File.listRoots() // veeery slow (potentially) + } else { + // 1000 times faster + val dlfiles = for { + locl <- localMountMap.values.toList + dl = locl.take(2) + if dl.drop(1) == ":" + ff = new JFile(s"$dl/") + } yield ff + dlfiles.distinct.toArray + } + } + + for( drive <- driveLetters ){ + val letter = drive.getAbsolutePath.take(1).toLowerCase // lowercase is typical user expectation + val winpath = stdpath(drive.getCanonicalPath) // retain uppercase, to match cygpath.exe behavior + //printf("letter[%s], path[%s]\n",letter,winpath) + localMountMap += s"/$letter" -> winpath + } + //printf("bareRoot[%s]\n",bareRoot) + localMountMap += "/" -> shellRoot // this must be last + localMountMap + } + + lazy val driveLettersLc:List[String] = { + val values = mountMap.values.toList + val letters = { + for { + dl <- values.map { _.take(2) } + if dl.drop(1) == ":" + } yield dl.toLowerCase + }.distinct + letters + } + + def eprint(xs: Any*):Unit = { + System.err.print("%s".format(xs:_*)) + } + def eprintf(fmt: String, xs: Any*):Unit = { + System.err.print(fmt.format(xs:_*)) + } + + def fileLines(f: JFile): Seq[String] = { + val fnorm = f.toString.replace('\\', '/') + if ( isWindows && fnorm.matches("/(proc|sys)(/.*)?")) { + execBinary("cat.exe",fnorm) + } else { + Using.resource(new BufferedReader(new FileReader(f))) { reader => + Iterator.continually(reader.readLine()).takeWhile(_ != null).toSeq + }.toSeq + } + } + + def envOrElse(varname: String, elseValue: String = ""): String = Option(System.getenv(varname)) match { + case None => elseValue + case Some(str) => str + } + + lazy val cwd = Paths.get(".").toAbsolutePath + lazy val fsep = java.io.File.separator + lazy val psep = sys.props("path.separator") + + def normPath(_pathstr: String): Path = { + val jpath: Path = _pathstr match { + case "." => JPaths.get(sys.props("user.dir")) + case _ => JPaths.get(_pathstr) + } + normPath(jpath) + } + def normPath(path: Path): Path = try { + val s = path.toString + if (s.length == 2 && s.take(2).endsWith(":")) { + cwd + } else { + path.toAbsolutePath.normalize + } + } catch { + case e: java.io.IOError => + path + } + + /* + // useful for benchmarking + def time(n: Int, func : (String) => Any): Unit = { + val t0 = System.currentTimeMillis + for (i <- 0 until n) { + func("bash.exe") + } + for (i <- 0 until n) { + func("bash") + } + val elapsed = System.currentTimeMillis - t0 + printf("%d iterations in %9.6f seconds\n", n * 2, elapsed.toDouble/1000.0) + } + */ + + // This is limited, in order to work on Windows, which is not Posix-Compliant. + def _chmod(path: Path, permissions:String="rw", allusers:Boolean=true): Boolean = { + val file = path.toFile + // set application user permissions + val x = permissions.contains("x") || file.canExecute + val r = permissions.contains("r") || file.canRead + val w = permissions.contains("w") || file.canWrite + + var ok = true + ok &&= file.setExecutable(x) + ok &&= file.setReadable(r) + ok &&= file.setWritable(w) + if( allusers ){ + //change permission for all users + ok &&= file.setExecutable(x, false) + ok &&= file.setReadable(r, false) + ok &&= file.setWritable(w, false) + } + ok + } + + // only verified on linux and Windows 11 + def dirIsCaseSensitiveUniversal(dir: JPath): Boolean = { + require(dir.toFile.isDirectory, s"not a directory [$dir]") + val pdir = dir.toAbsolutePath.toString + val p1 = Paths.get(pdir, "A") + val p2 = Paths.get(pdir, "a") + p1.toAbsolutePath == p2.toAbsolutePath + } + def sameFile(s1: String, s2: String): Boolean = { + s1 == s2 || { + // this branch addresses filesystem case-sensitivity + // must NOT call get() from this object (stack overflow) + val p1 = java.nio.file.Paths.get(s1).toAbsolutePath + val p2 = java.nio.file.Paths.get(s2).toAbsolutePath + p1 == p2 + } + } + // return drive letter, segments + def pathSegments(path: String): (String, Seq[String]) = { + // remove windows drive letter, if present + val (dl, pathRelative) = path.take(2) match { + case s if s.endsWith(":") => (path.take(2), path.drop(2)) + case s => ("", path) + } + (dl, pathRelative.split("[/\\\\]+").filter { _.nonEmpty }.toSeq) + } +} diff --git a/src/main/scala/vastblue/time/FileTime.scala b/src/main/scala/vastblue/time/FileTime.scala new file mode 100644 index 0000000..8b2fa5f --- /dev/null +++ b/src/main/scala/vastblue/time/FileTime.scala @@ -0,0 +1,552 @@ +package vastblue.time + +//import vastblue.pathextend.* + +import java.util.concurrent.TimeUnit +import java.time.ZoneId +//import java.time.{ZoneOffset} +import java.time.format.* +import java.time.LocalDateTime +//import io.github.chronoscala.Imports.* + +import java.time.temporal.{TemporalAdjuster, TemporalAdjusters} +//import java.time.temporal.{ChronoField} +import scala.util.matching.Regex +//import java.time.DayOfWeek +//import java.time.DayOfWeek.* + +object FileTime extends vastblue.time.TimeExtensions { + def zoneid = ZoneId.systemDefault + def zoneOffset = zoneid.getRules().getStandardOffset(now.toInstant()) + def zoneOffsetHours = zoneOffset.getHour + +// support usage "DateTimeConstants.FRIDAY" + /* + object DateTimeConstants { + def SUNDAY = java.time.DayOfWeek.SUNDAY + def MONDAY = java.time.DayOfWeek.MONDAY + def TUESDAY = java.time.DayOfWeek.TUESDAY + def WEDNESDAY = java.time.DayOfWeek.WEDNESDAY + def THURSDAY = java.time.DayOfWeek.THURSDAY + def FRIDAY = java.time.DayOfWeek.FRIDAY + def SATURDAY = java.time.DayOfWeek.SATURDAY + } + def SUNDAY = java.time.DayOfWeek.SUNDAY + def MONDAY = java.time.DayOfWeek.MONDAY + def TUESDAY = java.time.DayOfWeek.TUESDAY + def WEDNESDAY = java.time.DayOfWeek.WEDNESDAY + def THURSDAY = java.time.DayOfWeek.THURSDAY + def FRIDAY = java.time.DayOfWeek.FRIDAY + def SATURDAY = java.time.DayOfWeek.SATURDAY + */ + + //type DateTimeFormat = DateTimeFormatter + //type DateTimeZone = java.time.ZoneId + //type LocalDate = java.time.LocalDate + //type DateTime = LocalDateTime +//val DateTime = LocalDateTime + + /* + def parseLocalDate(_datestr:String, offset: Int=0): DateTime = { + parseDateJoda(_datestr, offset) // .toLocalDate + } + + lazy val timeDebug:Boolean = Option(System.getenv("TIME_DEBUG")) match { + case None => false + case _ => true + } + */ + lazy val NullDate: LocalDateTime = LocalDateTime.parse("0000-01-01T00:00:00") // .ofInstant(Instant.ofEpochMilli(0)) + + // Patterns permit but don't require time fields + // Used to parse both date and time from column 1. + // Permits but does not require column to be double-quoted. + lazy val YMDColumnPattern: Regex = """[^#\d]?(2\d{3})[-/](\d{1,2})[-/](\d{1,2})(.*)""".r + lazy val MDYColumnPattern: Regex = """[^#\d]?(\d{1,2})[-/](\d{1,2})[-/](2\d{3})(.*)""".r + + lazy val standardTimestampFormat = datetimeFmt6 + lazy val datetimeFmt8 = "yyyy-MM-dd HH:mm:ss-ss:S" + lazy val datetimeFmt7 = "yyyy-MM-dd HH:mm:ss.S" + lazy val datetimeFmt6 = "yyyy-MM-dd HH:mm:ss" // date-time-format + lazy val datetimeFmt5 = "yyyy-MM-dd HH:mm" // 12-hour format + lazy val datetimeFmt5b = "yyyy-MM-dd kk:mm" // 24-hour format + lazy val dateonlyFmt = "yyyy-MM-dd" // date-only-format + + lazy val datetimeFormatter8: DateTimeFormatter = dateTimeFormatPattern(datetimeFmt8) + lazy val datetimeFormatter7: DateTimeFormatter = dateTimeFormatPattern(datetimeFmt7) + lazy val datetimeFormatter6: DateTimeFormatter = dateTimeFormatPattern(datetimeFmt6) + lazy val datetimeFormatter5: DateTimeFormatter = dateTimeFormatPattern(datetimeFmt5) + lazy val datetimeFormatter5b: DateTimeFormatter = dateTimeFormatPattern(datetimeFmt5b) + lazy val dateonlyFormatter: DateTimeFormatter = dateTimeFormatPattern(dateonlyFmt) + + lazy val EasternTime: ZoneId = java.time.ZoneId.of("America/New_York") + lazy val MountainTime: ZoneId = java.time.ZoneId.of("America/Denver") + lazy val UTC: ZoneId = java.time.ZoneId.of("UTC") + + def LastDayAdjuster: TemporalAdjuster = TemporalAdjusters.lastDayOfMonth() + + // ============================== + + def dateTimeFormatPattern(fmt:String,zone:ZoneId = ZoneId.systemDefault()):DateTimeFormatter = { + val dtf1 = DateTimeFormatter.ofPattern(fmt).withZone(zone) + val dtf = if( fmt.length <= "yyyy-mm-dd".length ){ + import java.time.temporal.ChronoField + new DateTimeFormatterBuilder().append(dtf1) + .parseDefaulting(ChronoField.HOUR_OF_DAY, 0) + .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0) + .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0) + .toFormatter() + } else { + dtf1 + } + dtf + } + + /** + * Get a diff between two dates + * @param date1 the oldest date + * @param date2 the newest date + * @param timeUnit the unit in which you want the diff + * @return the diff value, in the provided unit + */ + def diffDays(date1: LocalDateTime, date2: LocalDateTime): Long = { + diff(date1, date2, TimeUnit.DAYS) + } + def diffHours(date1: LocalDateTime, date2: LocalDateTime): Long = { + diff(date1, date2, TimeUnit.HOURS) + } + def diffSeconds(date1: LocalDateTime, date2: LocalDateTime): Long = { + diff(date1, date2, TimeUnit.SECONDS) + } + def diff(date1: LocalDateTime, date2: LocalDateTime, timeUnit: TimeUnit): Long = { + val diffInMillies = date2.getMillis() - date1.getMillis() + timeUnit.convert(diffInMillies, TimeUnit.MILLISECONDS) + } + + // type LocalDateTime = LocalDateTime + // signed number of days between specified dates. + // if date1 > date2, a negative number of days is returned. + def daysBetween(idate1:LocalDateTime, idate2:LocalDateTime): Long = { + assert(idate1 != null,"idate1 is null") + assert(idate2 != null,"idate2 is null") + val elapsedDays:Long = if( idate1.getMillis() < idate2.getMillis() ){ + diffDays(idate1,idate2) + } else { + - diffDays(idate2,idate1) + } + elapsedDays + } + //private var hook = 0 + def secondsBetween(idate1:LocalDateTime,idate2:LocalDateTime): Long = { + val seconds = diffSeconds(idate1,idate2) // .getStandardDays + val elapsedSeconds:Long = if( idate1.getMillis() <= idate2.getMillis() ){ + seconds + } else { + - seconds // negative number + } + elapsedSeconds + } + def secondsSince(date1:LocalDateTime): Long = secondsBetween(date1,now) + + def endOfMonth(d: LocalDateTime): LocalDateTime = { + val month: java.time.YearMonth = { java.time.YearMonth.from(d) } + month.atEndOfMonth.atStartOfDay + } + + def minutesBetween(date1:LocalDateTime,date2:LocalDateTime):Double = { + secondsBetween(date1,date2).toDouble / 60.0 + } + def minutesSince(date1:LocalDateTime): Double = minutesBetween(date1,now) + + def hoursBetween(date1:LocalDateTime,date2:LocalDateTime):Double = { + minutesBetween(date1,date2) / 60.0 + } + def hoursSince(date1:LocalDateTime): Double = hoursBetween(date1,now) + + def whenModified(f:java.io.File):LocalDateTime = { + val lastmod = if (f.exists) f.lastModified else -1 + epoch2DateTime(lastmod, MountainTime) + } + + def epoch2DateTime(epoch:Long,timezone:java.time.ZoneId=UTC):LocalDateTime = { + val instant = java.time.Instant.ofEpochMilli(epoch) + java.time.LocalDateTime.ofInstant(instant,timezone) + } + + /** + * Returns days, hours, minutes, seconds between timestamps. + */ +// def getDuration(date1:LocalDateTime,date2:LocalDateTime): (Long, Long, Long, Long) = { +// val reverse = date1.getMillis() > date2.getMillis() +// val (d1,d2) = reverse match { +// case true => (date2,date1) +// case _ => (date1,date2) +//val d2d = idate1 to idate2 // new RichDuration(duration) +//val d2d = new RichDuration(between(idate1, idate2)) +// } +// val duration = diffDays(d1,d2) // .toDuration +// val days = duration.getStandardDays +// +// var (hours:Long, minutes:Long, seconds:Long) = ( +// duration.getStandardHours, +// duration.getStandardMinutes, +// duration.getStandardSeconds +// ) +// if( minutes > 0 ){ +// seconds -= minutes*60 +// } +// if( hours > 0 ){ +// minutes -= hours*60 +// } +// if( days > 0 ){ +// hours -= days * 24 +// } +// (days,hours,minutes,seconds) +// } + + def nowZoned(zone:ZoneId = MountainTime): LocalDateTime = LocalDateTime.now(zone) + lazy val now: LocalDateTime = nowZoned(MountainTime) + def nowUTC = LocalDateTime.now() + + //def fixDateFormat = vastblue.time.Time.fixDateFormat _ + //def ageInMinutes = vastblue.time.Time.ageInMinutes _ + def ageInMinutes(f:java.io.File):Double = { + if( f.exists ){ + val diff = (now.getMillis() - f.lastModified) / (60 * 1000).toDouble + diff + } else { + 1e6 // missing files are VERY stale + } + } + def ageInDays(f:java.io.File):Double = { + ageInMinutes(f) / (24 * 60) + } + def ageInDays(fname:String):Double = { + ageInDays(new java.io.File(fname)) + } + + def parse(str: String,format:String): LocalDateTime = { +// if( timeDebug ) System.err.print("parse(str=[%s], format=[%s]\n".format(str,format)) + if( format.length <= "yyyy-mm-dd".length ){ + LocalDateTime.parse(str,dateTimeFormatPattern(format)) + } else { + LocalDateTime.parse(str,dateTimeFormatPattern(format)) + } + } + /** The new parser does not depend on MDate */ + def parseDateNew(_datestr:String,format:String=""):LocalDateTime = { + val datestr = _datestr. + replaceAll("/","-"). // normalize field separator + replaceAll("\"",""). // remove quotes + replaceAll(""" (\d):"""," 0$1:"). // make sure all time fields are 2 digits (zero filled) + replaceAll("\\s+"," ").trim // compress random whitespace to a single space, then trim + + val pattern = (format != "",datestr.contains(":"),datestr.matches(""".* (AM|PM)\b.*"""),datestr.contains(".")) match { + case (true,_,_,_) => format // user-specified format + case (_,false,_,_) => "yyyy-MM-dd" + case (_,true,false,false) => "yyyy-MM-dd HH:mm:ss" + case (_,true, true,false) => "yyyy-MM-dd hh:mm:ss aa" + case (_,true,false, true) => "yyyy-MM-dd HH:mm:ss.SSS" + case (_,true, true, true) => "yyyy-MM-dd hh:mm:ss aa.SSS" + } + try { + parse(datestr,pattern) + } catch { + case e:IllegalArgumentException => + e.getMessage.contains("Illegal instant due to time zone offset") match { + case true => + throw e + case false => + parse(datestr,pattern) + } + } + } + + def parseDateTime(str:String):LocalDateTime = parseDateStr(str) + lazy val ThreeIntegerFields1 = """(\d{2,4})\D(\d{1,2})\D(\d{1,2})""".r + lazy val ThreeIntegerFields3 = """(\d{1,2})\D(\d{1,2})\D(\d{2,4})""".r + lazy val ThreeIntegerFields2 = """(\d{2,2})\D(\d{1,2})\D(\d{1,2})""".r + def parseDateStr(_inputdatestr: String): LocalDateTime = { // , offset: Int=0):LocalDateTime = { + val _datestr = _inputdatestr.trim.replaceAll("\"","") + if( _datestr.isEmpty ){ + BadDate + } else { + _datestr match { + case ThreeIntegerFields1(_y,_m,_d) => + if (_y.length > 2){ + val (y, m, d) = (_y.toInt, _m.toInt, _d.toInt) + new RichString("%4d-%02d-%02d".format(y,m,d)).toDateTime + } else { + val nums = List(_y, _m, _d).map { _.toInt } + val possibleDays = nums.zipWithIndex.filter { case (n, i) => n <= 31 } + val possibleMonths = nums.zipWithIndex.filter { case (n, i) => n <= 12 } + val (y, m, d) = possibleMonths match { + case (n,0) :: list => + (nums(2), nums(0), nums(1)) // m/d/y + case (n,1) :: list => + (nums(2), nums(1), nums(0)) // d/m/y + case _ => possibleDays match { + case (n,0) :: list => (nums(2), nums(1), nums(0)) // d/m/y + case _ => + (nums(2), nums(0), nums(1)) // m/d/y + } + } + val year = if (y >= 1000) y else y+2000 + new RichString("%4d-%02d-%02d".format(year,m,d)).toDateTime + } + case ThreeIntegerFields3(_m, _d, _y) => + val (y, m, d) = (_y.toInt, _m.toInt, _d.toInt) + val year = if (y >= 1000) y else y+2000 + new RichString("%4d-%02d-%02d".format(year,m,d)).toDateTime + case ThreeIntegerFields2(_x, _y, _z) => + val nums = List(_x, _y, _z).map { _.toInt } + // val possibleDays = nums.zipWithIndex.filter { case (n,i) => n <= 31 } + // val possibleMonths = nums.zipWithIndex.filter { case (n,i) => n <= 12 } + val List(y, m, d) = nums + val year = if (y >= 1000) y else y+2000 + new RichString("%4d-%02d-%02d".format(year,m,d)).toDateTime + case _ => + // next, treat yyyyMMdd (8 digits, no field separators) + if (_datestr.matches("""2\d{7}""")) { + new RichString(_datestr.replaceAll("(....)(..)(..)", "$1-$2-$3")).toDateTime + + } else if (_datestr.matches("""\d{2}\D\d{2}\D\d{2}""")) { + // MM-dd-yy + val fixed = _datestr.split("\\D").toList match { + case m :: d :: y :: Nil => + "%04d-%02d-%02d 00:00:00".format(2000 + y.toInt, m.toInt, d.toInt) + case _ => + _datestr // no fix + } + // printf("%s\n",datetimeFormatter.getClass) + // datetimeFormatter6.parse(fixed) + LocalDateTime.parse(fixed, datetimeFormatter6) + } else if (_datestr.matches("""2\d{3}\D\d{2}\D\d{2}\.\d{4}""")) { + // yyyy-MM-dd.HHMM + + val fixed = _datestr.replaceAll("""(....)\D(..)\D(..)\.(\d\d)(\d\d)""", "$1-$2-$3 $4:$5:00") + LocalDateTime.parse(fixed, datetimeFormatter6) + } else { + val datestr = _datestr.replaceAll("/", "-") + parseDateString(datestr) + /* + try { + } catch { + case e: Exception => + if (vastblue.MDate.debug) System.err.printf("e[%s]\n",e.getMessage) + val mdate = vastblue.MDate.parseDate(datestr) // .replaceAll("\\D+","")) + // val timestamp = new LocalDateTime(mdate.getEpoch) + val standardFormat = mdate.toString(standardTimestampFormat) + val timestamp = standardFormat.toDateTime + val hour = timestamp.getHour // hourOfDay.get + val extraHours = if (datestr.contains(" PM") && hour < 12) { + 12 + } else { + 0 + } + val hours = (offset + extraHours).toLong + timestamp.plusHours(hours) + } + */ + } + } + } + } + + def standardTime(datestr: String): String = { // }, offset: Int=0): String = { + //parseDateStr(datestr, offset).toString(standardTimestampFormat) + parseDateStr(datestr).toString(standardTimestampFormat) + } + def parseDate(datestr: String): LocalDateTime = { // }, offset: Int=0): LocalDateTime = { + parseDateStr(datestr) // , offset) + } + + def getDaysElapsed(idate1:LocalDateTime, idate2:LocalDateTime): Long = { + if( idate2.getMillis() < idate1.getMillis() ){ + //- (idate2 to idate1).getStandardDays + - diffDays(idate2,idate1) + } else { + //(idate1 to idate2).getStandardDays + diffDays(idate1,idate2) + } + } + def getDaysElapsed(datestr1:String,datestr2:String):Long = { + getDaysElapsed(parseDateStr(datestr1),parseDateStr(datestr2)) + } + def selectZonedFormat(_datestr:String):java.time.format.DateTimeFormatter = { + val datestr = _datestr.replaceAll("/","-") + val numfields = datestr.split("\\D+") + numfields.length <= 3 match { + case true => + dateonlyFormatter + case false => + datetimeFormatter6 + } + } + def ti(s:String): Int = { + s match { + case n if n.matches("0\\d+") => + n.replaceAll("0+(.)","$1").toInt + case n => + n.toInt + } + } + def numerifyNames(datestr:String) = { + val noweekdayName = datestr.replaceAll("(Sun[day]*|Mon[day]*|Tue[sday]*|Wed[nesday]*|Thu[rsday]*|Fri[day]*|Sat[urday]*),? *","") + noweekdayName match { + case str if str.matches("(?i).*[JFMASOND][aerpuco][nbrylgptvc][a-z]*.*") => + var ff = str.split("[,\\s]+") + if (ff(0).matches("\\d+")){ + // swap 1st and 2nd fields (e.g., convert "01 Jan" to "Jan 01") + val tmp = ff(0) + ff(0) = ff(1) + ff(1) = tmp + } + val month = monthAbbrev2Number(ff.head.take(3)) + ff = ff.drop(1) + val (day, year, timestr, tz) = ff.toList match { + case d :: y :: Nil => + (d.toInt, y.toInt, "", "") + case d :: y :: ts :: tz :: Nil if ts.contains(":") => + (d.toInt, y.toInt, " "+ts, " "+tz) + case d :: ts :: y :: tail if ts.contains(":") => + (d.toInt, y.toInt, " "+ts, "") + case d :: y :: ts :: tail => + (d.toInt, y.toInt, " "+ts, "") + case other => + sys.error(s"bad date [$other]") + } + "%4d-%02d-%02d%s%s".format(year,month,day,timestr,tz) + case str => + str + } + } + def monthAbbrev2Number(name:String):Int = { + name.toLowerCase.substring(0,3) match { + case "jan" => 1 + case "feb" => 2 + case "mar" => 3 + case "apr" => 4 + case "may" => 5 + case "jun" => 6 + case "jul" => 7 + case "aug" => 8 + case "sep" => 9 + case "oct" => 10 + case "nov" => 11 + case "dec" => 12 + } + } + + lazy val mmddyyyyPattern: Regex = """(\d{1,2})\D(\d{1,2})\D(\d{4})""".r + lazy val mmddyyyyTimePattern: Regex = """(\d{1,2})\D(\d{1,2})\D(\d{4})(\D\d\d:\d\d(:\d\d)?)""".r + lazy val mmddyyyyTimePattern2: Regex = """(\d{1,2})\D(\d{1,2})\D(\d{4})\D(\d\d):(\d\d)""".r + lazy val mmddyyyyTimePattern3: Regex = """(\d{1,2})\D(\d{1,2})\D(\d{4})\D(\d\d):(\d\d):(\d\d)""".r + lazy val mmddyyyyTimePattern3tz: Regex = """(\d{1,2})\D(\d{1,2})\D(\d{4})\D(\d\d):(\d\d):(\d\d)\D(-?[0-9]{4})""".r + lazy val yyyymmddPattern: Regex = """(\d{4})\D(\d{1,2})\D(\d{1,2})""".r + lazy val yyyymmddPatternWithTime: Regex = """(\d{4})\D(\d{1,2})\D(\d{1,2})(\D.+)""".r + lazy val yyyymmddPatternWithTime2: Regex = """(\d{4})\D(\d{1,2})\D(\d{1,2}) +(\d{2}):(\d{2})""".r + lazy val yyyymmddPatternWithTime3: Regex = """(\d{4})\D(\d{1,2})\D(\d{1,2})\D(\d{2}):(\d{2}):(\d{2})""".r + lazy val validYearPattern = """(1|2)\d{3}""" // only consider years between 1000 and 2999 + def parseDateString(_datestr:String): LocalDateTime = { + var datestr = _datestr. + replaceAll("/","-"). + replaceAll("#",""). + replaceAll("-[0-9]+:[0-9]+$",""). + replaceAll("([0-9])T([0-9])","$1 $2").trim + datestr = datestr match { + case mmddyyyyPattern(m,d,y) => + "%s-%02d-%02d".format(y,ti(m),ti(d)) + case mmddyyyyTimePattern(m,d,y,t) => + "%s-%02d-%02d%s".format(y,ti(m),ti(d),t) + case mmddyyyyTimePattern2(m,d,y,h,min) if y.matches(validYearPattern) => + "%s-%02d-%02d %02d:%02d".format(y,ti(m),h,min) + case mmddyyyyTimePattern3(m, d, y, h, min, s) if y.matches(validYearPattern) => + "%s-%02d-%02d %02d:%02d:02d".format(y, ti(m), h, min, s) + case mmddyyyyTimePattern3tz(m, d, y, h, min, s, tz) if y.matches(validYearPattern) => + "%s-%02d-%02d %02d:%02d:02d %s".format(y, ti(m), h, min, s) + case yyyymmddPattern(y,m,d) if y.matches(validYearPattern) => + "%s-%02d-%02d".format(y,ti(m),ti(d)) + case yyyymmddPatternWithTime(y,m,d,t) if y.matches(validYearPattern) => + "%s-%02d-%02d%s".format(y,ti(m),ti(d),t) + case yyyymmddPatternWithTime2(y,m,d,hr,min) if y.matches(validYearPattern) => + if (hr.toInt>12){ + "%s-%02d-%02d %s:%s".format(y,ti(m),ti(d),hr,min) + } else { + "%s-%02d-%02d %s:%s".format(y,ti(m),ti(d),hr,min) + } + case yyyymmddPatternWithTime3(y, m, d, hr, min, sec) if y.matches(validYearPattern) => + if (hr.toInt > 12) { + "%s-%02d-%02d %s:%s:%s".format(y, ti(m), ti(d), hr, min, sec) + } else { + "%s-%02d-%02d %s:%s:%s".format(y, ti(m), ti(d), hr, min, sec) + } + case other => + val withNums = numerifyNames(other) + withNums + } + + val numfields = datestr.split("\\D+").map { _.trim }.filter { _.nonEmpty }.map { _.toInt} + numfields.length match { + case 1 => + + val dstr = if (datestr.startsWith("2")) { + // e.g., 20220330 + datestr.replaceAll("(\\d{4})(\\d{2})(\\d{2})", "$1-$2-$3") + } else if (datestr.drop(4).startsWith("2")) { + // e.g., 03302022 + datestr.replaceAll("(\\d{2})(\\d{2})(\\d{4})", "$3-$1-$2") + } else { + sys.error(s"bad date format [$datestr]") + } + val fmtr = datetimeFormatter6 + LocalDateTime.parse(s"${dstr} 00:00:00",fmtr) + case 3 => + val fmtr = datetimeFormatter6 + LocalDateTime.parse(s"${datestr} 00:00:00",fmtr) + case 5 => + if (numfields(3) <= 12) { + LocalDateTime.parse(datestr, datetimeFormatter5) + } else { + LocalDateTime.parse(datestr, datetimeFormatter5b) + } + case 6 => + LocalDateTime.parse(datestr,datetimeFormatter6) + case 7 => + LocalDateTime.parse(datestr,datetimeFormatter7) + case _ => + // System.err.printf("%d datetime fields: [%s] [%s]\n".format(numfields.size,numfields.mkString("|"),datestr)) + LocalDateTime.parse(datestr,datetimeFormatter6) + } + } + class RichString(val s: String) extends AnyVal { + import java.time.* + def str = s // .replaceAll(" ","T") + def toDateTime: LocalDateTime = parseDateString(str) // LocalDateTime.parse(str,selectZonedFormat(str)) + def toInstant: Instant = Instant.parse(str) + // def toLocalDate = LocalDate.parse(str) +// def toDateTime:LocalDateTime = toLocalDate.atStartOfDay + def toDateTimeOption: Option[LocalDateTime] = toOption(toDateTime) +// def toLocalDateOption = toOption(toLocalDate) + def toDateTime(format: String): String = dateTimeFormat(format) + def toLocalDate(format: String): String = localDateTimeFormat(format) + def toDateTimeOption(format: String): Option[String] = toOption(toDateTime(format)) + def toLocalDateOption(format: String): Option[String] = toOption(toLocalDate(format)) + + private def toOption[A](f: => A): Option[A] = try { + Some(f) + } catch { + case _: IllegalArgumentException => None + } + + def dateTimeFormat(format: String): String = s.format(DateTimeFormatter.ofPattern(format)) // .parseDateTime(s) + def localDateTimeFormat(format: String): String = s.format(DateTimeFormatter.ofPattern(format)) // .parseLocalDate(s) + } + + lazy val BadDate: LocalDateTime = parseDateStr("1900-01-01") + lazy val EmptyDate: LocalDateTime = parseDateStr("1800-01-01") + def date2string(d:LocalDateTime,fmt:String="yyyy-MM-dd"): String = d match { + case EmptyDate => "" + case other => other.toString(fmt) + } +} diff --git a/src/test/scala/TestUniPath.scala b/src/test/scala/TestUniPath.scala new file mode 100644 index 0000000..f076b1a --- /dev/null +++ b/src/test/scala/TestUniPath.scala @@ -0,0 +1,44 @@ +import org.junit.Test + +import vastblue.file.Paths.* +import vastblue.pathextend.* + +class TestUniPath { + def testArgs = Seq.empty[String] + @Test def test1(): Unit = { + val wherebash = where("bash") + val test = Paths.get(wherebash) + printf("bash [%s]\n",test) + val bashVersion: String = exec(where("bash"),"-version") + printf("%s\n%s\n", test, bashVersion) + printf("bashPath [%s]\n",bashPath) + printf("shellRoot [%s]\n",shellRoot) + printf("systemDrive: [%s]\n",systemDrive) + printf("shellDrive [%s]\n",shellDrive) + printf("shellBaseDir [%s]\n",shellBaseDir) + printf("osName [%s]\n",osName) + printf("unamefull [%s]\n",unamefull) + printf("unameshort [%s]\n",unameshort) + printf("isCygwin [%s]\n",isCygwin) + printf("isMsys64 [%s]\n",isMsys64) + printf("isMingw64 [%s]\n",isMingw64) + printf("isGitSdk64 [%s]\n",isGitSdk64) + printf("isWinshell [%s]\n",isWinshell) + printf("bash in path [%s]\n",findInPath("bash").getOrElse("")) + printf("/etc/fstab [%s]\n",Paths.get("/etc/fstab")) + // dependent on /etc/fstab, in winshell environment + printf("javaHome [%s]\n",javaHome) + + printf("\n") + printf("all bash in path:\n") + val bashlist = findAllInPath("bash") + for (path <- bashlist) { + printf(" found at %-36s : ", s"[$path]") + printf("--version: [%s]\n", exec(path.toString, "--version").takeWhile(_ != '(')) + } + for ((key,valu) <- mountMap ){ + printf("mount %-22s -> %s\n", key, valu) + } + assert(bashPath.file.exists,s"bash not found") + } +} diff --git a/src/test/scala/vastblue/ParseDateSpec.scala b/src/test/scala/vastblue/ParseDateSpec.scala new file mode 100644 index 0000000..935c9cb --- /dev/null +++ b/src/test/scala/vastblue/ParseDateSpec.scala @@ -0,0 +1,77 @@ +package vastblue + +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class ParseDateSpec extends AnyFunSpec with Matchers with BeforeAndAfter { +//import vastblue.MDate + @volatile lazy val datePairs:List[(String,String)] = List( + ("01/16/15","2015/01/16"), + ("01/25/15","2015/01/25"), + ("01/26/15","2015/01/26"), + ("01/27/15","2015/01/27"), + ("01/29/15","2015/01/29"), + ("03/02/15","2015/03/02"), + ("03/05/15","2015/03/05"), + ("03/20/15","2015/03/20"), + ("04/01/15","2015/04/01"), + ("04/09/15","2015/04/09"), + ("06/05/15","2015/06/05"), + ("06/11/15","2015/06/11"), + ("06/26/15","2015/06/26"), + ("07/14/15","2015/07/14"), + ("07/16/15","2015/07/16"), + ("07/19/15","2015/07/19"), + ("08/04/15","2015/08/04"), + ("08/06/15","2015/08/06"), + ("08/18/15","2015/08/18"), + ("08/23/15","2015/08/23"), + ("08/31/15","2015/08/31"), + ("09/08/15","2015/09/08"), + ("10/21/15","2015/10/21"), + ("10/25/15","2015/10/25"), + ("11/14/15","2015/11/14"), + ("11/15/15","2015/11/15"), + ("11/16/15","2015/11/16"), + ("11/17/15","2015/11/17"), + ("11/22/15","2015/11/22"), + ("11/23/15","2015/11/23"), + ("11/29/15","2015/11/29"), + ("12/08/15","2015/12/08"), + ("12/11/15","2015/12/11"), + ("12/12/15","2015/12/12"), + ("12/13/15","2015/12/13"), + ("12/14/15","2015/12/14"), + ("12/25/15","2015/12/25"), + ) + @volatile lazy val dateTimePairs:List[(String,String)] = List( + ("Sat Oct 16 13:04:02 2021 -0600", "2021/10/16 13:04:02"), + ) + describe("MDate") { + describe ("parse()") { + it("should correctly parse dateTime Strings") { + var lnum = 0 + dateTimePairs.foreach { case (str,expected) => + import vastblue.time.FileTime.* + val md = parseDateStr(str) + val value:String = md.toString("yyyy/MM/dd HH:mm:ss") +// printf("expected[%s], value[%s]\n",expected,value) + assert(value == expected,s"line ${lnum}:\n [$value]\n [$expected]") + lnum += 1 + } + } + it("should correctly parse date Strings") { + var lnum = 0 + datePairs.foreach { case (str,expected) => + import vastblue.time.FileTime.* + val md = parseDateStr(str) + val value:String = md.toString("yyyy/MM/dd") +// printf("expected[%s], value[%s]\n",expected,value) + assert(value == expected,s"line ${lnum}:\n [$value]\n [$expected]") + lnum += 1 + } + } + } + } +} diff --git a/src/test/scala/vastblue/TimeSpec.scala b/src/test/scala/vastblue/TimeSpec.scala new file mode 100644 index 0000000..b805a40 --- /dev/null +++ b/src/test/scala/vastblue/TimeSpec.scala @@ -0,0 +1,35 @@ +package vastblue + +import vastblue.time.FileTime.* +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class TimeSpec extends AnyFunSpec with Matchers { // with BeforeAndAfter { + + describe("time functions and lazy vals should initialize without throwing exceptions") { + describe ("NullDate") { + it("should correctly initialize") { + printf("NullDate:%s\n",NullDate) + assert(true,"NullDate") + } + } + describe ("nowUTC show return differing timestamps after elapsed of time") { + it("should correctly initialize") { + val now1 = nowUTC + printf("======= now1[%s]\n",now1) + Thread.sleep(2000) + val now2 = nowUTC + printf("======= now2[%s]\n",now2) + val elapsedSeconds = secondsBetween(now1,now2) + printf("between %s and %s: %s Seconds elapsed\n",now1,now2,elapsedSeconds) + val elapsedMinutes = minutesBetween(now1,now2) + printf("between %s and %s: %s Minutes elapsed\n",now1,now2,elapsedMinutes) + val elapsedHours = hoursBetween(now1,now2) + printf("between %s and %s: %s Hours elapsed\n",now1,now2,elapsedHours) + val elapsedDays = daysBetween(now1,now2) + printf("between %s and %s: %s Days elapsed\n",now1,now2,elapsedDays) + assert(true,"passed") + } + } + } +} diff --git a/src/test/scala/vastblue/file/EzPathTest.scala b/src/test/scala/vastblue/file/EzPathTest.scala new file mode 100644 index 0000000..13d2cdb --- /dev/null +++ b/src/test/scala/vastblue/file/EzPathTest.scala @@ -0,0 +1,142 @@ +//#!/usr/bin/env -S scala -explain -cp target/scala-3.3.0/classes/* +package vastblue.file + +import vastblue.file.EzPath.* +//import vastblue.pathextend.* +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class EzPathTest extends AnyFunSpec with Matchers with BeforeAndAfter { + describe("EzPath constructors"){ + it("should correctly create and display EzPath objects"){ + val upathstr = "/opt/ue" + val wpathstr = upathstr.replace('/', '\\') + val posixAbsstr = s"$platformPrefix$upathstr" // can insert current working directory prefix + val windowsAbsstr = posixAbsstr.replace('/', '\\') // windows version of java.io.File.separator + val localAbsstr = if (isWindows) windowsAbsstr else posixAbsstr + printf("notWindows: %s\n", notWindows) + printf("isWindows: %s\n", isWindows) + + printf("\n") + printf("upathstr [%s]\n", upathstr) + printf("wpathstr [%s]\n", wpathstr) + printf("posixAbsstr [%s]\n", posixAbsstr) + printf("windowsAbsstr [%s]\n",windowsAbsstr) + printf("\n") + + // test whether input strings have forward or back slash + // accept defaults (also should match Paths.get) + + val unxa = PathUnx(upathstr) // should match java.nio.file.Paths.get + printf("unxa.pstr [%s], ", unxa.initstring) + printf("unxa.norm [%s], ", unxa.norm) + printf("unxa.sl [%s], ", unxa.sl) + printf("unxa.abs [%s]\n", unxa.abs) + //printf("\n") + assert(unxa.sl == Slash.Unx) + assert(unxa.initstring == upathstr) + assert(unxa.abs == posixAbsstr) + + val wina = PathWin(upathstr) // should match java.nio.file.Paths.get + printf("wina.pstr [%s], ", wina.initstring) + printf("wina.norm [%s], ", wina.norm) + printf("wina.sl [%s], ", wina.sl) + printf("wina.abs [%s]\n", wina.abs) + //printf("\n") + assert(wina.sl == Slash.Win) + assert(wina.initstring == upathstr) + assert(wina.abs == localAbsstr.replace('/', Slash.win)) + + val unxb = PathUnx(upathstr) // should match java.nio.file.Paths.get + printf("unxb.pstr [%s], ", unxb.initstring) + printf("unxb.norm [%s], ", unxb.norm) + printf("unxb.sl [%s], ", unxb.sl) + printf("unxb.abs [%s]\n", unxb.abs) + //printf("\n") + assert(unxb.sl == Slash.Unx) + assert(unxb.initstring == upathstr) + assert(unxb.abs == posixAbsstr) + + val winb = PathWin(upathstr) // should match java.nio.file.Paths.get + printf("winb.pstr [%s], ", winb.initstring) + printf("winb.norm [%s], ", winb.norm) + printf("winb.sl [%s], ", winb.sl) + printf("winb.abs [%s]\n", winb.abs) + //printf("\n") + assert(winb.sl == Slash.Win) + assert(winb.initstring == upathstr) + assert(winb.abs == localAbsstr.slash(winb.sl)) + + val winw = PathWin(windowsAbsstr) + printf("winw.pstr [%s], ", winw.initstring) + printf("winw.norm [%s], ", winw.norm) + printf("winw.sl [%s], ", winw.sl) + printf("winw.abs [%s]\n", winw.abs) + printf("\n") + assert(winw.sl == Slash.Win) + assert(winw.initstring == windowsAbsstr) + assert(winw.abs == windowsAbsstr) + + val ezpc = EzPath(wpathstr) // should match java.nio.file.Paths.get + printf("ezpc.pstr [%s], ", ezpc.initstring) + printf("ezpc.norm [%s], ", ezpc.norm) + printf("ezpc.sl [%s], ", ezpc.sl) + printf("ezpc.abs [%s]\n", ezpc.abs) + //printf("\n") + assert(ezpc.sl == defaultSlash) + if (isWindows){ + assert(ezpc.initstring == wpathstr) + assert(ezpc.abs == localAbsstr.replace('/', Slash.win)) + } else { + assert(ezpc.initstring == upathstr) + assert(ezpc.abs == localAbsstr) + } + + val ezpd = EzPath(wpathstr) // should match java.nio.file.Paths.get + printf("ezpd.pstr [%s], ", ezpd.initstring) + printf("ezpd.norm [%s], ", ezpd.norm) + printf("ezpd.sl [%s], ", ezpd.sl) + printf("ezpd.abs [%s]\n", ezpd.abs) + printf("\n") + assert(ezpc.sl == defaultSlash) + if (isWindows){ + assert(ezpd.initstring == wpathstr) + assert(ezpd.abs == windowsAbsstr) + } else { + assert(ezpc.initstring == upathstr) + assert(ezpc.abs == localAbsstr) + } + + val ezxw = EzPath(wpathstr, Slash.Win) // should match specified (same as default) slash + printf("ezxw.pstr [%s], ", ezxw.initstring) + printf("ezxw.norm [%s], ", ezxw.norm) + printf("ezxw.sl [%s], ", ezxw.sl) + printf("ezxw.abs [%s]\n", ezxw.abs) + //printf("\n") + assert(ezxw.sl == Slash.Win) + if (isWindows){ + assert(ezxw.initstring == wpathstr) + assert(ezxw.abs == windowsAbsstr) + } else { + assert(ezpc.initstring == upathstr) + assert(ezpc.abs == localAbsstr) + } + + val ezpu = EzPath(wpathstr, Slash.Unx) // should match non-default slash + printf("ezpu.pstr [%s], ", ezpu.initstring) + printf("ezpu.norm [%s], ", ezpu.norm) + printf("ezpu.sl [%s], ", ezpu.sl) + printf("ezpu.abs [%s]\n", ezpu.abs) + printf("\n") + assert(ezpu.sl == Slash.Unx) + if (isWindows){ + assert(ezpu.initstring == wpathstr) + assert(ezpu.abs == posixAbsstr) + } else { + assert(ezpc.initstring == upathstr) + assert(ezpc.abs == localAbsstr) + } + } + } +} diff --git a/src/test/scala/vastblue/file/FileSpec.scala b/src/test/scala/vastblue/file/FileSpec.scala new file mode 100644 index 0000000..0edc6d4 --- /dev/null +++ b/src/test/scala/vastblue/file/FileSpec.scala @@ -0,0 +1,290 @@ +package vastblue.file + +import vastblue.pathextend.* +import vastblue.file.Paths.{cwd, defaultDrive, isWindows} +import org.scalatest.* +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class FileSpec extends AnyFunSpec with Matchers with BeforeAndAfter { + var verbose = false // manual control + lazy val TMP = { + val gdir = Paths.get("/g") + //val str = gdir.localpath + gdir.isDirectory && gdir.paths.nonEmpty match { + case true => + "/g/tmp" + case false => + "/tmp" + } + } + /** + * similar to gnu 'touch '. + */ + def touch(p: Path): Int = { + var exitCode = 0 + try { + p.toFile.createNewFile() + } catch { + case _:Exception => + exitCode = 17 + } + exitCode + } + def touch(file: String): Int = { + touch(file.toPath) + } + lazy val testFile: Path = { + val fnamestr = s"${TMP}/youMayDeleteThisDebrisFile.txt" + isWindows match { + case true => + Paths.get(fnamestr) + case false => + Paths.get(fnamestr) + } + } + lazy val maxLines = 10 + lazy val testFileLines = (0 until maxLines).toList.map { _.toString } + + lazy val testfilename = "~/shellExecFileTest.out" + lazy val testfileb = { + val p = Paths.get(testfilename) + touch(p) + p + } + lazy val here = cwd.normalize.toString.toLowerCase + lazy val uhere = here.replaceAll("[a-zA-Z]:","").replace('\\','/') + lazy val hereDrive = here.replaceAll(":.*",":") match { + case drive if drive >= "a" && drive <= "z" => + drive + case _ => "" + } + lazy val gdrive = "g:/".path + lazy val gdriveTests = if (gdrive.exists) { // should NOT really be a function of whether driver exists! + List( + ("/g","g:\\"), + ("/g/","g:\\"), + ) + } else { + List( + ("/g","g:\\"), + ("/g/","g:\\"), + ) + } + lazy val expectedHomeDir = sys.props("user.home").replaceAll("/","\\") + lazy val fileDospathPairs = List( + (".", here), + (hereDrive, here), // jvm treats this as cwd, if on c: + ("/q/", "q:\\"), // assumes /etc/fstab mounts /cygdrive to / + ("/q", "q:\\"), // assumes /etc/fstab mounts /cygdrive to / + ("/c/", "c:\\"), + ("~", expectedHomeDir), + ("~/", expectedHomeDir), + ("/g", "g:\\"), + ("/g/", "g:\\"), + ("/c/data/", "c:\\data") + ) ::: gdriveTests + + lazy val nonCanonicalDefaultDrive = { + val dd = defaultDrive + dd != "c" + } + lazy val username = sys.props("user.name").toLowerCase + lazy val toStringPairs = List( + (".", uhere), + ("/q/", "/q"), + ("/q/file", "/q/file"), // assumes there is no Q: drive + (hereDrive, uhere), // jvm treats bare drive letter as cwd, if default drive + ("/c/", "/c"), + ("~", s"/users/${username}"), + ("~/", s"/users/${username}"), + ("/g", "/g"), + ("/g/", "/g"), + ("/c/data/", "/data") + ) + + before { + //vastblue.fileutils.touch(testfilename) + testFile.withWriter() { (w: PrintWriter) => + testFileLines.foreach { line => + w.print(line+"\n") + } + } + } +// after { +//// if( testFile.exists ) testFile.delete() +//// if( testfileb.exists ) testfileb.delete() +// } + + describe("File") { + describe ("#eachline") { + // def parseCsvLine(line:String,columnTypes:String,delimiter:String="") = { + it("should correctly deliver all file lines") { + //val lines = testFile.lines + System.out.printf("testFile[%s]\n",testFile) + for( (line,lnum) <- testFile.lines.toSeq.zipWithIndex ){ + val expected = testFileLines(lnum) + if(line != expected){ + println(s"line ${lnum}:\n [$line]\n [$expected]") + } + } + for( (line,lnum) <- testFile.lines.toSeq.zipWithIndex ){ + val expected = testFileLines(lnum) + if(line != expected){ + println(s"failure: line ${lnum}:\n [$line]\n [$expected]") + } else { + println(s"success: line ${lnum}:\n [$line]\n [$expected]") + } + assert(line == expected,s"line ${lnum}:\n [$line]\n [$expected]") + } + } + } + + describe ("#tilde-in-path-test") { + it("should see file in user home directory if present") { + val ok = testfileb.exists + if (ok) println(s"tilde successfully converted to path '$testfileb'") + assert(ok, s"error: cannot see file '$testfileb'") + } + it("should NOT see file in user home directory if NOT present") { + testfileb.delete() + val ok = !testfileb.exists + if (ok) println(s"delete() successfull, and correctly detected by 'exists' method on path '$testfileb'") + assert(ok, s"error: can still see file '$testfileb'") + } + } + // expected values of stdpath and localpath depend + // on whether g:/ exists. + // If so, c:/g is expected to resolve to /g and g:\\ +// lazy val (gu,gw) = os.dirExists("g:/") match { +// case true => ("/g", "g:\\") +// case false => ("/c/g","c:\\g") +// } + if( isWindows ){ + printf("gdrive.exists: %s\n",gdrive.exists) + printf("gdrive.isDirectory: %s\n",gdrive.isDirectory) + printf("gdrive.isRegularFileg: %s\n",gdrive.isDirectory) + printf("gdrive.isSymbolicLink: %s\n",gdrive.isSymbolicLink) + describe ("# dospath test") { + it("should correctly handle cygwin dospath drive designations, when present") { + var loop = -1 + for( (fname,expected) <- fileDospathPairs ){ + loop += 1 + printf("fname[%s], expected[%s]\n", fname, expected) + val file = Paths.get(fname) + printf("%-22s : %s\n",file.stdpath,file.exists) + val a = expected.toLowerCase + // val b = file.toString.toLowerCase + // val c = file.localpath.toLowerCase + val d = file.dospath.toLowerCase + if(a == d){ + println(s"a [$a] == d [$d]") + assert(a == d) + } else { + printf("expected[%s]\n",expected.toLowerCase) + printf("file.localpath[%s]\n",file.localpath.toLowerCase) + printf("error: expected[%s] not equal to dospath [%s]\n",expected.toLowerCase,file.localpath.toLowerCase) + if( file.exists && new JFile(expected).exists ){ + assert(a == d) + } else { + println(s"file.exists and expected.exists: [$file] == d [$expected]") + } + } + } + } + } + describe ("# toString test") { + it("should correctly handle toString") { + val upairs = toStringPairs.toArray.toSeq + printf("%d pairs\n",upairs.size) + for( (fname,expected) <- upairs ){ + if (true || verbose){ + printf("=====================\n") + printf("fname[%s]\n", fname) + printf("expec[%s]\n", expected) + } + val file: Path = Paths.get(fname) + printf("file.norm[%-22s] : %s\n",file.norm, file.exists) + printf("file.stdpath[%-22s] : %s\n",file.stdpath, file.exists) + val exp = expected.toLowerCase + val std = file.stdpath.toLowerCase + val nrm = file.norm.toLowerCase + printf("exp[%s] : std[%s] : nrm[%s]\n", exp, std, nrm) + //val c = file.localpath.toLowerCase + //val d = file.dospath.toLowerCase + if( ! std.endsWith(exp)){ + printf("error: toString[%s] doesn't end with expected[%s]\n",nrm, exp) + } + // note: in some cases (on Windows, for semi-absolute paths not on the default drive), the `stdpath` version` + // of the path must include a `cygdrive` version of the drive letter. This test is more subtle in order to + // recognize this case. + if (nonCanonicalDefaultDrive) { + printf("hereDrive[%s]\n", hereDrive) + if (std.endsWith(exp)) { + println(s"std[$std].endsWith(exp[$exp]) for hereDrive[$hereDrive]"); + } + assert(std.endsWith(exp)) + } else { + if (exp == std){ + println(s"std[$std] == exp[$exp]") + } else { + hook += 1 + } + if (defaultDrive.nonEmpty){ + assert(exp == std) // || exp.drop(2) == std.drop(2) || std.contains(exp)) + } else { + assert(exp == std) + } + } + + } + } + } + + def getVariants(p: Path): Seq[Path] = { + val pstr = p.toString.toLowerCase + def includeStdpath: Seq[String] = if (pstr.startsWith(defaultDrive)) { + List(p.stdpath) + } else { + Nil + } + + val variants:Seq[String] = List( + p.norm, + p.toString, + p.localpath, + p.dospath + ) ++ includeStdpath // stdpath fails round-trip test when default drive != C: + + val qtest = Paths.get("q:/file") + val vlist = variants.distinct.map { s => + + val p = Paths.get(s) + if (p.toString.take(1).toLowerCase != pstr.take(1)) { + hook += 1 + } + p + } + vlist.distinct + } + describe ("# File name consistency") { + it("round trip conversions should be consistent") { + for( fname <- (toStringPairs.toMap.keySet ++ fileDospathPairs.toMap.keySet).toList.distinct.sorted ){ + val f1: Path = Paths.get(fname) + val variants: Seq[Path] = getVariants(f1) + for( v <- variants ){ // not necessarily 4 variants (duplicates removed before map to Path) + //val (k1,k2) = (f1.key,v.key) + if (f1 != v ){ + printf("f1[%s]\nv[%s]\n",f1,v) + } + if (f1.equals(v)){ + println(s"f1[$f1] == v[$v]") + } + assert(f1.equals(v),s"f1[$f1] != variant v[$v]") + } + } + } + } + } + } +} diff --git a/src/test/scala/vastblue/file/FilenameTest.scala b/src/test/scala/vastblue/file/FilenameTest.scala new file mode 100644 index 0000000..262110b --- /dev/null +++ b/src/test/scala/vastblue/file/FilenameTest.scala @@ -0,0 +1,19 @@ +package vastblue.file + +import vastblue.pathextend.* +import vastblue.file.Paths.* +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class FilenameTest extends AnyFunSpec with Matchers with BeforeAndAfter { + describe("File.exists"){ + it("should correctly see whether a mapped dir (like W:/alltickers) exists or not"){ + val testdir = "w:/alltickers" + val jf = Paths.get(testdir) + printf("jf.exists [%s]\n",jf.exists) + //val bf = vastblue.Platform.getPath("/share","alltickers") + //assert(bf.exists) // not yet ready from prime-time + } + } +} diff --git a/src/test/scala/vastblue/file/PathSpec.scala b/src/test/scala/vastblue/file/PathSpec.scala new file mode 100644 index 0000000..acc8202 --- /dev/null +++ b/src/test/scala/vastblue/file/PathSpec.scala @@ -0,0 +1,263 @@ +package vastblue.file + +import org.scalatest.* +import vastblue.pathextend.* +import vastblue.file.Paths.{canExist, cwd, defaultDrive, isWindows, normPath} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class PathSpec extends AnyFunSpec with Matchers with BeforeAndAfter { + var hook = 0 + lazy val TMP = { + if (canExist("/g".path)) { + val gdir = Paths.get("/g") + //val str = gdir.localpath + gdir.isDirectory && gdir.paths.contains("/tmp") match { + case true => + "/g/tmp" + case false => + "/tmp" + } + } else { + "/tmp" + } + } + lazy val testFile: Path = { + val fnamestr = s"${TMP}/youMayDeleteThisDebrisFile.txt" + Paths.get(fnamestr) + } + + lazy val maxLines = 10 + lazy val testFileLines = (0 until maxLines).toList.map { _.toString } + + lazy val homeDirTestFile = "~/shellExecFileTest.out" + lazy val testfileb: Path = { + Paths.get(homeDirTestFile) + } + lazy val here = cwd.normalize.toString.toLowerCase + lazy val uhere = here.replaceAll("[a-zA-Z]:","").replace('\\','/') + lazy val hereDrive = here.replaceAll(":.*",":") match { + case drive if drive >= "a" && drive <= "z" => + drive + case _ => "" + } + + /** + * similar to gnu 'touch '. + */ + def touch(targetFile: Path): Int = { + var exitCode = 0 + try { + // line ending is a place holder, if no lines. + //targetFile.withWriter(){ _ => } + targetFile.toFile.createNewFile() + } catch { + case _:Exception => + exitCode = 17 + } + exitCode + } + def touch(file:String):Int = { + touch(file.toPath) + } + before { + // create homeDirTestFile + val tfpath = homeDirTestFile.path + touch(tfpath) + printf("homeDirTestFile: %s\n", homeDirTestFile) + // create testFile + testFile.withWriter() { w => + testFileLines.foreach { line => + w.print(line+"\n") + } + } + printf("testFile: %s\n", testFile) + } +// after { +//// if( testFile.exists ) testFile.delete() +//// if( testfileb.exists ) testfileb.delete() +// } + + describe("File") { + describe ("#eachline") { + // def parseCsvLine(line:String,columnTypes:String,delimiter:String="") = { + it("should correctly deliver all file lines") { + //val lines = testFile.lines + System.out.printf("testFile[%s]\n",testFile) + for( (line,lnum) <- testFile.lines.toSeq.zipWithIndex ){ + val expected = testFileLines(lnum) + if(line != expected){ + System.err.println(s"line ${lnum}:\n [$line]\n [$expected]") + } + } + for( (line,lnum) <- testFile.lines.toSeq.zipWithIndex ){ + val expected = testFileLines(lnum) + if(line != expected){ + System.err.println(s"line ${lnum}:\n [$line]\n [$expected]") + } + assert(line == expected,s"line ${lnum}:\n [$line]\n [$expected]") + } + } + } + + describe ("#tilde-in-path-test") { + it("should see file in user home directory if present") { + val ok = testfileb.exists + assert(ok, s"error: cannot see file '$testfileb'") + } + it("should NOT see file in user home directory if NOT present") { + val test: Boolean = testfileb.delete() + val ok = !testfileb.exists || !test + assert(ok, s"error: can still see file '$testfileb'") + } + } + // expected values of stdpath and localpath depend + // on whether g:/ exists. + // If so, c:/g is expected to resolve to /g and g:\\ +// lazy val (gu,gw) = os.dirExists("g:/") match { +// case true => ("/g", "g:\\") +// case false => ("/c/g","c:\\g") +// } + if( isWindows ){ + val expectedHomeDir = sys.props("user.home").replace('/','\\') + val gdrive = Paths.get("g:/") + printf("gdrive.exists: %s\n",gdrive.exists) + printf("gdrive.isDirectory: %s\n",gdrive.isDirectory) + printf("gdrive.isRegularFileg: %s\n",gdrive.isDirectory) + printf("gdrive.isSymbolicLink: %s\n",gdrive.isSymbolicLink) + val gdriveTests = if (gdrive.exists) { // should NOT really be a function of whether driver exists! + List( + ("/g","g:\\"), + ("/g/","g:\\"), + ) + } else { + List( + ("/g","g:\\"), + ("/g/","g:\\"), + ) + } + lazy val pathDospathPairs = List( + (".", here), + (hereDrive, here), // jvm treats this as cwd, if on c: + ("/q/", "q:\\"), // assumes /etc/fstab mounts /cygdrive to / + ("/q", "q:\\"), // assumes /etc/fstab mounts /cygdrive to / + ("/c/", "c:\\"), + ("~", expectedHomeDir), + ("~/", expectedHomeDir), + ("/g", "g:\\"), + ("/g/", "g:\\"), + ("/c/data/", "c:\\data") + ) ::: gdriveTests + + describe ("# Path dospath test") { + it("should correctly handle cygwin dospath drive designations, when present") { + var loop = -1 + for( (fname,expected) <- pathDospathPairs ){ + loop += 1 + val file = Paths.get(fname) + printf("%-22s : %s\n",file.stdpath,file.exists) + val a = expected.toLowerCase + // val b = file.toString.toLowerCase + // val c = file.localpath.toLowerCase + if( fname == "/g" ){ + hook += 1 + } + //def abs(p: Path) = p.toAbsolutePath.normalize + val d = file.dospath.toLowerCase + val df = normPath(d) + val af = normPath(a) + val sameFile = Files.isSameFile(af,df) + if( !sameFile ){ + System.err.printf("expected[%s]\n",expected.toLowerCase) + System.err.printf("file.localpath[%s]\n",file.localpath.toLowerCase) + System.err.printf("error: expected[%s] not equal to dospath [%s]\n",expected.toLowerCase,file.localpath.toLowerCase) + if( file.exists && new JFile(expected).exists ){ + assert(a == d) + } + } + } + } + } + lazy val nonCanonicalDefaultDrive = defaultDrive != "c:" + lazy val username = sys.props("user.name").toLowerCase + lazy val pathToStringPairs = List( + (".", uhere), + ("/q/", "/q"), + ("/q/file", "/q/file"), // assumes there is no Q: drive + (hereDrive, uhere), // jvm treats bare drive letter as cwd, if default drive + ("/c/", "/c"), + ("~", s"/users/${username}"), + ("~/", s"/users/${username}"), + ("/g", "/g"), + ("/g/", "/g"), + ("/c/data/", "/data") + ) + describe ("# Path stdpath test") { + it("should correctly handle path toString") { + val upairs = pathToStringPairs.toArray.toSeq + printf("%d pairs\n",upairs.size) + var loop = -1 + for( (fname,expected) <- upairs ){ + loop += 1 + if( fname == "/q/file" ){ + hook += 1 + } + val file: Path = Paths.get(fname).toAbsolutePath.normalize() + printf("%-22s : %s\n",file.stdpath,file.exists) + val exp = expected.toLowerCase + val std = file.stdpath.toLowerCase + //val loc = file.localpath.toLowerCase + //val dos = file.dospath.toLowerCase + if (nonCanonicalDefaultDrive){ + if( !std.endsWith(exp) ){ + System.err.printf("error: stdpath[%s] not endsWith exp[%s]\n",std, exp) + } + assert(std.endsWith(expected)) // in this case, there should also be a cygdrive prefix (e.g., "/c") + } else { + if(exp != std){ + System.err.printf("error: expected[%s] not equal to toString [%s]\n",exp,std) + } + assert(exp == std) + } + + } + } + } + def getVariants(p: Path): Seq[Path] = { + val stdpathToo = if (nonCanonicalDefaultDrive) Nil else Seq(p.stdpath) + val variants:Seq[String] = Seq( + p.toString, + p.localpath, + p.dospath + ) ++ stdpathToo + + variants.distinct.map { s => + if( s == "q:/file" ){ + hook += 1 + } + val u = Paths.get(s) + u + } + } + describe ("# Path consistency") { + it("round trip conversions should be consistent") { + for( fname <- (pathToStringPairs.toMap.keySet ++ pathDospathPairs.toMap.keySet).toList.distinct.sorted ){ + if( fname == "/q/file" ){ + hook += 1 + } + val f1 = Paths.get(fname) + val variants:Seq[Path] = getVariants(f1) + for( v <- variants ){ // not necessarily 4 variants (duplicates removed before map to Path) + //val (k1,k2) = (f1.key,v.key) + val sameFile = Files.isSameFile(f1,v) + if (!sameFile){ + System.err.printf("f1[%s]\nv[%s]\n",f1,v) + } + assert(sameFile,s"f1[$f1] != variant v[$v]") + } + } + } + } + } + } +} diff --git a/src/test/scala/vastblue/file/PathnameTest.scala b/src/test/scala/vastblue/file/PathnameTest.scala new file mode 100644 index 0000000..7df2d1b --- /dev/null +++ b/src/test/scala/vastblue/file/PathnameTest.scala @@ -0,0 +1,55 @@ +package vastblue.file + +import vastblue.pathextend.* +import vastblue.file.Paths.* +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class PathnameTest extends AnyFunSpec with Matchers with BeforeAndAfter { + lazy val TMP = { + val gdir = Paths.get("/g") + gdir.isDirectory && gdir.paths.nonEmpty match { + case true => + "/g/tmp" + case false => + "/tmp" + } + } + + describe ("special-chars") { + it("should correctly handle filenames with special characters") { + val testfilenames = Seq( + s"${TMP}/_Завещание&chapter=all" + ,s"${TMP}/Canada's_Border.mp3" + // ,s"${TMP}/ï" + ,s"${TMP}/Canada&s_Border.mp3" + ,s"${TMP}/Canada=s_Border.mp3" + ,s"${TMP}/Canada!s_Border.mp3" + ,s"${TMP}/philosophy&chapter=all" + ,s"${TMP}/_2&chapter=all" + ,s"${TMP}/_3&chapter=all" + ) + for( testfilename <- testfilenames ){ + val testfile = Paths.get(testfilename) + val testPossible = testfile.parentFile match { + case dir if dir.isDirectory => + true + case _ => + false + } + if( ! testPossible ){ + hook += 1 + } else { + if( ! testfile.exists ){ + // create dummy test file + testfile.withWriter(){ w => + w.printf("abc\n") + } + } + printf("[%s]\n",testfile.stdpath) + } + } + } + } +} diff --git a/src/test/scala/vastblue/file/RootRelativeTest.scala b/src/test/scala/vastblue/file/RootRelativeTest.scala new file mode 100644 index 0000000..6b8596e --- /dev/null +++ b/src/test/scala/vastblue/file/RootRelativeTest.scala @@ -0,0 +1,79 @@ +package vastblue.file + +import vastblue.pathextend.* +import vastblue.file.Paths.* +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers + +class RootRelativeTest extends AnyFunSpec with Matchers with BeforeAndAfter { + describe("Root-relative paths"){ + it("should correctly resolve pathRelative paths in Windows"){ + // NOTE: current working directory is set before running test (e.g., in IDE) + val currentWorkingDirectory = Paths.get(".").toAbsolutePath.getRoot.toString.take(2) + if (currentWorkingDirectory.contains(":")){ + // windows os + printf("cwd: %s\n", currentWorkingDirectory) + val testdirs = Seq("/opt", "/OPT", "/$RECYCLE.BIN") + val mounts = mountMap.keySet.toArray + for (testdir <- testdirs){ + val mounted = mounts.find((dir: String) => sameFile(dir, testdir)) + val thisPath = mounted match { + case Some(str) => + mountMap(str) + case None => + testdir + } + val jf = Paths.get(thisPath) + printf("[%s]: exists [%s]\n",jf.norm, jf.exists) + val sameDriveLetter = jf.toString.take(2).equalsIgnoreCase(currentWorkingDirectory) + if (mounted.isEmpty && !sameDriveLetter){ + hook += 1 + } + // if path is not affected by mount map, drive letters must match + assert(mounted.nonEmpty || sameDriveLetter) + } + } + } + + it("should avoid incorrectly applying mountMap, if present"){ + printf("%s\n", envpath) + printf("%s\n", jvmpath) + val mounts = mountMap.keySet.toArray + if (mounts.nonEmpty){ + val testdirs = Seq("/opt", "/optx") + for (dir <- testdirs){ + val mounted = mounts.find((s: String) => + sameFile(s, dir) + ) + val thisPath = mounted match { + case Some(str) => + mountMap(str) + case None => + dir + } + val jf = java.nio.file.Paths.get(thisPath) + printf("[%s]: exists [%s]\n",jf.norm, jf.exists) + val testdir = java.nio.file.Paths.get(dir) + if (mounted.nonEmpty != testdir.exists){ + hook += 1 + } + assert(mounted.nonEmpty == testdir.exists) + } + } + } + } + + def envpath: String = { + val psep = java.io.File.pathSeparator + val entries: List[String] = Option(System.getenv("PATH")).getOrElse("").split(psep).map { _.toString }.toList + val path: String = entries.map { _.replace('\\', '/').toLowerCase }.distinct.mkString(";") + path + } + def jvmpath: String = { + val psep = java.io.File.pathSeparator + val entries: List[String] = sys.props("java.library.path").split(psep).map { _.toString }.toList + val path: String = entries.map { _.replace('\\', '/').toLowerCase }.distinct.mkString(";") + path + } +}