diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_cmake.py b/crates/hm-dsl-engine/harmont-py/harmont/_cmake.py index 480677b9..04c092fa 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_cmake.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_cmake.py @@ -23,9 +23,9 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any, Self, overload -from ._toolchain import make_install_chain +from ._toolchain import advance_install, make_install_chain from .cache import CacheForever, CacheOnChange if TYPE_CHECKING: @@ -232,6 +232,28 @@ class CMakeToolchain: ccache: bool generator: str + 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. Every + ``CMakeProject`` spawned from the returned toolchain forks from this step, + so prep runs before the configure+build. + + Examples: + >>> import harmont as hm + >>> tc = hm.cmake().setup("./scripts/gen-headers.sh") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def project( self, *, diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_elixir.py b/crates/hm-dsl-engine/harmont-py/harmont/_elixir.py index de445fd5..cadd7b83 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_elixir.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_elixir.py @@ -11,10 +11,10 @@ import re from dataclasses import dataclass from dataclasses import field as dataclass_field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from ._toolchain import make_install_chain -from .cache import CacheForever, CacheOnChange +from ._toolchain import advance_install, make_install_chain +from .cache import CacheForever, CacheOnChange, CachePolicy if TYPE_CHECKING: from ._step import Step @@ -73,6 +73,29 @@ class ElixirProject: installed: Step _plt_step: Step | None = dataclass_field(default=None, init=False, repr=False) + 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 project; 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 (``compile``/``test``/…) fork from this + step, so they all see its results. + + Examples: + >>> import harmont as hm + >>> proj = hm.elixir(path="elixir").setup("mix proto.gen") + >>> hm.pipeline([proj.compile(), proj.test()]) + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step: if kw.get("label") is None: kw["label"] = default_label diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_go.py b/crates/hm-dsl-engine/harmont-py/harmont/_go.py index c4de1bb8..1fa1aaa3 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_go.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_go.py @@ -9,13 +9,14 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from ._toolchain import make_install_chain +from ._toolchain import advance_install, make_install_chain from .cache import CacheForever if TYPE_CHECKING: from ._step import Step + from .cache import CachePolicy APT_PACKAGES = ("curl", "ca-certificates", "git") @@ -46,6 +47,27 @@ class GoToolchain: 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.go(path=".").setup("go generate ./...") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step: if kw.get("label") is None: kw["label"] = default_label diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_js.py b/crates/hm-dsl-engine/harmont-py/harmont/_js.py index 1b94542f..1b1e4aaa 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_js.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_js.py @@ -21,10 +21,11 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, Self from ._detect import detect from ._toolchain import ( + advance_install, bun_install_cmd, deno_install_cmd, make_install_chain, @@ -34,6 +35,7 @@ if TYPE_CHECKING: from ._step import Step + from .cache import CachePolicy Runtime = Literal["node", "bun", "deno"] PackageManager = Literal["npm", "pnpm", "yarn-classic", "yarn-berry", "bun", "deno"] @@ -124,6 +126,27 @@ class JsProject: run_prefix: str tag: str + 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 project; 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 + >>> proj = hm.js.project(path=".").setup("npm run codegen") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def install(self) -> Step: """Return the dependency-install step (the unambiguous default leaf).""" return self.installed diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_python.py b/crates/hm-dsl-engine/harmont-py/harmont/_python.py index 782a5787..0177338f 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_python.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_python.py @@ -17,13 +17,14 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from ._toolchain import make_install_chain +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", "python3", "python3-venv") @@ -60,6 +61,27 @@ class PythonToolchain: path: str installed: Step # uv-sync 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.python(path=".").setup("uv run python scripts/codegen.py") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step: if kw.get("label") is None: kw["label"] = default_label diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index 88d1fa60..7c563b67 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -11,10 +11,10 @@ import re import shlex from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self from ._cargo import CargoOpts, cargo_flags -from ._toolchain import make_install_chain +from ._toolchain import advance_install, make_install_chain from .cache import CacheForever, CacheOnChange if TYPE_CHECKING: @@ -151,6 +151,28 @@ class RustToolchain: 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 (and projects spawned from it) fork from + this step, so prep runs before the cargo warmup precompile. + + Examples: + >>> import harmont as hm + >>> tc = hm.rust.toolchain().setup("cargo install sqlx-cli") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def _wrap(self, cargo: str) -> str: return f". $HOME/.cargo/env && cd {self.path} && {cargo}" diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_toolchain.py b/crates/hm-dsl-engine/harmont-py/harmont/_toolchain.py index 0479be3a..f9c825a7 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_toolchain.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_toolchain.py @@ -13,8 +13,9 @@ from __future__ import annotations +import dataclasses from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, TypeVar from ._step import scratch from .cache import CacheTTL @@ -27,6 +28,38 @@ APT_TTL = timedelta(days=1) +class _HasInstalled(Protocol): + # Read-only member (property form) so frozen-dataclass toolchains, whose + # `installed` field is read-only, satisfy the protocol. A bare + # `installed: Step` annotation declares a *writable* member, which frozen + # instances do not match. + @property + def installed(self) -> Step: ... + + +_ProjectT = TypeVar("_ProjectT", bound="_HasInstalled") + + +def advance_install( + project: _ProjectT, + cmd: str, + *, + cwd: str | None = None, + label: str | None = None, + cache: CachePolicy | None = None, + env: dict[str, str] | None = None, +) -> _ProjectT: + """Return a copy of a toolchain object with one command appended to its + install chain. Every action method emitted from the returned object forks + from the new step. Shared implementation behind each toolchain's ``setup()``. + """ + new_installed = project.installed.sh(cmd, cwd=cwd, label=label, cache=cache, env=env) + # All callers are frozen dataclasses carrying an `installed: Step` field, but + # the Protocol bound cannot express "is a dataclass", so the replace below + # cannot satisfy its DataclassInstance upper bound — hence the narrow ignore. + return dataclasses.replace(project, installed=new_installed) # ty: ignore[invalid-argument-type] + + def apt_install_cmd(packages: tuple[str, ...]) -> str: """Single shell string: ``apt-get update && apt-get install -y ``.""" pkgs = " ".join(packages) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_zig.py b/crates/hm-dsl-engine/harmont-py/harmont/_zig.py index 7b1de4c2..de455948 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_zig.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_zig.py @@ -19,13 +19,14 @@ import re from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, overload +from typing import TYPE_CHECKING, Any, Self, overload -from ._toolchain import make_install_chain +from ._toolchain import advance_install, make_install_chain from .cache import CacheForever if TYPE_CHECKING: from ._step import Step + from .cache import CachePolicy APT_PACKAGES = ("curl", "ca-certificates", "xz-utils") @@ -66,6 +67,27 @@ class ZigProject: 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 project; 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 + >>> proj = hm.zig(path=".").setup("zig build gen") + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def _emit(self, cmd: str, default_label: str, **kw: Any) -> Step: if kw.get("label") is None: kw["label"] = default_label @@ -106,6 +128,28 @@ class ZigToolchain: version: 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. Every + ``ZigProject`` spawned from the returned toolchain forks from this step. + + Examples: + >>> import harmont as hm + >>> tc = hm.zig().setup("zig build gen") + >>> hm.pipeline([tc.project("lib-a").test()]) + """ + return advance_install(self, cmd, cwd=cwd, label=label, cache=cache, env=env) + def project(self, path: str = ".") -> ZigProject: """Create a ``ZigProject`` rooted at ``path`` from this toolchain. diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_setup.py b/crates/hm-dsl-engine/harmont-py/tests/test_setup.py new file mode 100644 index 00000000..21d69e56 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_setup.py @@ -0,0 +1,58 @@ +"""`.setup()` splices a prep step into a toolchain's install chain so that +action leaves fork from it. One parametrized test over every install-bearing +toolchain object.""" +from __future__ import annotations + +import json + +import pytest + +import harmont as hm + +# (label, factory) for every toolchain object that owns an `installed` chain. +# Each factory returns an object exposing `.installed` and `.setup()`. +TOOLCHAINS = [ + ("elixir", lambda: hm.elixir(path=".")), + ("python", lambda: hm.python(path=".")), + ("go", lambda: hm.go(path=".")), + ("js", lambda: hm.js.project(path=".")), + ("zig_project", lambda: hm.zig(path=".")), + ("zig_toolchain", lambda: hm.zig()), + ("rust_toolchain", lambda: hm.rust.toolchain()), # RustEntry is NOT callable + ("cmake_toolchain", lambda: hm.cmake()), +] + + +def _render_keys_and_edges(leaf: hm.Step) -> tuple[dict, list]: + """Render a one-leaf pipeline and return (nodes-by-index-key, edges).""" + doc = json.loads(hm.pipeline_to_json(hm.pipeline([leaf]))) + graph = doc["graph"] + keys = [n["step"]["key"] for n in graph["nodes"]] + cmds = [n["step"].get("cmd") for n in graph["nodes"]] + return {"keys": keys, "cmds": cmds}, graph["edges"] + + +@pytest.mark.parametrize( + ("label", "factory"), TOOLCHAINS, ids=lambda v: v if isinstance(v, str) else "" +) +def test_setup_advances_install_chain(label: str, factory) -> None: + proj = factory() + before = proj.installed + advanced = proj.setup("echo __SETUP_MARKER__", label="setup-marker") + + # Immutable: original object's cursor is untouched; a new object is returned. + assert proj.installed is before + assert advanced is not proj + assert advanced.installed is not before + assert type(advanced) is type(proj) + + # The setup command renders, as an ancestor of the install cursor. + info, _edges = _render_keys_and_edges(advanced.installed) + assert any(c and "__SETUP_MARKER__" in c for c in info["cmds"]), info + + +def test_setup_is_chainable() -> None: + proj = hm.elixir(path=".").setup("echo __ONE__").setup("echo __TWO__") + info, _edges = _render_keys_and_edges(proj.installed) + assert any(c and "__ONE__" in c for c in info["cmds"]) + assert any(c and "__TWO__" in c for c in info["cmds"]) diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts index 9546504d..ca30e36f 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts @@ -170,6 +170,16 @@ export class CMakeToolchain { return this._installed; } + /** Append a post-install command and return an advanced toolchain; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Project actions forked + * off this toolchain see its results. (On built-based projects, splice prep + * here, pre-configure: hm.cmake().setup("…").project({ path: "." }).) + * @example hm.cmake().setup("conan install .").project({ path: "." }) */ + setup(cmd: string, opts?: StepOptions): CMakeToolchain { + return new CMakeToolchain(this._installed.sh(cmd, opts), this.compiler, this.ccache, this.generator); + } + project(opts?: CMakeProjectOptions): CMakeProject { const path = opts?.path ?? "."; const preset = opts?.preset; diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts index 906d6db5..38f46527 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts @@ -42,6 +42,15 @@ export class ElixirProject { return this._installed; } + /** Append a post-install command and return an advanced project; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.elixir({ path: "elixir" }).setup("mix proto.gen").compile() */ + setup(cmd: string, opts?: StepOptions): ElixirProject { + return new ElixirProject(this.path, this._installed.sh(cmd, opts)); + } + private _sh(parent: Step, cmd: string, opts?: ActionOptions): Step { const { env: userEnv, ...rest } = opts ?? {}; return parent.sh(cmd, { env: { ...ELIXIR_ENV, ...userEnv }, ...rest }); diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts index f04364a7..d925ddad 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts @@ -27,6 +27,15 @@ export class GoToolchain { return this._installed; } + /** Append a post-install command and return an advanced toolchain; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.go({ path: "." }).setup("go generate ./...").build() */ + setup(cmd: string, opts?: StepOptions): GoToolchain { + return new GoToolchain(this.path, this._installed.sh(cmd, opts)); + } + build(opts?: ActionOptions): Step { return this._installed.sh(`cd ${this.path} && go build ./...`, { label: ":go: build", diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts index 84eff313..db196327 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts @@ -84,12 +84,14 @@ export class JsProject { private readonly _installed: Step; private readonly _runPrefix: string; private readonly _tag: string; + private readonly _pm: PackageManager; constructor(path: string, installed: Step, pm: PackageManager, tag: string) { this.path = path; this._installed = installed; this._runPrefix = RUN_PREFIX[pm]; this._tag = tag; + this._pm = pm; } /** The dependency-install step (`npm ci`, `bun install`, `deno install`, …). @@ -98,6 +100,15 @@ export class JsProject { return this._installed; } + /** Append a post-install command and return an advanced project; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.js.project({ path: "web" }).setup("npm run codegen").run("build") */ + setup(cmd: string, opts?: StepOptions): JsProject { + return new JsProject(this.path, this._installed.sh(cmd, opts), this._pm, this._tag); + } + /** Run a package.json script / deno.json task by name. * This is the uniform action across all PMs — for native tooling * (`deno test`, `bun test`) define a script or drop to `.sh()`. */ diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts index 41987350..6c16e1ad 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts @@ -37,6 +37,15 @@ export class PythonToolchain { return this._installed; } + /** Append a post-install command and return an advanced toolchain; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.python({ path: "." }).setup("uv run python gen.py").test() */ + setup(cmd: string, opts?: StepOptions): PythonToolchain { + return new PythonToolchain(this.path, this._installed.sh(cmd, opts)); + } + test(opts?: ActionOptions): Step { return this._installed.sh(`cd ${this.path} && uv run pytest`, { label: ":python: test", diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts index 5012718e..f160eae6 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts @@ -148,6 +148,16 @@ export class RustToolchain { return this._installed; } + /** Append a post-install command and return an advanced toolchain; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. (On warmup-based projects, splice prep + * here, pre-warmup: hm.rust.toolchain().setup("…").project({ path: "." }).) + * @example hm.rust.toolchain().setup("cargo install sqlx-cli").build() */ + setup(cmd: string, opts?: StepOptions): RustToolchain { + return new RustToolchain(this.path, this._installed.sh(cmd, opts)); + } + _cargo(cmd: string, label: string, opts?: ActionOptions): Step { return this._installed.sh( `. $HOME/.cargo/env && cd ${this.path} && ${cmd}`, diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts index e789bf25..f61d846e 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts @@ -37,6 +37,15 @@ export class ZigToolchain { return this._installed; } + /** Append a post-install command and return an advanced toolchain; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.zig().setup("zig build gen").project(".") */ + setup(cmd: string, opts?: StepOptions): ZigToolchain { + return new ZigToolchain(this._installed.sh(cmd, opts)); + } + project(path: string = "."): ZigProject { return new ZigProject(path, this._installed); } @@ -55,6 +64,15 @@ export class ZigProject { return this._installed; } + /** Append a post-install command and return an advanced project; chainable. + * For prep steps the toolchain's actions must depend on but the SDK does not + * model natively (codegen, fixtures, extra tooling). Action methods on the + * returned object fork from this step. + * @example hm.zig({ path: "." }).setup("zig build gen").build() */ + setup(cmd: string, opts?: StepOptions): ZigProject { + return new ZigProject(this.path, this._installed.sh(cmd, opts)); + } + build(opts?: ActionOptions): Step { return this._installed.sh(`cd ${this.path} && zig build`, { label: `:zig: ${this.path} build`, diff --git a/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts new file mode 100644 index 00000000..709ef5b2 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { pipeline } from "@harmont/hm"; +import * as hm from "@harmont/hm/toolchains"; + +function cmdsOf(leaf: any): string[] { + const ir = pipeline([leaf]); + return ir.graph.nodes.map((n: any) => n.step.cmd).filter(Boolean); +} + +describe("toolchain .setup()", () => { + it("advances the install chain and is immutable", () => { + const proj = hm.elixir({ path: "." }); + const before = proj.install(); + const advanced = proj.setup("echo __SETUP_MARKER__"); + expect(advanced).not.toBe(proj); + expect(advanced.install()).not.toBe(before); + const cmds = cmdsOf(advanced.install()); + expect(cmds.some((c) => c.includes("__SETUP_MARKER__"))).toBe(true); + }); + + it("is chainable", () => { + const proj = hm.elixir({ path: "." }).setup("echo __ONE__").setup("echo __TWO__"); + const cmds = cmdsOf(proj.install()); + expect(cmds.some((c) => c.includes("__ONE__"))).toBe(true); + expect(cmds.some((c) => c.includes("__TWO__"))).toBe(true); + }); +}); + +const FACTORIES: Array<[string, () => any]> = [ + ["elixir", () => hm.elixir({ path: "." })], + ["python", () => hm.python({ path: "." })], + ["go", () => hm.go({ path: "." })], + ["js", () => hm.js.project({ path: "." })], + ["zigProject", () => hm.zig({ path: "." })], + ["zigToolchain", () => hm.zig()], + ["rustToolchain", () => hm.rust.toolchain()], + ["cmakeToolchain", () => hm.cmake()], +]; + +describe.each(FACTORIES)("%s .setup()", (_label, make) => { + it("advances the chain (renders the setup cmd)", () => { + const advanced = make().setup("echo __MARK__"); + const ir = pipeline([advanced.install()]); + const cmds = ir.graph.nodes.map((n: any) => n.step.cmd).filter(Boolean); + expect(cmds.some((c: string) => c.includes("__MARK__"))).toBe(true); + }); +});