├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── README.md ├── build-native.sh ├── build.sbt ├── ci ├── script.sh └── windows.bat ├── project ├── Common.scala ├── Dependencies.scala ├── build.properties └── plugins.sbt ├── smoke-test.sc ├── smoke-test.sh ├── src ├── main │ ├── resources │ │ └── META-INF │ │ │ └── native-image │ │ │ └── org.scala-lang │ │ │ └── scala-lang │ │ │ └── native-image.properties │ └── scala │ │ └── pl │ │ └── msitko │ │ └── teleport │ │ ├── Commands.scala │ │ ├── Entities.scala │ │ ├── Handler.scala │ │ ├── Main.scala │ │ ├── Storage.scala │ │ └── Style.scala └── test │ └── scala │ └── pl │ └── msitko │ └── teleport │ └── TeleportStateSpec.scala └── teleport.sh /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | .DS_Store 4 | *.bloop 5 | *.metals 6 | .ammonite 7 | .bash_history 8 | teleport-scala 9 | teleport-scala.jar -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.4.2 2 | 3 | align = more 4 | assumeStandardLibraryStripMargin = true 5 | binPack.parentConstructors = true 6 | danglingParentheses = false 7 | indentOperator = spray 8 | maxColumn = 120 9 | newlines.alwaysBeforeTopLevelStatements = true 10 | rewrite.redundantBraces.maxLines = 5 11 | rewrite.rules = [RedundantBraces, RedundantParens, SortImports, SortModifiers, PreferCurlyFors] 12 | runner.optimizer.forceConfigStyleOnOffset = -1 13 | trailingCommas = preserve 14 | verticalMultiline.arityThreshold = 120 15 | 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Some parts copied from https://github.com/remkop/picocli-native-image-maven-demo/blob/master/.travis.yml 2 | jobs: 3 | include: 4 | - os: linux 5 | env: JAVA_HOME="$HOME/.sdkman/candidates/java/current" 6 | - os: osx 7 | env: JAVA_HOME="$HOME/.sdkman/candidates/java/current" 8 | - os: windows 9 | language: shell 10 | env: JAVA_HOME="$HOME/.sdkman/candidates/java/current" 11 | 12 | branches: 13 | only: 14 | - master 15 | # Ruby regex to match tags. Required, or travis won't trigger deploys when 16 | # a new tag is pushed. Version tags should be of the form: v0.1.0 17 | - /^v\d+\.\d+\.\d+.*$/ 18 | 19 | before_install: 20 | - if [ $TRAVIS_OS_NAME = windows ]; then choco install zip unzip ; fi 21 | - if [ $TRAVIS_OS_NAME = windows ]; then choco install visualstudio2017-workload-vctools ; fi 22 | - curl -sL https://get.sdkman.io | bash 23 | - mkdir -p "$HOME/.sdkman/etc/" 24 | - echo sdkman_auto_answer=true > "$HOME/.sdkman/etc/config" 25 | - echo sdkman_auto_selfupdate=true >> "$HOME/.sdkman/etc/config" 26 | - source "$HOME/.sdkman/bin/sdkman-init.sh" 27 | 28 | install: 29 | - sdk install java 20.0.0.r11-grl 30 | - if [ $TRAVIS_OS_NAME != windows ]; then gu install native-image ; fi 31 | - if [ $TRAVIS_OS_NAME = windows ]; then gu.cmd install native-image ; fi 32 | - if [ $TRAVIS_OS_NAME != windows ]; then native-image --version ; fi 33 | - if [ $TRAVIS_OS_NAME = windows ]; then native-image.cmd --version ; fi 34 | - sdk install sbt 35 | 36 | script: 37 | - ./ci/script.sh 38 | 39 | deploy: 40 | provider: releases 41 | api_key: $GITHUB_TOKEN 42 | file_glob: true 43 | file: release/teleport-scala* 44 | skip_cleanup: true 45 | on: 46 | tags: true 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/note/teleport-scala.svg?branch=master)](https://travis-ci.com/note/teleport-scala) 2 | [![GitHub release](https://img.shields.io/github/v/release/note/teleport-scala.svg)](https://GitHub.com/note/teleport-scala/releases/) 3 | 4 | # teleport-scala 5 | 6 | A clone of [teleport](https://github.com/bollu/teleport) written in Scala as a showcase that writing native CLIs in Scala 7 | might not be such a bad idea. 8 | 9 | [![asciicast](https://asciinema.org/a/310481.svg)](https://asciinema.org/a/310481) 10 | 11 | ## How to build teleport-scala 12 | 13 | You should have following on your `$PATH`: 14 | 15 | * Java JDK 11 16 | * `sbt` 17 | * `native-image` 18 | 19 | You can consult file `.travis.yml` in case of difficulties in installing prerequisites. Once you have everything installed 20 | you can: 21 | 22 | ``` 23 | ./build.sh 24 | ``` 25 | 26 | As a result `teleport-scala.jar` and executable `teleport-scala` should be created. 27 | 28 | ## Bringing `tp` into scope 29 | 30 | If you watched the asciiname animation you may have noticed that it uses `tp` command as opposed to `teleport-scala`. 31 | To bring `tp` into scope add the following to your `.zshrc`/`.bashrc`: 32 | 33 | ``` 34 | source /your/path/to/teleport-scala/teleport.sh 35 | ``` 36 | 37 | It's crucial that executable `teleport-scala` is in the same directory as `teleport.sh` (which is the case by default after running `./build.sh`). 38 | 39 | ### For curious - what's the point of having `tp` in addition to `teleport-scala`? 40 | 41 | The problem is that `goto` command cannot be fully implemented in a subprocess; it's not possible for the 42 | `teleport-scala` to change working directory of the caller process. Therefore, `teleport-scala goto point` returns 43 | status code 2 and prints the absolute path of the `point`. As bash function `fp` is sourced it can change the working 44 | directory. 45 | 46 | If that sounds vague to you just read `teleport.sh` file - it's just a few lines of code. 47 | 48 | ## Usage 49 | 50 | ``` 51 | > tp --help 52 | Usage: 53 | teleport-scala [--no-colors] [--no-headers] add 54 | teleport-scala [--no-colors] [--no-headers] list 55 | teleport-scala [--no-colors] [--no-headers] remove 56 | teleport-scala [--no-colors] [--no-headers] goto 57 | teleport-scala [--no-colors] [--no-headers] version 58 | 59 | teleport: A tool to quickly switch between directories 60 | 61 | Options and flags: 62 | --help 63 | Display this help text. 64 | --no-colors 65 | Disable ANSI color codes 66 | --no-headers 67 | Disable printing headers for tabular data 68 | 69 | Subcommands: 70 | add 71 | add a teleport point 72 | list 73 | list all teleport points 74 | remove 75 | remove a teleport point 76 | goto 77 | go to a created teleport point 78 | version 79 | display version 80 | ``` 81 | 82 | ## Running smoke-test 83 | 84 | You need to build the project first. If it's built then: 85 | 86 | ``` 87 | ./smoke-test.sh 88 | ``` 89 | -------------------------------------------------------------------------------- /build-native.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any failure 4 | set -e 5 | 6 | sbt assembly 7 | find target -iname "*.jar" -exec cp {} teleport-scala.jar \; 8 | native-image --verbose -H:+ReportExceptionStackTraces --static --no-fallback -jar teleport-scala.jar teleport-scala 9 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import Common._ 2 | import Dependencies._ 3 | import wartremover.Wart 4 | 5 | lazy val root = (project in file(".")) 6 | .enablePlugins(BuildInfoPlugin, GitVersioning) 7 | .commonSettings("teleport-scala") 8 | .settings( 9 | libraryDependencies ++= mainDeps ++ testDeps, 10 | buildInfoKeys := Seq[BuildInfoKey](name, version, scalaVersion, sbtVersion, git.baseVersion, git.gitHeadCommit), 11 | buildInfoPackage := "pl.msitko.teleport", 12 | buildInfoUsePackageAsPath := true, 13 | ) 14 | .settings( 15 | wartremoverWarnings in (Compile, compile) --= Seq( 16 | Wart.StringPlusAny 17 | ) 18 | ) 19 | -------------------------------------------------------------------------------- /ci/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any failure 4 | set -e 5 | 6 | sbt assembly 7 | 8 | VERSION=`sbt version | tail -n 1 | awk '{print $2}'` 9 | 10 | if [ $TRAVIS_OS_NAME = windows ]; then 11 | find target -iname "*.jar" -exec cp {} teleport-scala.jar \; 12 | ci/windows.bat 13 | elif [ $TRAVIS_OS_NAME = osx ]; then 14 | native-image --verbose --no-fallback -jar "target/scala-2.13/teleport-scala-assembly-$VERSION.jar" teleport-scala 15 | else 16 | native-image --verbose --static --no-fallback -jar "target/scala-2.13/teleport-scala-assembly-$VERSION.jar" teleport-scala 17 | fi 18 | 19 | # smoke-test uses teleport-scala built above 20 | ./smoke-test.sh 21 | 22 | mkdir release 23 | 24 | if [ $TRAVIS_OS_NAME = windows ]; then 25 | mv teleport-scala.exe release 26 | else 27 | mv teleport-scala "release/teleport-scala-$TRAVIS_OS_NAME" 28 | fi 29 | -------------------------------------------------------------------------------- /ci/windows.bat: -------------------------------------------------------------------------------- 1 | call "C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\VC\Auxiliary\Build\vcvars64.bat" 2 | 3 | %HOME%/.sdkman/candidates/java/current/bin/native-image.cmd --verbose --static --no-fallback -H:+ReportExceptionStackTraces -jar teleport-scala.jar teleport-scala 4 | -------------------------------------------------------------------------------- /project/Common.scala: -------------------------------------------------------------------------------- 1 | import com.softwaremill.SbtSoftwareMill.autoImport.{commonSmlBuildSettings, wartRemoverSettings} 2 | import org.scalafmt.sbt.ScalafmtPlugin.autoImport.scalafmtOnCompile 3 | import sbt.Keys._ 4 | import sbt.Project 5 | 6 | object Common { 7 | implicit class ProjectFrom(project: Project) { 8 | def commonSettings(nameArg: String): Project = project.settings( 9 | name := nameArg, 10 | organization := "pl.msitko", 11 | 12 | scalaVersion := "2.13.1", 13 | scalafmtOnCompile := true, 14 | 15 | commonSmlBuildSettings, 16 | wartRemoverSettings, 17 | 18 | scalacOptions ++= Seq( 19 | "-Ymacro-annotations", 20 | "-Xfatal-warnings" 21 | ) 22 | ) 23 | } 24 | } -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val circeVersion = "0.13.0" 5 | 6 | lazy val mainDeps = Seq ( 7 | "org.typelevel" %% "cats-effect" % "2.1.1", 8 | "com.monovore" %% "decline" % "1.0.0", 9 | "com.lihaoyi" %% "fansi" % "0.2.9", 10 | "com.lihaoyi" %% "os-lib" % "0.6.3", 11 | "io.circe" %% "circe-core" % circeVersion, 12 | "io.circe" %% "circe-parser" % circeVersion, 13 | "io.circe" %% "circe-generic" % circeVersion 14 | ) 15 | 16 | lazy val testDeps = Seq("org.scalatest" %% "scalatest" % "3.1.0" % Test) 17 | } 18 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.8 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill" % "1.8.5") 2 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10") 3 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") 4 | addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") -------------------------------------------------------------------------------- /smoke-test.sc: -------------------------------------------------------------------------------- 1 | import $ivy.`com.lihaoyi::os-lib:0.6.3` 2 | import $ivy.`com.lihaoyi::utest:0.7.2` 3 | 4 | import utest._ 5 | 6 | val testDir = os.pwd / "teleport_guinea_pig" 7 | 8 | @main 9 | def main(executable: String) = { 10 | if(os.exists(testDir)) { 11 | println(s"$testDir already exists") 12 | sys.exit(2) 13 | } 14 | 15 | os.makeDir(testDir) 16 | List("tdir_a", "tdir_b", "tdir_c").map(testDir / _).foreach(os.makeDir) 17 | 18 | val verify = verifyExecutable(executable)_ 19 | 20 | val tests = Tests{ 21 | Symbol("`list` should return status code 0 if $HOME/.teleport-data does not exists") - { 22 | verify(List("list"), 0) 23 | } 24 | Symbol("`add` should return status code 0 for existing dir") - { 25 | verify(List("add", "tpoint0", "tdir_a"), 0) 26 | verify(List("add", "tpoint1", "tdir_b"), 0) 27 | } 28 | Symbol("`add` should return status code 1 for non existing dir") - { 29 | verify(List("add", "tpointX", "nonexistent"), 1) 30 | } 31 | Symbol("`list` should confirm that all previous points were created") - { 32 | val listResult = verify(List("list"), 0) 33 | 34 | assert(listResult.find(l => l.contains("tpoint0") && l.contains("tdir_a")).isDefined, 35 | listResult.find(l => l.contains("tpoint1") && l.contains("tdir_b")).isDefined, 36 | listResult.find(_.contains("tpointX")).isEmpty, 37 | listResult.find(_.contains("tpointX")).isEmpty) 38 | } 39 | Symbol("`--no-headers list` should not contain headers")- { 40 | val listResult = verify(List("list"), 0) 41 | assert(listResult.size == 3) 42 | 43 | val noheadersListResult = verify(List("--no-headers", "list"), 0) 44 | 45 | assert(noheadersListResult.size == 2) 46 | } 47 | Symbol("`--no-colors list` should not contain colors") - { 48 | val colorlessListResult = verify(List("--no-colors", "list"), 0) 49 | val noheadersListResult = verify(List("--no-headers", "list"), 0) 50 | val expectedLine = "tpoint0\t/root/teleport_guinea_pig/tdir_a" 51 | assert(colorlessListResult.contains(expectedLine)) 52 | assert(!noheadersListResult.contains(expectedLine)) 53 | } 54 | } 55 | 56 | runTests(tests) 57 | } 58 | 59 | def runTests(tests: Tests): Unit = { 60 | val result = TestRunner.runAndPrint(tests, "teleport-scala") 61 | val rendered = TestRunner.renderResults(List("teleport-scala" -> result)) 62 | 63 | println(rendered._1.render) 64 | 65 | if(rendered._3 > 0) { 66 | sys.exit(1) 67 | } else { 68 | sys.exit(0) 69 | } 70 | } 71 | 72 | def verifyExecutable(executable: String)(arguments: List[String], expectedExitCode: Int): Vector[String] = { 73 | val cmd = s"../$executable" :: arguments 74 | val res = os.proc(cmd).call(cwd = os.pwd / "teleport_guinea_pig", check = false) 75 | scala.Predef.assert(res.exitCode == expectedExitCode, s"Unexpected status code: ${res.exitCode}, expected: ${expectedExitCode} for $cmd") 76 | res.out.lines 77 | } 78 | -------------------------------------------------------------------------------- /smoke-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit on any failure 4 | set -e 5 | 6 | if [ "$TRAVIS_OS_NAME" = windows ] || [ "$TRAVIS_OS_NAME" = osx ] ; then 7 | # We skip on both windows and osx but for different reasons 8 | # In case of windows it's about difficulty to programatically mount a volume 9 | # In case of macos it's about lack of support for docker on travis ci 10 | # The alternative would be to run non-dockerized ammonite but currently tests do some assumptions about paths 11 | echo "Skipping smoke-test, running basic test instead..." 12 | 13 | if [ "$TRAVIS_OS_NAME" = windows ] ; then 14 | EXTENSION=".exe" 15 | else 16 | EXTENSION="" 17 | fi 18 | 19 | # It does not verify a lot, mostly just that executable is not corrupted 20 | OUT=`./teleport-scala$EXTENSION --no-colors list` 21 | 22 | if [ "$OUT" != "teleport points: (total 0)" ]; then 23 | echo "unexpected output of 'teleport-scala.exe --no-colors list':" 24 | echo $OUT 25 | exit 1 26 | fi 27 | 28 | # The following almost works, the missing part is mounting a volume 29 | # On Windows desktop you can make it work by sharing C drive using Docker UI: 30 | # https://docs.microsoft.com/en-us/archive/blogs/stevelasker/configuring-docker-for-windows-volumes 31 | # There's a thread about possibility of doing that programmatically: 32 | # https://forums.docker.com/t/is-there-a-way-to-share-drives-via-command-line/35967 33 | # One of recent comments mentions https://github.com/docker/for-win/issues/5139 which looks promising 34 | # 35 | # winpty docker run -it -v $(pwd)/smoke-test.sc:/root/smoke-test.sc \ 36 | # -v $(pwd)/teleport-scala:/root/teleport-scala \ 37 | # -v $HOME/.cache/coursier:/root/.cache/coursier \ 38 | # lolhens/ammonite /bin/bash -c "cd /root && amm /root/smoke-test.sc teleport-scala.exe" 39 | 40 | else # It's run either on travis on linux or locally 41 | docker run -it -v $(pwd)/smoke-test.sc:/root/smoke-test.sc \ 42 | -v $(pwd)/teleport-scala:/root/teleport-scala \ 43 | -v $HOME/.cache/coursier:/root/.cache/coursier \ 44 | note/ammonite:2.13-2.0.4 /bin/bash -c "cd /root && amm /root/smoke-test.sc teleport-scala" 45 | fi 46 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/native-image/org.scala-lang/scala-lang/native-image.properties: -------------------------------------------------------------------------------- 1 | Args = --initialize-at-build-time=scala.runtime.Statics$VM 2 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Commands.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.syntax.all._ 4 | import com.monovore.decline.{Command, Opts} 5 | 6 | final case class GlobalFlags(colors: Boolean, headers: Boolean) 7 | 8 | sealed trait CmdOptions extends Product with Serializable 9 | 10 | final case class AddCmdOptions(name: String, folderPath: Option[String]) extends CmdOptions 11 | final case object ListCmdOptions extends CmdOptions 12 | final case class RemoveCmdOptions(name: String) extends CmdOptions 13 | final case class GotoCmdOptions(name: String) extends CmdOptions 14 | final case object VersionCmdOptions extends CmdOptions 15 | 16 | object Commands { 17 | 18 | val flags: Opts[GlobalFlags] = { 19 | val nocolorsOpt = booleanFlag("no-colors", help = "Disable ANSI color codes") 20 | val noheadersOpt = booleanFlag("no-headers", help = "Disable printing headers for tabular data") 21 | (nocolorsOpt, noheadersOpt).mapN((noColors, noHeaders) => GlobalFlags(!noColors, !noHeaders)) 22 | } 23 | 24 | val subcommands = { 25 | val nameOpt = Opts.argument[String]("NAME") 26 | 27 | val add = 28 | Command( 29 | name = "add", 30 | header = "add a teleport point" 31 | )((nameOpt, Opts.argument[String]("FOLDERPATH").orNone).mapN(AddCmdOptions)) 32 | 33 | val list = 34 | Command( 35 | name = "list", 36 | header = "list all teleport points" 37 | )(Opts.unit.map(_ => ListCmdOptions)) 38 | 39 | val remove = 40 | Command( 41 | name = "remove", 42 | header = "remove a teleport point" 43 | )(nameOpt.map(RemoveCmdOptions)) 44 | 45 | val goto = 46 | Command( 47 | name = "goto", 48 | header = "go to a created teleport point" 49 | )(nameOpt.map(GotoCmdOptions)) 50 | 51 | val version = 52 | Command( 53 | name = "version", 54 | header = "display version" 55 | )(Opts.unit.map(_ => VersionCmdOptions)) 56 | 57 | Opts 58 | .subcommand(add) 59 | .orElse(Opts.subcommand(list)) 60 | .orElse(Opts.subcommand(remove)) 61 | .orElse(Opts.subcommand(goto)) 62 | .orElse(Opts.subcommand(version)) 63 | } 64 | 65 | val appCmd: Opts[(GlobalFlags, CmdOptions)] = (flags, subcommands).tupled 66 | 67 | private def booleanFlag(long: String, help: String): Opts[Boolean] = 68 | Opts.flag(long = long, help = help).map(_ => true).withDefault(false) 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Entities.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.syntax.all._ 4 | import io.circe.{Decoder, Encoder} 5 | import os.Path 6 | import io.circe.generic.JsonCodec 7 | 8 | import scala.util.Try 9 | import Codecs._ 10 | 11 | object Codecs { 12 | implicit val pathEncoder: Encoder[Path] = Encoder.encodeString.contramap(_.toString()) 13 | implicit val pathDecoder: Decoder[Path] = Decoder.decodeString.emapTry(s => Try(Path(s))) 14 | } 15 | 16 | @JsonCodec 17 | final case class TeleportPoint(name: String, absFolderPath: Path) { 18 | def fansi(implicit s: Style): _root_.fansi.Str = s"$name\t" + s.emphasis(absFolderPath.toString()) 19 | } 20 | 21 | // `points` are ordered descending in addition order (i.e. newest first) 22 | @JsonCodec 23 | final case class TeleportState(points: List[TeleportPoint]) extends AnyVal { 24 | 25 | def prepend(t: TeleportPoint): Option[TeleportState] = 26 | points.find(_.name == t.name).fold(TeleportState(t :: points).some)(_ => None) 27 | 28 | def afterRemoval(name: String): (Option[TeleportPoint], TeleportState) = { 29 | val res = points.partition(_.name == name) 30 | (res._1.headOption, TeleportState(res._2)) 31 | } 32 | } 33 | 34 | object TeleportState { 35 | def empty: TeleportState = TeleportState(points = List.empty) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Handler.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.effect.IO 4 | import os.Path 5 | import cats.syntax.all._ 6 | 7 | import scala.util.Try 8 | 9 | sealed trait TeleportError extends Product with Serializable { 10 | def fansi(implicit s: Style): _root_.fansi.Str = s.error(msg) 11 | def msg: String 12 | } 13 | 14 | final case class DirectoryDoesNotExists(path: Path) extends TeleportError { 15 | override def msg: String = s"User error: directory $path does not exists" 16 | } 17 | 18 | final case class IsFile(path: Path) extends TeleportError { 19 | override def msg: String = s"User error: $path is a file but teleport expects is to be a directory" 20 | } 21 | 22 | final case class TeleportPointAlreadyExists(name: String) extends TeleportError { 23 | override def msg: String = s"User error: teleport point [$name] already exists" 24 | } 25 | 26 | final case class TeleportPointNotFound(name: String) extends TeleportError { 27 | override def msg: String = s"User error: teleport point [$name] does not exist" 28 | } 29 | 30 | class Handler(storage: Storage) { 31 | 32 | def add(cmd: AddCmdOptions): IO[Either[TeleportError, TeleportPoint]] = { 33 | val absolutePath = cmd.folderPath.map(fp => Try(Path(fp)).getOrElse(os.pwd / fp)) 34 | val path = absolutePath.fold(os.pwd)(identity) 35 | if (os.exists(path)) if (os.isDir(path)) { 36 | for { 37 | state <- storage.read() 38 | newPoint = TeleportPoint(cmd.name, path) 39 | newStateOpt = state.prepend(newPoint) 40 | res <- newStateOpt 41 | .map(newState => storage.write(newState) *> (newPoint).asRight.pure[IO]) 42 | .getOrElse(TeleportPointAlreadyExists(cmd.name).asLeft.pure[IO]) 43 | } yield res 44 | } else { 45 | IsFile(path).asLeft.pure[IO] 46 | } 47 | else { 48 | DirectoryDoesNotExists(path).asLeft.pure[IO] 49 | } 50 | } 51 | 52 | def list(): IO[TeleportState] = storage.read() 53 | 54 | def remove(cmd: RemoveCmdOptions): IO[Either[TeleportError, Unit]] = 55 | for { 56 | currentState <- storage.read() 57 | (toBeRemoved, updated) = currentState.afterRemoval(cmd.name) 58 | res <- toBeRemoved match { 59 | case Some(_) => storage.write(updated) *> ().asRight.pure[IO] 60 | case None => TeleportPointNotFound(cmd.name).asLeft.pure[IO] 61 | } 62 | } yield res 63 | 64 | def goto(cmd: GotoCmdOptions): IO[Either[TeleportError, os.Path]] = { 65 | storage.read().map { state => 66 | state.points.find(_.name == cmd.name) match { 67 | case Some(p) => 68 | // We may ensure whether the directory still exists 69 | p.absFolderPath.asRight 70 | case None => TeleportPointNotFound(cmd.name).asLeft 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Main.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.effect.ExitCase.Canceled 4 | import cats.effect._ 5 | import cats.syntax.all._ 6 | import com.monovore.decline._ 7 | 8 | object Main extends IOApp { 9 | 10 | def run(args: List[String]): IO[ExitCode] = { 11 | 12 | // TODO: add comment that global flags have to go before subcommand 13 | val command: Command[(GlobalFlags, CmdOptions)] = Command( 14 | name = "teleport-scala", 15 | header = "teleport: A tool to quickly switch between directories", 16 | helpFlag = true)(Commands.appCmd) 17 | val storage = new Storage(os.home / ".teleport-data") 18 | val handler = new Handler(storage) 19 | 20 | val program = command.parse(args) match { 21 | case Right((globalFlags, cmd)) => 22 | val style = if (globalFlags.colors) { 23 | DefaultStyle 24 | } else { 25 | NoColorsStyle 26 | } 27 | dispatchCmd(globalFlags, cmd, handler)(style) 28 | 29 | case Left(e) => 30 | IO(println(e.toString())) *> IO(ExitCode.Error) 31 | } 32 | 33 | // if program has any resources they can be released here: 34 | program.guaranteeCase { 35 | case Canceled => 36 | IO.unit 37 | case _ => 38 | IO.unit 39 | } 40 | } 41 | 42 | private def dispatchCmd(globalFlags: GlobalFlags, cmd: CmdOptions, handler: Handler)( 43 | implicit style: Style): IO[ExitCode] = 44 | cmd match { 45 | case cmd: AddCmdOptions => 46 | handler.add(cmd).map { 47 | case Right(tpPoint) => 48 | println(s"Creating teleport point: ${style.emphasis(tpPoint.name)}") 49 | ExitCode.Success 50 | case Left(err) => 51 | println(err.fansi) 52 | ExitCode.Error 53 | } 54 | 55 | case ListCmdOptions => 56 | handler.list().map { state => 57 | if (globalFlags.headers) { 58 | println("teleport points: " + style.emphasis(s"(total ${state.points.size})")) 59 | } 60 | state.points.map(_.fansi).foreach(println) 61 | ExitCode.Success 62 | } 63 | 64 | case cmd: RemoveCmdOptions => 65 | handler.remove(cmd).map { 66 | case Right(_) => 67 | println(s"removed teleport point [${style.emphasis(cmd.name)}]") 68 | ExitCode.Success 69 | case Left(err) => 70 | println(err.fansi) 71 | ExitCode.Error 72 | } 73 | 74 | case cmd: GotoCmdOptions => 75 | handler.goto(cmd).map { 76 | case Right(absolutePath) => 77 | println(absolutePath) 78 | ExitCode(2) 79 | case Left(err) => 80 | println(err.fansi) 81 | ExitCode.Error 82 | } 83 | 84 | case VersionCmdOptions => 85 | IO(println(BuildInfo.version)) *> IO(ExitCode.Success) 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Storage.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.effect.IO 4 | import os.Path 5 | import io.circe.parser.parse 6 | import cats.syntax.all._ 7 | import io.circe.syntax._ 8 | import pl.msitko.teleport.TeleportState._ 9 | 10 | @SuppressWarnings(Array("org.wartremover.warts.Null")) // integration with java interface (RuntimeException) 11 | final case class TeleportException(msg: String, underlying: Option[Exception]) 12 | extends RuntimeException(msg, underlying.orNull) 13 | 14 | class Storage(location: Path) { 15 | 16 | def read(): IO[TeleportState] = 17 | IO(os.exists(location)).flatMap { 18 | case true => 19 | IO(os.read(location)).flatMap { content => 20 | IO.fromEither(parse(content).flatMap(parsed => parsed.as[TeleportState]).leftMap { e => 21 | TeleportException(e.getMessage, e.some) 22 | }) 23 | } 24 | case false => 25 | TeleportState.empty.pure[IO] 26 | } 27 | 28 | def write(state: TeleportState): IO[Unit] = { 29 | val content = state.asJson.noSpaces 30 | IO(os.write.over(location, content)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/scala/pl/msitko/teleport/Style.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import fansi.Str 4 | 5 | trait Style { 6 | def emphasis(input: String): fansi.Str 7 | def error(input: String): fansi.Str 8 | } 9 | 10 | object DefaultStyle extends Style { 11 | override def emphasis(input: String): Str = fansi.Color.LightBlue(input) 12 | 13 | override def error(input: String): Str = fansi.Color.Red(input) 14 | } 15 | 16 | object NoColorsStyle extends Style { 17 | override def emphasis(input: String): Str = Str(input) 18 | 19 | override def error(input: String): Str = Str(input) 20 | } 21 | -------------------------------------------------------------------------------- /src/test/scala/pl/msitko/teleport/TeleportStateSpec.scala: -------------------------------------------------------------------------------- 1 | package pl.msitko.teleport 2 | 3 | import cats.syntax.all._ 4 | import org.scalactic.TypeCheckedTripleEquals 5 | import org.scalatest.wordspec.AnyWordSpec 6 | 7 | class TeleportStateSpec extends AnyWordSpec with TypeCheckedTripleEquals { 8 | 9 | val initialState = TeleportState( 10 | List( 11 | TeleportPoint("a", os.pwd / "a"), 12 | TeleportPoint("b", os.pwd / "b"), 13 | TeleportPoint("c", os.pwd / "c") 14 | )) 15 | 16 | "afterRemoval" should { 17 | "return (Some, newState) in case name already existed in the state" in { 18 | val expected = TeleportState( 19 | List( 20 | TeleportPoint("a", os.pwd / "a"), 21 | TeleportPoint("c", os.pwd / "c") 22 | )) 23 | 24 | assert(initialState.afterRemoval("b") === (TeleportPoint("b", os.pwd / "b").some -> expected)) 25 | } 26 | 27 | "return (None, previousState) in case name already existed in the state" in { 28 | assert(initialState.afterRemoval("nonexisting") === (None -> initialState)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /teleport.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copied from https://unix.stackexchange.com/a/351658: 4 | if test -n "$BASH" ; then script=$BASH_SOURCE 5 | elif test -n "$TMOUT"; then script=${.sh.file} 6 | elif test -n "$ZSH_NAME" ; then script=${(%):-%x} 7 | elif test ${0##*/} = dash; then x=$(lsof -p $$ -Fn0 | tail -1); script=${x#n} 8 | else script=$0 9 | fi 10 | 11 | TELEPORT_SCALA_DIR=`dirname $script` 12 | 13 | # mostly copied from https://github.com/bollu/teleport 14 | function tp() { 15 | # $@ takes all arguments of the shell script and passes it along to `teleport-scala 16 | # which is our tool 17 | OUTPUT=`${TELEPORT_SCALA_DIR}/teleport-scala $@` 18 | # return code 2 tells the shell script to cd to whatever `teleport` outputs 19 | if [ $? -eq 2 ] 20 | then cd "$OUTPUT" 21 | else echo "$OUTPUT" 22 | fi 23 | } 24 | 25 | fpath=(`pwd` $fpath) 26 | --------------------------------------------------------------------------------