github.com/stackb/rules_proto@v0.0.0-20240221195024-5428336c51f1/rules/proto_compile.bzl (about)

     1  """proto_compile.bzl provides the proto_compile rule.
     2  
     3  This runs the protoc tool and generates output source files.
     4  """
     5  
     6  load("@rules_proto//proto:defs.bzl", "ProtoInfo")
     7  load(":providers.bzl", "ProtoCompileInfo", "ProtoPluginInfo")
     8  
     9  def _uniq(iterable):
    10      """Returns a list of unique elements in `iterable`.
    11  
    12      Requires all the elements to be hashable.
    13      Args:
    14        iterable: An iterable to filter.
    15      Returns:
    16        A new list with all unique elements from `iterable`.
    17      """
    18      unique_elements = {element: None for element in iterable}
    19  
    20      return list(unique_elements.keys())
    21  
    22  def _ctx_replace_args(ctx, args):
    23      return [_ctx_replace_arg(ctx, arg) for arg in args]
    24  
    25  def _ctx_replace_arg(ctx, arg):
    26      arg = arg.replace("{BIN_DIR}", ctx.bin_dir.path)
    27      arg = arg.replace("{PACKAGE}", ctx.label.package)
    28      arg = arg.replace("{NAME}", ctx.label.name)
    29  
    30      if arg.find("{PROTO_LIBRARY_BASENAME}") != -1:
    31          basename = ctx.attr.proto.label.name
    32          if basename.endswith("_proto"):
    33              basename = basename[:len(basename) - len("_proto")]
    34          arg = arg.replace("{PROTO_LIBRARY_BASENAME}", basename)
    35      return arg
    36  
    37  def _plugin_label_key(label):
    38      """_plugin_label_key converts a label into a string.  
    39  
    40      This is needed due to an edge case about how Labels are parsed and
    41      represented. Consider the label
    42      "@build_stack_rules_proto//plugin/scalapb/scalapb:protoc-gen-scala". If this
    43      string is the value for an attr.label in the same workspace
    44      build_stack_rules_proto, the workspace name is actually ommitted and becomes
    45      the empty string.  However, if is is the value for an attr.string and then
    46      parsed into a label in Starlark, the workspace name is preserved.  To resolve
    47      this issue, we just ignore the workspace name altogether, hoping that no-one
    48      tries to use two different plugins having a different workspace_name but
    49      otherwise identical package and name.
    50      """
    51      key = "%s:%s" % (label.package, label.name)
    52  
    53      return key
    54  
    55  def get_protoc_executable(ctx):
    56      if ctx.file.protoc:
    57          return ctx.file.protoc
    58      protoc_toolchain_info = ctx.toolchains[str(Label("//toolchain:protoc"))]
    59      return protoc_toolchain_info.protoc_executable
    60  
    61  def _descriptor_proto_path(proto, proto_info):
    62      """Convert a proto File to the path within the descriptor file.
    63  
    64      Adapted from https://github.com/bazelbuild/rules_go
    65      """
    66  
    67      # Strip proto_source_root
    68      path = _strip_path_prefix(proto.path, proto_info.proto_source_root)
    69  
    70      # Strip root
    71      path = _strip_path_prefix(path, proto.root.path)
    72  
    73      # Strip workspace root
    74      path = _strip_path_prefix(path, proto.owner.workspace_root)
    75  
    76      return path
    77  
    78  def _strip_path_prefix(path, prefix):
    79      """Strip a prefix from a path if it exists and any remaining prefix slashes
    80  
    81      Args:
    82          path: <string>
    83          prefix: <string>
    84      Returns:
    85          <string>
    86      """
    87      if path.startswith(prefix):
    88          path = path[len(prefix):]
    89      if path.startswith("/"):
    90          path = path[1:]
    91      return path
    92  
    93  def is_windows(ctx):
    94      return ctx.configuration.host_path_separator == ";"
    95  
    96  def _proto_compile_impl(ctx):
    97      # mut <list<File>>
    98      outputs = [] + ctx.outputs.outputs
    99  
   100      # mut <?string> If defined, we are using the srcs to predict the outputs
   101      # srcgen_ext = None
   102      if len(ctx.attr.srcs) > 0:
   103          if len(ctx.outputs.outputs) > 0:
   104              fail("rule must provide 'srcs' or 'outputs', but not both")
   105  
   106          # srcgen_ext = ctx.attr.srcgen_ext
   107          outputs = [ctx.actions.declare_file(name) for name in ctx.attr.srcs]
   108  
   109      ###
   110      ### Part 1: setup variables used in scope
   111      ###
   112  
   113      # const <bool> verbosity flag
   114      verbose = ctx.attr.verbose
   115  
   116      # const <File> the protoc file from the toolchain
   117      protoc = get_protoc_executable(ctx)
   118  
   119      # const <ProtoInfo> proto provider
   120      proto_info = ctx.attr.proto[ProtoInfo]
   121  
   122      # const <list<ProtoPluginInfo>> plugins to be applied
   123      plugins = [plugin[ProtoPluginInfo] for plugin in ctx.attr.plugins]
   124  
   125      # const <dict<string,string>>
   126      outs = {_plugin_label_key(Label(k)): v for k, v in ctx.attr.outs.items()}
   127  
   128      # const <dict<string,File>.  outputs indexed by basename.
   129      outputs_by_basename = {f.basename: f for f in outputs}
   130  
   131      # mut <list<File>> set of descriptors for the compile action
   132      descriptors = proto_info.transitive_descriptor_sets.to_list()
   133  
   134      # mut <list<File>> tools for the compile action
   135      tools = [protoc]
   136  
   137      # mut <list<string>> argument list for protoc execution
   138      args = [] + ctx.attr.args
   139  
   140      # mut <list<File>> inputs for the compile action
   141      inputs = []
   142  
   143      # mut <list<File>> The (filtered) set of .proto files to compile
   144      protos = []
   145  
   146      # mut <list<opaque>> Plugin input manifests
   147      input_manifests = []
   148  
   149      # mut <dict<string,string>> post-processing modifications for the compile action
   150      mods = dict()
   151  
   152      ###
   153      ### Part 2: per-plugin args
   154      ###
   155  
   156      for plugin in plugins:
   157          ### Part 2.1: build protos list
   158  
   159          # add all protos unless excluded
   160          for proto in proto_info.direct_sources:
   161              if any([
   162                  proto.dirname.endswith(exclusion) or proto.path.endswith(exclusion)
   163                  for exclusion in plugin.exclusions
   164              ]) or proto in protos:  # TODO: When using import_prefix, the ProtoInfo.direct_sources list appears to contain duplicate records, this line removes these. https://github.com/bazelbuild/bazel/issues/9127
   165                  continue
   166  
   167              # Proto not excluded
   168              protos.append(proto)
   169  
   170          # augment proto list with those attached to plugin
   171          for info in plugin.supplementary_proto_deps:
   172              for src in info.direct_sources:
   173                  protos.append(src)
   174              descriptors += info.transitive_descriptor_sets.to_list()
   175  
   176          # Include extra plugin data files
   177          inputs += plugin.data
   178  
   179          ### Part 2.2: build --plugin argument
   180  
   181          # const <string> The name of the plugin
   182          plugin_name = plugin.protoc_plugin_name if plugin.protoc_plugin_name else plugin.name
   183  
   184          # const <?File> Add plugin executable if not a built-in plugin
   185          plugin_tool = plugin.tool if plugin.tool else None
   186          is_builtin = plugin.tool == None
   187  
   188          # Add plugin runfiles if plugin has a tool
   189          if plugin_tool:
   190              tools.append(plugin_tool)
   191  
   192              # const <depset<File>, <list<opaque>>
   193              plugin_runfiles, plugin_input_manifests = ctx.resolve_tools(tools = [plugin.tool_target])
   194              if plugin_input_manifests:
   195                  input_manifests.extend(plugin_input_manifests)
   196              tools += plugin_runfiles.to_list()
   197  
   198              # If Windows, mangle the path.
   199              plugin_tool_path = plugin_tool.path
   200              if is_windows(ctx):
   201                  plugin_tool_path = plugin_tool.path.replace("/", "\\")
   202  
   203              args.append("--plugin=protoc-gen-{}={}".format(plugin_name, plugin_tool_path))
   204  
   205          ### Part 2.3: build --{name}_out=OPTIONS argument
   206  
   207          # mut <string>
   208          out = plugin.out
   209          if ctx.label.workspace_root:
   210              # special handling for "{BIN_DIR}".  If we are dealing with a
   211              # formatted output string (like for a .srcjar), cannot just append
   212              # "external/repo" to the string.
   213              if out.find("{BIN_DIR}") != -1:
   214                  out = out.replace("{BIN_DIR}", "{BIN_DIR}/" + ctx.label.workspace_root)
   215              else:
   216                  out = "/".join([out, ctx.label.workspace_root])
   217  
   218          # dict<key=label.package+label.name,value=list<string>>
   219          options = {_plugin_label_key(Label(k)): v for k, v in ctx.attr.options.items()}
   220  
   221          # const <list<string>>
   222          opts = plugin.options + [opt for opt in options.get(_plugin_label_key(plugin.label), [])]
   223          if is_builtin and opts:
   224              # builtins can't use the --opt flags
   225              out = "{}:{}".format(",".join(opts), out)
   226          else:
   227              for opt in opts:
   228                  args.append("--{}_opt={}".format(plugin_name, opt))
   229  
   230          # override with the out configured on the rule if specified
   231          plugin_out = outs.get(_plugin_label_key(plugin.label), None)
   232          if plugin_out:
   233              # bin-dir relative is implied for plugin_out overrides.  Workspace
   234              # root might be empty, so filter empty strings via this list
   235              # comprehension.
   236              out = "/".join([e for e in [ctx.bin_dir.path, ctx.label.workspace_root, plugin_out] if e])
   237          args.append("--{}_out={}".format(plugin_name, out))
   238  
   239          ### Part 2.4: setup awk modifications if any
   240          for k, v in plugin.mods.items():
   241              mods[k] = v
   242  
   243      ###
   244      ### Part 3: trailing args
   245      ###
   246  
   247      ### Part 3.1: add descriptor sets
   248  
   249      descriptors = _uniq(descriptors)
   250      inputs += descriptors
   251  
   252      args.append("--descriptor_set_in={}".format(ctx.configuration.host_path_separator.join(
   253          [d.path for d in descriptors],
   254      )))
   255  
   256      ### Part 3.2: add proto file args
   257  
   258      protos = _uniq(protos)
   259      for proto in protos:
   260          args.append(_descriptor_proto_path(proto, proto_info))
   261  
   262      ### Step 3.3: build args object
   263  
   264      replaced_args = _ctx_replace_args(ctx, _uniq(args))
   265      final_args = ctx.actions.args()
   266      final_args.use_param_file("@%s", use_always = False)
   267      final_args.add_all(replaced_args)
   268  
   269      ###
   270      ### Step 4: command action
   271      ###
   272      commands = [
   273          "set -euo pipefail",
   274          "mkdir -p ./" + ctx.label.package,
   275          protoc.path + " $@",  # $@ is replaced with args list
   276      ]
   277  
   278      # if the rule declares any mappings, setup copy file commands to move them
   279      # into place
   280      if len(ctx.attr.output_mappings) > 0:
   281          copy_commands = []
   282          out_dir = ctx.bin_dir.path
   283          if ctx.label.workspace_root:
   284              out_dir = "/".join([out_dir, ctx.label.workspace_root])
   285          for mapping in ctx.attr.output_mappings:
   286              basename, _, intermediate_filename = mapping.partition("=")
   287              intermediate_filename = "/".join([out_dir, intermediate_filename])
   288              output = outputs_by_basename.get(basename, None)
   289              if not output:
   290                  fail("the mapped file '%s' was not listed in outputs" % basename)
   291              copy_commands.append("cp '{}' '{}'".format(intermediate_filename, output.path))
   292          copy_script = ctx.actions.declare_file(ctx.label.name + "_copy.sh")
   293          ctx.actions.write(copy_script, "\n".join(copy_commands), is_executable = True)
   294          inputs.append(copy_script)
   295          commands.append(copy_script.path)
   296  
   297      # if there are any mods to apply, set those up now
   298      if len(mods):
   299          mv_commands = []
   300          for suffix, action in mods.items():
   301              for f in outputs:
   302                  if f.short_path.endswith(suffix):
   303                      mv_commands.append("awk '%s' %s > %s.tmp" % (action, f.path, f.path))
   304                      mv_commands.append("mv %s.tmp %s" % (f.path, f.path))
   305          mv_script = ctx.actions.declare_file(ctx.label.name + "_mv.sh")
   306          ctx.actions.write(mv_script, "\n".join(mv_commands), is_executable = True)
   307          inputs.append(mv_script)
   308          commands.append(mv_script.path)
   309  
   310      if verbose:
   311          before = ["env", "pwd", "ls -al .", "echo '\n##### SANDBOX BEFORE RUNNING PROTOC'", "find * -type l"]
   312          after = ["echo '\n##### SANDBOX AFTER RUNNING PROTOC'", "find * -type f"]
   313          commands = before + commands + after
   314  
   315          for c in commands:
   316              # buildifier: disable=print
   317              print("COMMAND:", c)
   318          for f in tools:
   319              # buildifier: disable=print
   320              print("TOOL:", f.path)
   321          for a in replaced_args:
   322              # buildifier: disable=print
   323              print("ARG:", a)
   324          for f in protos:
   325              # buildifier: disable=print
   326              print("PROTO:", f.path)
   327          for f in inputs:
   328              # buildifier: disable=print
   329              print("INPUT:", f.path)
   330          for f in outputs:
   331              # buildifier: disable=print
   332              print("EXPECTED OUTPUT:", f.path)
   333  
   334      ctx.actions.run_shell(
   335          arguments = [final_args],
   336          command = "\n".join(commands),
   337          inputs = inputs,
   338          mnemonic = "Protoc",
   339          outputs = outputs,
   340          progress_message = "Compiling protoc outputs for %r" % [f.basename for f in protos],
   341          tools = tools,
   342          input_manifests = input_manifests,
   343          env = {"BAZEL_BINDIR": ctx.bin_dir.path},
   344      )
   345  
   346      return [
   347          ProtoCompileInfo(label = ctx.label, outputs = outputs),
   348          DefaultInfo(files = depset(outputs)),
   349      ]
   350  
   351  proto_compile = rule(
   352      implementation = _proto_compile_impl,
   353      attrs = {
   354          "args": attr.string_list(
   355              doc = "List of additional protoc args",
   356          ),
   357          "outputs": attr.output_list(
   358              doc = "List of source files we expect to be generated (relative to package)",
   359          ),
   360          "srcs": attr.string_list(
   361              doc = "List of source files we expect to be regenerated (relative to package)",
   362          ),
   363          "plugins": attr.label_list(
   364              doc = "List of ProtoPluginInfo providers",
   365              mandatory = True,
   366              providers = [ProtoPluginInfo],
   367          ),
   368          "options": attr.string_list_dict(
   369              doc = "List of additional options, keyed by proto_plugin label",
   370          ),
   371          "outs": attr.string_dict(
   372              doc = "Output location, keyed by proto_plugin label",
   373          ),
   374          "output_mappings": attr.string_list(
   375              doc = "strings of the form A=B where A is a file named in attr.outputs and B is the actual file generated in the execroot",
   376          ),
   377          "proto": attr.label(
   378              doc = "The single ProtoInfo provider",
   379              mandatory = True,
   380              providers = [ProtoInfo],
   381          ),
   382          "protoc": attr.label(
   383              doc = "Overrides the protoc from the toolchain",
   384              allow_single_file = True,
   385              executable = True,
   386              cfg = "exec",
   387          ),
   388          "verbose": attr.bool(
   389              doc = "The verbosity flag.",
   390          ),
   391      },
   392      toolchains = ["@build_stack_rules_proto//toolchain:protoc"],
   393  )