Skip to content

Commit 6837414

Browse files
tempozbduffany
andauthored
Use flags-as-proto to determine command line schema (take two) (#8155)
We now use `bazel help flags-as-proto` as the primary method of determining the command line schema, only falling back to the usage-based parsing if the bazel version is too old to support `flags-as-proto`. Switched from using `BoolLike` to the more-specific `RequiresValue` and `HasNegative`, as those are what is present in the `FlagCollection` proto. They also allow us to tell the difference between boolean options (or boolean/enum options) and expansion options, which allows us more precision and accuracy in parsing: we can now remove values specified via `=` to expansion options (which improves our canonicalization). Added a test to confirm that `flags-as-proto` allows us to correctly handle undocumented options that are specified via `common` in a `bazelrc` file (which was the primary motivation for this change). Added a mock help function in the tests that uses `flags-as-proto` help and tested it everywhere the usage-style help was tested to ensure there were no regressions. Added some options to the canonicalization tests to ensure we are handling expansion options and options that use `BoolOrEnumConverter` in bazel (`--subcommands` and `--experimental_convenience_symlinks`) correctly. Added a test to ensure the options produced through `flags-as-proto` match up with those produced by parsing usage. --------- Co-authored-by: Brandon Duffany <brandon@buildbuddy.io>
1 parent 4987ddd commit 6837414

16 files changed

+1588
-854
lines changed

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
* text=auto
2+
3+
# base64 files are technically all valid ASCII characters, but are not
4+
# human-readable, not diff-able in a meaningful way, and we do not want
5+
# git to fix line endings for them. Thus they are, per
6+
# https://git-scm.com/book/ms/v2/Customizing-Git-Git-Attribute, "files [that]
7+
# look like text files but for all intents and purposes are to be treated as
8+
# binary data."
9+
*.b64 binary

cli/parser/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ go_library(
1010
"//cli/log",
1111
"//cli/storage",
1212
"//cli/workspace",
13+
"//proto:bazel_flags_go_proto",
1314
"//proto:remote_execution_go_proto",
1415
"//server/remote_cache/digest",
1516
"//server/util/disk",
17+
"//server/util/proto",
1618
"@com_github_google_shlex//:shlex",
1719
],
1820
)

cli/parser/parser.go

Lines changed: 193 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package parser
55
import (
66
"bufio"
77
"bytes"
8+
"encoding/base64"
89
"fmt"
910
"io"
1011
"os"
@@ -21,8 +22,10 @@ import (
2122
"github.com/buildbuddy-io/buildbuddy/cli/workspace"
2223
"github.com/buildbuddy-io/buildbuddy/server/remote_cache/digest"
2324
"github.com/buildbuddy-io/buildbuddy/server/util/disk"
25+
"github.com/buildbuddy-io/buildbuddy/server/util/proto"
2426
"github.com/google/shlex"
2527

28+
bfpb "github.com/buildbuddy-io/buildbuddy/proto/bazel_flags"
2629
repb "github.com/buildbuddy-io/buildbuddy/proto/remote_execution"
2730
)
2831

@@ -82,6 +85,34 @@ var (
8285
flagShortNamePattern = regexp.MustCompile(`^[a-z]$`)
8386
)
8487

88+
// Before Bazel 7, the flag protos did not contain the `RequiresValue` field,
89+
// so there is no way to identify expansion options, which must be parsed
90+
// differently. Since there are only nineteen such options (and bazel 6 is
91+
// currently only receiving maintenance support and thus unlikely to add new
92+
// expansion options), we can just enumerate them here so that we can correctly
93+
// identify them in the absence of that field.
94+
var preBazel7ExpansionOptions = map[string]struct{}{
95+
"noincompatible_genquery_use_graphless_query": struct{}{},
96+
"incompatible_genquery_use_graphless_query": struct{}{},
97+
"persistent_android_resource_processor": struct{}{},
98+
"persistent_multiplex_android_resource_processor": struct{}{},
99+
"persistent_android_dex_desugar": struct{}{},
100+
"persistent_multiplex_android_dex_desugar": struct{}{},
101+
"start_app": struct{}{},
102+
"debug_app": struct{}{},
103+
"java_debug": struct{}{},
104+
"remote_download_minimal": struct{}{},
105+
"remote_download_toplevel": struct{}{},
106+
"long": struct{}{},
107+
"short": struct{}{},
108+
"expunge_async": struct{}{},
109+
"experimental_spawn_scheduler": struct{}{},
110+
"experimental_persistent_javac": struct{}{},
111+
"null": struct{}{},
112+
"order_results": struct{}{},
113+
"noorder_results": struct{}{},
114+
}
115+
85116
// OptionSet contains a set of Option schemas, indexed for ease of parsing.
86117
type OptionSet struct {
87118
All []*Option
@@ -138,10 +169,19 @@ func (s *OptionSet) Next(args []string, start int) (option *Option, value string
138169
longName = longName[:eqIndex]
139170
option = s.ByName[longName]
140171
optValue = &v
141-
// Unlike command options, startup options don't allow specifying
142-
// booleans as --name=0, --name=false etc.
143-
if s.IsStartupOptions && option != nil && option.BoolLike {
144-
return nil, "", -1, fmt.Errorf("in option %q: option %q does not take a value", startToken, option.Name)
172+
if option != nil && !option.RequiresValue {
173+
// Unlike command options, startup options don't allow specifying
174+
// values for options that do not require values.
175+
if s.IsStartupOptions {
176+
return nil, "", -1, fmt.Errorf("in option %q: option %q does not take a value", startToken, option.Name)
177+
}
178+
// Boolean options may specify values, but expansion options ignore
179+
// values and output a warning. Since we canonicalize the options and
180+
// remove the value ourselves, we should output the warning instead.
181+
if !option.HasNegative {
182+
log.Warnf("option '--%s' is an expansion option. It does not accept values, and does not change its expansion based on the value provided. Value '%s' will be ignored.", option.Name, *optValue)
183+
optValue = nil
184+
}
145185
}
146186
} else {
147187
option = s.ByName[longName]
@@ -150,7 +190,7 @@ func (s *OptionSet) Next(args []string, start int) (option *Option, value string
150190
if option == nil && strings.HasPrefix(longName, "no") {
151191
longName := strings.TrimPrefix(longName, "no")
152192
option = s.ByName[longName]
153-
if option != nil && !option.BoolLike {
193+
if option != nil && !option.HasNegative {
154194
return nil, "", -1, fmt.Errorf("illegal use of 'no' prefix on non-boolean option: %s", startToken)
155195
}
156196
v := "0"
@@ -171,8 +211,11 @@ func (s *OptionSet) Next(args []string, start int) (option *Option, value string
171211
}
172212
next = start + 1
173213
if optValue == nil {
174-
if option.BoolLike {
175-
v := "1"
214+
if !option.RequiresValue {
215+
v := ""
216+
if option.HasNegative {
217+
v = "1"
218+
}
176219
optValue = &v
177220
} else {
178221
if start+1 >= len(args) {
@@ -184,7 +227,7 @@ func (s *OptionSet) Next(args []string, start int) (option *Option, value string
184227
}
185228
}
186229
// Canonicalize boolean values.
187-
if option.BoolLike {
230+
if option.HasNegative {
188231
if *optValue == "false" || *optValue == "no" {
189232
*optValue = "0"
190233
} else if *optValue == "true" || *optValue == "yes" {
@@ -197,18 +240,26 @@ func (s *OptionSet) Next(args []string, start int) (option *Option, value string
197240
// formatoption returns a canonical representation of an option name=value
198241
// assignment as a single token.
199242
func formatOption(option *Option, value string) string {
200-
if option.BoolLike {
201-
// We use "--name" or "--noname" as the canonical representation for
202-
// bools, since these are the only formats allowed for startup options.
203-
// Subcommands like "build" and "run" do allow other formats like
204-
// "--name=true" or "--name=0", but we choose to stick with the lowest
205-
// common demoninator between subcommands and startup options here,
206-
// mainly to avoid confusion.
207-
if value == "1" {
208-
return "--" + option.Name
209-
}
243+
if option.RequiresValue {
244+
return "--" + option.Name + "=" + value
245+
}
246+
if !option.HasNegative {
247+
return "--" + option.Name
248+
}
249+
// We use "--name" or "--noname" as the canonical representation for
250+
// bools, since these are the only formats allowed for startup options.
251+
// Subcommands like "build" and "run" do allow other formats like
252+
// "--name=true" or "--name=0", but we choose to stick with the lowest
253+
// common demoninator between subcommands and startup options here,
254+
// mainly to avoid confusion.
255+
if value == "1" || value == "true" || value == "yes" || value == "" {
256+
return "--" + option.Name
257+
}
258+
if value == "0" || value == "false" || value == "no" {
210259
return "--no" + option.Name
211260
}
261+
// Account for flags that have negative forms, but also accept non-boolean
262+
// arguments, like `--subcommands=pretty_print`
212263
return "--" + option.Name + "=" + value
213264
}
214265

@@ -229,17 +280,16 @@ type Option struct {
229280
// Each occurrence of the flag value is accumulated in a list.
230281
Multi bool
231282

232-
// BoolLike specifies whether the flag uses Bazel's "boolean value syntax"
233-
// [1]. Options that are bool-like allow a "no" prefix to be used in order
234-
// to set the value to false.
235-
//
236-
// BoolLike flags are also parsed differently. Their name and value, if any,
237-
// must appear as a single token, which means the "=" syntax has to be used
238-
// when assigning a value. For example, "bazel build --subcommands false" is
239-
// actually equivalent to "bazel build --subcommands=true //false:false".
240-
//
241-
// [1]: https://github.com/bazelbuild/bazel/blob/824ecba998a573198c1fe07c8bf87ead680aae92/src/main/java/com/google/devtools/common/options/OptionDefinition.java#L255-L264
242-
BoolLike bool
283+
// HasNegative specifies whether the flag allows a "no" prefix" to be used in
284+
// order to set the value to false.
285+
HasNegative bool
286+
287+
// Flags that do not require a value must be parsed differently. Their name
288+
// and value, if any,must appear as a single token, which means the "=" syntax
289+
// has to be used when assigning a value. For example, "bazel build
290+
// --subcommands false" is actually equivalent to "bazel build
291+
// --subcommands=true //false:false".
292+
RequiresValue bool
243293
}
244294

245295
// BazelHelpFunc returns the output of "bazel help <topic>". This output is
@@ -279,10 +329,11 @@ func parseHelpLine(line, topic string) *Option {
279329
}
280330

281331
return &Option{
282-
Name: name,
283-
ShortName: shortName,
284-
Multi: multi,
285-
BoolLike: no != "" || description == "",
332+
Name: name,
333+
ShortName: shortName,
334+
Multi: multi,
335+
HasNegative: no != "",
336+
RequiresValue: no == "" && description != "",
286337
}
287338
}
288339

@@ -350,15 +401,111 @@ func (s *CommandLineSchema) CommandSupportsOpt(opt string) bool {
350401
return false
351402
}
352403

404+
// DecodeHelpFlagsAsProto takes the output of `bazel help flags-as-proto` and
405+
// returns the FlagCollection proto message it encodes.
406+
func DecodeHelpFlagsAsProto(protoHelp string) (*bfpb.FlagCollection, error) {
407+
b, err := base64.StdEncoding.DecodeString(protoHelp)
408+
if err != nil {
409+
return nil, err
410+
}
411+
flagCollection := &bfpb.FlagCollection{}
412+
if err := proto.Unmarshal(b, flagCollection); err != nil {
413+
return nil, err
414+
}
415+
return flagCollection, nil
416+
}
417+
418+
// GetOptionSetsFromProto takes a FlagCollection proto message, converts it into
419+
// Options, places each option into OptionSets based on the commands it
420+
// specifies (creating new OptionSets if necessary), and then returns a map
421+
// such that those OptionSets are keyed by the associated command (or "startup"
422+
// in the case of startup options).
423+
func GetOptionSetsfromProto(flagCollection *bfpb.FlagCollection) (map[string]*OptionSet, error) {
424+
sets := make(map[string]*OptionSet)
425+
for _, info := range flagCollection.FlagInfos {
426+
if info.GetName() == "bazelrc" {
427+
// `bazel help flags-as-proto` incorrectly reports `bazelrc` as not
428+
// allowing multiple values.
429+
// See https://github.com/bazelbuild/bazel/issues/24730 for more info.
430+
v := true
431+
info.AllowsMultiple = &v
432+
}
433+
if info.GetName() == "experimental_convenience_symlinks" || info.GetName() == "subcommands" {
434+
// `bazel help flags-as-proto` incorrectly reports
435+
// `experimental_convenience_symlinks` and `subcommands` as not
436+
// having negative forms.
437+
// See https://github.com/bazelbuild/bazel/issues/24882 for more info.
438+
v := true
439+
info.HasNegativeFlag = &v
440+
}
441+
if info.RequiresValue == nil {
442+
// If flags-as-proto does not support RequiresValue, mark flags with
443+
// negative forms and known expansion flags as not requiring values, and
444+
// mark all other flags as requiring values.
445+
if info.GetHasNegativeFlag() {
446+
v := false
447+
info.RequiresValue = &v
448+
} else if _, ok := preBazel7ExpansionOptions[info.GetName()]; ok {
449+
v := false
450+
info.RequiresValue = &v
451+
} else {
452+
v := true
453+
info.RequiresValue = &v
454+
}
455+
}
456+
o := &Option{
457+
Name: info.GetName(),
458+
ShortName: info.GetAbbreviation(),
459+
Multi: info.GetAllowsMultiple(),
460+
HasNegative: info.GetHasNegativeFlag(),
461+
RequiresValue: info.GetRequiresValue(),
462+
}
463+
for _, cmd := range info.GetCommands() {
464+
var set *OptionSet
465+
var ok bool
466+
if set, ok = sets[cmd]; !ok {
467+
set = &OptionSet{
468+
All: []*Option{},
469+
ByName: make(map[string]*Option),
470+
ByShortName: make(map[string]*Option),
471+
}
472+
sets[cmd] = set
473+
}
474+
set.All = append(set.All, o)
475+
set.ByName[o.Name] = o
476+
if o.ShortName != "" {
477+
set.ByShortName[o.ShortName] = o
478+
}
479+
}
480+
}
481+
return sets, nil
482+
}
483+
353484
// GetCommandLineSchema returns the effective CommandLineSchemas for the given
354485
// command line.
355486
func getCommandLineSchema(args []string, bazelHelp BazelHelpFunc, onlyStartupOptions bool) (*CommandLineSchema, error) {
356-
startupHelp, err := bazelHelp("startup_options")
357-
if err != nil {
358-
return nil, err
487+
var optionSets map[string]*OptionSet
488+
// try flags-as-proto first; fall back to parsing help if bazel version does not support it.
489+
if protoHelp, err := bazelHelp("flags-as-proto"); err == nil {
490+
flagCollection, err := DecodeHelpFlagsAsProto(protoHelp)
491+
if err != nil {
492+
return nil, err
493+
}
494+
sets, err := GetOptionSetsfromProto(flagCollection)
495+
if err != nil {
496+
return nil, err
497+
}
498+
optionSets = sets
359499
}
360-
schema := &CommandLineSchema{
361-
StartupOptions: parseBazelHelp(startupHelp, "startup_options"),
500+
schema := &CommandLineSchema{}
501+
if startupOptions, ok := optionSets["startup"]; ok {
502+
schema.StartupOptions = startupOptions
503+
} else {
504+
startupHelp, err := bazelHelp("startup_options")
505+
if err != nil {
506+
return nil, err
507+
}
508+
schema.StartupOptions = parseBazelHelp(startupHelp, "startup_options")
362509
}
363510
bazelCommands, err := BazelCommands()
364511
if err != nil {
@@ -394,11 +541,15 @@ func getCommandLineSchema(args []string, bazelHelp BazelHelpFunc, onlyStartupOpt
394541
if schema.Command == "" {
395542
return schema, nil
396543
}
397-
commandHelp, err := bazelHelp(schema.Command)
398-
if err != nil {
399-
return nil, err
544+
if commandOptions, ok := optionSets[schema.Command]; ok {
545+
schema.CommandOptions = commandOptions
546+
} else {
547+
commandHelp, err := bazelHelp(schema.Command)
548+
if err != nil {
549+
return nil, err
550+
}
551+
schema.CommandOptions = parseBazelHelp(commandHelp, schema.Command)
400552
}
401-
schema.CommandOptions = parseBazelHelp(commandHelp, schema.Command)
402553
return schema, nil
403554
}
404555

0 commit comments

Comments
 (0)