feat: add js_expand_template rule that support stamp var substitutions (#384)

diff --git a/js/private/BUILD.bazel b/js/private/BUILD.bazel
index c98e426..f6d6b08 100644
--- a/js/private/BUILD.bazel
+++ b/js/private/BUILD.bazel
@@ -1,6 +1,7 @@
 "Internal implementation details"
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//js:defs.bzl", "js_binary")
 
 exports_files(
     glob(["*.bzl"]),
@@ -116,3 +117,11 @@
     srcs = ["js_info.bzl"],
     visibility = ["//js:__subpackages__"],
 )
+
+js_binary(
+    name = "expand_template_binary",
+    entry_point = "expand_template.js",
+    # meant to run out of the execroot
+    env = {"BAZEL_BINDIR": "."},
+    visibility = ["//visibility:public"],
+)
diff --git a/js/private/expand_template.bzl b/js/private/expand_template.bzl
new file mode 100644
index 0000000..8153dd9
--- /dev/null
+++ b/js/private/expand_template.bzl
@@ -0,0 +1,115 @@
+"expand_template rule"
+
+load("@aspect_bazel_lib//lib:expand_make_vars.bzl", _expand_locations = "expand_locations", _expand_variables = "expand_variables")
+load("@aspect_bazel_lib//lib:stamping.bzl", "STAMP_ATTRS", "maybe_stamp")
+load("@bazel_skylib//lib:dicts.bzl", "dicts")
+
+def _expand_substitutions(ctx, substitutions):
+    result = {}
+    for k, v in substitutions.items():
+        result[k] = " ".join([
+            _expand_variables(ctx, e, outs = [ctx.outputs.out], attribute_name = "substitutions")
+            for e in _expand_locations(ctx, v, ctx.attr.data).split(" ")
+        ])
+    return result
+
+def _impl(ctx):
+    substitutions = _expand_substitutions(ctx, ctx.attr.substitutions)
+
+    stamp = maybe_stamp(ctx)
+    if stamp:
+        substitutions = dicts.add(substitutions, _expand_substitutions(ctx, ctx.attr.stamp_substitutions))
+
+        inputs = [
+            ctx.file.template,
+            stamp.volatile_status_file,
+            stamp.stable_status_file,
+        ]
+
+        args = ctx.actions.args()
+        args.add(ctx.file.template.path)
+        args.add(ctx.outputs.out.path)
+        args.add(stamp.volatile_status_file)
+        args.add(stamp.stable_status_file)
+        args.add(substitutions)
+        args.add(ctx.attr.is_executable)
+        args.use_param_file("%s", use_always = True)
+
+        ctx.actions.run(
+            arguments = [args],
+            outputs = [ctx.outputs.out],
+            inputs = inputs,
+            executable = ctx.executable._expand_template_binary,
+        )
+    else:
+        ctx.actions.expand_template(
+            template = ctx.file.template,
+            output = ctx.outputs.out,
+            substitutions = substitutions,
+            is_executable = ctx.attr.is_executable,
+        )
+
+expand_template_lib = struct(
+    doc = """Template expansion with stamp var support.
+
+This performs a simple search over the template file for the keys in substitutions,
+and replaces them with the corresponding values.
+
+Values may also use location templates as documented in
+[expand_locations](https://github.com/aspect-build/bazel-lib/blob/main/docs/expand_make_vars.md#expand_locations)
+as well as [configuration variables](https://docs.bazel.build/versions/main/skylark/lib/ctx.html#var)
+such as `$(BINDIR)`, `$(TARGET_CPU)`, and `$(COMPILATION_MODE)` as documented in
+[expand_variables](https://github.com/aspect-build/bazel-lib/blob/main/docs/expand_make_vars.md#expand_variables).
+
+If stamp attribute is True, stamp variable substitutions are supported with {{STAMP_VAR}} tokens.
+""",
+    implementation = _impl,
+    attrs = dict({
+        "template": attr.label(
+            doc = "The template file to expand.",
+            mandatory = True,
+            allow_single_file = True,
+        ),
+        "substitutions": attr.string_dict(
+            doc = """Mapping of strings to substitutions.
+            
+            Subtitutions can contain $(execpath :target) and $(rootpath :target)
+            expansions, $(MAKEVAR) expansions and {{STAMP_VAR}} expansions when
+            stamping is enabled for the target.
+            """,
+        ),
+        "stamp_substitutions": attr.string_dict(
+            doc = """Mapping of strings to substitutions.
+
+            There are overlayed on top of substitutions when stamping is enabled
+            for the target.
+            
+            Subtitutions can contain $(execpath :target) and $(rootpath :target)
+            expansions, $(MAKEVAR) expansions and {{STAMP_VAR}} expansions when
+            stamping is enabled for the target.
+            """,
+        ),
+        "out": attr.output(
+            doc = "Where to write the expanded file.",
+            mandatory = True,
+        ),
+        "is_executable": attr.bool(
+            doc = "Whether to mark the output file as executable.",
+        ),
+        "data": attr.label_list(
+            doc = "List of targets for additional lookup information.",
+            allow_files = True,
+        ),
+        "_expand_template_binary": attr.label(
+            executable = True,
+            default = Label("//js/private:expand_template_binary"),
+            cfg = "exec",
+        ),
+    }, **STAMP_ATTRS),
+)
+
+expand_template = rule(
+    doc = expand_template_lib.doc,
+    implementation = expand_template_lib.implementation,
+    attrs = expand_template_lib.attrs,
+)
diff --git a/js/private/expand_template.js b/js/private/expand_template.js
new file mode 100644
index 0000000..3628c13
--- /dev/null
+++ b/js/private/expand_template.js
@@ -0,0 +1,89 @@
+const fs = require('fs')
+
+/**
+ * The status files are expected to look like
+ * BUILD_SCM_HASH 83c699db39cfd74526cdf9bebb75aa6f122908bb
+ * BUILD_SCM_LOCAL_CHANGES true
+ * STABLE_BUILD_SCM_VERSION 6.0.0-beta.6+12.sha-83c699d.with-local-changes
+ * BUILD_TIMESTAMP 1520021990506
+ *
+ * Parsing regex is created based on Bazel documentation describing the status file schema:
+ *   The key names can be anything but they may only use upper case letters and underscores. The
+ *   first space after the key name separates it from the value. The value is the rest of the line
+ *   (including additional whitespace).
+ */
+function _parseStatusFile(statusFilePath) {
+    const results = {}
+    const statusFile = fs.readFileSync(statusFilePath, { encoding: 'utf-8' })
+    for (const match of `\n${statusFile}`.matchAll(/^([^\s]+)\s+(.*)/gm)) {
+        // Lines which go unmatched define an index value of `0` and should be skipped.
+        if (match.index === 0) {
+            continue
+        }
+        results[match[1]] = match[2]
+    }
+    return results
+}
+
+function _unquoteArgs(s) {
+    return s.replace(/^'(.*)'$/, '$1')
+}
+
+function _replaceAll(value, token, replacement) {
+    // String.prototype.replaceAll was only added in Node.js 5; polyfill
+    // if it is not available
+    if (value.replaceAll) {
+        return value.replaceAll(token, replacement)
+    } else {
+        while (value.indexOf(token) != -1) {
+            value = value.replace(token, replacement)
+        }
+        return value
+    }
+}
+
+function main(args) {
+    args = fs
+        .readFileSync(args[0], { encoding: 'utf-8' })
+        .split('\n')
+        .map(_unquoteArgs)
+    const [
+        template,
+        out,
+        volatileStatusFile,
+        stableStatusFile,
+        substitutionsJson,
+        isExecutable,
+    ] = args
+
+    const substitutions = JSON.parse(substitutionsJson)
+
+    const statuses = {
+        ..._parseStatusFile(volatileStatusFile),
+        ..._parseStatusFile(stableStatusFile),
+    }
+
+    const statusSubstitutions = []
+    for (const key of Object.keys(statuses)) {
+        statusSubstitutions.push([`{{${key}}}`, statuses[key]])
+    }
+
+    for (const key of Object.keys(substitutions)) {
+        let value = substitutions[key]
+        statusSubstitutions.forEach(([token, replacement]) => {
+            value = _replaceAll(value, token, replacement)
+        })
+        substitutions[key] = value
+    }
+
+    let content = fs.readFileSync(template, { encoding: 'utf-8' })
+    for (const key of Object.keys(substitutions)) {
+        content = _replaceAll(content, key, substitutions[key])
+    }
+    const mode = isExecutable ? 0o777 : 0x666
+    fs.writeFileSync(out, content, { mode })
+}
+
+if (require.main === module) {
+    process.exitCode = main(process.argv.slice(2))
+}
diff --git a/js/private/test/expand_template/BUILD.bazel b/js/private/test/expand_template/BUILD.bazel
new file mode 100644
index 0000000..974b069
--- /dev/null
+++ b/js/private/test/expand_template/BUILD.bazel
@@ -0,0 +1,46 @@
+load("@aspect_bazel_lib//lib:diff_test.bzl", "diff_test")
+load("//js/private:expand_template.bzl", "expand_template")
+
+expand_template(
+    name = "a_tmpl_stamp",
+    out = "a_stamp",
+    data = ["a.tmpl"],
+    stamp = 1,
+    stamp_substitutions = {
+        "{{VERSION}}": "v{{BUILD_SCM_VERSION}}",
+    },
+    substitutions = {
+        "{{WORKSPACE}}": "$(WORKSPACE)",
+        "{{VERSION}}": "v0.0.0",
+        "{{TMPL_PATH}}": "$(rootpath a.tmpl)",
+    },
+    template = "a.tmpl",
+)
+
+diff_test(
+    name = "a_stamp_test",
+    file1 = ":a_stamp",
+    file2 = "a_stamp_expected",
+)
+
+expand_template(
+    name = "a_tmpl",
+    out = "a",
+    data = ["a.tmpl"],
+    stamp = 0,
+    stamp_substitutions = {
+        "{{VERSION}}": "v{{BUILD_SCM_VERSION}}",
+    },
+    substitutions = {
+        "{{WORKSPACE}}": "$(WORKSPACE)",
+        "{{VERSION}}": "v0.0.0",
+        "{{TMPL_PATH}}": "$(rootpath a.tmpl)",
+    },
+    template = "a.tmpl",
+)
+
+diff_test(
+    name = "a_test",
+    file1 = ":a",
+    file2 = "a_expected",
+)
diff --git a/js/private/test/expand_template/a.tmpl b/js/private/test/expand_template/a.tmpl
new file mode 100644
index 0000000..abe382f
--- /dev/null
+++ b/js/private/test/expand_template/a.tmpl
@@ -0,0 +1,6 @@
+WORKSPACE: {{WORKSPACE}}
+VERSION: {{VERSION}}
+TMPL_PATH: {{TMPL_PATH}}
+WORKSPACE: {{WORKSPACE}}
+VERSION: {{VERSION}}
+TMPL_PATH: {{TMPL_PATH}}
diff --git a/js/private/test/expand_template/a_expected b/js/private/test/expand_template/a_expected
new file mode 100644
index 0000000..d67a616
--- /dev/null
+++ b/js/private/test/expand_template/a_expected
@@ -0,0 +1,6 @@
+WORKSPACE: aspect_rules_js
+VERSION: v0.0.0
+TMPL_PATH: js/private/test/expand_template/a.tmpl
+WORKSPACE: aspect_rules_js
+VERSION: v0.0.0
+TMPL_PATH: js/private/test/expand_template/a.tmpl
diff --git a/js/private/test/expand_template/a_stamp_expected b/js/private/test/expand_template/a_stamp_expected
new file mode 100644
index 0000000..668b850
--- /dev/null
+++ b/js/private/test/expand_template/a_stamp_expected
@@ -0,0 +1,6 @@
+WORKSPACE: aspect_rules_js
+VERSION: v1.2.3
+TMPL_PATH: js/private/test/expand_template/a.tmpl
+WORKSPACE: aspect_rules_js
+VERSION: v1.2.3
+TMPL_PATH: js/private/test/expand_template/a.tmpl