diff --git a/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala b/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala index 53669c86..34f893eb 100644 --- a/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ b/core/js/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala @@ -2,9 +2,19 @@ package caseapp.core.app import scala.scalajs.js.Dynamic.{global => g} -trait PlatformCommandsMethods { +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: String, args: Seq[String]): Unit = { + printLine("Completion installation not available on Scala.js") + exit(1) + } + + def completionsUninstall(completionsWorkingDirectory: String, args: Seq[String]): Unit = { + printLine("Completion uninstallation not available on Scala.js") + exit(1) + } } diff --git a/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala b/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala index 7812e8e8..ad7d64c0 100644 --- a/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ b/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala @@ -1,9 +1,13 @@ package caseapp.core.app -import java.nio.charset.StandardCharsets +import caseapp.core.complete.{Bash, Zsh} + +import java.io.File +import java.nio.charset.{Charset, StandardCharsets} import java.nio.file.{Files, Paths, StandardOpenOption} +import java.util.Arrays -trait PlatformCommandsMethods { +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)) @@ -14,4 +18,189 @@ trait PlatformCommandsMethods { val output = s"completeMain(${args.toSeq})" Files.write(path, output.getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND) } + + // Adapted from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/installcompletions/InstallCompletions.scala + def completionsInstall(completionsWorkingDirectory: String, args: Seq[String]): Unit = { + val (options, rem) = CaseApp.process[PlatformCommandsMethods.CompletionsInstallOptions](args) + + lazy val completionsDir = Paths.get(options.output.getOrElse(completionsWorkingDirectory)) + + val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) + val format = PlatformCommandsMethods.getFormat(options.format).getOrElse { + printLine( + "Cannot determine current shell, pass the shell you use with --shell, like", + toStderr = true + ) + printLine("", toStderr = true) + printLine( + s" $name ${completionsCommandName.mkString(" ")} install --shell zsh", + toStderr = true + ) + printLine( + s" $name ${completionsCommandName.mkString(" ")} install --shell bash", + toStderr = true + ) + printLine("", toStderr = true) + exit(1) + } + + val (rcScript, defaultRcFile) = format match { + case Bash.id | Bash.shellName => + val script = Bash.script(name) + val defaultRcFile = Paths.get(sys.props("user.home")).resolve(".bashrc") + (script, defaultRcFile) + case Zsh.id | Zsh.shellName => + val completionScript = Zsh.script(name) + val zDotDir = Paths.get(Option(System.getenv("ZDOTDIR")).getOrElse(sys.props("user.home"))) + val defaultRcFile = zDotDir.resolve(".zshrc") + val dir = completionsDir.resolve("zsh") + val completionScriptDest = dir.resolve(s"_$name") + val content = completionScript.getBytes(Charset.defaultCharset()) + val needsWrite = !Files.exists(completionScriptDest) || + !Arrays.equals(Files.readAllBytes(completionScriptDest), content) + if (needsWrite) { + printLine(s"Writing $completionScriptDest") + Files.createDirectories(completionScriptDest.getParent) + Files.write(completionScriptDest, content) + } + val script = Seq( + s"""fpath=("$dir" $$fpath)""", + "compinit" + ).map(_ + System.lineSeparator()).mkString + (script, defaultRcFile) + case _ => + printLine(s"Unrecognized or unsupported shell: $format") + exit(1) + } + + if (options.env) + println(rcScript) + else { + val rcFile = options.rcFile.map(Paths.get(_)).getOrElse(defaultRcFile) + val banner = options.banner.replace("{NAME}", name) + val updated = ProfileFileUpdater.addToProfileFile( + rcFile, + banner, + rcScript, + Charset.defaultCharset() + ) + + val q = "\"" + val evalCommand = + s"eval $q$$($progName ${completionsCommandName.mkString(" ")} install --env)$q" + if (updated) { + printLine(s"Updated $rcFile", toStderr = true) + printLine("", toStderr = true) + printLine( + s"It is recommended to reload your shell, or source $rcFile in the " + + "current session, for its changes to be taken into account.", + toStderr = true + ) + printLine("", toStderr = true) + printLine( + "Alternatively, enable completions in the current session with", + toStderr = true + ) + printLine("", toStderr = true) + printLine(s" $evalCommand", toStderr = true) + printLine("", toStderr = true) + } + else { + printLine(s"$rcFile already up-to-date.", toStderr = true) + printLine("", toStderr = true) + printLine("If needed, enable completions in the current session with", toStderr = true) + printLine("", toStderr = true) + printLine(s" $evalCommand", toStderr = true) + printLine("", toStderr = true) + } + } + } + + def completionsUninstall(completionsWorkingDirectory: String, args: Seq[String]): Unit = { + val (options, rem) = CaseApp.process[PlatformCommandsMethods.CompletionsUninstallOptions](args) + + val name = options.name.getOrElse(Paths.get(progName).getFileName.toString) + + val home = Paths.get(sys.props("user.home")) + val zDotDir = Option(System.getenv("ZDOTDIR")).map(Paths.get(_)).getOrElse(home) + val rcFiles = options.rcFile.map(file => Seq(Paths.get(file))).getOrElse(Seq( + zDotDir.resolve(".zshrc"), + home.resolve(".bashrc") + )).filter(Files.exists(_)) + + 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) + } + } +} + +object PlatformCommandsMethods { + import caseapp.{HelpMessage, Name} + import caseapp.core.help.Help + import caseapp.core.parser.Parser + + // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/installcompletions/InstallCompletionsOptions.scala + // format: off + final case class CompletionsInstallOptions( + @HelpMessage("Print completions to stdout") + env: Boolean = false, + @HelpMessage("Custom completions name") + name: Option[String] = None, + @HelpMessage("Name of the shell, either zsh or bash") + @Name("shell") + format: Option[String] = None, + @HelpMessage("Completions output directory") + @Name("o") + output: Option[String] = None, + @HelpMessage("Custom banner in comment placed in rc file") + banner: String = "{NAME} completions", + @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell") + rcFile: Option[String] = None + ) + // format: on + + object CompletionsInstallOptions { + implicit lazy val parser: Parser[CompletionsInstallOptions] = Parser.derive + implicit lazy val help: Help[CompletionsInstallOptions] = Help.derive + } + + // from https://github.com/VirtusLab/scala-cli/blob/eced0b35c769eca58ae6f1b1a3be0f29a8700859/modules/cli/src/main/scala/scala/cli/commands/uninstallcompletions/SharedUninstallCompletionsOptions.scala + // format: off + final case class CompletionsUninstallOptions( + @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell") + rcFile: Option[String] = None, + @HelpMessage("Custom banner in comment placed in rc file") + banner: String = "{NAME} completions", + @HelpMessage("Custom completions name") + name: Option[String] = None + ) + // format: on + + object CompletionsUninstallOptions { + implicit lazy val parser: Parser[CompletionsUninstallOptions] = Parser.derive + implicit lazy val help: Help[CompletionsUninstallOptions] = Help.derive + } + + def getFormat(format: Option[String]): Option[String] = + format.map(_.trim).filter(_.nonEmpty) + .orElse { + Option(System.getenv("SHELL")).map(_.split(File.separator).last).map { + case Bash.shellName => Bash.id + case Zsh.shellName => Zsh.id + case other => other + } + } } diff --git a/core/native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala b/core/native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala index 8d9a1d37..d012dcee 100644 --- a/core/native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ b/core/native/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala @@ -3,10 +3,22 @@ package caseapp.core.app import java.nio.charset.StandardCharsets import java.nio.file.{Files, Paths} -trait PlatformCommandsMethods { +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 = () + + def completionsInstall(completionsWorkingDirectory: String, args: Seq[String]): Unit = { + // The JVM implementation might just work from here + printLine("Completion installation not available on Scala Native") + exit(1) + } + + def completionsUninstall(completionsWorkingDirectory: String, args: Seq[String]): Unit = { + // The JVM implementation might just work from here + printLine("Completion uninstallation not available on Scala Native") + exit(1) + } } 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 d90ebc3e..54108a3a 100644 --- a/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala +++ b/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala @@ -2,8 +2,7 @@ package caseapp.core.app import caseapp.core.commandparser.RuntimeCommandParser import caseapp.core.complete.{Bash, CompletionItem, Zsh} -import caseapp.core.help.{Help, HelpFormat, RuntimeCommandsHelp} -import caseapp.core.help.RuntimeCommandHelp +import caseapp.core.help.{Help, HelpFormat, RuntimeCommandHelp, RuntimeCommandsHelp} abstract class CommandsEntryPoint extends PlatformCommandsMethods { @@ -34,12 +33,23 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { def enableCompletionsCommand: Boolean = false def completionsCommandName: List[String] = List("completions") - - def completionsPrintUsage(): Nothing = { + def completionsCommandAliases: List[List[String]] = List( + completionsCommandName, + List("completion") + ) + + def completionsPrintInstructions(): Unit = { + printLine("To install completions, run", toStderr = true) + printLine("", toStderr = true) printLine( - s"Usage: $progName ${completionsCommandName.mkString(" ")} format [dest]", + s" $progName ${completionsCommandName.mkString(" ")} install", toStderr = true ) + printLine("", toStderr = true) + } + + def completionsPrintUsage(): Nothing = { + completionsPrintInstructions() exit(1) } @@ -49,13 +59,12 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { } def completePrintUsage(): Nothing = { - printLine( - s"Usage: $progName ${completeCommandName.mkString(" ")} format index ...args...", - toStderr = true - ) + completionsPrintInstructions() exit(1) } + def completionsWorkingDirectory: Option[String] = None + def completionsMain(args: Array[String]): Unit = { def script(format: String): String = @@ -65,11 +74,16 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { case _ => completeUnrecognizedFormat(format) } - args match { - case Array(format, dest) => + + (completionsWorkingDirectory, args) match { + case (Some(dir), Array("install", args0 @ _*)) => + completionsInstall(dir, args0) + case (Some(dir), Array("uninstall", args0 @ _*)) => + completionsUninstall(dir, args0) + case (_, Array(format, dest)) => val script0 = script(format) writeCompletions(script0, dest) - case Array(format) => + case (_, Array(format)) => val script0 = script(format) printLine(script0) case _ => @@ -130,23 +144,28 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { val actualArgs = PlatformUtil.arguments(args) if (enableCompleteCommand && actualArgs.startsWith(completeCommandName.toArray[String])) completeMain(actualArgs.drop(completeCommandName.length)) - else if ( - enableCompletionsCommand && actualArgs.startsWith(completionsCommandName.toArray[String]) - ) - completionsMain(actualArgs.drop(completionsCommandName.length)) - else - defaultCommand match { + else { + val completionAliasOpt = + if (enableCompletionsCommand) completionsCommandAliases.find(actualArgs.startsWith(_)) + else None + completionAliasOpt match { + case Some(completionAlias) => + completionsMain(actualArgs.drop(completionAlias.length)) case None => - RuntimeCommandParser.parse(commands, actualArgs.toList) match { + defaultCommand match { case None => - printUsage() - case Some((commandName, command, commandArgs)) => + RuntimeCommandParser.parse(commands, actualArgs.toList) match { + case None => + printUsage() + case Some((commandName, command, commandArgs)) => + command.main(commandProgName(commandName), commandArgs.toArray) + } + case Some(defaultCommand0) => + val (commandName, command, commandArgs) = + RuntimeCommandParser.parse(defaultCommand0, commands, actualArgs.toList) command.main(commandProgName(commandName), commandArgs.toArray) } - case Some(defaultCommand0) => - val (commandName, command, commandArgs) = - RuntimeCommandParser.parse(defaultCommand0, commands, actualArgs.toList) - command.main(commandProgName(commandName), commandArgs.toArray) } + } } } diff --git a/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala b/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala new file mode 100644 index 00000000..7b250284 --- /dev/null +++ b/core/shared/src/main/scala/caseapp/core/app/ProfileFileUpdater.scala @@ -0,0 +1,100 @@ +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} + +// initially adapted from https://github.com/coursier/coursier/blob/d9a0fcc1af4876bec7f19a18f2c93d808e06df8d/modules/env/src/main/scala/coursier/env/ProfileUpdater.scala#L44-L137 + +object ProfileFileUpdater { + + private def startEndIndices(start: String, end: String, content: String): Option[(Int, Int)] = { + val startIdx = content.indexOf(start) + if (startIdx >= 0) { + val endIdx = content.indexOf(end, startIdx + 1) + if (endIdx >= 0) + Some(startIdx, endIdx + end.length) + else + None + } + else + None + } + + def addToProfileFile( + file: Path, + title: String, + addition: String, + charset: Charset + ): Boolean = { + + def updated(content: String): Option[String] = { + val start = s"# >>> $title >>>\n" + val endStr = s"# <<< $title <<<\n" + val withTags = "\n" + + start + + addition.stripSuffix("\n") + "\n" + endStr + if (content.contains(withTags)) + None + else + Some { + startEndIndices(start, endStr, content) match { + case None => + content + withTags + case Some((startIdx, endIdx)) => + content.take(startIdx) + + withTags + + content.drop(endIdx) + } + } + } + + var updatedSomething = false + val contentOpt = Some(file) + .filter(Files.exists(_)) + .map(f => new String(Files.readAllBytes(f), charset)) + for (updatedContent <- updated(contentOpt.getOrElse(""))) { + Option(file.getParent).map(createDirectories(_)) + Files.write(file, updatedContent.getBytes(charset)) + updatedSomething = true + } + updatedSomething + } + + def removeFromProfileFile( + file: Path, + title: String, + charset: Charset + ): Boolean = { + + def updated(content: String): Option[String] = { + val start = s"# >>> $title >>>\n" + val end = s"# <<< $title <<<\n" + startEndIndices(start, end, content).map { + case (startIdx, endIdx) => + content.take(startIdx).stripSuffix("\n") + + content.drop(endIdx) + } + } + + var updatedSomething = false + val contentOpt = Some(file) + .filter(Files.exists(_)) + .map(f => new String(Files.readAllBytes(f), charset)) + for (updatedContent <- updated(contentOpt.getOrElse(""))) { + Option(file.getParent).map(createDirectories(_)) + Files.write(file, updatedContent.getBytes(charset)) + 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) => + } + +}