go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/buildifier/buildifier.go (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package buildifier implements processing of Starlark files via buildifier. 16 // 17 // Buildifier is primarily intended for Bazel files. We try to disable as much 18 // of Bazel-specific logic as possible, keeping only generally useful 19 // Starlark rules. 20 package buildifier 21 22 import ( 23 "bytes" 24 "fmt" 25 "runtime" 26 "strings" 27 "sync" 28 29 "github.com/bazelbuild/buildtools/build" 30 "github.com/bazelbuild/buildtools/warn" 31 32 "go.chromium.org/luci/common/data/stringset" 33 "go.chromium.org/luci/common/errors" 34 "go.chromium.org/luci/common/sync/parallel" 35 "go.chromium.org/luci/lucicfg/vars" 36 "go.chromium.org/luci/starlark/interpreter" 37 ) 38 39 var ( 40 // ErrActionableFindings is returned by Lint if there are actionable findings. 41 ErrActionableFindings = errors.New("some *.star files have linter warnings, please fix them") 42 ) 43 44 // formattingCategory is linter check to represent `lucicfg fmt` checks. 45 // 46 // It's not a real buildifier category, we should be careful not to pass it to 47 // warn.FileWarnings. 48 const formattingCategory = "formatting" 49 50 // Finding is information about one linting or formatting error. 51 // 52 // Implements error interface. Non-actionable findings are assumed to be 53 // non-blocking errors. 54 type Finding struct { 55 Path string `json:"path"` 56 Start *Position `json:"start,omitempty"` 57 End *Position `json:"end,omitempty"` 58 Category string `json:"string,omitempty"` 59 Message string `json:"message,omitempty"` 60 Actionable bool `json:"actionable,omitempty"` 61 } 62 63 // Position indicates a position within a file. 64 type Position struct { 65 Line int `json:"line"` // starting from 1 66 Column int `json:"column"` // in runes, starting from 1 67 Offset int `json:"offset"` // absolute offset in bytes 68 } 69 70 // Error returns a short summary of the finding. 71 func (f *Finding) Error() string { 72 switch { 73 case f.Path == "": 74 return f.Category 75 case f.Start == nil: 76 return fmt.Sprintf("%s: %s", f.Path, f.Category) 77 default: 78 return fmt.Sprintf("%s:%d: %s", f.Path, f.Start.Line, f.Category) 79 } 80 } 81 82 // Format returns a detailed reported that can be printed to stderr. 83 func (f *Finding) Format() string { 84 if strings.ContainsRune(f.Message, '\n') { 85 return fmt.Sprintf("%s: %s\n\n", f.Error(), f.Message) 86 } else { 87 return fmt.Sprintf("%s: %s\n", f.Error(), f.Message) 88 } 89 } 90 91 // Lint applies linting and formatting checks to the given files. 92 // 93 // getRewriterForPath should return a Rewriter, given the path which 94 // needs linting. This will be used to check the 'format' lint check. 95 // If getRewriterForPath is nil, we will use vars.GetDefaultRewriter for 96 // this. 97 // 98 // Returns all findings and a non-nil error (usually a MultiError) if some 99 // findings are blocking. 100 func Lint(loader interpreter.Loader, paths []string, lintChecks []string, getRewriterForPath func(path string) (*build.Rewriter, error)) (findings []*Finding, err error) { 101 checks, err := normalizeLintChecks(lintChecks) 102 if err != nil { 103 return nil, err 104 } 105 106 if getRewriterForPath == nil { 107 getRewriterForPath = func(path string) (*build.Rewriter, error) { 108 return vars.GetDefaultRewriter(), nil 109 } 110 } 111 112 // Transform unrecognized linter checks into warning-level findings. 113 allPossible := allChecks() 114 buildifierWarns := make([]string, 0, checks.Len()) 115 checkFmt := false 116 for _, check := range checks.ToSortedSlice() { 117 switch { 118 case !allPossible.Has(check): 119 findings = append(findings, &Finding{ 120 Category: "linter", 121 Message: fmt.Sprintf("Unknown linter check %q", check), 122 }) 123 case check == formattingCategory: 124 checkFmt = true 125 default: 126 buildifierWarns = append(buildifierWarns, check) 127 } 128 } 129 130 if len(paths) == 0 || (!checkFmt && len(buildifierWarns) == 0) { 131 return findings, nil 132 } 133 134 errs := Visit(loader, paths, func(path string, body []byte, f *build.File) (merr errors.MultiError) { 135 if len(buildifierWarns) != 0 { 136 findings := warn.FileWarnings(f, buildifierWarns, nil, warn.ModeWarn, newFileReader(loader)) 137 for _, f := range findings { 138 merr = append(merr, &Finding{ 139 Path: path, 140 Start: &Position{ 141 Line: f.Start.Line, 142 Column: f.Start.LineRune, 143 Offset: f.Start.Byte, 144 }, 145 End: &Position{ 146 Line: f.End.Line, 147 Column: f.End.LineRune, 148 Offset: f.End.Byte, 149 }, 150 Category: f.Category, 151 Message: f.Message, 152 Actionable: f.Actionable, 153 }) 154 } 155 } 156 157 rewriter, err := getRewriterForPath(f.Path) 158 if err != nil { 159 return errors.MultiError{err} 160 } 161 162 if checkFmt && !bytes.Equal(build.FormatWithRewriter(rewriter, f), body) { 163 merr = append(merr, &Finding{ 164 Path: path, 165 Category: formattingCategory, 166 Message: `The file is not properly formatted, use 'lucicfg fmt' to format it.`, 167 Actionable: true, 168 }) 169 } 170 return merr 171 }) 172 if len(errs) == 0 { 173 return findings, nil 174 } 175 176 // Extract findings into a dedicated slice. Return an overall error if there 177 // are actionable findings. 178 filtered := errs[:0] 179 actionable := false 180 for _, err := range errs { 181 if f, ok := err.(*Finding); ok { 182 findings = append(findings, f) 183 if f.Actionable { 184 actionable = true 185 } 186 } else { 187 filtered = append(filtered, err) 188 } 189 } 190 if actionable { 191 filtered = append(filtered, ErrActionableFindings) 192 } 193 194 if len(filtered) == 0 { 195 return findings, nil 196 } 197 return findings, filtered 198 } 199 200 // Visitor processes a parsed Starlark file, returning all errors encountered 201 // when processing it. 202 type Visitor func(path string, body []byte, f *build.File) errors.MultiError 203 204 // Visit parses Starlark files using Buildifier and calls the callback for each 205 // parsed file, in parallel. 206 // 207 // Collects all errors from all callbacks in a single joint multi-error. 208 func Visit(loader interpreter.Loader, paths []string, v Visitor) errors.MultiError { 209 210 m := sync.Mutex{} 211 perPath := make(map[string]errors.MultiError, len(paths)) 212 213 parallel.WorkPool(runtime.NumCPU(), func(tasks chan<- func() error) { 214 for _, path := range paths { 215 path := path 216 tasks <- func() error { 217 var errs []error 218 switch body, f, err := parseFile(loader, path); { 219 case err != nil: 220 errs = []error{err} 221 case f != nil: 222 errs = v(path, body, f) 223 } 224 m.Lock() 225 perPath[path] = errs 226 m.Unlock() 227 return nil 228 } 229 } 230 }) 231 232 // Assemble errors in original order. 233 var errs errors.MultiError 234 for _, path := range paths { 235 errs = append(errs, perPath[path]...) 236 } 237 return errs 238 } 239 240 // parseFile parses a Starlark module using the buildifier parser. 241 // 242 // Returns (nil, nil, nil) if the module is a native Go module. 243 func parseFile(loader interpreter.Loader, path string) ([]byte, *build.File, error) { 244 switch dict, src, err := loader(path); { 245 case err != nil: 246 return nil, nil, err 247 case dict != nil: 248 return nil, nil, nil 249 default: 250 body := []byte(src) 251 f, err := build.ParseDefault(path, body) 252 if f != nil { 253 f.Type = build.TypeDefault // always generic Starlark file, not a BUILD 254 f.Label = path // lucicfg loader paths ~= map to Bazel labels 255 } 256 return body, f, err 257 } 258 } 259 260 // newFileReader returns a warn.FileReader based on the loader. 261 // 262 // Note: *warn.FileReader doesn't protect its caching guts with any locks so we 263 // can't share a single copy across multiple goroutines. 264 func newFileReader(loader interpreter.Loader) *warn.FileReader { 265 return warn.NewFileReader(func(path string) ([]byte, error) { 266 switch dict, src, err := loader(path); { 267 case err != nil: 268 return nil, err 269 case dict != nil: 270 return nil, nil // skip native modules 271 default: 272 return []byte(src), nil 273 } 274 }) 275 } 276 277 // normalizeLintChecks replaces `all` with an explicit list of checks and does 278 // other similar transformations. 279 // 280 // Checks has a form ["<optional initial category>", "+warn", "-warn", ...]. 281 // Where <optional initial category> can be `none`, `default` or `all`. 282 // 283 // Doesn't check all added checks are actually defined. 284 func normalizeLintChecks(checks []string) (stringset.Set, error) { 285 if len(checks) == 0 { 286 checks = []string{"default"} 287 } 288 289 var set stringset.Set 290 if cat := checks[0]; !strings.HasPrefix(cat, "+") && !strings.HasPrefix(cat, "-") { 291 switch cat { 292 case "none": 293 set = stringset.New(0) 294 case "all": 295 set = allChecks() 296 case "default": 297 set = defaultChecks() 298 default: 299 return nil, fmt.Errorf( 300 `unrecognized linter checks category %q: must be one of "none", "all", "default" `+ 301 `(if you want to enable individual checks, use "+name" syntax)`, cat) 302 } 303 checks = checks[1:] 304 } else { 305 set = defaultChecks() 306 } 307 308 for _, check := range checks { 309 switch { 310 case strings.HasPrefix(check, "+"): 311 set.Add(check[1:]) 312 case strings.HasPrefix(check, "-"): 313 set.Del(check[1:]) 314 default: 315 return nil, fmt.Errorf(`use "+name" to enable a check or "-name" to disable it, got %q instead`, check) 316 } 317 } 318 319 return set, nil 320 } 321 322 func allChecks() stringset.Set { 323 s := stringset.NewFromSlice(warn.AllWarnings...) 324 s.Add(formattingCategory) 325 return s 326 } 327 328 func defaultChecks() stringset.Set { 329 s := stringset.NewFromSlice(warn.DefaultWarnings...) 330 s.Add(formattingCategory) 331 s.Del("load-on-top") // order of loads may matter in lucicfg 332 s.Del("uninitialized") // this check doesn't work well with lambdas and inner functions 333 return s 334 }