Skip to content

Commit

Permalink
Allow multi-command apps to have a "completions install" command (#521)
Browse files Browse the repository at this point in the history
To install completions
  • Loading branch information
alexarchambault authored Oct 31, 2023
1 parent 9a5ed77 commit 77b4aa3
Show file tree
Hide file tree
Showing 5 changed files with 359 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
193 changes: 191 additions & 2 deletions core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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)
}

Expand All @@ -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 =
Expand All @@ -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 _ =>
Expand Down Expand Up @@ -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)
}
}
}
}
Loading

0 comments on commit 77b4aa3

Please sign in to comment.