diff --git a/build.sc b/build.sc index 82bdb308..8e46b14e 100644 --- a/build.sc +++ b/build.sc @@ -224,6 +224,13 @@ object tests extends Module { Deps.utest ) def testFramework = "utest.runner.Framework" + // not sure why we need to manually add this one + def sources = T.sources(super.sources() ++ CrossSources.extraSourcesDirs( + scalaVersion(), + millSourcePath, + "js", + "test" + )) } } trait TestsNative extends Tests0 with CaseAppScalaNativeModule { diff --git a/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala b/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala deleted file mode 100644 index b666e487..00000000 --- a/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ /dev/null @@ -1,22 +0,0 @@ -package caseapp.core.app - -import caseapp.core.complete.{CompletionsInstallOptions, CompletionsUninstallOptions} - -import scala.scalajs.js.Dynamic.{global => g} - -trait PlatformCommandsMethods { self: CommandsEntryPoint => - private lazy val fs = g.require("fs") - protected def writeCompletions(script: String, dest: String): Unit = - fs.writeFileSync(dest, script) - protected def completeMainHook(args: Array[String]): Unit = () - - def completionsInstall(completionsWorkingDirectory: Option[String], args: CompletionsInstallOptions): Unit = { - printLine("Completion installation not available on Scala.js") - exit(1) - } - - def completionsUninstall(completionsWorkingDirectory: Option[String], args: CompletionsUninstallOptions): Unit = { - printLine("Completion uninstallation not available on Scala.js") - exit(1) - } -} diff --git a/core/js/src/main/scala/caseapp/core/app/nio/File.scala b/core/js/src/main/scala/caseapp/core/app/nio/File.scala new file mode 100644 index 00000000..ab06cae0 --- /dev/null +++ b/core/js/src/main/scala/caseapp/core/app/nio/File.scala @@ -0,0 +1,5 @@ +package caseapp.core.app.nio + +object File { + def separator = Path.nodePath.sep.asInstanceOf[String] +} diff --git a/core/js/src/main/scala/caseapp/core/app/nio/FileOps.scala b/core/js/src/main/scala/caseapp/core/app/nio/FileOps.scala new file mode 100644 index 00000000..425d2b7a --- /dev/null +++ b/core/js/src/main/scala/caseapp/core/app/nio/FileOps.scala @@ -0,0 +1,34 @@ +package caseapp.core.app.nio + +import scala.scalajs.js +import scala.scalajs.js.Dynamic.{global => g} + +object FileOps { + + private lazy val nodeFs = g.require("fs") + private lazy val nodeOs = g.require("os") + private lazy val nodeProcess = g.require("process") + + def readFile(path: Path): String = + nodeFs.readFileSync(path.underlying, js.Dictionary("encoding" -> "utf8")).asInstanceOf[String] + def writeFile(path: Path, content: String): Unit = + nodeFs.writeFileSync(path.underlying, content) + def appendToFile(path: Path, content: String): Unit = + nodeFs.writeFileSync(path.underlying, content, js.Dictionary("mode" -> "a")) + def readEnv(varName: String): Option[String] = + nodeProcess.env.asInstanceOf[js.Dictionary[String]].get(varName) + def homeDir: Path = + Paths.get(nodeOs.homedir().asInstanceOf[String]) + + def createDirectories(path: Path): Unit = + nodeFs.mkdirSync(path.underlying, js.Dictionary("recursive" -> true)) + + // simple aliases, to avoid explicit imports of Files, + // which might point to _root_.java.nio.file.Files or _root_.caseapp.core.app.nio.Files + def exists(path: Path): Boolean = + Files.exists(path) + def isRegularFile(path: Path): Boolean = + Files.isRegularFile(path) + def deleteIfExists(path: Path): Boolean = + Files.deleteIfExists(path) +} diff --git a/core/js/src/main/scala/caseapp/core/app/nio/Files.scala b/core/js/src/main/scala/caseapp/core/app/nio/Files.scala new file mode 100644 index 00000000..ce6263b4 --- /dev/null +++ b/core/js/src/main/scala/caseapp/core/app/nio/Files.scala @@ -0,0 +1,20 @@ +package caseapp.core.app.nio + +import scala.scalajs.js +import scala.scalajs.js.Dynamic.{global => g} + +object Files { + + def exists(path: Path): Boolean = + nodeFs.existsSync(path.underlying).asInstanceOf[Boolean] + def createDirectories(path: Path): Unit = + nodeFs.mkdirSync(path.underlying, js.Dictionary("recursive" -> true)) + def isRegularFile(path: Path): Boolean = + exists(path) && nodeFs.statSync(path.underlying).isFile().asInstanceOf[Boolean] + def deleteIfExists(path: Path): Boolean = { + nodeFs.rmSync(path.underlying, js.Dictionary("recursive" -> true)) + true + } + + private lazy val nodeFs = g.require("fs") +} diff --git a/core/js/src/main/scala/caseapp/core/app/nio/Path.scala b/core/js/src/main/scala/caseapp/core/app/nio/Path.scala new file mode 100644 index 00000000..14c926eb --- /dev/null +++ b/core/js/src/main/scala/caseapp/core/app/nio/Path.scala @@ -0,0 +1,19 @@ +package caseapp.core.app.nio + +import scala.scalajs.js +import scala.scalajs.js.Dynamic.{global => g} + +final case class Path(underlying: String) { + import Path.nodePath + def resolve(chunk: String): Path = + Path(nodePath.join(underlying, chunk).asInstanceOf[String]) + def getFileName: Path = + Path(nodePath.basename(underlying).asInstanceOf[String]) + def getParent: Path = + Path(nodePath.join(underlying, "..").asInstanceOf[String]) + override def toString: String = underlying +} + +object Path { + private[nio] lazy val nodePath = g.require("path") +} diff --git a/core/js/src/main/scala/caseapp/core/app/nio/Paths.scala b/core/js/src/main/scala/caseapp/core/app/nio/Paths.scala new file mode 100644 index 00000000..2fb65c30 --- /dev/null +++ b/core/js/src/main/scala/caseapp/core/app/nio/Paths.scala @@ -0,0 +1,6 @@ +package caseapp.core.app.nio + +object Paths { + def get(path: String): Path = + Path(path) +} diff --git a/core/jvm-native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala b/core/jvm-native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala deleted file mode 100644 index e14889e2..00000000 --- a/core/jvm-native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ /dev/null @@ -1,219 +0,0 @@ -package caseapp.core.app - -import caseapp.core.complete.{ - Bash, - CompletionItem, - CompletionsInstallOptions, - CompletionsUninstallOptions, - Fish, - Zsh -} - -import java.io.File -import java.nio.charset.{Charset, StandardCharsets} -import java.nio.file.{Files, Path, Paths, StandardOpenOption} - -import java.util.Arrays - -trait PlatformCommandsMethods { self: CommandsEntryPoint => - protected def writeCompletions(script: String, dest: String): Unit = { - val destPath = Paths.get(dest) - Files.write(destPath, script.getBytes(StandardCharsets.UTF_8)) - } - - protected def completeMainHook(args: Array[String]): Unit = - for (path <- completionDebugFile) { - val output = s"completeMain(${args.toSeq})" - Files.write(path, output.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND) - } - - def completionsInstalledMessage( - rcFile: String, - updated: Boolean - ): Iterator[String] = { - val q = "\"" - val evalCommand = - s"eval $q$$($progName ${completionsCommandName.mkString(" ")} install --env)$q" - if (updated) - Iterator( - s"Updated $rcFile", - "", - s"It is recommended to reload your shell, or source $rcFile in the " + - "current session, for its changes to be taken into account.", - "", - "Alternatively, enable completions in the current session with", - "", - s" $evalCommand", - "" - ) - else - Iterator( - s"$rcFile already up-to-date.", - "", - "If needed, enable completions in the current session with", - "", - s" $evalCommand", - "" - ) - } - - def shell: Option[String] = Option(System.getenv("SHELL")) - def completionHome: Path = Paths.get(sys.props("user.home")) - def completionXdgHome: Option[Path] = Option(System.getenv("XDG_CONFIG_HOME")).map(Paths.get(_)) - def completionZDotDir: Option[Path] = Option(System.getenv("ZDOTDIR")).map(Paths.get(_)) - def completionDebugFile: Option[Path] = - Option(System.getenv("CASEAPP_COMPLETION_DEBUG")).map(Paths.get(_)) - - private def fishRcFile(name: String): Path = - completionXdgHome - .getOrElse(completionHome.resolve(".config")) - .resolve("fish") - .resolve("completions") - .resolve(s"$name.fish") - - private def zshrcFile: Path = - completionZDotDir - .getOrElse(completionHome) - .resolve(".zshrc") - private def bashrcFile: Path = - completionHome.resolve(".bashrc") - - private def zshCompletionWorkingDir(forcedOutputDir: Option[String]): Path = - forcedOutputDir - .orElse(completionsWorkingDirectory) - .map(Paths.get(_).resolve("zsh")) - .getOrElse { - val zDotDir = completionZDotDir.getOrElse(completionHome) - completionXdgHome - .getOrElse(zDotDir.resolve(".config")) - .resolve("zsh") - .resolve("completions") - } - - // Adapted from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/installcompletions/InstallCompletions.scala - def completionsInstall( - completionsWorkingDirectory: Option[String], - options: CompletionsInstallOptions - ): Unit = { - val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) - val format = PlatformCommandsMethods.getFormat(options.format, shell).getOrElse { - printLine( - "Cannot determine current shell, pass the shell you use with --shell, like", - toStderr = true - ) - printLine("", toStderr = true) - for (shell <- Seq(Bash.shellName, Zsh.shellName, Fish.shellName)) - printLine( - s" $name ${completionsCommandName.mkString(" ")} install --shell $shell", - toStderr = true - ) - printLine("", toStderr = true) - exit(1) - } - - val (rcScript, defaultRcFile) = format match { - case Bash.id | Bash.shellName => - (Bash.script(name), bashrcFile) - case Fish.id | Fish.shellName => - (Fish.script(name), fishRcFile(name)) - case Zsh.id | Zsh.shellName => - val completionScript = Zsh.script(name) - val dir = zshCompletionWorkingDir(options.output) - val completionScriptDest = dir.resolve(s"_$name") - val needsWrite = - !Files.exists(completionScriptDest) || - new String( - Files.readAllBytes(completionScriptDest), - StandardCharsets.UTF_8 - ) != completionScript - if (needsWrite) { - printLine(s"Writing $completionScriptDest") - Files.createDirectories(completionScriptDest.getParent) - Files.write(completionScriptDest, completionScript.getBytes(StandardCharsets.UTF_8)) - } - val script = Seq(s"""fpath=("$dir" $$fpath)""", "compinit") - .map(_ + System.lineSeparator()) - .mkString - (script, zshrcFile) - case _ => - printLine(s"Unrecognized or unsupported shell: $format", toStderr = true) - exit(1) - } - - if (options.env) - println(rcScript) - else { - val rcFile = format match { - case Fish.id | Fish.shellName => - options.output.map(Paths.get(_)).map(_.resolve(s"$name.fish")).getOrElse(defaultRcFile) - case _ => - options.rcFile.map(Paths.get(_)).getOrElse(defaultRcFile) - } - val banner = options.banner.replace("{NAME}", name) - val updated = ProfileFileUpdater.addToProfileFile( - rcFile, - banner, - rcScript, - Charset.defaultCharset() - ) - - for (line <- completionsInstalledMessage(rcFile.toString, updated)) - printLine(line, toStderr = true) - } - } - - def completionsUninstall( - completionsWorkingDirectory: Option[String], - options: CompletionsUninstallOptions - ): Unit = { - val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) - - val rcFiles = options.rcFile - .map(file => Seq(Paths.get(file))) - .getOrElse(Seq(zshrcFile, bashrcFile)) - .filter(Files.exists(_)) - - val maybeDelete = Seq( - zshCompletionWorkingDir(options.output).resolve(s"_$name"), - fishRcFile(name) - ) - - for (rcFile <- rcFiles) { - val banner = options.banner.replace("{NAME}", name) - - val updated = ProfileFileUpdater.removeFromProfileFile( - rcFile, - banner, - Charset.defaultCharset() - ) - - if (updated) { - printLine(s"Updated $rcFile", toStderr = true) - printLine(s"$name completions uninstalled successfully", toStderr = true) - } - else - printLine(s"No $name completion section found in $rcFile", toStderr = true) - } - - for (f <- maybeDelete) - if (Files.isRegularFile(f)) { - val deleted = Files.deleteIfExists(f) - if (deleted) - printLine(s"Removed $f", toStderr = true) - } - } - -} - -object PlatformCommandsMethods { - def getFormat(format: Option[String], shellOpt: Option[String]): Option[String] = - format.map(_.trim).filter(_.nonEmpty) - .orElse { - shellOpt.map(_.split(File.separator).last).map { - case Bash.shellName => Bash.id - case Fish.shellName => Fish.id - case Zsh.shellName => Zsh.id - case other => other - } - } -} diff --git a/core/jvm-native/src/main/scala/caseapp/core/app/nio/FileOps.scala b/core/jvm-native/src/main/scala/caseapp/core/app/nio/FileOps.scala new file mode 100644 index 00000000..f537efdc --- /dev/null +++ b/core/jvm-native/src/main/scala/caseapp/core/app/nio/FileOps.scala @@ -0,0 +1,34 @@ +package caseapp.core.app.nio + +import java.nio.charset.StandardCharsets +import java.nio.file.{FileAlreadyExistsException, Files, Path, Paths, StandardOpenOption} + +object FileOps { + + def readFile(path: Path): String = + new String(Files.readAllBytes(path), StandardCharsets.UTF_8) + def writeFile(path: Path, content: String): Unit = + Files.write(path, content.getBytes(StandardCharsets.UTF_8)) + def appendToFile(path: Path, content: String): Unit = + Files.write(path, content.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND) + def readEnv(varName: String): Option[String] = + Option(System.getenv(varName)) + def homeDir: Path = + Paths.get(sys.props("user.home")) + + def createDirectories(path: Path): Unit = + try Files.createDirectories(path) + catch { + // Ignored, see https://bugs.openjdk.java.net/browse/JDK-8130464 + case _: FileAlreadyExistsException if Files.isDirectory(path) => + } + + // simple aliases, to avoid explicit imports of Files, + // which might point to _root_.java.nio.file.Files or _root_.caseapp.core.app.nio.Files + def exists(path: Path): Boolean = + Files.exists(path) + def isRegularFile(path: Path): Boolean = + Files.isRegularFile(path) + def deleteIfExists(path: Path): Boolean = + Files.deleteIfExists(path) +} diff --git a/core/jvm-native/src/main/scala/caseapp/core/app/nio/package.scala b/core/jvm-native/src/main/scala/caseapp/core/app/nio/package.scala new file mode 100644 index 00000000..6d04a171 --- /dev/null +++ b/core/jvm-native/src/main/scala/caseapp/core/app/nio/package.scala @@ -0,0 +1,17 @@ +package caseapp.core.app + +package object nio { + + type Path = java.nio.file.Path + + object Paths { + def get(path: String): Path = + java.nio.file.Paths.get(path) + } + + object File { + def separator: String = + java.io.File.separator + } + +} diff --git a/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala b/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala index 485b186a..4e25010c 100644 --- a/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala +++ b/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala @@ -1,5 +1,6 @@ package caseapp.core.app +import caseapp.core.app.nio._ import caseapp.core.commandparser.RuntimeCommandParser import caseapp.core.complete.{ Bash, @@ -11,7 +12,7 @@ import caseapp.core.complete.{ } import caseapp.core.help.{Help, HelpFormat, RuntimeCommandHelp, RuntimeCommandsHelp} -abstract class CommandsEntryPoint extends PlatformCommandsMethods { +abstract class CommandsEntryPoint { def defaultCommand: Option[Command[_]] = None def commands: Seq[Command[_]] @@ -89,6 +90,175 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { def completionsWorkingDirectory: Option[String] = None + protected def completeMainHook(args: Array[String]): Unit = + for (path <- completionDebugFile) { + val output = s"completeMain(${args.toSeq})" + FileOps.appendToFile(path, output) + } + + def completionsInstalledMessage( + rcFile: String, + updated: Boolean + ): Iterator[String] = { + val q = "\"" + val evalCommand = + s"eval $q$$($progName ${completionsCommandName.mkString(" ")} install --env)$q" + if (updated) + Iterator( + s"Updated $rcFile", + "", + s"It is recommended to reload your shell, or source $rcFile in the " + + "current session, for its changes to be taken into account.", + "", + "Alternatively, enable completions in the current session with", + "", + s" $evalCommand", + "" + ) + else + Iterator( + s"$rcFile already up-to-date.", + "", + "If needed, enable completions in the current session with", + "", + s" $evalCommand", + "" + ) + } + + def shell: Option[String] = FileOps.readEnv("SHELL") + def completionHome: Path = FileOps.homeDir + def completionXdgHome: Option[Path] = FileOps.readEnv("XDG_CONFIG_HOME").map(Paths.get(_)) + def completionZDotDir: Option[Path] = FileOps.readEnv("ZDOTDIR").map(Paths.get(_)) + def completionDebugFile: Option[Path] = + FileOps.readEnv("CASEAPP_COMPLETION_DEBUG").map(Paths.get(_)) + + private def fishRcFile(name: String): Path = + completionXdgHome + .getOrElse(completionHome.resolve(".config")) + .resolve("fish") + .resolve("completions") + .resolve(s"$name.fish") + + private def zshrcFile: Path = + completionZDotDir + .getOrElse(completionHome) + .resolve(".zshrc") + private def bashrcFile: Path = + completionHome.resolve(".bashrc") + + private def zshCompletionWorkingDir(forcedOutputDir: Option[String]): Path = + forcedOutputDir + .orElse(completionsWorkingDirectory) + .map(Paths.get(_).resolve("zsh")) + .getOrElse { + val zDotDir = completionZDotDir.getOrElse(completionHome) + completionXdgHome + .getOrElse(zDotDir.resolve(".config")) + .resolve("zsh") + .resolve("completions") + } + + // Adapted from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/installcompletions/InstallCompletions.scala + def completionsInstall( + completionsWorkingDirectory: Option[String], + options: CompletionsInstallOptions + ): Unit = { + val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) + val format = CommandsEntryPoint.getFormat(options.format, shell, File.separator).getOrElse { + printLine( + "Cannot determine current shell, pass the shell you use with --shell, like", + toStderr = true + ) + printLine("", toStderr = true) + for (shell <- Seq(Bash.shellName, Zsh.shellName, Fish.shellName)) + printLine( + s" $name ${completionsCommandName.mkString(" ")} install --shell $shell", + toStderr = true + ) + printLine("", toStderr = true) + exit(1) + } + + val (rcScript, defaultRcFile) = format match { + case Bash.id | Bash.shellName => + (Bash.script(name), bashrcFile) + case Fish.id | Fish.shellName => + (Fish.script(name), fishRcFile(name)) + case Zsh.id | Zsh.shellName => + val completionScript = Zsh.script(name) + val dir = zshCompletionWorkingDir(options.output) + val completionScriptDest = dir.resolve(s"_$name") + val needsWrite = !FileOps.exists(completionScriptDest) || + FileOps.readFile(completionScriptDest) != completionScript + if (needsWrite) { + printLine(s"Writing $completionScriptDest") + FileOps.createDirectories(completionScriptDest.getParent) + FileOps.writeFile(completionScriptDest, completionScript) + } + val script = Seq(s"""fpath=("$dir" $$fpath)""", "compinit") + .map(_ + System.lineSeparator()) + .mkString + (script, zshrcFile) + case _ => + printLine(s"Unrecognized or unsupported shell: $format", toStderr = true) + exit(1) + } + + if (options.env) + println(rcScript) + else { + val rcFile = format match { + case Fish.id | Fish.shellName => + options.output.map(Paths.get(_)).map(_.resolve(s"$name.fish")).getOrElse(defaultRcFile) + case _ => + options.rcFile.map(Paths.get(_)).getOrElse(defaultRcFile) + } + val banner = options.banner.replace("{NAME}", name) + val updated = ProfileFileUpdater.addToProfileFile(rcFile, banner, rcScript) + + for (line <- completionsInstalledMessage(rcFile.toString, updated)) + printLine(line, toStderr = true) + } + } + + def completionsUninstall( + completionsWorkingDirectory: Option[String], + options: CompletionsUninstallOptions + ): Unit = { + val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) + + val rcFiles = options.rcFile + .map(file => Seq(Paths.get(file))) + .getOrElse(Seq(zshrcFile, bashrcFile)) + .filter(FileOps.exists(_)) + + val maybeDelete = Seq( + zshCompletionWorkingDir(options.output).resolve(s"_$name"), + fishRcFile(name) + ) + + for (rcFile <- rcFiles) { + val banner = options.banner.replace("{NAME}", name) + + val updated = ProfileFileUpdater.removeFromProfileFile(rcFile, banner) + + if (updated) { + printLine(s"Updated $rcFile", toStderr = true) + printLine(s"$name completions uninstalled successfully", toStderr = true) + } + else + printLine(s"No $name completion section found in $rcFile", toStderr = true) + } + + for (f <- maybeDelete) + if (FileOps.isRegularFile(f)) { + val deleted = FileOps.deleteIfExists(f) + if (deleted) + printLine(s"Removed $f", toStderr = true) + } + } + def completionsMain(args: Array[String]): Unit = { def script(format: String): String = @@ -111,7 +281,7 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { completionsUninstall(completionsWorkingDirectory, options) case Array(format, dest) => val script0 = script(format) - writeCompletions(script0, dest) + FileOps.writeFile(Paths.get(dest), script0) case Array(format) => val script0 = script(format) printLine(script0) @@ -203,3 +373,16 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { } } } + +object CommandsEntryPoint { + def getFormat(format: Option[String], shellOpt: Option[String], fileSep: String): Option[String] = + format.map(_.trim).filter(_.nonEmpty) + .orElse { + shellOpt.map(_.split(fileSep).last).map { + case Bash.shellName => Bash.id + case Fish.shellName => Fish.id + case Zsh.shellName => Zsh.id + case other => other + } + } +} diff --git a/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala b/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala index 7b250284..b6df80cf 100644 --- a/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala +++ b/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala @@ -2,8 +2,7 @@ package caseapp.core.app // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/internal/ProfileFileUpdater.scala -import java.nio.charset.Charset -import java.nio.file.{FileAlreadyExistsException, Files, Path} +import caseapp.core.app.nio._ // initially adapted from https://github.com/coursier/coursier/blob/d9a0fcc1af4876bec7f19a18f2c93d808e06df8d/modules/env/src/main/scala/coursier/env/ProfileUpdater.scala#L44-L137 @@ -25,8 +24,7 @@ object ProfileFileUpdater { def addToProfileFile( file: Path, title: String, - addition: String, - charset: Charset + addition: String ): Boolean = { def updated(content: String): Option[String] = { @@ -52,11 +50,11 @@ object ProfileFileUpdater { var updatedSomething = false val contentOpt = Some(file) - .filter(Files.exists(_)) - .map(f => new String(Files.readAllBytes(f), charset)) + .filter(FileOps.exists(_)) + .map(f => FileOps.readFile(f)) for (updatedContent <- updated(contentOpt.getOrElse(""))) { - Option(file.getParent).map(createDirectories(_)) - Files.write(file, updatedContent.getBytes(charset)) + Option(file.getParent).map(FileOps.createDirectories(_)) + FileOps.writeFile(file, updatedContent) updatedSomething = true } updatedSomething @@ -64,8 +62,7 @@ object ProfileFileUpdater { def removeFromProfileFile( file: Path, - title: String, - charset: Charset + title: String ): Boolean = { def updated(content: String): Option[String] = { @@ -80,21 +77,13 @@ object ProfileFileUpdater { var updatedSomething = false val contentOpt = Some(file) - .filter(Files.exists(_)) - .map(f => new String(Files.readAllBytes(f), charset)) + .filter(FileOps.exists(_)) + .map(f => FileOps.readFile(f)) for (updatedContent <- updated(contentOpt.getOrElse(""))) { - Option(file.getParent).map(createDirectories(_)) - Files.write(file, updatedContent.getBytes(charset)) + Option(file.getParent).map(FileOps.createDirectories(_)) + FileOps.writeFile(file, updatedContent) updatedSomething = true } updatedSomething } - - private def createDirectories(path: Path): Unit = - try Files.createDirectories(path) - catch { - // Ignored, see https://bugs.openjdk.java.net/browse/JDK-8130464 - case _: FileAlreadyExistsException if Files.isDirectory(path) => - } - } diff --git a/tests/js/src/test/scala/caseapp/os.scala b/tests/js/src/test/scala/caseapp/os.scala new file mode 100644 index 00000000..2d70ad2d --- /dev/null +++ b/tests/js/src/test/scala/caseapp/os.scala @@ -0,0 +1,134 @@ +package caseapp + +import scala.scalajs.js +import scala.scalajs.js.Dynamic.{global => g} + +import java.util.regex.Pattern + +object os { + + trait PathChunk { + def segments: Seq[String] + def ups: Int + } + + object PathChunk { + def checkSegment(s: String): Unit = + () // TODO + implicit class StringPathChunk(s: String) extends PathChunk { + checkSegment(s) + def segments = Seq(s) + def ups = 0 + override def toString() = s + } + } + + class RelPath private[os] (val ups: Int, val segments: Array[String]) { + def asSubPath: SubPath = { + require(ups == 0) + new SubPath(segments) + } + + override def toString(): String = + (Iterator.fill(ups)("..") ++ segments.iterator).mkString("/") + override def hashCode = segments.hashCode() + ups.hashCode() + override def equals(o: Any): Boolean = o match { + case p: RelPath => segments == p.segments && p.ups == ups + case p: SubPath => segments == p.segments && ups == 0 + case _ => false + } + } + + class SubPath private[os] (val segments: Seq[String]) { + def /(chunk: PathChunk): SubPath = { + require(chunk.ups <= segments.length) + new SubPath(segments.take(segments.length - chunk.ups) ++ chunk.segments) + } + + override def toString(): String = + segments.mkString("/") + override def hashCode = segments.hashCode() + override def equals(o: Any): Boolean = o match { + case p: SubPath => segments == p.segments + case p: RelPath => segments == p.segments && p.ups == 0 + case _ => false + } + } + + object SubPath { + val sub: SubPath = new SubPath(Seq.empty) + } + + class Path private[os] (val underlying: String) { + def /(chunk: PathChunk): Path = { + val elems = List.fill(chunk.ups)("..") ++ chunk.segments + val newPath = nodePath + .applyDynamic("join")((underlying +: elems).map(x => x: js.Any): _*) + .asInstanceOf[String] + new Path(newPath) + } + + def relativeTo(other: Path): os.RelPath = { + val rel = nodePath.relative(other.underlying, underlying).asInstanceOf[String] + val elems = rel.split(Pattern.quote(nodePath.sep.asInstanceOf[String])) + val ups = elems.takeWhile(_ == "..").length + new RelPath(ups, elems.drop(ups)) + } + + def toNIO = caseapp.core.app.nio.Path(underlying) + override def toString(): String = underlying + } + + object Path { + def apply(path: String): Path = + new Path(path) + } + + def sub: SubPath = SubPath.sub + + private lazy val fs = g.require("fs") + private lazy val nodePath = g.require("path") + private lazy val nodeOs = g.require("os") + + object exists { + def apply(path: Path): Boolean = + fs.existsSync(path.underlying).asInstanceOf[Boolean] + } + + object isDir { + def apply(path: Path): Boolean = + exists(path) && + fs.statSync(path.underlying).isDirectory().asInstanceOf[Boolean] + } + + object read { + def apply(path: Path): String = + fs.readFileSync(path.underlying, js.Dictionary("encoding" -> "utf8")) + .asInstanceOf[String] + } + + object remove { + object all { + def apply(path: Path): Unit = + fs.rmSync(path.underlying, js.Dictionary("recursive" -> true, "force" -> true)) + } + } + + object temp { + + object dir { + def apply(prefix: String): Path = + new Path(fs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), prefix)).asInstanceOf[String]) + } + + } + + object walk { + def apply(path: Path): Seq[Path] = + fs.readdirSync(path.underlying, js.Dictionary("recursive" -> true)) + .asInstanceOf[js.Array[String]] + .toVector + .map(subPath => Path(nodePath.join(path.underlying, subPath).asInstanceOf[String])) + } + +} diff --git a/tests/jvm-native/src/test/scala/caseapp/CompletionInstallTests.scala b/tests/shared/src/test/scala/caseapp/CompletionInstallTests.scala similarity index 100% rename from tests/jvm-native/src/test/scala/caseapp/CompletionInstallTests.scala rename to tests/shared/src/test/scala/caseapp/CompletionInstallTests.scala