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