All Downloads are FREE. Search and download functionalities are using the official Maven repository.

caseapp.core.app.CommandsEntryPoint.scala Maven / Gradle / Ivy

The newest version!
package caseapp.core.app

import caseapp.core.app.nio._
import caseapp.core.commandparser.RuntimeCommandParser
import caseapp.core.complete.{
  Bash,
  CompletionItem,
  CompletionsInstallOptions,
  CompletionsUninstallOptions,
  Fish,
  Zsh
}
import caseapp.core.help.{Help, HelpFormat, RuntimeCommandHelp, RuntimeCommandsHelp}

abstract class CommandsEntryPoint {

  def defaultCommand: Option[Command[_]] = None
  def commands: Seq[Command[_]]

  def progName: String
  def description: String = ""
  def summaryDesc: String = ""

  def help: RuntimeCommandsHelp =
    RuntimeCommandsHelp(
      progName,
      Some(description).filter(_.nonEmpty),
      defaultCommand.map(_.finalHelp: Help[_]).getOrElse(Help[Unit]()),
      commands.map(cmd => RuntimeCommandHelp(cmd.names, cmd.finalHelp, cmd.group, cmd.hidden)),
      Some(summaryDesc).filter(_.nonEmpty)
    )

  def helpFormat: HelpFormat =
    HelpFormat.default()

  private def commandProgName(commandName: List[String]): String =
    (progName +: commandName).mkString(" ")

  def enableCompleteCommand: Boolean    = false
  def completeCommandName: List[String] = List("complete")

  def enableCompletionsCommand: Boolean    = false
  def completionsCommandName: List[String] = List("completions")
  def completionsCommandAliases: List[List[String]] = List(
    completionsCommandName,
    List("completion")
  )

  def completionsPrintInstructions(): Unit = {
    printLine("To install completions, run", toStderr = true)
    printLine("", toStderr = true)
    printLine(
      s"  $progName ${completionsCommandName.mkString(" ")} install",
      toStderr = true
    )
    printLine("", toStderr = true)
  }

  def completePrintInstructions(toStderr: Boolean): Unit = {
    val formats = Seq(Bash.id, Zsh.id, Fish.id)
    printLine("To manually get completions, run", toStderr = toStderr)
    printLine("", toStderr = toStderr)
    printLine(
      s"  $progName ${completeCommandName.mkString(" ")} ${formats.mkString("|")} index command...",
      toStderr = toStderr
    )
    printLine("", toStderr = toStderr)
    printLine(
      "where index starts from one, and command... includes the command name, like",
      toStderr = toStderr
    )
    printLine("", toStderr = toStderr)
    printLine(
      s"  $progName ${completeCommandName.mkString(" ")} ${Zsh.id} 2 $progName --",
      toStderr = toStderr
    )
    printLine("", toStderr = toStderr)
    printLine("to get completions for '--'", toStderr = toStderr)
  }

  def completionsPrintUsage(): Nothing = {
    completionsPrintInstructions()
    exit(1)
  }

  def completeUnrecognizedFormat(format: String): Nothing = {
    printLine(s"Unrecognized completion format '$format'", toStderr = true)
    exit(1)
  }

  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 =
      format match {
        case Bash.shellName | Bash.id => Bash.script(progName)
        case Fish.shellName | Fish.id => Fish.script(progName)
        case Zsh.shellName | Zsh.id   => Zsh.script(progName)
        case _ =>
          completeUnrecognizedFormat(format)
      }

    args match {
      case Array("install", args0 @ _*) =>
        val (options, rem) = CaseApp.process[CompletionsInstallOptions](args0)
        if (rem.all.nonEmpty)
          sys.error(s"Unexpected arguments passed to completions install: ${rem.all.mkString(" ")}")
        completionsInstall(completionsWorkingDirectory, options)
      case Array("uninstall", args0 @ _*) =>
        val (options, rem) = CaseApp.process[CompletionsUninstallOptions](args0)
        completionsUninstall(completionsWorkingDirectory, options)
      case Array(format, dest) =>
        val script0 = script(format)
        FileOps.writeFile(Paths.get(dest), script0)
      case Array(format) =>
        val script0 = script(format)
        printLine(script0)
      case _ =>
        completionsPrintUsage()
    }
  }

  def complete(args: Seq[String], index: Int): List[CompletionItem] =
    defaultCommand match {
      case None =>
        RuntimeCommandParser.complete(commands, args.toList, index)
      case Some(defaultCommand0) =>
        RuntimeCommandParser.complete(defaultCommand0, commands, args.toList, index)
    }

  def completeMain(args: Array[String]): Unit = {
    completeMainHook(args)
    args match {
      case Array(format, indexStr, userArgs @ _*) =>
        val index          = indexStr.toInt - 2 // -1 for argv[0], and -1 as indices start at 1
        val prefix: String = userArgs.applyOrElse(index + 1, (_: Int) => "")
        val items = complete(userArgs.toList.drop(1), index)
          .flatMap { item =>
            val values = item.values.filter(_.startsWith(prefix))
            if (values.isEmpty) Nil
            else Seq(CompletionItem(values.head, item.description, values.tail.toList))
          }
        format match {
          case Bash.id =>
            printLine(Bash.print(items))
          case Fish.id =>
            printLine(Fish.print(items))
          case Zsh.id =>
            printLine(Zsh.print(items))
          case _ =>
            completeUnrecognizedFormat(format)
        }
      case Array("--help" | "--usage" | "-h") =>
        completePrintInstructions(toStderr = false)
      case _ =>
        completePrintInstructions(toStderr = true)
        exit(1)
    }
  }

  def exit(code: Int): Nothing =
    PlatformUtil.exit(code)
  def printLine(line: String, toStderr: Boolean): Unit =
    if (toStderr)
      System.err.println(line)
    else
      println(line)
  final def printLine(line: String): Unit =
    printLine(line, toStderr = false)

  def printUsage(): Nothing = {
    val usage = help.help(helpFormat, showHidden = false)
    printLine(usage)
    exit(0)
  }

  def main(args: Array[String]): Unit = {
    val actualArgs = PlatformUtil.arguments(args)
    if (enableCompleteCommand && actualArgs.startsWith(completeCommandName.toArray[String]))
      completeMain(actualArgs.drop(completeCommandName.length))
    else {
      val completionAliasOpt =
        if (enableCompletionsCommand) completionsCommandAliases.find(actualArgs.startsWith(_))
        else None
      completionAliasOpt match {
        case Some(completionAlias) =>
          completionsMain(actualArgs.drop(completionAlias.length))
        case None =>
          defaultCommand match {
            case None =>
              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)
          }
      }
    }
  }
}

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
        }
      }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy