├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── build.sbt ├── core └── src │ ├── main │ └── scala │ │ └── io │ │ └── chrisdavenport │ │ └── catscript │ │ └── Main.scala │ └── test │ └── scala │ └── io │ └── chrisdavenport │ └── catscript │ └── MainSpec.scala ├── project ├── build.properties └── plugins.sbt ├── simple-server.catscript ├── site ├── Gemfile └── docs │ └── index.md └── test.catscript /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['*'] 13 | push: 14 | branches: ['*'] 15 | tags: [v*] 16 | 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | jobs: 21 | build: 22 | name: Build and Test 23 | strategy: 24 | matrix: 25 | os: [ubuntu-latest] 26 | scala: [2.13.5, 3.0.0] 27 | java: [adopt@1.8] 28 | runs-on: ${{ matrix.os }} 29 | steps: 30 | - name: Checkout current branch (full) 31 | uses: actions/checkout@v2 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Setup Java and Scala 36 | uses: olafurpg/setup-scala@v10 37 | with: 38 | java-version: ${{ matrix.java }} 39 | 40 | - name: Cache sbt 41 | uses: actions/cache@v2 42 | with: 43 | path: | 44 | ~/.sbt 45 | ~/.ivy2/cache 46 | ~/.coursier/cache/v1 47 | ~/.cache/coursier/v1 48 | ~/AppData/Local/Coursier/Cache/v1 49 | ~/Library/Caches/Coursier/v1 50 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 51 | 52 | - name: Setup Ruby 53 | if: matrix.scala == '2.13.5' 54 | uses: ruby/setup-ruby@v1 55 | with: 56 | ruby-version: 3.0.1 57 | 58 | - name: Install microsite dependencies 59 | if: matrix.scala == '2.13.5' 60 | run: | 61 | gem install saas 62 | gem install jekyll -v 4.2.0 63 | 64 | - name: Check that workflows are up to date 65 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 66 | 67 | - run: sbt ++${{ matrix.scala }} test mimaReportBinaryIssues 68 | 69 | - if: matrix.scala == '2.13.5' 70 | run: sbt ++${{ matrix.scala }} site/makeMicrosite 71 | 72 | publish: 73 | name: Publish Artifacts 74 | needs: [build] 75 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') 76 | strategy: 77 | matrix: 78 | os: [ubuntu-latest] 79 | scala: [2.13.5] 80 | java: [adopt@1.8] 81 | runs-on: ${{ matrix.os }} 82 | steps: 83 | - name: Checkout current branch (full) 84 | uses: actions/checkout@v2 85 | with: 86 | fetch-depth: 0 87 | 88 | - name: Setup Java and Scala 89 | uses: olafurpg/setup-scala@v10 90 | with: 91 | java-version: ${{ matrix.java }} 92 | 93 | - name: Cache sbt 94 | uses: actions/cache@v2 95 | with: 96 | path: | 97 | ~/.sbt 98 | ~/.ivy2/cache 99 | ~/.coursier/cache/v1 100 | ~/.cache/coursier/v1 101 | ~/AppData/Local/Coursier/Cache/v1 102 | ~/Library/Caches/Coursier/v1 103 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 104 | 105 | - uses: olafurpg/setup-gpg@v3 106 | 107 | - name: Setup Ruby 108 | uses: ruby/setup-ruby@v1 109 | with: 110 | ruby-version: 3.0.1 111 | 112 | - name: Install microsite dependencies 113 | run: | 114 | gem install saas 115 | gem install jekyll -v 4.2.0 116 | 117 | - name: Publish artifacts to Sonatype 118 | env: 119 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 120 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 121 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 122 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 123 | run: sbt ++${{ matrix.scala }} ci-release 124 | 125 | - uses: christopherdavenport/create-ghpages-ifnotexists@v1 126 | 127 | - name: Publish microsite 128 | run: sbt ++${{ matrix.scala }} site/publishMicrosite -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea/ 3 | # vim 4 | *.sw? 5 | 6 | # Ignore [ce]tags files 7 | tags 8 | 9 | .bloop 10 | .metals 11 | metals.sbt 12 | .vscode 13 | .bsp 14 | test-folder/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. 4 | 5 | Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. If you are being harassed, please contact us immediately so that we can support you. 6 | 7 | ## Moderation 8 | 9 | Any questions, concerns, or moderation requests please contact a member of the project. 10 | 11 | - [Christopher Davenport](mailto:chris@christopherdavenport.tech) 12 | 13 | [Scala Code of Conduct]: https://www.scala-lang.org/conduct/ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Christopher Davenport 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # catscript - Cats Scripting [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.chrisdavenport/catscript_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.chrisdavenport/catscript_2.12) ![Code of Consuct](https://img.shields.io/badge/Code%20of%20Conduct-Scala-blue.svg) 2 | 3 | 4 | 5 | ## Quick Start 6 | 7 | - [Install Coursier if you don't already have it](https://get-coursier.io/docs/cli-installation.html#native-launcher) 8 | - [Install SBT if you don't already have it](https://www.scala-sbt.org/release/docs/Installing-sbt-on-Mac.html) or via `cs install sbt-launcher` 9 | 10 | ```sh 11 | # Coursier and SBT are Pre-requisites 12 | cs install --contrib catscript 13 | 14 | # Example Full Installation From Scratch 15 | curl -fLo cs https://git.io/coursier-cli-"$(uname | tr LD ld)" 16 | chmod +x cs 17 | ./cs install cs 18 | cs update cs 19 | cs install sbt-launcher 20 | cs install --contrib catscript 21 | ``` 22 | 23 | Then write apps as simply as 24 | 25 | ```scala 26 | #!/usr/bin/env catscript 27 | // interpreter: IOApp.Simple 28 | // scala: 3.0.0 29 | // dependency: "org.http4s" %% "http4s-ember-client" % "0.23.0-RC1" 30 | 31 | import cats.effect._ 32 | import cats.effect.std.Console 33 | 34 | def run: IO[Unit] = Console[IO].println("Hello world!!!") 35 | ``` 36 | 37 | SheBangs are optional, but make it so you can execute the files, rather than invoke 38 | the interpreter on the file, which I find useful. 39 | 40 | You can also write them for a specific version without installing catscript with the shebang directly if you have coursier installed. 41 | 42 | ```scala 43 | #!/usr/bin/env cs launch io.chrisdavenport::catscript:0.1.1 -- 44 | // interpreter: IOApp.Simple 45 | // scala: 3.0.0 46 | 47 | import cats.effect._ 48 | import cats.effect.std.Console 49 | 50 | def run: IO[Unit] = Console[IO].println("Hello world!!!") 51 | ``` 52 | 53 | ### Available Interpreters 54 | 55 | Defaults to `IOApp.Simple`. Headers section is terminated by the first line which does not start with `//` excluding the `#!` 56 | #### IOApp.Simple 57 | 58 | Scala and Interpreter can both be left absent, in which case they default to 59 | `2.13.5` and `IOApp.Simple`. 60 | 61 | ```scala 62 | #!/usr/bin/env catscript 63 | import cats.effect._ 64 | import cats.effect.std.Console 65 | 66 | def run: IO[Unit] = Console[IO].println("Hello world!!!") 67 | ``` 68 | 69 | #### IOApp 70 | 71 | Args are whatever you invoke the file with and should work correctly. 72 | 73 | ```scala 74 | #!./usr/bin/env catscript 75 | // interpreter: IOApp 76 | // scala: 3.0.0 77 | 78 | import cats.effect._ 79 | import cats.effect.std.Console 80 | 81 | def run(args: List[String]): IO[ExitCode] = 82 | Console[IO].println(s"Received $args- Hello from IOApp") 83 | .as(ExitCode.Success) 84 | ``` 85 | 86 | #### App 87 | 88 | Works like a worksheet, entire script is within `def main(args: Array[String]): Unit` 89 | 90 | ```scala 91 | #!./usr/bin/env catscript 92 | // interpreter: App 93 | // scala: 3.0.0 94 | 95 | println("Hi There!") 96 | ``` 97 | 98 | #### Raw 99 | 100 | Takes your code and places it there with no enhancements, you're responsible 101 | for initiating your own MainClass that has a `main`. Top-Level Declarations 102 | depend on the version of scala whether or not those are allowed. 103 | 104 | ```scala 105 | #!./usr/bin/env catscript 106 | // interpreter: Raw 107 | // scala: 3.0.0 108 | 109 | object Main { 110 | def main(args: Array[String]): Unit = { 111 | println("Your code, as requested") 112 | } 113 | } 114 | ``` 115 | 116 | ### Script Headers 117 | 118 | - `scala`: Sets the Scala Version, last header wins. `scala: 3.0.0-RC2` 119 | - `sbt`: Sets the SBT Version, last header wins `sbt: 1.5.0` 120 | - `interpreter`: Sets which interpreter to use, last header wins. `interpreter: IOApp.Simple` 121 | - `dependency`: Repeating Header, allows you to set libraryDependencies for the script. `dependency: "org.http4s" %% "http4s-ember-client" % "1.0.0-M21"` 122 | - `scalac`: Repeating Header, allows you to set scalaOptions for the script. `scalac: -language:higherKinds` 123 | - `compiler-plugin`: Repeating Header, allows you to add compiler plugins. `compiler-plugin: "org.typelevel" % "kind-projector" % "0.11.3" cross CrossVersion.full` 124 | - `sbt-plugin`: Repeating Header, allows you to add sbt plugins. `sbt-plugin: "io.github.davidgregory084" % "sbt-tpolecat" % "0.1.16"` 125 | 126 | ### Commands 127 | 128 | - `catscript help` - Outputs help text 129 | - `catscript clear-cache` - Clears all cached artifacts 130 | - `catscript file` - Runs catscript against the provided file 131 | 132 | ### Options 133 | 134 | - `--verbose` - turns on some logging of internal catscript processes. 135 | - `--no-cache` - Disables caching will recreate every time. 136 | - `--compile-only` - Does not execute the resulting executable. (Useful to confirm compilation) 137 | - `--sbt-output` - Puts the produced sbt project in this location rather than a temporary directory. (This is editor compliant and should allow you to debug with editor support and then bring your findings back to a script.) 138 | - `--sbt-file` - Allows you to specify a `Build.sbt` file location to use with the script. this will ignore script headers. 139 | 140 | ### VSCode Highlighting 141 | 142 | Note the `.catscript` extension is entirely arbitrary. Any file will work, but having an extension 143 | makes recognizing files that use this format easier, and will allow syntax highlighting. 144 | 145 | settings.json 146 | ``` 147 | "files.associations": { 148 | "*.catscript": "scala", 149 | } 150 | ``` 151 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | val Scala213 = "2.13.5" 2 | 3 | ThisBuild / crossScalaVersions := Seq(Scala213, "3.0.0") 4 | ThisBuild / scalaVersion := Scala213 5 | 6 | val catsV = "2.6.1" 7 | val catsEffectV = "3.1.1" 8 | val fs2V = "3.0.6" 9 | 10 | val munitCatsEffectV = "1.0.5" 11 | 12 | // Projects 13 | lazy val `catscript` = project.in(file(".")) 14 | .disablePlugins(MimaPlugin) 15 | .enablePlugins(NoPublishPlugin) 16 | .aggregate(core) 17 | 18 | lazy val core = project.in(file("core")) 19 | .settings(commonSettings) 20 | .enablePlugins(JavaAppPackaging) 21 | .settings( 22 | name := "catscript", 23 | ) 24 | 25 | lazy val site = project.in(file("site")) 26 | .disablePlugins(MimaPlugin) 27 | .enablePlugins(DavenverseMicrositePlugin) 28 | .settings(commonSettings) 29 | .dependsOn(core) 30 | .settings{ 31 | import microsites._ 32 | Seq( 33 | micrositeDescription := "Cats Scripting", 34 | micrositeAuthor := "Christopher Davenport", 35 | ) 36 | } 37 | 38 | // General Settings 39 | lazy val commonSettings = Seq( 40 | testFrameworks += new TestFramework("munit.Framework"), 41 | 42 | libraryDependencies ++= Seq( 43 | "org.typelevel" %% "cats-core" % catsV, 44 | "org.typelevel" %% "alleycats-core" % catsV, 45 | 46 | "org.typelevel" %% "cats-effect" % catsEffectV, 47 | 48 | "co.fs2" %% "fs2-core" % fs2V, 49 | "co.fs2" %% "fs2-io" % fs2V, 50 | 51 | "org.typelevel" %% "munit-cats-effect-3" % munitCatsEffectV % Test, 52 | ) 53 | ) -------------------------------------------------------------------------------- /core/src/main/scala/io/chrisdavenport/catscript/Main.scala: -------------------------------------------------------------------------------- 1 | package io.chrisdavenport.catscript 2 | 3 | import cats._ 4 | import cats.syntax.all._ 5 | import cats.effect._ 6 | // import scala.concurrent.duration._ 7 | import java.nio.file.Paths 8 | import scala.sys.process 9 | import scala.sys.process.ProcessLogger 10 | import cats.ApplicativeThrow 11 | import java.nio.file.Path 12 | import scodec.bits.ByteVector 13 | import scala.util.control.NoStackTrace 14 | 15 | object Main extends IOApp { 16 | val console = cats.effect.std.Console.make[IO] 17 | 18 | def run(args: List[String]): IO[ExitCode] = { 19 | for { 20 | args <- Arguments.fromBaseArgs[IO](args) 21 | _ <- if (args.verbose) console.println(s"Interpreted: $args") else IO.unit 22 | command = Command.fromArgs(args) 23 | _ <- if (args.verbose) console.println(s"Found Command: $command") else IO.unit 24 | _ <- Command.app[IO](command, args) 25 | } yield ExitCode.Success 26 | } 27 | } 28 | sealed trait Command 29 | object Command { 30 | case object ClearCache extends Command 31 | case object Script extends Command 32 | case object Help extends Command 33 | 34 | def fromArgs(args: Arguments): Command = args.fileOrCommand match { 35 | case "clear-cache" => ClearCache 36 | case "help" => Help 37 | case _ => 38 | if (args.catsScriptArgs.exists(_ === "-h") || args.catsScriptArgs.exists(_ === "--help")) Help 39 | else Script 40 | } 41 | 42 | def app[F[_]: Async](command: Command, args: Arguments): F[Unit] = { 43 | val console = cats.effect.std.Console.make[F] 44 | command match { 45 | case ClearCache => Cache.clearCache(args.verbose) 46 | case Help => 47 | cats.effect.std.Console.make[F].println{ 48 | """catscript: catscript [--no-cache] [--compile-only] [--verbose] (file | help | clear-cache) [script-args] 49 | |Cats Scripting 50 | | 51 | |Options: 52 | | --no-cache: Bypasses caching mechanism creating full project each run 53 | | --compile-only: Does not run script, just compiles and caches stdout: cache-location4 54 | | --sbt-output: Puts the produced sbt project in this location rather than a temporary directory 55 | | --verbose: Verbose 56 | | 57 | |Commands: 58 | | help: Display this help text 59 | | clear-cache: Clears all cached artifacts 60 | | file: Script to run 61 | |""".stripMargin 62 | } 63 | case Script => for { 64 | filePath <- Sync[F].delay(Paths.get(args.fileOrCommand)) 65 | fileContent <- fs2.io.file.Files[F].readAll(filePath, 512) 66 | .through(fs2.text.utf8Decode) 67 | .compile 68 | .string 69 | parsed <- Parser.simpleParser(fileContent).liftTo[F] 70 | config = Config.configFromHeaders(parsed._1) 71 | _ <- if (args.verbose) console.println(s"Config Loaded: $config") else Applicative[F].unit 72 | cacheStrategy <- Cache.determineCacheStrategy[F](args, filePath, fileContent) 73 | _ <- if (args.verbose) console.println(s"Cache Strategy: $cacheStrategy") else Applicative[F].unit 74 | _ <- cacheStrategy match { 75 | case Cache.NoCache => 76 | args.sbtOutput.fold(fs2.io.file.Files[F].tempDirectory())(path => 77 | Resource.eval(Sync[F].delay(Paths.get(path))) 78 | ).use{tempFolder => 79 | { 80 | if (args.verbose) console.println(s"SBT Project Output: $tempFolder") 81 | else Applicative[F].unit 82 | } >> 83 | Files.createInFolder(tempFolder, config, parsed._2, args.sbtFile.map(Paths.get(_))) >> 84 | Files.stageExecutable(tempFolder) >> { 85 | if (args.compileOnly) Applicative[F].unit 86 | else 87 | Files.executeExecutable( 88 | tempFolder.resolve("target").resolve("universal").resolve("stage"), 89 | args.scriptArgs 90 | ) 91 | } 92 | } 93 | case Cache.ReuseExecutable(cachedExecutableDirectory) => 94 | if (args.compileOnly) console.println(cachedExecutableDirectory) 95 | else Files.executeExecutable(cachedExecutableDirectory, args.scriptArgs) 96 | case Cache.NewCachedValue(cachedExecutableDirectory, fileContentSha) => 97 | args.sbtOutput.fold(fs2.io.file.Files[F].tempDirectory())(path => 98 | Resource.eval(Sync[F].delay(Paths.get(path))) 99 | ).use{tempFolder => 100 | val stageDir = tempFolder.resolve("target").resolve("universal").resolve("stage") 101 | 102 | { 103 | if (args.verbose) console.println(s"SBT Project Output: $tempFolder") 104 | else Applicative[F].unit 105 | } >> 106 | Files.createInFolder(tempFolder, config, parsed._2, args.sbtFile.map(Paths.get(_))) >> 107 | Files.stageExecutable[F](tempFolder) >> 108 | fs2.Stream(fileContentSha).through(fs2.text.utf8Encode) 109 | .through(fs2.io.file.Files[F].writeAll(stageDir.resolve("script_sha"))) 110 | .compile 111 | .drain >> 112 | fs2.io.file.Files[F].exists(cachedExecutableDirectory).ifM( 113 | fs2.io.file.Files[F].deleteDirectoryRecursively(cachedExecutableDirectory), 114 | Applicative[F].unit 115 | ) >> 116 | fs2.io.file.Files[F].createDirectories(cachedExecutableDirectory.getParent()) >> 117 | fs2.io.file.Files[F].move(stageDir, cachedExecutableDirectory) >> { 118 | if (args.compileOnly) console.println(cachedExecutableDirectory) 119 | else 120 | Files.executeExecutable( 121 | cachedExecutableDirectory, 122 | args.scriptArgs 123 | ) 124 | } 125 | } 126 | } 127 | } yield () 128 | } 129 | } 130 | } 131 | 132 | case class Arguments(catsScriptArgs: List[String], fileOrCommand: String, scriptArgs: List[String]){ 133 | val verbose: Boolean = catsScriptArgs.exists(_ == "--verbose") 134 | val noCache: Boolean = catsScriptArgs.exists(_ == "--no-cache") 135 | val compileOnly: Boolean = catsScriptArgs.exists(_ == "--compile-only") 136 | private val sbtOutputPattern: scala.util.matching.Regex = "--sbt-output=(.*)".r 137 | val sbtOutput: Option[String] = catsScriptArgs.collectFirstSome(s => sbtOutputPattern.findFirstMatchIn(s).map(_.group(1))) 138 | private val sbtBuildFilePattern: scala.util.matching.Regex = "--sbt-file=(.*)".r 139 | val sbtFile: Option[String] = catsScriptArgs.collectFirstSome(s => sbtBuildFilePattern.findFirstMatchIn(s).map(_.group(1))) 140 | 141 | override def toString: String = s"Arguments(catscriptArgs=$catsScriptArgs, fileOrCommand=$fileOrCommand, scriptArgs=$scriptArgs)" 142 | } 143 | object Arguments { 144 | // [-options] file [script options] 145 | // All options for catscript MUST start with - 146 | // The first argument without - will be interpreted as the file 147 | def fromBaseArgs[F[_]: ApplicativeThrow](args: List[String]): F[Arguments] = { 148 | if (args.isEmpty) new RuntimeException("No Arguments Provided - Valid File is Required").raiseError 149 | else { 150 | val split = args.takeWhile(_.startsWith("-")).flatMap(s => s.split(" ").toList) 151 | val list = args.dropWhile(_.startsWith("-")) 152 | val nelO = list.toNel 153 | nelO match { 154 | case None => new RuntimeException(s"Initial Arguments Provided: $split - Valid File is Required").raiseError 155 | case Some(value) => 156 | val file = value.head 157 | val otherArgs = value.tail 158 | Arguments(split, file, otherArgs).pure[F] 159 | } 160 | } 161 | } 162 | } 163 | 164 | // Optional #! 165 | // Uninterrupted section of comments broken by newlines 166 | // Rest is the body 167 | object Parser { 168 | def simpleParser(inputText: String): Either[Throwable, (List[(String, String)], String)] = Either.catchNonFatal{ 169 | val text = { 170 | val base = inputText 171 | if (base.startsWith("#!")) { 172 | val idx = base.indexOf("\n")+1 173 | val out = base.substring(idx) 174 | out 175 | } else base 176 | } 177 | val lines = fs2.Stream(text) 178 | .through(fs2.text.lines) 179 | 180 | val headersLines = lines 181 | .takeWhile(_.startsWith("//")) 182 | .filter(x => x.contains(":")) // Comments are allowed that dont follow x:z 183 | .compile 184 | .to(List) 185 | val headers = headersLines.map{ 186 | s => 187 | val idx = s.indexOf(":") // :space is required in for a valid header 188 | val header = s.slice(2, idx).trim() 189 | val value = s.slice(idx + 1, s.length()) 190 | (header, value) 191 | } 192 | val restText = lines 193 | .intersperse("\n") 194 | .compile 195 | .string 196 | (headers, restText) 197 | } 198 | 199 | 200 | } 201 | 202 | case class Config( 203 | scala: String, 204 | sbt: String, 205 | interpreter: Config.Interpreter, 206 | name: String, 207 | dependencies: List[String], 208 | scalacOptions: List[String], 209 | compilerPlugins: List[String], 210 | sbtPlugins: List[String] 211 | ) 212 | object Config { 213 | sealed trait Interpreter 214 | object Interpreter { 215 | case object Raw extends Interpreter 216 | case object App extends Interpreter 217 | case object IOApp extends Interpreter 218 | case object IOAppSimple extends Interpreter 219 | def fromString(s: String): Option[Interpreter] = s match { 220 | case "IOApp.Simple" => IOAppSimple.some 221 | case "IOAppSimple" => IOAppSimple.some 222 | case "ioapp.simple" => IOAppSimple.some 223 | case "ioappsimple" => IOAppSimple.some 224 | case "IOApp" => IOApp.some 225 | case "ioapp" => IOApp.some 226 | case "App" => App.some 227 | case "app" => App.some 228 | case "Raw" => Raw.some 229 | case "raw" => Raw.some 230 | case _ => None 231 | } 232 | } 233 | 234 | // TODO Interpreter 235 | def configFromHeaders(headers: List[(String, String)]): Config = { 236 | val scala = headers.findLast(_._1.toLowerCase() === "scala").map(_._2.trim()).getOrElse("2.13.5") 237 | val sbt = headers.findLast(_._1.toLowerCase() === "sbt").map(_._2.trim()).getOrElse("1.5.0") 238 | val interpreter = headers.findLast(_._1.toLowerCase() === "interpreter") 239 | .map(_._2.trim()) 240 | .flatMap(Config.Interpreter.fromString) 241 | .getOrElse(Config.Interpreter.IOAppSimple) 242 | val name = headers.findLast(_._1.toLowerCase() === "name").map(_._2.trim()).getOrElse("Example Script") 243 | val dependencies = 244 | headers.filter(_._1.toLowerCase() == "dependency").map(_._2.trim()) 245 | val scalacOptions = headers.filter(_._1.toLowerCase() == "scalac").map(_._2.trim()) 246 | val compilerPlugins = headers.filter(_._1.toLowerCase() == "compiler-plugin").map(_._2.trim()) 247 | val sbtPlugins = headers.filter(_._1.toLowerCase() == "sbt-plugin").map(_._2.trim()) 248 | Config(scala, sbt, interpreter, name, dependencies, scalacOptions, compilerPlugins, sbtPlugins) 249 | } 250 | } 251 | 252 | object Files { 253 | 254 | // build.sbt 255 | def buildFile(config: Config): String = { 256 | val fs2 = """libraryDependencies += "co.fs2" %% "fs2-io" % "3.0.6"""" 257 | val fs2Maybe = config.interpreter match { 258 | case Config.Interpreter.IOApp => fs2 259 | case Config.Interpreter.IOAppSimple => fs2 260 | case Config.Interpreter.App => "" 261 | case Config.Interpreter.Raw => "" 262 | } 263 | s""" 264 | |scalaVersion := "${config.scala}" 265 | |name := "script" 266 | |enablePlugins(JavaAppPackaging) 267 | | 268 | |${config.scalacOptions.map(s => s"""scalacOptions += "$s" """).intercalate("\n")} 269 | |${config.compilerPlugins.map(s => s"addCompilerPlugin($s)").intercalate("\n")} 270 | |$fs2Maybe 271 | |${config.dependencies.map(s => s"libraryDependencies += $s").intercalate("\n")} 272 | |""".stripMargin 273 | } 274 | // project/build.properties 275 | def buildProperties(config: Config) = s"sbt.version=${config.sbt}\n" 276 | 277 | // project/plugins.sbt 278 | def pluginsFile(config: Config) = 279 | s"""addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") // Used for script for args passing 280 | | 281 | |${config.sbtPlugins.map(s => s"addSbtPlugin($s)").intercalate("\n")} 282 | |""".stripMargin 283 | 284 | def main(config: Config, script: String): String = { 285 | val nameAsClass = config.name.replaceAll("\\s", "") 286 | config.interpreter match { 287 | case Config.Interpreter.IOAppSimple => 288 | s"""object $nameAsClass extends cats.effect.IOApp.Simple { 289 | |$script 290 | |} 291 | |""".stripMargin 292 | case Config.Interpreter.IOApp => 293 | s"""object $nameAsClass extends cats.effect.IOApp { 294 | |$script 295 | |} 296 | |""".stripMargin 297 | case Config.Interpreter.App => 298 | s"""object $nameAsClass {;def main(args: Array[String]): Unit = { 299 | |$script 300 | |} 301 | |} 302 | |""".stripMargin 303 | case Config.Interpreter.Raw => script 304 | } 305 | } 306 | 307 | def writeFile[F[_]: Async](file: java.nio.file.Path, text: String): F[Unit] = { 308 | fs2.io.file.Files[F].deleteIfExists(file) >> 309 | fs2.Stream(text) 310 | .covary[F] 311 | .through(fs2.text.utf8Encode) 312 | .through( 313 | fs2.io.file.Files[F].writeAll(file) //List(StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING) 314 | ).compile.drain 315 | } 316 | 317 | def copyFile[F[_]: Async](from: java.nio.file.Path, to: java.nio.file.Path): F[Unit] = { 318 | fs2.io.file.Files[F].deleteIfExists(to) >> 319 | fs2.io.file.Files[F].copy(from, to) 320 | .void 321 | } 322 | def createInFolder[F[_]: Async](sbtFolder: java.nio.file.Path, config: Config, script: String, buildFilePath: Option[Path]): F[Unit] = { 323 | val files = fs2.io.file.Files[F] 324 | val buildFile = Files.buildFile(config) 325 | val buildProperties = Files.buildProperties(config) 326 | val plugins = Files.pluginsFile(config) 327 | val main = Files.main(config, script) 328 | val sbtBuildPath = Paths.get(sbtFolder.toString, "build.sbt") 329 | for { 330 | _ <- files.exists(sbtFolder).ifM( 331 | Applicative[F].unit, 332 | files.createDirectory(sbtFolder).void 333 | ) 334 | _ <- buildFilePath match { 335 | case Some(p) => files.exists(p).ifM( 336 | copyFile(p, sbtBuildPath), 337 | (new RuntimeException("existing build.sbt file specified to use does not exist") with scala.util.control.NoStackTrace).raiseError 338 | ) 339 | case None => writeFile(sbtBuildPath, buildFile) 340 | } 341 | project = Paths.get(sbtFolder.toString(), "project") 342 | _ <- files.exists(project).ifM( 343 | Applicative[F].unit, 344 | files.createDirectory(project).void 345 | ) 346 | _ <- writeFile(Paths.get(sbtFolder.toString(), "project", "build.properties"), buildProperties) 347 | _ <- writeFile(Paths.get(sbtFolder.toString(), "project", "plugins.sbt"), plugins) 348 | 349 | scala = Paths.get(sbtFolder.toString(), "src", "main", "scala") 350 | _ <- files.exists(scala).ifM( 351 | Applicative[F].unit, 352 | files.createDirectories(scala).void 353 | ) 354 | _ <- writeFile(Paths.get(sbtFolder.toString(), "src", "main", "scala", "script.scala"), main) 355 | } yield () 356 | } 357 | 358 | def stageExecutable[F[_]: Async](sbtFolder: java.nio.file.Path): F[Unit] = { 359 | val soCompile = new scala.collection.mutable.ListBuffer[String] 360 | val loggerCompile = ProcessLogger(s => soCompile.addOne(s), e => soCompile.addOne(e)) 361 | val compile = Resource.make(Sync[F].delay(process.Process(s"sbt compile", sbtFolder.toFile()).run(loggerCompile)))(s => Sync[F].delay(s.destroy())).use(i => Async[F].delay(i.exitValue())) 362 | 363 | val soStage = new scala.collection.mutable.ListBuffer[String] 364 | val loggerStage = ProcessLogger(s => soStage.addOne(s), e => soStage.addOne(e)) 365 | val stage = Resource.make(Sync[F].delay(process.Process(s"sbt stage", sbtFolder.toFile()).run(loggerStage)))(s => Sync[F].delay(s.destroy())).use(i => Async[F].delay(i.exitValue())) 366 | 367 | compile.flatMap{ 368 | case 0 => stage.flatMap{ 369 | case 0 => Applicative[F].unit 370 | case _ => 371 | val standardOut = soStage.toList 372 | standardOut.traverse_[F, Unit](s => 373 | cats.effect.std.Console.make[F].println(s) 374 | ) >> (new RuntimeException("sbt stage failed, is enablePlugins(JavaAppPackaging) in your build.sbt file?") with scala.util.control.NoStackTrace).raiseError 375 | } 376 | case _ => 377 | val standardOut = soCompile.toList 378 | standardOut.traverse_[F, Unit](s => 379 | cats.effect.std.Console.make[F].println(s) 380 | ) >> (new RuntimeException("sbt compile failed") with scala.util.control.NoStackTrace).raiseError 381 | } 382 | } 383 | 384 | def executeExecutable[F[_]: Async](stageDirectory: java.nio.file.Path, scriptArgs: List[String]): F[Unit] = { 385 | val p = stageDirectory.resolve("bin").resolve("script") 386 | def execute = Resource.make(Sync[F].delay(process.Process(s"${p.toString} ${scriptArgs.mkString(" ")}").run()))(s => Sync[F].delay(s.destroy())).use(i => Sync[F].delay(i.exitValue())) 387 | execute.void 388 | } 389 | } 390 | 391 | object Cache { 392 | // Cache Protocol 393 | // --nocache disables caching entirely, run only in temp 394 | // Cache Location determined by Operating System Specific if unable to determine falls back to nocache 395 | // TODO Cache Flag, Environment Variable CATSCRIPT_CACHE 396 | // Provided file is resolved to absolute location // TODO including resolving symlinks 397 | // That absolute path is hashed through SHA-1 (this means we have 1 cache per file) 398 | // We check the SHA-1 of the script body to `script_sha`, 399 | // if they match then the current executable present is reused 400 | // otherwise create the temp directory create the project 401 | // then copy the staged output into the location and write the current SHA-1 to script_sha 402 | sealed trait CacheStrategy extends Product with Serializable 403 | case object NoCache extends CacheStrategy 404 | case class ReuseExecutable(cachedExecutableDirectory: Path) extends CacheStrategy 405 | case class NewCachedValue(cachedExecutableDirectory: Path, fileContentSha: String) extends CacheStrategy 406 | 407 | def determineCacheStrategy[F[_]: Async](args: Arguments, filePath: Path, fileContent: String): F[CacheStrategy] = { 408 | if (args.catsScriptArgs.exists(_ == "--no-cache")) NoCache.pure[F].widen 409 | else getOS.flatMap{ 410 | case None => NoCache.pure[F].widen 411 | case Some(os) => cacheLocation(os).flatMap{ path => 412 | import java.security.MessageDigest 413 | 414 | Sync[F].delay{ 415 | val SHA1 = MessageDigest.getInstance("SHA-1") 416 | val absolute = filePath.toAbsolutePath().toString() 417 | val absolutePathSha = ByteVector.view(SHA1.digest(absolute.getBytes())).toHex 418 | path.resolve(absolutePathSha) 419 | }.flatMap{ cachedExecutableDirectory => 420 | val shaFile = cachedExecutableDirectory.resolve("script_sha") 421 | fs2.io.file.Files[F].exists(shaFile).ifM( 422 | { 423 | for { 424 | scriptSha <- fs2.io.file.Files[F].readAll(shaFile, 4096).through(fs2.text.utf8Decode).compile.string 425 | testSha <- Sync[F].delay{ 426 | val SHA1 = MessageDigest.getInstance("SHA-1") 427 | ByteVector.view(SHA1.digest(fileContent.getBytes())).toHex 428 | } 429 | } yield if (scriptSha === testSha) ReuseExecutable(cachedExecutableDirectory) else NewCachedValue(cachedExecutableDirectory, testSha) 430 | }, 431 | Sync[F].delay{ 432 | val SHA1 = MessageDigest.getInstance("SHA-1") 433 | ByteVector.view(SHA1.digest(fileContent.getBytes())).toHex 434 | }.map(NewCachedValue(cachedExecutableDirectory, _)).widen[CacheStrategy] 435 | ) 436 | } 437 | } 438 | } 439 | } 440 | 441 | def clearCache[F[_]: Async](verbose: Boolean): F[Unit] = { 442 | val c = cats.effect.std.Console.make[F] 443 | getOS.flatMap{ 444 | case None => 445 | (new RuntimeException("clear-cache cannot determine OS") with NoStackTrace).raiseError[F, Unit] 446 | case Some(os) => 447 | cacheLocation(os).flatMap{ case cacheDirectory => 448 | fs2.io.file.Files[F].exists(cacheDirectory).ifM( 449 | fs2.io.file.Files[F].walk(cacheDirectory, 1).drop(1).evalMap(path => 450 | fs2.io.file.Files[F].deleteDirectoryRecursively(path) >> 451 | { if (verbose) c.println(s"Deleted: $path") else Applicative[F].unit } 452 | ).compile.drain, 453 | Applicative[F].unit 454 | ) 455 | } 456 | } 457 | } 458 | 459 | sealed trait OS 460 | case object Linux extends OS 461 | case object OSX extends OS 462 | case object Windows extends OS 463 | case object Solaris extends OS 464 | 465 | // Can't determine OS, don't cache 466 | private def getOS[F[_]: Sync]: F[Option[OS]] = { 467 | Sync[F].delay(System.getProperty("os.name").toLowerCase).map{ 468 | // Linux, Unix, Aix 469 | case linux if linux.contains("nux") || linux.contains("nix") || linux.contains("aix") => Linux.some 470 | case mac if mac.contains("mac") => OSX.some 471 | case windows if windows.contains("win") => Windows.some 472 | case solaris if solaris.contains("sunos") => Solaris.some 473 | case _ => None 474 | } 475 | } 476 | 477 | // Mirroring Coursier Semantics 478 | // on Linux, ~/.cache/catscript/v0. This also applies to Linux-based CI environments, and FreeBSD too. 479 | // on OS X, ~/Library/Caches/Catscript/v0. 480 | // on Windows, %LOCALAPPDATA%\Catscript\Cache\v0, which, for user Chris, typically corresponds to C:\Users\Chris\AppData\Local\Catscript\Cache\v1. 481 | private def cacheLocation[F[_]: Sync](os: OS): F[java.nio.file.Path] = Sync[F].delay(os match { 482 | case Linux | Solaris => 483 | val home = System.getProperty("user.home") 484 | Paths.get(s"$home/.cache/catscript/v0").toAbsolutePath 485 | case OSX => 486 | val home = System.getProperty("user.home") 487 | Paths.get(s"$home/Library/Caches/Catscript/v0").toAbsolutePath 488 | case Windows => 489 | val home = System.getProperty("user.home") 490 | Paths.get(home ++ """\Catscript\Cache\v0""").toAbsolutePath 491 | }) 492 | 493 | } -------------------------------------------------------------------------------- /core/src/test/scala/io/chrisdavenport/catscript/MainSpec.scala: -------------------------------------------------------------------------------- 1 | package io.chrisdavenport.catscript 2 | 3 | import munit.CatsEffectSuite 4 | // import cats.effect._ 5 | 6 | class MainSpec extends CatsEffectSuite { 7 | 8 | test("Main should exit succesfully") { 9 | assert(true) 10 | // Main.run(List.empty[String]).map(ec => 11 | // assertEquals(ec, ExitCode.Success) 12 | // ) 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.0 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.16") 2 | addSbtPlugin("io.chrisdavenport" % "sbt-davenverse" % "0.0.6") 3 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6") -------------------------------------------------------------------------------- /simple-server.catscript: -------------------------------------------------------------------------------- 1 | #!./core/target/universal/stage/bin/catscript 2 | // dependency: "org.http4s" %% "http4s-ember-server" % "1.0.0-M21" 3 | // dependency: "org.http4s" %% "http4s-dsl" % "1.0.0-M21" 4 | // dependency: "ch.qos.logback" % "logback-classic" % "1.2.3", 5 | import org.http4s.ember.server._ 6 | import org.http4s.dsl.io._ 7 | import org.http4s._ 8 | import org.http4s.implicits._ 9 | import cats.effect._ 10 | 11 | val routes = HttpRoutes.of[IO]{ 12 | case GET -> Root => Ok("Hello World!") 13 | case GET -> Root / "hello" / you => 14 | Ok(s"Hello $you") 15 | } 16 | 17 | def run = for { 18 | _ <- EmberServerBuilder 19 | .default[IO] 20 | .withHttpApp(routes.orNotFound) 21 | .build 22 | .use(_ => IO.never) 23 | } yield () 24 | -------------------------------------------------------------------------------- /site/Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem "jekyll", ">= 4.0.0" 4 | gem "jekyll-relative-links" 5 | gem "sass" -------------------------------------------------------------------------------- /site/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | --- 5 | 6 | # catscript - Cats Scripting [![Build Status](https://travis-ci.com/ChristopherDavenport/catscript.svg?branch=master)](https://travis-ci.com/ChristopherDavenport/catscript) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.chrisdavenport/catscript_2.12/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.chrisdavenport/catscript_2.12) 7 | 8 | ## Quick Start 9 | 10 | To use catscript in an existing SBT project with Scala 2.11 or a later version, add the following dependencies to your 11 | `build.sbt` depending on your needs: 12 | 13 | ```scala 14 | libraryDependencies ++= Seq( 15 | "io.chrisdavenport" %% "catscript" % "" 16 | ) 17 | ``` -------------------------------------------------------------------------------- /test.catscript: -------------------------------------------------------------------------------- 1 | #!./core/target/universal/stage/bin/catscript 2 | // interpreter: IOApp.Simple 3 | // scala: 3.0.0 4 | // dependency: "org.http4s" %% "http4s-ember-client" % "0.23.0-RC1" 5 | 6 | import cats.effect._ 7 | import cats.effect.std.Console 8 | 9 | def run: IO[Unit] = Console[IO].println(s"Hello From IOApp.Simple!") 10 | // def run(args: List[String]): IO[ExitCode] = Console[IO].println(s"Received $args- Hello from IOApp").as(ExitCode.Success) 11 | --------------------------------------------------------------------------------