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 )