blob: a72d8deff71bd4e878416c65d9c8fea3ce8196e7 [file] [log] [blame]
// Copyright 2022 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package base contains code shared by other CLI subpackages.
package base
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/bazelbuild/buildtools/build"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/system/filesystem"
"go.chromium.org/luci/lucicfg/buildifier"
"go.chromium.org/luci/lucicfg/vars"
"google.golang.org/protobuf/encoding/prototext"
)
// ConfigName is the file name we will be used for lucicfg formatting
const ConfigName = ".lucicfgfmtrc"
// sentinel is used to prevent the walking functions in this package from walking
// across a source control boundary. As of 2023 Q1 we are only worried about Git
// repos, but should we ever support more VCS's and this walking code is still
// required (i.e. this hasn't been replaced with a WORKSPACE style config file),
// this should be extended.
var sentinel = []string{".git"}
// RewriterFactory is used to map from 'file to be formatted' to a Rewriter object,
// via its GetRewriter method.
//
// This struct is obtained via the GetRewriterFactory function.
type RewriterFactory struct {
rules []pathRules
configFilePath string
}
type pathRules struct {
path string // absolute path to the folder where this rules applies.
rules *buildifier.LucicfgFmtConfig_Rules
}
// CheckForBogusConfig will look for any config files contained in a subdirectory of entryPath
// (recursively).
//
// Because we intend for there to be at most one config file per workspace, and for that config
// file to be located at the root of the workspace, any such extra config files would be errors.
// Due to the 'stateless' nature of fmt and lint, we search down the directory hierarchy here to
// try to detect such misconfiguration, but in the future when these subcommands become
// stateful (like validate currently is), we may remove this check.
func CheckForBogusConfig(entryPath string) error {
// Traverse downwards
if err := filepath.WalkDir(entryPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip checking of entry path, downwards is exclusive
if d.IsDir() && path != entryPath {
if _, err := os.Stat(filepath.Join(path, ConfigName)); err == nil {
return errors.Reason(
"\nFound a config in a subdirectory<%s> of a star file."+
"Please move to the highest common ancestor directory - %s\n",
path,
entryPath).Err()
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}); err != nil {
return err
} else {
return nil
}
}
func findConfigPathUpwards(path string) (string, error) {
var currentDir = path
for {
if _, err := os.Stat(filepath.Join(currentDir, ConfigName)); err == nil {
return filepath.Join(currentDir, ConfigName), nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
} else {
var parent = filepath.Dir(currentDir)
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil || parent == currentDir {
return "", nil
}
currentDir = parent
}
}
}
func convertOrderingToTable(nameOrdering []string) map[string]int {
count := len(nameOrdering)
table := make(map[string]int, count)
// This sequentially gives the names a priority value in the range
// [-count, 0). This ensures that all names have distinct priority
// values that sort them in the specified order. Since all priority
// values are less than the default 0, all names present in the
// ordering will sort before names that don't appear in the ordering.
for i, n := range nameOrdering {
table[n] = i - count
}
return table
}
func rewriterFromConfig(nameOrdering map[string]int) *build.Rewriter {
var rewriter = vars.GetDefaultRewriter()
if nameOrdering != nil {
rewriter.NamePriority = nameOrdering
rewriter.RewriteSet = append(rewriter.RewriteSet, "callsort")
}
return rewriter
}
// GetRewriterFactory will attempt to create a RewriterFactory object
//
// If configPath is empty, or points to a file which doesn't exist, the returned
// factory will just produce GetDefaultRewriter() when asked about any path.
// We will return an error if the config file is invalid.
func GetRewriterFactory(configPath string) (rewriterFactory *RewriterFactory, err error) {
rewriterFactory = &RewriterFactory{
rules: []pathRules{},
configFilePath: "",
}
if configPath == "" {
return
}
contents, err := os.ReadFile(configPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Printf("Failed on reading file - %s", configPath)
return nil, err
} else {
return rewriterFactory, nil
}
}
luci := &buildifier.LucicfgFmtConfig{}
if err := prototext.Unmarshal(contents, luci); err != nil {
return nil, err
}
return getPostProcessedRewriterFactory(configPath, luci)
}
// getPostProcessedRewriterFactory will contain all logic used to make sure
// RewriterFactory is normalized the way we want.
//
// Currently, we will fix paths so that they are absolute.
// We will also perform a check so that there are no duplicate paths and
// all paths are delimited with "/"
func getPostProcessedRewriterFactory(configPath string, cfg *buildifier.LucicfgFmtConfig) (*RewriterFactory, error) {
pathSet := stringset.New(0)
rules := cfg.Rules
rulesSlice := make([]pathRules, 0)
for ruleIndex, rule := range rules {
// If a rule doesn't have any paths, err out and notify users
if len(rule.Path) == 0 {
return nil, errors.Reason(
"rule[%d]: Does not contain any paths",
ruleIndex).Err()
}
for rulePathIndex, pathInDir := range rule.Path {
// Fix paths. Update to use absolute path.
fixedPathInDir := filepath.Clean(
filepath.Join(filepath.Dir(configPath), pathInDir),
)
// Check for duplicate paths. If there is, return error
if pathSet.Contains(stringset.NewFromSlice(fixedPathInDir)) {
return nil, errors.Reason(
"rule[%d].path[%d]: Found duplicate path '%s'",
ruleIndex, rulePathIndex, pathInDir).Err()
}
// Check for backslash in path, if there is, return error
if strings.Contains(pathInDir, "\\") {
return nil, errors.Reason(
"rule[%d].path[%d]: Path should not contain backslash '%s'",
ruleIndex, rulePathIndex, pathInDir).Err()
}
// Add into set to check later if duplicate
pathSet.Add(fixedPathInDir)
if fixedPathInDirAbs, err := filepath.Abs(fixedPathInDir); err != nil {
return nil, errors.Annotate(err, "rule[%d].path[%d]: filepath.Abs error %s",
ruleIndex, rulePathIndex, pathInDir).Err()
} else {
fixedPathInDir = fixedPathInDirAbs
}
rulesSlice = append(rulesSlice, pathRules{
fixedPathInDir,
rule,
})
}
}
return &RewriterFactory{
rulesSlice,
filepath.Dir(configPath),
}, nil
}
// GetRewriter will return the Rewriter which is appropriate for formatting
// the file at `path`, using the previously loaded formatting configuration.
//
// Note the method signature will pass in values that we need to evaluate
// the correct rewriter.
//
// We will accept both relative and absolute paths.
func (f *RewriterFactory) GetRewriter(path string) (*build.Rewriter, error) {
rules := f.rules
// Check if path is abs, if not, fix it
if !filepath.IsAbs(path) {
return nil, errors.Reason("GetRewriter got non-absolute path: %q", path).Err()
}
longestPathMatch := ""
var matchingRule *buildifier.LucicfgFmtConfig_Rules
// Find the path that best matches the one we are processing.
for _, rule := range rules {
commonAncestor, err := filesystem.GetCommonAncestor(
[]string{rule.path, path},
sentinel,
)
if err != nil {
return nil, err
}
commonAncestor = filepath.Clean(commonAncestor)
if commonAncestor == rule.path && len(commonAncestor) > len(longestPathMatch) {
longestPathMatch = commonAncestor
matchingRule = rule.rules
}
}
if matchingRule != nil && matchingRule.FunctionArgsSort != nil {
return rewriterFromConfig(
convertOrderingToTable(matchingRule.FunctionArgsSort.Arg),
), nil
}
return vars.GetDefaultRewriter(), nil
}
// GuessRewriterFactoryFunc will find the common ancestor dir from all given paths
// and return a func that returns the rewriter factory.
//
// Will look for a config file upwards(inclusive). If found, it will be used to determine
// rewriter properties. It will also look downwards(exclusive) to expose any misplaced
// config files.
func GuessRewriterFactoryFunc(paths []string) (*RewriterFactory, error) {
// Find the common ancestor
commonAncestorPath, err := filesystem.GetCommonAncestor(paths, sentinel)
if errors.Is(err, filesystem.ErrRootSentinel) {
// we hit the repo root, just return function that returns default rewriter
rewriterFactory, err := GetRewriterFactory("")
if err != nil {
return nil, err
}
return rewriterFactory, nil
}
if err != nil {
// other errors are fatal
return nil, err
}
if err := CheckForBogusConfig(commonAncestorPath); err != nil {
return nil, err
}
luciConfigPath, err := findConfigPathUpwards(commonAncestorPath)
if err != nil {
return nil, err
}
rewriterFactory, err := GetRewriterFactory(luciConfigPath)
if err != nil {
return nil, err
}
return rewriterFactory, nil
}