Repository: railwayapp/nixpacks (rust)

Issue:
Scala sbt support
### Feature request

Provide support for Scala sbt projects to compile to jar files that can be deployed similarly to the way maven and gradle is handled

### Motivation

I want to build docker images for my sbt application while currently only maven and gradle are supported.

### Contribution

Would love to help by sending a PR

Pull request:
feat(scala): Added ScalaProvider
<!-- Thanks for sending a pull request! -->
<!-- Please add a useful description explaining what this PR does -->

Fixes #714

The ScalaProvider adds support for the scala ecosystem, not covered by maven and gradle which includes sbt, mill and scala-cli.

Added support for sbt projects.

<!-- PR Checklist  -->
<!-- - [ ] Tests are added/updated if needed  -->
<!-- - [ ] Docs are updated if needed  -->


Patch:
diff --git a/docs/pages/docs/providers/scala.md b/docs/pages/docs/providers/scala.md
new file mode 100644
index 0000000..e7bdbbb
--- /dev/null
+++ b/docs/pages/docs/providers/scala.md
@@ -0,0 +1,67 @@
+---
+title: Scala
+---
+
+# {% $markdoc.frontmatter.title %}
+
+Currently sbt projects are supported for Scala. For gradle and maven support, please look
+at Java. Scala is detected by `build.sbt` in project root.
+
+## SBT Setup
+
+### Requirements
+
+The project should contain the `sbt-native-packager` sbt plugin. This can be done
+by adding the plugin to `project/plugins.sbt`.
+
+```scala
+// Check https://github.com/sbt/sbt-native-packager for version
+addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "x.x.x")
+```
+
+After that enable the `JavaAppPackaging` plugin in `build.sbt` and set the
+`executableScriptName` to `main`. An example of the `build.sbt` can be seen here.
+
+```scala
+val scala3Version = "3.2.2"
+
+lazy val root = project
+  .in(file("."))
+  .settings(
+    name := "scala-sbt",
+    version := "0.1.0-SNAPSHOT",
+
+    scalaVersion := scala3Version,
+
+    // This is required by nixpacks
+    executableScriptName := "main"
+  )
+  // sbt-native-packager is the tool used by nixpacks
+  // to generate the package
+  .enablePlugins(JavaAppPackaging)
+
+```
+
+The `executableScriptName` is required for `nixpacks` to run the right executable
+in the start command.
+
+### Build
+
+`sbt-native-package` is used for building the package.
+
+```
+sbt stage
+```
+
+This creates the required packages and also a convenient script to run
+at `./target/universal/stage/bin/main`
+
+### Start
+
+Run the built script:
+
+```sh
+./target/universal/stage/bin/main
+```
+
+The script picks up `JAVA_OPTS` to provide jvm or java arguments to the system.
diff --git a/docs/sidebar.ts b/docs/sidebar.ts
index d27a454..5dce544 100644
--- a/docs/sidebar.ts
+++ b/docs/sidebar.ts
@@ -51,6 +51,7 @@ export const sidebarItems: ISidebarSection[] = [
       { href: "/docs/providers/rust", text: "Rust" },
       { href: "/docs/providers/staticfile", text: "Staticfile" },
       { href: "/docs/providers/swift", text: "Swift" },
+      { href: "/docs/providers/scala", text: "Scala" },
       { href: "/docs/providers/zig-lang", text: "Zig" },
     ],
   },
diff --git a/examples/scala-sbt/.gitignore b/examples/scala-sbt/.gitignore
new file mode 100644
index 0000000..9e79245
--- /dev/null
+++ b/examples/scala-sbt/.gitignore
@@ -0,0 +1,32 @@
+# macOS
+.DS_Store
+
+# sbt specific
+dist/*
+target/
+lib_managed/
+src_managed/
+project/boot/
+project/plugins/project/
+project/local-plugins.sbt
+.history
+.ensime
+.ensime_cache/
+.sbt-scripted/
+local.sbt
+
+# Bloop
+.bsp
+
+# VS Code
+.vscode/
+
+# Metals
+.bloop/
+.metals/
+metals.sbt
+
+# IDEA
+.idea
+.idea_modules
+/.worksheet/
diff --git a/examples/scala-sbt/build.sbt b/examples/scala-sbt/build.sbt
new file mode 100644
index 0000000..a715f33
--- /dev/null
+++ b/examples/scala-sbt/build.sbt
@@ -0,0 +1,17 @@
+val scala3Version = "3.2.2"
+
+lazy val root = project
+  .in(file("."))
+  .settings(
+    name := "scala-sbt",
+    version := "0.1.0-SNAPSHOT",
+
+    scalaVersion := scala3Version,
+
+    // This is required by nixpacks
+    executableScriptName := "main"
+  )
+  // sbt-native-packager is the tool used by nixpacks
+  // to generate the package
+  .enablePlugins(JavaAppPackaging)
+
diff --git a/examples/scala-sbt/project/build.properties b/examples/scala-sbt/project/build.properties
new file mode 100644
index 0000000..46e43a9
--- /dev/null
+++ b/examples/scala-sbt/project/build.properties
@@ -0,0 +1,1 @@
+sbt.version=1.8.2
diff --git a/examples/scala-sbt/project/plugins.sbt b/examples/scala-sbt/project/plugins.sbt
new file mode 100644
index 0000000..18dadf3
--- /dev/null
+++ b/examples/scala-sbt/project/plugins.sbt
@@ -0,0 +1,1 @@
+addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.13")
diff --git a/examples/scala-sbt/src/main/scala/Main.scala b/examples/scala-sbt/src/main/scala/Main.scala
new file mode 100644
index 0000000..b51644b
--- /dev/null
+++ b/examples/scala-sbt/src/main/scala/Main.scala
@@ -0,0 +1,5 @@
+@main def hello: Unit = 
+  println("Hello world!")
+  println(msg)
+
+def msg = "I was compiled by Scala 3. :)"
diff --git a/src/lib.rs b/src/lib.rs
index d5ca931..ce5fa59 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -38,8 +38,8 @@ use providers::{
     csharp::CSharpProvider, dart::DartProvider, deno::DenoProvider, elixir::ElixirProvider,
     fsharp::FSharpProvider, go::GolangProvider, haskell::HaskellStackProvider, java::JavaProvider,
     node::NodeProvider, php::PhpProvider, python::PythonProvider, ruby::RubyProvider,
-    rust::RustProvider, staticfile::StaticfileProvider, swift::SwiftProvider, zig::ZigProvider,
-    Provider,
+    rust::RustProvider, scala::ScalaProvider, staticfile::StaticfileProvider, swift::SwiftProvider,
+    zig::ZigProvider, Provider,
 };
 
 mod chain;
@@ -59,6 +59,7 @@ pub fn get_providers() -> &'static [&'static (dyn Provider)] {
         &GolangProvider {},
         &HaskellStackProvider {},
         &JavaProvider {},
+        &ScalaProvider {},
         &PhpProvider {},
         &RubyProvider {},
         &NodeProvider {},
diff --git a/src/providers/mod.rs b/src/providers/mod.rs
index 629eba7..01af32d 100644
--- a/src/providers/mod.rs
+++ b/src/providers/mod.rs
@@ -18,6 +18,7 @@ pub mod procfile;
 pub mod python;
 pub mod ruby;
 pub mod rust;
+pub mod scala;
 pub mod staticfile;
 pub mod swift;
 pub mod zig;
diff --git a/src/providers/scala.rs b/src/providers/scala.rs
new file mode 100644
index 0000000..e18b0a2
--- /dev/null
+++ b/src/providers/scala.rs
@@ -0,0 +1,102 @@
+use super::Provider;
+use crate::nixpacks::{
+    app::App,
+    environment::Environment,
+    nix::pkg::Pkg,
+    plan::{
+        phase::{Phase, StartPhase},
+        BuildPlan,
+    },
+};
+use anyhow::Result;
+
+pub struct ScalaProvider {}
+
+/**
+ * Scala provider currently supports sbt.
+ * - The sbt project requires sbt-native-packager, a popular packaging
+ *   tool used by the community to package apps. Setting executableScriptName and
+ *   enabling the JavaAppPackaging plugin are required. Please check examples/scala-sbt
+ *   for an example.
+ *
+ * TODO: Add support for scala-cli and mill
+ */
+impl Provider for ScalaProvider {
+    fn name(&self) -> &str {
+        "scala"
+    }
+
+    fn detect(&self, app: &App, _env: &Environment) -> Result<bool> {
+        Ok(app.includes_file("build.sbt"))
+    }
+
+    fn get_build_plan(&self, app: &App, _env: &Environment) -> Result<Option<BuildPlan>> {
+        if self.is_using_sbt(app) {
+            let pkgs = self.get_sbt_dep_pkgs();
+            let setup = Phase::setup(Some(pkgs));
+
+            let mut build = Phase::build(None);
+            let sbt_exe = self.get_sbt_exe();
+
+            build.add_cmd(format!("{sbt_exe} stage"));
+            build.add_cache_directory("/root/.sbt");
+            build.add_cache_directory("/root/.ivy2/cache");
+            build.add_cache_directory("/root/.cache/coursier");
+            build.depends_on_phase("setup");
+
+            let start_cmd = self.get_start_cmd(app).map(StartPhase::new);
+
+            let plan = BuildPlan::new(&vec![setup, build], start_cmd);
+            Ok(Some(plan))
+        } else {
+            Ok(None)
+        }
+    }
+}
+
+impl ScalaProvider {
+    fn get_sbt_exe(&self) -> String {
+        "sbt".to_string()
+    }
+
+    fn get_start_cmd(&self, app: &App) -> Option<String> {
+        if self.is_using_sbt(app) {
+            Some("./target/universal/stage/bin/main".to_string())
+        } else {
+            None
+        }
+    }
+
+    fn is_using_sbt(&self, app: &App) -> bool {
+        app.includes_file("build.sbt")
+    }
+
+    pub fn get_sbt_dep_pkgs(&self) -> Vec<Pkg> {
+        let pkgs = vec![self.get_sbt_pkg(), self.get_jdk_pkg()];
+        pkgs
+    }
+
+    fn get_sbt_pkg(&self) -> Pkg {
+        Pkg::new("sbt")
+    }
+
+    fn get_jdk_pkg(&self) -> Pkg {
+        // sbt uses jdk pkg to compile and package the project
+        // already so we should use the same package for the start phase
+        // to prevent conflict.
+        Pkg::new("jdk")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_sbt_package() {
+        let scala = ScalaProvider {};
+
+        assert!(scala.is_using_sbt(&App::new("examples/scala-sbt").unwrap()));
+        assert!(!scala.is_using_sbt(&App::new("examples/node").unwrap()));
+    }
+}


Test patch:
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index 6dbb197..ddce9dd 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -815,6 +815,13 @@ async fn test_java_maven() {
     assert!(output.contains("Built with Spring Boot"));
 }
 
+#[tokio::test]
+async fn test_scala_sbt() {
+    let name = simple_build("./examples/scala-sbt").await;
+    let output = run_image(&name, None).await;
+    assert!(output.contains("I was compiled by Scala 3"));
+}
+
 #[tokio::test]
 async fn test_zig() {
     let name = simple_build("./examples/zig").await;
diff --git a/tests/snapshots/generate_plan_tests__scala_sbt.snap b/tests/snapshots/generate_plan_tests__scala_sbt.snap
new file mode 100644
index 0000000..9598e2a
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__scala_sbt.snap
@@ -0,0 +1,40 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "NIXPACKS_METADATA": "scala"
+  },
+  "phases": {
+    "build": {
+      "name": "build",
+      "dependsOn": [
+        "install",
+        "setup"
+      ],
+      "cmds": [
+        "sbt stage"
+      ],
+      "cacheDirectories": [
+        "/root/.cache/coursier",
+        "/root/.ivy2/cache",
+        "/root/.sbt"
+      ]
+    },
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "sbt",
+        "jdk"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "./target/universal/stage/bin/main"
+  }
+}