diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 55bf3a65..90d6eb10 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -84,6 +84,7 @@ jobs: - python-uv - react - rust + - scala - typescript - zig-js - zig diff --git a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py index 9448277f..5862ca52 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py @@ -40,6 +40,7 @@ from ._pipeline import pipeline_to_json from ._python import python from ._rust import RustProject, rust +from ._scala import ScalaProject, scala from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests from ._toolchain import apt_base @@ -309,6 +310,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "JsProject", "Pipeline", "RustProject", + "ScalaProject", "Step", "Target", "apt_base", @@ -329,6 +331,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "py", "python", "rust", + "scala", "scratch", "sh", "target", diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_scala.py b/crates/hm-dsl-engine/harmont-py/harmont/_scala.py new file mode 100644 index 00000000..771ba196 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/harmont/_scala.py @@ -0,0 +1,337 @@ +"""Scala toolchain and CI-pipeline DSL. + +Public surface is the module-level ``scala`` namespace: + + hm.scala.toolchain(...) -> ScalaToolchain (install-only) + hm.scala.project(...) -> ScalaProject (full CI DAG) +""" + +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Self + +from ._toolchain import advance_install, make_install_chain +from .cache import CacheForever, CacheOnChange + +if TYPE_CHECKING: + from ._step import Step + from .cache import CachePolicy + +APT_PACKAGES = ("curl", "ca-certificates", "openjdk-17-jdk-headless") +_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") # e.g. 3.3.8 +_MIN_VERSION = (2, 13, 0) + + +def _parse_version(version: str) -> tuple[int, ...]: + return tuple(int(p) for p in version.split(".")) + + +def coursier_install_cmd(version: str = "3.3.8") -> str: + return ( + "curl -fL https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz " + "| gzip -d > cs && " + "chmod +x cs && " + "./cs setup --yes && " + f"./cs install scala:{version} && " + "ln -sf /root/.local/share/coursier/bin/* /usr/local/bin/" + ) + + +def _compile_cmd(*, query: str | None = None, test: bool = False) -> str: + scope = "" + if query: + scope += f"{query} / " + if test: + scope += "Test / " + return f"sbt {scope}compile" + + +# sbt [ / ] test [ …] [ -- ] +def _test_cmd( + *, + query: str | None = None, + testnames: tuple[str, ...] = (), + options: dict[str, Any] | None = None, +) -> str: + scope = f"{query} / " if query else "" + names = (" " + " ".join(testnames)) if testnames else "" + opts = "" + if options: + rendered = " ".join(f"{key} {shlex.quote(str(value))}" for key, value in options.items()) + opts = f" -- {rendered}" + return f"sbt {scope}test{names}{opts}" + + +def _fmt_cmd(*, check: bool = True) -> str: + return "sbt scalafmtCheckAll" if check else "sbt scalafmtAll" + + +@dataclass(frozen=True) +class ScalaToolchain: + """Scala install chain built by ``hm.scala.toolchain()``. + + Holds the coursier install step. Action methods (``compile``, ``test``, + ``fmt``, ``warmup``) attach leaves to ``installed`` and forward Step kwargs + (``label``, ``cache``, ``env``, ``image`` …) unchanged. + """ + + path: str + installed: Step + + def setup( + self, + cmd: str, + *, + cwd: str | None = None, + label: str | None = None, + cache: CachePolicy | None = None, + env: dict[str, str] | None = None, + ) -> Self: + """Append a post-install command and return an advanced toolchain; chainable. + + Use for prep steps the toolchain's actions must depend on but that the SDK + does not model natively — code generation, fixtures, extra tooling. The + returned object's action methods fork from this step. + + Examples: + >>> import harmont as hm + >>> tc = hm.scala.toolchain(path=".").setup("cs install scalafix") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + + def _wrap(self, sbt_cmd: str) -> str: + return ( + f'export PATH="$PATH:$HOME/.local/share/coursier/bin" && cd {self.path} && {sbt_cmd}' + ) + + def _emit(self, sbt_cmd: str, default_label: str, **kw: Any) -> Step: + if kw.get("label") is None: + kw["label"] = default_label + return self.installed.sh(self._wrap(sbt_cmd), **kw) + + def compile(self, *, query: str | None = None, test: bool = False, **kw: Any) -> Step: + """Compile the project (``sbt compile``); ``test=True`` compiles the test + sources (``sbt Test / compile``).""" + cmd = _compile_cmd(query=query, test=test) + return self._emit(cmd, f":scala: {self.path} compile", **kw) + + def test( + self, + *, + query: str | None = None, + testnames: tuple[str, ...] = (), + options: dict[str, Any] | None = None, + **kw: Any, + ) -> Step: + """Run the test suite (``sbt test``). ``query`` scopes to a subproject, + ``testnames`` selects specific suites, ``options`` pass through after ``--``.""" + cmd = _test_cmd(query=query, testnames=testnames, options=options) + return self._emit(cmd, f":scala: {self.path} test", **kw) + + def fmt(self, *, check: bool = True, **kw: Any) -> Step: + """Format Scala sources. ``check=True`` (default) verifies formatting via + ``sbt scalafmtCheckAll`` and fails on drift; ``check=False`` rewrites in + place via ``sbt scalafmtAll``.""" + cmd = _fmt_cmd(check=check) + return self._emit(cmd, f":scala: {self.path} format", **kw) + + def warmup(self, **kw: Any) -> Step: + """Pre-resolve dependencies (``sbt update``) so later steps reuse the cache. + Used internally by ``hm.scala.project()``.""" + return self._emit("sbt update", ":scala: warmup", **kw) + + +@dataclass(frozen=True) +class ScalaProject: + """High-level Scala CI DAG built by ``hm.scala.project()``. + + ``compile`` and ``test`` attach to a shared ``warmup`` step so dependency + resolution is reused; ``fmt`` forks off the toolchain install to run in + parallel. ``ci()`` returns the standard DAG in one call. + """ + + path: str + toolchain: ScalaToolchain + warmup: Step + + def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step: + if kw.get("label") is None: + kw["label"] = default_label + return self.warmup.sh(self.toolchain._wrap(cmd), **kw) # noqa: SLF001 + + def compile(self, *, query: str | None = None, test: bool = False, **kw: Any) -> Step: + """Compile the project (``sbt compile``); ``test=True`` compiles the test + sources (``sbt Test / compile``).""" + cmd = _compile_cmd(query=query, test=test) + return self._emit(cmd, f":scala: {self.path} sbt compile", **kw) + + def test( + self, + *, + query: str | None = None, + testnames: tuple[str, ...] = (), + options: dict[str, Any] | None = None, + **kw: Any, + ) -> Step: + """Run the test suite (``sbt test``). ``query`` scopes to a subproject, + ``testnames`` selects specific suites, ``options`` pass through after ``--``.""" + cmd = _test_cmd(query=query, testnames=testnames, options=options) + return self._emit(cmd, f":scala: {self.path} sbt test", **kw) + + def fmt(self, *, check: bool = True, **kw: Any) -> Step: + """Format Scala sources (``sbt scalafmtCheckAll`` when ``check=True``, + default). Forks off the toolchain install so it runs in parallel with the + warmup rather than waiting on dependency resolution.""" + return self.toolchain.fmt(check=check, **kw) + + def ci(self) -> tuple[Step, ...]: + """Zero-config Scala CI DAG: ``compile`` and ``test`` off the shared warmup, + ``fmt`` (verify) in parallel off the install. + + Examples: + >>> import harmont as hm + >>> proj = hm.scala.project() + >>> hm.pipeline(list(proj.ci())) + """ + return self.compile(), self.test(), self.fmt() + + +def _make_scala( + *, + path: str = ".", + version: str = "3.3.8", + image: str | None = None, + base: Step | None = None, +) -> ScalaToolchain: + if not _VERSION_RE.match(version): + msg = f"hm.scala: invalid version {version!r}\n -> use a valid X.Y.Z Scala version" + raise ValueError(msg) + + if _parse_version(version) < _MIN_VERSION: + msg = f"hm.scala: unsupported version {version!r}\n -> use Scala 2.13.0 or newer" + raise ValueError(msg) + + installed = make_install_chain( + apt_packages=APT_PACKAGES, + install_cmd=coursier_install_cmd(version), + install_cache=CacheForever(env_keys=()), + lang_tag="scala", + install_tag="install", + image=image, + base=base, + ) + return ScalaToolchain(path=path, installed=installed) + + +def _make_scala_project( + *, + path: str = ".", + version: str = "3.3.8", + image: str | None = None, + base: Step | None = None, + cache: CachePolicy | None = None, +) -> ScalaProject: + tc = _make_scala(path=path, version=version, image=image, base=base) + + if not path: + path = "." + + prefix = "" if path == "." else f"{path}/" + build_file = f"{prefix}build.sbt" + properties_file = f"{prefix}project/build.properties" + main_glob = f"{prefix}**/src/main/*.scala" + test_glob = f"{prefix}**/src/test/*.scala" + + warmup_cache = cache or CacheOnChange( + paths=(build_file, main_glob, test_glob, properties_file) + ) + + warm = tc._emit("sbt update", ":scala: sbt warmup", cache=warmup_cache) # noqa: SLF001 + return ScalaProject(path=path, toolchain=tc, warmup=warm) + + +class ScalaEntry: + """Namespace for ``hm.scala.toolchain()`` and ``hm.scala.project()``.""" + + @staticmethod + def toolchain( + *, + path: str = ".", + scala_version: str = "3.3.8", + image: str | None = None, + base: Step | None = None, + ) -> ScalaToolchain: + """Install the Scala toolchain via coursier. + + Produces a ``ScalaToolchain`` whose ``installed`` step runs coursier; + action methods attach leaves to it. Use ``project()`` instead when you + want a shared warmup step across compile/test/fmt. + + Args: + path: Path to the workspace root. + scala_version: Pinned Scala version, e.g. ``"3.3.8"`` (2.13.0 or newer). + image: Local-mode Docker base image override. + base: Existing ``Step`` to attach the install to instead of emitting + a fresh apt-base step. Use to share one apt-base across toolchains. + + Returns: + A ``ScalaToolchain`` ready for action methods. + + Raises: + ValueError: If ``scala_version`` is malformed or older than 2.13.0. + + Examples: + >>> import harmont as hm + >>> tc = hm.scala.toolchain(scala_version="3.3.8") + >>> hm.pipeline([tc.compile(), tc.test()]) + """ + return _make_scala(path=path, version=scala_version, image=image, base=base) + + @staticmethod + def project( + *, + path: str = ".", + scala_version: str = "3.3.8", + image: str | None = None, + base: Step | None = None, + cache: CachePolicy | None = None, + ) -> ScalaProject: + """Build a high-level Scala CI DAG. + + Installs the toolchain via coursier, warms a dependency cache keyed on + ``build.sbt``/``project/build.properties``/``**/*.scala``, and returns a + ``ScalaProject`` whose ``compile``/``test``/``fmt`` build on that warmup. + + Args: + path: Path to the workspace root. + scala_version: Pinned Scala version, e.g. ``"3.3.8"`` (2.13.0 or newer). + image: Local-mode Docker base image override. + base: Existing ``Step`` to attach the install to instead of emitting + a fresh apt-base step. + cache: Override the warmup cache policy. Defaults to ``CacheOnChange`` + keyed on the build files and ``**/*.scala`` sources. + + Returns: + A ``ScalaProject`` exposing the common CI steps. + + Raises: + ValueError: If ``scala_version`` is malformed or older than 2.13.0. + + Examples: + >>> import harmont as hm + >>> proj = hm.scala.project() + >>> hm.pipeline(list(proj.ci())) + """ + return _make_scala_project( + path=path, + version=scala_version, + image=image, + base=base, + cache=cache, + ) + + +scala: ScalaEntry = ScalaEntry() diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_scala.py b/crates/hm-dsl-engine/harmont-py/tests/test_scala.py new file mode 100644 index 00000000..2d8372f6 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_scala.py @@ -0,0 +1,270 @@ +"""Scala toolchain and project abstraction tests.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +import harmont as hm +from harmont._scala import _test_cmd +from harmont.cache import CacheOnChange +from harmont.keygen import resolve_pipeline_keys + + +def _cmds(p: dict) -> list[str]: + return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + + +def _step_by_substring(p: dict, needle: str) -> dict: + for n in p["graph"]["nodes"]: + if needle in (n["step"].get("cmd") or ""): + return n["step"] + msg = f"no command step containing {needle!r}" + raise AssertionError(msg) + + +class TestScalaToolchain: + def test_full_chain(self): + toolchain = hm.scala.toolchain(path="svc") + project = hm.pipeline([toolchain.compile()]) + cmds = _cmds(project) + assert any("apt-get install" in cmd for cmd in cmds) + assert any("cs install" in cmd for cmd in cmds) + assert any("cd svc && sbt compile" in cmd for cmd in cmds) + + def test_scala_project_actions_share_install_step(self): + s = hm.scala.toolchain(path="svc") + pipeline = hm.pipeline([s.warmup(), s.compile(), s.test(), s.fmt()]) + cmds = _cmds(pipeline) + assert len([c for c in cmds if "apt-get install" in c]) == 1 + assert len([c for c in cmds if "cs install" in c]) == 1 + + def test_cache_forever(self): + toolchain = hm.scala.toolchain(path="cli") + pipeline = hm.pipeline([toolchain.compile()]) + coursier_install = _step_by_substring(pipeline, "cs install") + assert coursier_install["cache"]["policy"] == "forever" + + def test_version_in_coursier_install_cmd(self): + tc = hm.scala.toolchain(path=".", scala_version="3.8.0") + pipeline = hm.pipeline([tc.compile()]) + scala_toolchain = _step_by_substring(pipeline, "cs install") + assert "scala:3.8.0" in scala_toolchain["cmd"] + + def test_invalid_version_rejected(self): + with pytest.raises(ValueError, match="version"): + hm.scala.toolchain(scala_version="not a valid; version") + + def test_below_minimum_version_rejected(self): + with pytest.raises(ValueError, match=r"2\.13"): + hm.scala.toolchain(scala_version="2.12.18") + + def test_installed_escape_hatch(self): + tc = hm.scala.toolchain(path="cli") + custom = tc.installed.sh("cd cli && sbt compile", label=":scala: custom") + p = hm.pipeline([custom]) + cmds = _cmds(p) + assert any("sbt compile" in cmd for cmd in cmds) + + def test_action_labels(self): + tc = hm.scala.toolchain(path=".") + assert tc.compile().label == ":scala: . compile" + assert tc.test().label == ":scala: . test" + assert tc.fmt().label == ":scala: . format" + + def test_action_label_override(self): + tc = hm.scala.toolchain(path=".") + s = tc.compile(label=":scala: dev compile") + assert s.label == ":scala: dev compile" + + def test_action_cache_forwarded(self): + tc = hm.scala.toolchain(path=".") + s = tc.compile(cache=CacheOnChange(paths=("build.sbt",))) + assert s.cache == CacheOnChange(paths=("build.sbt",)) + + def test_image_emitted_on_apt_step(self): + tc = hm.scala.toolchain(path=".", image="alpine:3.20") + p = hm.pipeline([tc.compile()]) + apt_step = _step_by_substring(p, "apt-get install") + assert apt_step.get("image") == "alpine:3.20" + + def test_with_base_skips_apt(self): + base = hm.scratch().sh("custom base", label="base") + tc = hm.scala.toolchain(path="cli", base=base) + p = hm.pipeline([tc.compile()]) + cmds = _cmds(p) + assert not any("apt-get install" in c for c in cmds) + assert any("custom base" in c for c in cmds) + assert any("cs install" in c for c in cmds) + assert any("cd cli && sbt compile" in c for c in cmds) + + def test_warmup_returns_step(self): + tc = hm.scala.toolchain(path="cli") + step = tc.warmup() + assert step.cmd is not None + assert "sbt update" in step.cmd + + def test_warmup_chains_from_installed(self): + tc = hm.scala.toolchain(path="cli") + w = tc.warmup() + assert w.parent is tc.installed + + def test_warmup_default_label(self): + tc = hm.scala.toolchain(path=".") + assert tc.warmup().label == ":scala: warmup" + + def test_warmup_label_override(self): + tc = hm.scala.toolchain(path=".") + assert tc.warmup(label=":scala: pre-compile").label == ":scala: pre-compile" + + def test_fmt_check_is_default(self): + tc = hm.scala.toolchain(path=".") + assert tc.fmt().cmd.endswith("sbt scalafmtCheckAll") + + def test_fmt_check_false_writes_in_place(self): + tc = hm.scala.toolchain(path=".") + assert tc.fmt(check=False).cmd.endswith("sbt scalafmtAll") + + +class TestScalaProject: + def test_project_has_methods(self): + proj = hm.scala.project(path="cli") + assert proj.warmup.cmd is not None + assert proj.compile().cmd is not None + assert proj.test().cmd is not None + assert proj.fmt().cmd is not None + + def test_empty_path_throws_error_is_caught(self): + proj = hm.scala.project(path="") + graph = { + "nodes": [ + { + "step": { + "key": "a", + "cmd": "sbt compile", + "cache": {"policy": "on_change", "paths": list(proj.warmup.cache.paths)}, + } + } + ], + "node_holes": [], + "edge_property": "directed", + "edges": [], + } + + with tempfile.TemporaryDirectory() as d: + res = resolve_pipeline_keys( + graph, + pipeline_org="default", + pipeline_slug="default", + now=0, + base_path=Path(d), + env={}, + ) + assert res["nodes"][0]["step"]["cache"]["key"] is not None + + def test_warmup_implicit_cache_on_change(self): + proj = hm.scala.project(path="cli") + assert proj.warmup.cache == CacheOnChange( + paths=( + "cli/build.sbt", + "cli/**/src/main/*.scala", + "cli/**/src/test/*.scala", + "cli/project/build.properties", + ) + ) + + def test_warmup_implicit_cache_dot_path(self): + proj = hm.scala.project(path=".") + assert proj.warmup.cache == CacheOnChange( + paths=( + "build.sbt", + "**/src/main/*.scala", + "**/src/test/*.scala", + "project/build.properties", + ) + ) + + def test_warmup_cache_override(self): + custom = CacheOnChange(paths=("build.sbt",)) + proj = hm.scala.project(path=".", cache=custom) + assert proj.warmup.cache == custom + + def test_test_command(self): + proj = hm.scala.project(path="cli") + assert "sbt test" in proj.test().cmd + + def test_fmt_command(self): + proj = hm.scala.project(path="cli") + assert proj.fmt().cmd.endswith("sbt scalafmtCheckAll") + + def test_test_chains_off_warmup(self): + proj = hm.scala.project(path=".") + assert proj.test().parent is proj.warmup + + def test_fmt_chains_off_install(self): + proj = hm.scala.project(path=".") + assert proj.fmt().parent is proj.toolchain.installed + + def test_ci_returns_compile_test_fmt(self): + proj = hm.scala.project(path=".") + steps = proj.ci() + cmds = [s.cmd for s in steps] + assert any(c.endswith("sbt compile") for c in cmds) + assert any("sbt test" in c for c in cmds) + # CI verifies formatting rather than rewriting it in place. + assert any(c.endswith("sbt scalafmtCheckAll") for c in cmds) + + def test_toolchain_escape_hatch(self): + proj = hm.scala.project(path="cli") + custom = proj.toolchain.installed.sh("custom", label="custom") + assert custom.parent is proj.toolchain.installed + + def test_with_base_skips_apt(self): + base = hm.scratch().sh("custom base", label="base") + proj = hm.scala.project(path="cli", base=base) + p = hm.pipeline([proj.test(), proj.compile(), proj.fmt()]) + cmds = _cmds(p) + assert not any("apt-get install" in c for c in cmds) + assert any("custom base" in c for c in cmds) + + def test_labels(self): + proj = hm.scala.project(path="cli") + assert proj.warmup.label == ":scala: sbt warmup" + assert proj.test().label == ":scala: cli sbt test" + assert proj.fmt().label == ":scala: cli format" + assert proj.compile().label == ":scala: cli sbt compile" + + def test_pipeline_ir(self): + proj = hm.scala.project(path="cli") + p = hm.pipeline([proj.test(), proj.fmt(), proj.compile()]) + cmds = _cmds(p) + assert any("sbt update" in c for c in cmds) + assert any("sbt test" in c for c in cmds) + assert any("sbt scalafmtCheckAll" in c for c in cmds) + assert any("sbt compile" in c for c in cmds) + assert len([c for c in cmds if "cs install" in c]) == 1 + assert len([c for c in cmds if "apt-get install" in c]) == 1 + + def test_version_forwarded(self): + proj = hm.scala.project(path=".", scala_version="3.3.4") + p = hm.pipeline([proj.test()]) + cs_install = _step_by_substring(p, "cs install") + assert "cs install scala:3.3.4" in cs_install["cmd"] + + +class TestTestCmd: + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({}, "sbt test"), + ({"query": "core"}, "sbt core / test"), + ({"testnames": ("FooSpec", "BarSpec")}, "sbt test FooSpec BarSpec"), + ({"query": "core", "testnames": ("FooSpec",)}, "sbt core / test FooSpec"), + ({"options": {"-only": "FooSpec"}}, "sbt test -- -only FooSpec"), + ({"options": {"-o": "a b"}}, "sbt test -- -o 'a b'"), + ], + ) + def test_renders(self, kwargs: dict, expected: str): + assert _test_cmd(**kwargs) == expected diff --git a/crates/hm/src/cli/init.rs b/crates/hm/src/cli/init.rs index 50b65c25..cbd72be2 100644 --- a/crates/hm/src/cli/init.rs +++ b/crates/hm/src/cli/init.rs @@ -9,6 +9,7 @@ pub enum TemplateKind { Nextjs, Js, Rust, + Scala, Zig, Python, } diff --git a/crates/hm/src/commands/init.rs b/crates/hm/src/commands/init.rs index b7462e01..2aecee8d 100644 --- a/crates/hm/src/commands/init.rs +++ b/crates/hm/src/commands/init.rs @@ -45,6 +45,11 @@ impl TemplateKind { filename: "pipeline.py", content: include_str!("init_templates/rust.py"), }, + Self::Scala => Template { + label: "Scala", + filename: "pipeline.py", + content: include_str!("init_templates/scala.py"), + }, Self::Zig => Template { label: "Zig", filename: "pipeline.py", @@ -65,6 +70,7 @@ const ALL: &[TemplateKind] = &[ TemplateKind::Nextjs, TemplateKind::Js, TemplateKind::Rust, + TemplateKind::Scala, TemplateKind::Zig, TemplateKind::Python, ]; diff --git a/crates/hm/src/commands/init_templates/scala.py b/crates/hm/src/commands/init_templates/scala.py new file mode 100644 index 00000000..c5cc1342 --- /dev/null +++ b/crates/hm/src/commands/init_templates/scala.py @@ -0,0 +1,24 @@ +"""Scala CI pipeline.""" + +from __future__ import annotations + +import harmont as hm +from harmont._scala import ScalaProject + + +@hm.target() +def project() -> ScalaProject: + # project() warms a shared dependency cache (keyed on build.sbt + sources) + # so compile/test reuse one `sbt update`. + return hm.scala.project(path=".") + + +@hm.pipeline( + "ci", + env={"CI": "true"}, + triggers=[hm.push(branch="main")], +) +def ci(project: hm.Target[ScalaProject]) -> tuple[hm.Step, ...]: + # ci() is the zero-config DAG: compile + test sharing one warmup, plus a + # scalafmt check running in parallel off the toolchain install. + return project.ci() diff --git a/crates/hm/tests/cmd_init.rs b/crates/hm/tests/cmd_init.rs index 06ea91dd..5496d549 100644 --- a/crates/hm/tests/cmd_init.rs +++ b/crates/hm/tests/cmd_init.rs @@ -19,9 +19,11 @@ fn hm() -> Command { // ── non-interactive (--template) ────────────────────────────── #[rstest] -fn init_rust_creates_pipeline_py() { +#[case::rust("rust", "hm.rust.project(")] +#[case::scala("scala", "hm.scala.project(")] +fn init_creates_project_pipeline_py(#[case] slug: &str, #[case] entrypoint: &str) { let dir = tempfile::tempdir().unwrap(); - hm().args(["init", "--template", "rust", "--dir"]) + hm().args(["init", "--template", slug, "--dir"]) .arg(dir.path()) .assert() .success(); @@ -35,15 +37,15 @@ fn init_rust_creates_pipeline_py() { "expected pipeline decorator" ); assert!( - content.contains("hm.rust.project("), - "expected rust.project() entrypoint, got:\n{content}" + content.contains(entrypoint), + "expected {entrypoint} entrypoint, got:\n{content}" ); assert!( content.contains(".ci()"), "expected the one-call .ci() DAG, got:\n{content}" ); assert!( - !content.contains("rust.toolchain("), + !content.contains(&format!("{slug}.toolchain(")), "template should not use the legacy toolchain() API, got:\n{content}" ); } @@ -177,6 +179,7 @@ fn init_unknown_template_rejected_by_clap() { #[case::nextjs("nextjs")] #[case::js("js")] #[case::rust("rust")] +#[case::scala("scala")] #[case::zig("zig")] #[case::python("python")] fn init_all_templates_create_files(#[case] slug: &str) { @@ -200,6 +203,7 @@ fn has_python() -> bool { #[case::cmake("cmake")] #[case::elixir("elixir")] #[case::rust("rust")] +#[case::scala("scala")] #[case::python("python")] fn init_python_templates_roundtrip_render(#[case] slug: &str) { if !has_python() { diff --git a/examples/scala/.gitignore b/examples/scala/.gitignore new file mode 100644 index 00000000..bddd1888 --- /dev/null +++ b/examples/scala/.gitignore @@ -0,0 +1,2 @@ +/.bsp/ +target/ diff --git a/examples/scala/.hm/pipeline.py b/examples/scala/.hm/pipeline.py new file mode 100644 index 00000000..61928973 --- /dev/null +++ b/examples/scala/.hm/pipeline.py @@ -0,0 +1,14 @@ +"""Scala example pipeline.""" + +from __future__ import annotations + +import harmont as hm + + +@hm.pipeline( + "ci", + env={"CI": "true"}, + triggers=[hm.push(branch="main")], +) +def ci() -> tuple[hm.Step, ...]: + return hm.scala.project(path=".").ci() diff --git a/examples/scala/.scalafmt.conf b/examples/scala/.scalafmt.conf new file mode 100644 index 00000000..daee252c --- /dev/null +++ b/examples/scala/.scalafmt.conf @@ -0,0 +1,2 @@ +version = "3.8.3" +runner.dialect = scala213 \ No newline at end of file diff --git a/examples/scala/build.sbt b/examples/scala/build.sbt new file mode 100644 index 00000000..9c7c3d42 --- /dev/null +++ b/examples/scala/build.sbt @@ -0,0 +1,14 @@ +import Dependencies._ + +ThisBuild / scalaVersion := "2.13.16" +ThisBuild / version := "0.1.0-SNAPSHOT" +ThisBuild / organization := "com.example" +ThisBuild / organizationName := "example" + +lazy val root = (project in file(".")) + .settings( + name := "Scala Seed Project", + libraryDependencies += munit % Test + ) + +// See https://www.scala-sbt.org/1.x/docs/Using-Sonatype.html for instructions on how to publish to Sonatype. diff --git a/examples/scala/project/Dependencies.scala b/examples/scala/project/Dependencies.scala new file mode 100644 index 00000000..1edb07a7 --- /dev/null +++ b/examples/scala/project/Dependencies.scala @@ -0,0 +1,5 @@ +import sbt._ + +object Dependencies { + lazy val munit = "org.scalameta" %% "munit" % "0.7.29" +} diff --git a/examples/scala/project/build.properties b/examples/scala/project/build.properties new file mode 100644 index 00000000..dabdb159 --- /dev/null +++ b/examples/scala/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.11 diff --git a/examples/scala/project/plugins.sbt b/examples/scala/project/plugins.sbt new file mode 100644 index 00000000..9d5223d9 --- /dev/null +++ b/examples/scala/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.1") \ No newline at end of file diff --git a/examples/scala/src/main/scala/example/Hello.scala b/examples/scala/src/main/scala/example/Hello.scala new file mode 100644 index 00000000..80ea40a9 --- /dev/null +++ b/examples/scala/src/main/scala/example/Hello.scala @@ -0,0 +1,9 @@ +package example + +object Hello extends Greeting with App { + println(greeting) +} + +trait Greeting { + lazy val greeting: String = "hello" +} diff --git a/examples/scala/src/test/scala/example/HelloSpec.scala b/examples/scala/src/test/scala/example/HelloSpec.scala new file mode 100644 index 00000000..d57b5ed4 --- /dev/null +++ b/examples/scala/src/test/scala/example/HelloSpec.scala @@ -0,0 +1,7 @@ +package example + +class HelloSpec extends munit.FunSuite { + test("say hello") { + assertEquals(Hello.greeting, "hello") + } +}