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 ad7d64c0..a49662c5 100644 --- a/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala +++ b/core/jvm/src/main/scala/caseapp/core/app/PlatformCommandsMethods.scala @@ -1,6 +1,6 @@ package caseapp.core.app -import caseapp.core.complete.{Bash, Zsh} +import caseapp.core.complete.{Bash, Fish, Zsh} import java.io.File import java.nio.charset.{Charset, StandardCharsets} @@ -32,14 +32,11 @@ trait PlatformCommandsMethods { self: CommandsEntryPoint => 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 - ) + for (shell <- Seq(Bash.shellName, Zsh.shellName, Fish.shellName)) + printLine( + s" $name ${completionsCommandName.mkString(" ")} install --shell $shell", + toStderr = true + ) printLine("", toStderr = true) exit(1) } @@ -49,6 +46,15 @@ trait PlatformCommandsMethods { self: CommandsEntryPoint => val script = Bash.script(name) val defaultRcFile = Paths.get(sys.props("user.home")).resolve(".bashrc") (script, defaultRcFile) + case Fish.id | Fish.shellName => + val script = Fish.script(name) + val defaultRcFile = + Option(System.getenv("XDG_CONFIG_HOME")).map(Paths.get(_)) + .getOrElse(Paths.get(sys.props("user.home"), ".config")) + .resolve("fish") + .resolve("completions") + .resolve(s"$name.fish") + (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"))) @@ -69,14 +75,19 @@ trait PlatformCommandsMethods { self: CommandsEntryPoint => ).map(_ + System.lineSeparator()).mkString (script, defaultRcFile) case _ => - printLine(s"Unrecognized or unsupported shell: $format") + printLine(s"Unrecognized or unsupported shell: $format", toStderr = true) exit(1) } if (options.env) println(rcScript) else { - val rcFile = options.rcFile.map(Paths.get(_)).getOrElse(defaultRcFile) + val rcFile = format match { + case Fish.id | Fish.shellName => + options.output.map(Paths.get(_, 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, @@ -123,9 +134,14 @@ trait PlatformCommandsMethods { self: CommandsEntryPoint => val home = Paths.get(sys.props("user.home")) val zDotDir = Option(System.getenv("ZDOTDIR")).map(Paths.get(_)).getOrElse(home) + val fishCompletionsDir = options.output.map(Paths.get(_)) + .getOrElse(sys.env.get("XDG_CONFIG_HOME").map(Paths.get(_)).getOrElse(home) + .resolve("fish") + .resolve("completions")) val rcFiles = options.rcFile.map(file => Seq(Paths.get(file))).getOrElse(Seq( zDotDir.resolve(".zshrc"), - home.resolve(".bashrc") + home.resolve(".bashrc"), + fishCompletionsDir.resolve(s"$name.fish") )).filter(Files.exists(_)) for (rcFile <- rcFiles) { @@ -159,15 +175,15 @@ object PlatformCommandsMethods { env: Boolean = false, @HelpMessage("Custom completions name") name: Option[String] = None, - @HelpMessage("Name of the shell, either zsh or bash") + @HelpMessage("Name of the shell, either zsh, fish or bash") @Name("shell") format: Option[String] = None, - @HelpMessage("Completions output directory") + @HelpMessage("Completions output directory (defaults to $XDG_CONFIG_HOME/fish/completions on fish)") @Name("o") output: Option[String] = None, - @HelpMessage("Custom banner in comment placed in rc file") + @HelpMessage("Custom banner in comment placed in rc file (bash or zsh only)") banner: String = "{NAME} completions", - @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell") + @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell (bash or zsh only)") rcFile: Option[String] = None ) // format: on @@ -180,12 +196,15 @@ object PlatformCommandsMethods { // 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") + @HelpMessage("Path to `*rc` file, defaults to `.bashrc` or `.zshrc` depending on shell (bash or zsh only)") 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 + name: Option[String] = None, + @HelpMessage("Completions output directory (defaults to $XDG_CONFIG_HOME/fish/completions on fish)") + @Name("o") + output: Option[String] = None, ) // format: on @@ -199,6 +218,7 @@ object PlatformCommandsMethods { .orElse { Option(System.getenv("SHELL")).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/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala b/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala index 54108a3a..41c9df8e 100644 --- a/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala +++ b/core/shared/src/main/scala/caseapp/core/app/CommandsEntryPoint.scala @@ -1,7 +1,7 @@ package caseapp.core.app import caseapp.core.commandparser.RuntimeCommandParser -import caseapp.core.complete.{Bash, CompletionItem, Zsh} +import caseapp.core.complete.{Bash, CompletionItem, Fish, Zsh} import caseapp.core.help.{Help, HelpFormat, RuntimeCommandHelp, RuntimeCommandsHelp} abstract class CommandsEntryPoint extends PlatformCommandsMethods { @@ -70,6 +70,7 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { 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) @@ -114,6 +115,8 @@ abstract class CommandsEntryPoint extends PlatformCommandsMethods { format match { case Bash.id => printLine(Bash.print(items)) + case Fish.id => + printLine(Fish.print(items)) case Zsh.id => printLine(Zsh.print(items)) case _ => diff --git a/core/shared/src/main/scala/caseapp/core/complete/Fish.scala b/core/shared/src/main/scala/caseapp/core/complete/Fish.scala new file mode 100644 index 00000000..36f970ce --- /dev/null +++ b/core/shared/src/main/scala/caseapp/core/complete/Fish.scala @@ -0,0 +1,30 @@ +package caseapp.core.complete + +object Fish { + + val shellName: String = + "fish" + val id: String = + s"$shellName-v1" + + def script(progName: String): String = + s""" + complete $progName -a '($progName complete $id (math 1 + (count (__fish_print_cmd_args))) (__fish_print_cmd_args))' + |""".stripMargin + + private def escape(s: String): String = + s.replace("\t", " ").linesIterator.find(_ => true).getOrElse("") + def print(items: Seq[CompletionItem]): String = { + val newLine = System.lineSeparator() + val b = new StringBuilder + for (item <- items; value <- item.values) { + b.append(escape(value)) + for (desc <- item.description) { + b.append("\t") + b.append(escape(desc)) + } + b.append(newLine) + } + b.result() + } +} diff --git a/project/Mima.scala b/project/Mima.scala index a4f1b08f..9be90ba7 100644 --- a/project/Mima.scala +++ b/project/Mima.scala @@ -8,7 +8,7 @@ import scala.sys.process._ object Mima { def binaryCompatibilityVersions: Set[String] = - Seq("git", "tag", "--merged", "HEAD^", "--contains", "8e0544e5ce1f9ce1dd3bd85308332b5dd1cd4985") + Seq("git", "tag", "--merged", "HEAD^", "--contains", "920fb43865d3f35c5f2c9abb5bcc91768ab9de45") .!! .linesIterator .map(_.trim) diff --git a/tests/shared/src/test/scala/caseapp/CompletionTests.scala b/tests/shared/src/test/scala/caseapp/CompletionTests.scala index 9ae0e58d..56b9ca69 100644 --- a/tests/shared/src/test/scala/caseapp/CompletionTests.scala +++ b/tests/shared/src/test/scala/caseapp/CompletionTests.scala @@ -1,6 +1,6 @@ package caseapp -import caseapp.core.complete.{Bash, CompletionItem, Zsh} +import caseapp.core.complete.{Bash, CompletionItem, Fish, Zsh} import utest._ object CompletionTests extends TestSuite { @@ -155,6 +155,13 @@ object CompletionTests extends TestSuite { assert(compRely.contains(expectedCompRely)) } + test("fish") { + val res = Prog.complete(Seq("back-tick", "-"), 1) + val compRely = Fish.print(res) + val expectedCompRely = "--backtick\tA pattern with backtick `--`\n".stripMargin + + assert(compRely.contains(expectedCompRely)) + } test("zsh") { val res = Prog.complete(Seq("back-tick", "-"), 1) val expected = List(