blob: 55c362f4331886debdfed8c26dbe376aeee77925 [file]
load("@rules_proto//proto:defs.bzl", "ProtoInfo")
load("//:plugin.bzl", "ProtoPluginInfo")
load(
"//internal:common.bzl",
"ProtoCompileInfo",
"copy_file",
"descriptor_proto_path",
"get_int_attr",
"get_output_filename",
"strip_path_prefix",
)
ProtoLibraryAspectNodeInfo = provider(
fields = {
"output_files": "The files generated by this aspect and its transitive dependencies, as a dict indexed by the root directory",
"output_dirs": "The directories generated by this aspect and its transitive dependencies, as a depset",
},
)
proto_compile_attrs = {
"verbose": attr.int(
doc = "The verbosity level. Supported values and results are 1: *show command*, 2: *show command and sandbox after running protoc*, 3: *show command and sandbox before and after running protoc*, 4. *show env, command, expected outputs and sandbox before and after running protoc*",
),
"verbose_string": attr.string(
doc = "String version of the verbose string, used for aspect",
default = "0",
),
"prefix_path": attr.string(
doc = "Path to prefix to the generated files in the output directory. Cannot be set when merge_directories == False",
),
"merge_directories": attr.bool(
doc = "If true, all generated files are merged into a single directory with the name of current label and these new files returned as the outputs. If false, the original generated files are returned across multiple roots",
default = True,
),
}
proto_compile_aspect_attrs = {
"verbose_string": attr.string(
doc = "String version of the verbose string, used for aspect",
values = ["", "None", "0", "1", "2", "3", "4"],
default = "0",
),
}
def proto_compile_impl(ctx):
# Aggregate output files and dirs created by the aspect as it has walked the deps
output_files_dicts = [dep[ProtoLibraryAspectNodeInfo].output_files for dep in ctx.attr.deps]
output_dirs = depset(transitive=[
dep[ProtoLibraryAspectNodeInfo].output_dirs for dep in ctx.attr.deps
])
# Check merge_directories and prefix_path
if not ctx.attr.merge_directories and ctx.attr.prefix_path:
fail("Attribute prefix_path cannot be set when merge_directories is false")
# Build outputs
final_output_files = {}
final_output_files_list = []
final_output_dirs = depset()
prefix_path = ctx.attr.prefix_path
if not ctx.attr.merge_directories:
# Pass on outputs directly when not merging
for output_files_dict in output_files_dicts:
final_output_files.update(**output_files_dict)
final_output_files_list = [f for files in final_output_files.values() for f in files]
final_output_dirs = output_dirs
elif output_dirs:
# If we have any output dirs specified, we declare a single output
# directory and merge all files in one go. This is necessary to prevent
# path prefix conflicts
# Declare single output directory
dir_name = ctx.label.name
if prefix_path:
dir_name = dir_name + "/" + prefix_path
new_dir = ctx.actions.declare_directory(dir_name)
final_output_dirs = depset(direct=[new_dir])
# Build copy command for directory outputs
# Use cp {}/. rather than {}/* to allow for empty output directories from a plugin (e.g when no service exists,
# so no files generated)
command_parts = ["cp -r {} '{}'".format(
" ".join(["'" + d.path + "/.'" for d in output_dirs.to_list()]),
new_dir.path,
)]
# Extend copy command with file outputs
command_input_files = []
for output_files_dict in output_files_dicts:
for root, files in output_files_dict.items():
for file in files:
# Strip root from file path
path = strip_path_prefix(file.path, root)
# Prefix path is contained in new_dir.path created above and
# used below
# Add command to copy file to output
command_input_files.append(file)
command_parts.append("cp '{}' '{}'".format(
file.path,
"{}/{}".format(new_dir.path, path),
))
# Add debug options
if ctx.attr.verbose > 1:
command_parts = command_parts + ["echo '\n##### SANDBOX AFTER MERGING DIRECTORIES'", "find . -type l"]
if ctx.attr.verbose > 2:
command_parts = ["echo '\n##### SANDBOX BEFORE MERGING DIRECTORIES'", "find . -type l"] + command_parts
if ctx.attr.verbose > 0:
print("Directory merge command: {}".format(" && ".join(command_parts)))
# Copy directories and files to shared output directory in one action
ctx.actions.run_shell(
mnemonic = "CopyDirs",
inputs = depset(direct=command_input_files, transitive=[output_dirs]),
outputs = [new_dir],
command = " && ".join(command_parts),
progress_message = "copying directories and files to {}".format(new_dir.path),
)
else:
# Otherwise, if we only have output files, build the output tree by
# aggregating files created by aspect into one directory
output_root = ctx.bin_dir.path + "/"
if ctx.label.workspace_root:
output_root += ctx.label.workspace_root + "/"
if ctx.label.package:
output_root += ctx.label.package + "/"
output_root += ctx.label.name
final_output_files[output_root] = []
for output_files_dict in output_files_dicts:
for root, files in output_files_dict.items():
for file in files:
# Strip root from file path
path = strip_path_prefix(file.path, root)
# Prepend prefix path if given
if prefix_path:
path = prefix_path + "/" + path
# Copy file to output
final_output_files[output_root].append(copy_file(
ctx,
file,
"{}/{}".format(ctx.label.name, path),
))
final_output_files_list = final_output_files[output_root]
# Create depset containing all outputs
if ctx.attr.merge_directories:
# If we've merged directories, we have copied files/dirs that are now direct rather than
# transitive dependencies
all_outputs = depset(direct=final_output_files_list + final_output_dirs.to_list())
else:
# If we have not merged directories, all files/dirs are transitive
all_outputs = depset(
transitive=[depset(direct=final_output_files_list), final_output_dirs]
)
# Create default and proto compile providers
return [
ProtoCompileInfo(
label=ctx.label,
output_files=final_output_files,
output_dirs=final_output_dirs,
),
DefaultInfo(
files=all_outputs,
data_runfiles=ctx.runfiles(transitive_files=all_outputs),
),
]
def proto_compile_aspect_impl(target, ctx):
###
### Part 1: Setup common state
###
# Load attrs
verbose = get_int_attr(ctx.attr, "verbose_string") # Integer verbosity level
plugins = [plugin[ProtoPluginInfo] for plugin in ctx.attr._plugins]
# Load toolchain
protoc_toolchain_info = ctx.toolchains[str(Label("//protobuf:toolchain_type"))]
protoc = protoc_toolchain_info.protoc_executable
fixer = protoc_toolchain_info.fixer_executable
# Load ProtoInfo of the current node
proto_info = target[ProtoInfo]
# The directory where the outputs will be generated, relative to the package. This contains the aspect _prefix attr
# to disambiguate different aspects that may share the same plugins and would otherwise try to touch the same file.
# The same is true for the verbose_string attr.
rel_outdir = "{}/{}_verb{}".format(ctx.label.name, ctx.attr._prefix, verbose)
# The full path to the package output directory, relative to workspace root
package_root = ctx.bin_dir.path
if ctx.label.workspace_root:
package_root += "/" + ctx.label.workspace_root
if ctx.label.package:
package_root += "/" + ctx.label.package
# The path to the
full_outdir = package_root + "/" + rel_outdir
# The lists of generated files and directories that we expect to be produced.
output_files = []
output_dirs = []
###
### Part 2: Setup plugins
###
# Each plugin is isolated to its own execution of protoc, as plugins may have differing exclusions that cannot be
# expressed in a single protoc execution for all plugins.
for plugin in plugins:
###
### Part 2.1: Fetch plugin tool and runfiles
###
# Files required for running the plugin
plugin_runfiles = []
# Plugin input manifests
plugin_input_manifests = None
# Get plugin name
plugin_name = plugin.name
if plugin.protoc_plugin_name:
plugin_name = plugin.protoc_plugin_name
# Add plugin executable if not a built-in plugin
plugin_tool = None
if plugin.tool_executable:
plugin_tool = plugin.tool_executable
# Add plugin runfiles if plugin has a tool
if plugin.tool:
plugin_runfiles, plugin_input_manifests = ctx.resolve_tools(tools = [plugin.tool])
plugin_runfiles = plugin_runfiles.to_list()
# Add extra plugin data files to runfiles
plugin_runfiles += plugin.data
# Check plugin outputs
if plugin.output_directory and (plugin.out or plugin.outputs or plugin.empty_template):
fail("Proto plugin {} cannot use output_directory in conjunction with outputs, out or empty_template".format(plugin.name))
###
### Part 2.2: Gather proto files and filter by exclusions
###
protos = [] # The filtered set of .proto files to compile
for proto in proto_info.direct_sources:
# Check for exclusion
if any([
proto.dirname.endswith(exclusion) or proto.path.endswith(exclusion)
for exclusion in plugin.exclusions
]) or proto in protos:
# When using import_prefix, the ProtoInfo.direct_sources list appears to contain duplicate records,
# the final check 'proto in protos' removes these. See https://github.com/bazelbuild/bazel/issues/9127
continue
# Proto not excluded
protos.append(proto)
# Skip plugin if all proto files have now been excluded
if len(protos) == 0:
if verbose > 2:
print(
'Skipping plugin "{}" for "{}" as all proto files have been excluded'.format(plugin.name, ctx.label)
)
continue
###
### Part 2.3: Declare per-proto generated outputs from plugin
###
plugin_outputs = []
for proto in protos:
for pattern in plugin.outputs:
plugin_outputs.append(ctx.actions.declare_file("{}/{}".format(
rel_outdir, get_output_filename(proto, pattern, proto_info),
)))
# Append current plugin outputs to global outputs before looking at per-plugin outputs; these are manually added
# globally as there may be srcjar outputs.
output_files.extend(plugin_outputs)
###
### Part 2.4: Declare per-plugin outputs
###
# Some protoc plugins generate a set of output files (like python) while others generate a single 'archive' file
# that contains the individual outputs (like java). Jar outputs are gathered as a special case as we need to
# post-process them to have a 'srcjar' extension (java_library rules don't accept source jars with a 'jar'
# extension).
out_file = None
if plugin.out:
# Define out file
out_file = ctx.actions.declare_file("{}/{}".format(
rel_outdir, plugin.out.replace("{name}", ctx.label.name),
))
plugin_outputs.append(out_file)
if not out_file.path.endswith(".jar"):
# Add output direct to global outputs
output_files.append(out_file)
else:
# Create .srcjar from .jar for global outputs
output_files.append(copy_file(
ctx, out_file, "{}.srcjar".format(out_file.basename.rpartition(".")[0]), sibling = out_file,
))
###
### Part 2.5: Declare plugin output directory
###
# Some plugins outputs a structure that cannot be predicted from the input file paths alone. For these plugins,
# we simply declare the directory.
if plugin.output_directory:
out_file = ctx.actions.declare_directory(rel_outdir + "/" + "_plugin_" + plugin.name)
plugin_outputs.append(out_file)
output_dirs.append(out_file)
###
### Part 2.6: Build command
###
# Determine the outputs expected by protoc.
# When plugin.empty_template is not set, protoc will output directly to the final targets. When set, we will
# direct the plugin outputs to a temporary folder, then use the fixer executable to write to the final targets.
if plugin.empty_template:
# Create path list for fixer
fixer_paths_file = ctx.actions.declare_file(rel_outdir + "/" + "_plugin_ef_" + plugin.name + ".txt")
ctx.actions.write(fixer_paths_file, '\n'.join([
file.path.partition(full_outdir + "/")[2] for file in plugin_outputs
]))
# Create output directory for protoc to write into
fixer_dir = ctx.actions.declare_directory(rel_outdir + "/" + "_plugin_ef_" + plugin.name)
out_arg = fixer_dir.path
plugin_protoc_outputs = [fixer_dir]
# Apply fixer
ctx.actions.run(
inputs = [fixer_paths_file, fixer_dir, plugin.empty_template],
outputs = plugin_outputs,
arguments = [
fixer_paths_file.path, plugin.empty_template.path, fixer_dir.path, full_outdir
],
progress_message = "Applying fixer for {} plugin on target {}".format(plugin.name, target.label),
executable = fixer,
)
else:
# No fixer, protoc writes files directly
out_arg = out_file.path if out_file else full_outdir
plugin_protoc_outputs = plugin_outputs
# Argument list for protoc execution
args = ctx.actions.args()
# Add descriptors
pathsep = ctx.configuration.host_path_separator
args.add("--descriptor_set_in={}".format(pathsep.join(
[f.path for f in proto_info.transitive_descriptor_sets.to_list()],
)))
# Add --plugin if not a built-in plugin
if plugin_tool:
# If Windows, mangle the path. It's done a bit awkwardly with
# `host_path_seprator` as there is no simple way to figure out what's
# the current OS.
plugin_tool_path = None
if ctx.configuration.host_path_separator == ";":
plugin_tool_path = plugin_tool.path.replace("/", "\\")
else:
plugin_tool_path = plugin_tool.path
args.add("--plugin=protoc-gen-{}={}".format(plugin_name, plugin_tool_path))
# Add plugin --*_out/--*_opt args
if plugin.options:
opts_str = ",".join(
[option.replace("{name}", ctx.label.name) for option in plugin.options],
)
if plugin.separate_options_flag:
args.add("--{}_opt={}".format(plugin_name, opts_str))
else:
out_arg = "{}:{}".format(opts_str, out_arg)
args.add("--{}_out={}".format(plugin_name, out_arg))
# Add source proto files as descriptor paths
for proto in protos:
args.add(descriptor_proto_path(proto, proto_info))
###
### Part 2.7: Specify protoc command
###
mnemonic = "ProtoCompile"
command = ("mkdir -p '{}' && ".format(full_outdir)) + protoc.path + " $@" # $@ is replaced with args list
inputs = proto_info.transitive_descriptor_sets.to_list() + plugin_runfiles # Proto files are not inputs, as they come via the descriptor sets
tools = [protoc] + ([plugin_tool] if plugin_tool else [])
# Amend command with debug options
if verbose > 0:
print("{}:".format(mnemonic), protoc.path, args)
if verbose > 1:
command += " && echo '\n##### SANDBOX AFTER RUNNING PROTOC' && find . -type f "
if verbose > 2:
command = "echo '\n##### SANDBOX BEFORE RUNNING PROTOC' && find . -type l && " + command
if verbose > 3:
command = "env && " + command
for f in inputs:
print("INPUT:", f.path)
for f in protos:
print("TARGET PROTO:", f.path)
for f in tools:
print("TOOL:", f.path)
for f in plugin_outputs:
print("EXPECTED OUTPUT:", f.path)
# Run protoc
ctx.actions.run_shell(
mnemonic = mnemonic,
command = command,
arguments = [args],
inputs = inputs,
tools = tools,
outputs = plugin_protoc_outputs,
use_default_shell_env = plugin.use_built_in_shell_environment,
input_manifests = plugin_input_manifests if plugin_input_manifests else [],
progress_message = "Compiling protoc outputs for {} plugin on target {}".format(plugin.name, target.label),
)
###
### Step 3: Generate providers
###
# Gather transitive info
transitive_infos = [dep[ProtoLibraryAspectNodeInfo] for dep in ctx.rule.attr.deps]
output_files_dict = {}
if output_files:
output_files_dict[full_outdir] = output_files
transitive_output_dirs = []
for transitive_info in transitive_infos:
output_files_dict.update(**transitive_info.output_files)
transitive_output_dirs.append(transitive_info.output_dirs)
return [
ProtoLibraryAspectNodeInfo(
output_files = output_files_dict,
output_dirs = depset(direct=output_dirs, transitive=transitive_output_dirs),
),
]