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  }