go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/cli/base/config.go (about)

     1  // Copyright 2022 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 base contains code shared by other CLI subpackages.
    16  package base
    17  
    18  import (
    19  	"fmt"
    20  	"io/fs"
    21  	"os"
    22  	"path/filepath"
    23  	"strings"
    24  
    25  	"github.com/bazelbuild/buildtools/build"
    26  	"go.chromium.org/luci/common/data/stringset"
    27  	"go.chromium.org/luci/common/errors"
    28  	"go.chromium.org/luci/common/system/filesystem"
    29  	"go.chromium.org/luci/lucicfg/buildifier"
    30  	"go.chromium.org/luci/lucicfg/vars"
    31  	"google.golang.org/protobuf/encoding/prototext"
    32  )
    33  
    34  // ConfigName is the file name we will be used for lucicfg formatting
    35  const ConfigName = ".lucicfgfmtrc"
    36  
    37  // sentinel is used to prevent the walking functions in this package from walking
    38  // across a source control boundary. As of 2023 Q1 we are only worried about Git
    39  // repos, but should we ever support more VCS's and this walking code is still
    40  // required (i.e. this hasn't been replaced with a WORKSPACE style config file),
    41  // this should be extended.
    42  var sentinel = []string{".git"}
    43  
    44  // RewriterFactory is used to map from 'file to be formatted' to a Rewriter object,
    45  // via its GetRewriter method.
    46  //
    47  // This struct is obtained via the GetRewriterFactory function.
    48  type RewriterFactory struct {
    49  	rules          []pathRules
    50  	configFilePath string
    51  }
    52  
    53  type pathRules struct {
    54  	path  string // absolute path to the folder where this rules applies.
    55  	rules *buildifier.LucicfgFmtConfig_Rules
    56  }
    57  
    58  // CheckForBogusConfig will look for any config files contained in a subdirectory of entryPath
    59  // (recursively).
    60  //
    61  // Because we intend for there to be at most one config file per workspace, and for that config
    62  // file to be located at the root of the workspace, any such extra config files would be errors.
    63  // Due to the 'stateless' nature of fmt and lint, we search down the directory hierarchy here to
    64  // try to detect such misconfiguration, but in the future when these subcommands become
    65  // stateful (like validate currently is), we may remove this check.
    66  func CheckForBogusConfig(entryPath string) error {
    67  	// Traverse downwards
    68  	if err := filepath.WalkDir(entryPath, func(path string, d fs.DirEntry, err error) error {
    69  		if err != nil {
    70  			return err
    71  		}
    72  		// Skip checking of entry path, downwards is exclusive
    73  		if d.IsDir() && path != entryPath {
    74  			if _, err := os.Stat(filepath.Join(path, ConfigName)); err == nil {
    75  				return errors.Reason(
    76  					"\nFound a config in a subdirectory<%s> of a star file."+
    77  						"Please move to the highest common ancestor directory - %s\n",
    78  					path,
    79  					entryPath).Err()
    80  			} else if !errors.Is(err, os.ErrNotExist) {
    81  				return err
    82  			}
    83  		}
    84  		return nil
    85  	}); err != nil {
    86  		return err
    87  	} else {
    88  		return nil
    89  	}
    90  }
    91  
    92  func findConfigPathUpwards(path string) (string, error) {
    93  	var currentDir = path
    94  	for {
    95  		if _, err := os.Stat(filepath.Join(currentDir, ConfigName)); err == nil {
    96  			return filepath.Join(currentDir, ConfigName), nil
    97  		} else if !errors.Is(err, os.ErrNotExist) {
    98  			return "", err
    99  		} else {
   100  			var parent = filepath.Dir(currentDir)
   101  
   102  			if _, err := os.Stat(filepath.Join(path, ".git")); err == nil || parent == currentDir {
   103  				return "", nil
   104  			}
   105  
   106  			currentDir = parent
   107  		}
   108  	}
   109  }
   110  
   111  func convertOrderingToTable(nameOrdering []string) map[string]int {
   112  	count := len(nameOrdering)
   113  	table := make(map[string]int, count)
   114  	// This sequentially gives the names a priority value in the range
   115  	// [-count, 0). This ensures that all names have distinct priority
   116  	// values that sort them in the specified order. Since all priority
   117  	// values are less than the default 0, all names present in the
   118  	// ordering will sort before names that don't appear in the ordering.
   119  	for i, n := range nameOrdering {
   120  		table[n] = i - count
   121  	}
   122  	return table
   123  }
   124  
   125  func rewriterFromConfig(nameOrdering map[string]int) *build.Rewriter {
   126  	var rewriter = vars.GetDefaultRewriter()
   127  	if nameOrdering != nil {
   128  		rewriter.NamePriority = nameOrdering
   129  		rewriter.RewriteSet = append(rewriter.RewriteSet, "callsort")
   130  	}
   131  	return rewriter
   132  }
   133  
   134  // GetRewriterFactory will attempt to create a RewriterFactory object
   135  //
   136  // If configPath is empty, or points to a file which doesn't exist, the returned
   137  // factory will just produce GetDefaultRewriter() when asked about any path.
   138  // We will return an error if the config file is invalid.
   139  func GetRewriterFactory(configPath string) (rewriterFactory *RewriterFactory, err error) {
   140  	rewriterFactory = &RewriterFactory{
   141  		rules:          []pathRules{},
   142  		configFilePath: "",
   143  	}
   144  	if configPath == "" {
   145  		return
   146  	}
   147  	contents, err := os.ReadFile(configPath)
   148  	if err != nil {
   149  		if !errors.Is(err, os.ErrNotExist) {
   150  			fmt.Printf("Failed on reading file - %s", configPath)
   151  			return nil, err
   152  		} else {
   153  			return rewriterFactory, nil
   154  		}
   155  	}
   156  	luci := &buildifier.LucicfgFmtConfig{}
   157  
   158  	if err := prototext.Unmarshal(contents, luci); err != nil {
   159  		return nil, err
   160  	}
   161  	return getPostProcessedRewriterFactory(configPath, luci)
   162  }
   163  
   164  // getPostProcessedRewriterFactory will contain all logic used to make sure
   165  // RewriterFactory is normalized the way we want.
   166  //
   167  // Currently, we will fix paths so that they are absolute.
   168  // We will also perform a check so that there are no duplicate paths and
   169  // all paths are delimited with "/"
   170  func getPostProcessedRewriterFactory(configPath string, cfg *buildifier.LucicfgFmtConfig) (*RewriterFactory, error) {
   171  	pathSet := stringset.New(0)
   172  	rules := cfg.Rules
   173  	rulesSlice := make([]pathRules, 0)
   174  	for ruleIndex, rule := range rules {
   175  		// If a rule doesn't have any paths, err out and notify users
   176  		if len(rule.Path) == 0 {
   177  			return nil, errors.Reason(
   178  				"rule[%d]: Does not contain any paths",
   179  				ruleIndex).Err()
   180  		}
   181  		for rulePathIndex, pathInDir := range rule.Path {
   182  			// Fix paths. Update to use absolute path.
   183  			fixedPathInDir := filepath.Clean(
   184  				filepath.Join(filepath.Dir(configPath), pathInDir),
   185  			)
   186  			// Check for duplicate paths. If there is, return error
   187  			if pathSet.Contains(stringset.NewFromSlice(fixedPathInDir)) {
   188  				return nil, errors.Reason(
   189  					"rule[%d].path[%d]: Found duplicate path '%s'",
   190  					ruleIndex, rulePathIndex, pathInDir).Err()
   191  			}
   192  			// Check for backslash in path, if there is, return error
   193  			if strings.Contains(pathInDir, "\\") {
   194  				return nil, errors.Reason(
   195  					"rule[%d].path[%d]: Path should not contain backslash '%s'",
   196  					ruleIndex, rulePathIndex, pathInDir).Err()
   197  			}
   198  			// Add into set to check later if duplicate
   199  			pathSet.Add(fixedPathInDir)
   200  			if fixedPathInDirAbs, err := filepath.Abs(fixedPathInDir); err != nil {
   201  				return nil, errors.Annotate(err, "rule[%d].path[%d]: filepath.Abs error %s",
   202  					ruleIndex, rulePathIndex, pathInDir).Err()
   203  			} else {
   204  				fixedPathInDir = fixedPathInDirAbs
   205  			}
   206  
   207  			rulesSlice = append(rulesSlice, pathRules{
   208  				fixedPathInDir,
   209  				rule,
   210  			})
   211  		}
   212  	}
   213  
   214  	return &RewriterFactory{
   215  		rulesSlice,
   216  		filepath.Dir(configPath),
   217  	}, nil
   218  }
   219  
   220  // GetRewriter will return the Rewriter which is appropriate for formatting
   221  // the file at `path`, using the previously loaded formatting configuration.
   222  //
   223  // Note the method signature will pass in values that we need to evaluate
   224  // the correct rewriter.
   225  //
   226  // We will accept both relative and absolute paths.
   227  func (f *RewriterFactory) GetRewriter(path string) (*build.Rewriter, error) {
   228  	rules := f.rules
   229  	// Check if path is abs, if not, fix it
   230  	if !filepath.IsAbs(path) {
   231  		return nil, errors.Reason("GetRewriter got non-absolute path: %q", path).Err()
   232  	}
   233  	longestPathMatch := ""
   234  	var matchingRule *buildifier.LucicfgFmtConfig_Rules
   235  
   236  	// Find the path that best matches the one we are processing.
   237  	for _, rule := range rules {
   238  		commonAncestor, err := filesystem.GetCommonAncestor(
   239  			[]string{rule.path, path},
   240  			sentinel,
   241  		)
   242  
   243  		if err != nil {
   244  			return nil, err
   245  		}
   246  
   247  		commonAncestor = filepath.Clean(commonAncestor)
   248  		if commonAncestor == rule.path && len(commonAncestor) > len(longestPathMatch) {
   249  			longestPathMatch = commonAncestor
   250  			matchingRule = rule.rules
   251  		}
   252  	}
   253  	if matchingRule != nil && matchingRule.FunctionArgsSort != nil {
   254  		return rewriterFromConfig(
   255  			convertOrderingToTable(matchingRule.FunctionArgsSort.Arg),
   256  		), nil
   257  	}
   258  
   259  	return vars.GetDefaultRewriter(), nil
   260  }
   261  
   262  // GuessRewriterFactoryFunc will find the common ancestor dir from all given paths
   263  // and return a func that returns the rewriter factory.
   264  //
   265  // Will look for a config file upwards(inclusive). If found, it will be used to determine
   266  // rewriter properties. It will also look downwards(exclusive) to expose any misplaced
   267  // config files.
   268  func GuessRewriterFactoryFunc(paths []string) (*RewriterFactory, error) {
   269  	// Find the common ancestor
   270  	commonAncestorPath, err := filesystem.GetCommonAncestor(paths, sentinel)
   271  
   272  	if errors.Is(err, filesystem.ErrRootSentinel) {
   273  		// we hit the repo root, just return function that returns default rewriter
   274  		rewriterFactory, err := GetRewriterFactory("")
   275  		if err != nil {
   276  			return nil, err
   277  		}
   278  		return rewriterFactory, nil
   279  	}
   280  	if err != nil {
   281  		// other errors are fatal
   282  		return nil, err
   283  	}
   284  	if err := CheckForBogusConfig(commonAncestorPath); err != nil {
   285  		return nil, err
   286  	}
   287  
   288  	luciConfigPath, err := findConfigPathUpwards(commonAncestorPath)
   289  	if err != nil {
   290  		return nil, err
   291  	}
   292  	rewriterFactory, err := GetRewriterFactory(luciConfigPath)
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  
   297  	return rewriterFactory, nil
   298  }