github.com/blend/go-sdk@v1.20220411.3/profanity/profanity.go (about)

     1  /*
     2  
     3  Copyright (c) 2022 - Present. Blend Labs, Inc. All rights reserved
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file.
     5  
     6  */
     7  
     8  package profanity
     9  
    10  import (
    11  	"fmt"
    12  	"io"
    13  	"os"
    14  	"path/filepath"
    15  	"strings"
    16  
    17  	"gopkg.in/yaml.v3"
    18  
    19  	"github.com/blend/go-sdk/ansi"
    20  	"github.com/blend/go-sdk/ex"
    21  )
    22  
    23  // New creates a new profanity engine with a given set of config options.
    24  func New(options ...Option) *Profanity {
    25  	var p Profanity
    26  	for _, option := range options {
    27  		option(&p)
    28  	}
    29  	return &p
    30  }
    31  
    32  // Profanity parses rules from the filesystem and applies them to a given root path.
    33  // Creating a full rules set.
    34  type Profanity struct {
    35  	Config Config
    36  	Stdout io.Writer
    37  	Stderr io.Writer
    38  }
    39  
    40  // Process processes the profanity rules.
    41  func (p *Profanity) Process() error {
    42  	p.Verbosef("using rules file: %q", p.Config.RulesFileOrDefault())
    43  	if ruleFilter := p.Config.Rules.String(); ruleFilter != "" {
    44  		p.Verbosef("using rule filter: %s", ruleFilter)
    45  	}
    46  	if fileFilter := p.Config.Files.String(); fileFilter != "" {
    47  		p.Verbosef("using file filter: %s", fileFilter)
    48  	}
    49  	if dirFilter := p.Config.Dirs.String(); dirFilter != "" {
    50  		p.Verbosef("using dir filter: %s", dirFilter)
    51  	}
    52  	err := p.Walk(p.Config.RootOrDefault())
    53  	if err != nil {
    54  		p.Verbosef("profanity %s!", ansi.Red("failed"))
    55  		return err
    56  	}
    57  	p.Verbosef("profanity %s!", ansi.Green("ok"))
    58  	return nil
    59  }
    60  
    61  // Walk walks a given path, inheriting a set of rules.
    62  func (p *Profanity) Walk(path string, rules ...RuleSpec) error {
    63  	dirs, files, err := ListDir(path)
    64  	if err != nil {
    65  		return ex.New("profanity; invalid walk path", ex.OptMessagef("path: %q", path), ex.OptInner(err))
    66  	}
    67  
    68  	var didFail bool
    69  	var fullFilePath string
    70  	for _, file := range files {
    71  		if file.Name() == p.Config.RulesFileOrDefault() {
    72  			fullFilePath = filepath.Join(path, file.Name())
    73  			p.Debugf("reading rules file: %q", filepath.Join(path, fullFilePath))
    74  			foundRules, err := p.ReadRuleSpecsFile(fullFilePath)
    75  			if err != nil {
    76  				return err
    77  			}
    78  			rules = append(rules, foundRules...)
    79  		}
    80  	}
    81  
    82  	for _, file := range files {
    83  		if file.Name() == p.Config.RulesFileOrDefault() {
    84  			continue
    85  		}
    86  
    87  		fullFilePath = filepath.Join(path, file.Name())
    88  		if p.Config.Files.Allow(fullFilePath) {
    89  			contents, err := os.ReadFile(fullFilePath)
    90  			if err != nil {
    91  				return err
    92  			}
    93  			for _, rule := range rules {
    94  				if p.Config.Rules.Allow(rule.ID) {
    95  					if rule.Files.Allow(fullFilePath) {
    96  						p.Debugf("%s; checking %s", rule.ID, fullFilePath)
    97  						res := rule.Check(fullFilePath, contents)
    98  						if res.Err != nil {
    99  							return res.Err
   100  						}
   101  						if !res.OK {
   102  							didFail = true
   103  							p.Errorf("%v\n", p.FormatRuleResultFailure(rule, res))
   104  							if p.Config.ExitFirstOrDefault() {
   105  								return ErrFailure
   106  							}
   107  						}
   108  					}
   109  				}
   110  			}
   111  		}
   112  	}
   113  
   114  	var fullDirPath string
   115  	for _, dir := range dirs {
   116  		if dir.Name() == ".git" {
   117  			continue
   118  		}
   119  		if strings.HasPrefix(dir.Name(), "_") {
   120  			continue
   121  		}
   122  		fullDirPath = filepath.Join(path, dir.Name())
   123  		if p.Config.Dirs.Allow(fullDirPath) {
   124  			if err := p.Walk(fullDirPath, rules...); err != nil {
   125  				if err != ErrFailure || p.Config.ExitFirstOrDefault() {
   126  					return err
   127  				}
   128  				didFail = true
   129  			}
   130  		}
   131  	}
   132  
   133  	if didFail {
   134  		return ErrFailure
   135  	}
   136  	return nil
   137  }
   138  
   139  // ReadRuleSpecsFile reads rules from a file path.
   140  //
   141  // It is expected to be passed the fully qualified path for the rules file.
   142  func (p *Profanity) ReadRuleSpecsFile(filename string) (rules []RuleSpec, err error) {
   143  	contents, readErr := os.Open(filename)
   144  	if readErr != nil {
   145  		err = ex.New(readErr, ex.OptMessagef("file: %s", filename))
   146  		return
   147  	}
   148  	defer contents.Close()
   149  	rules, err = p.ReadRuleSpecsFromReader(filename, contents)
   150  	return
   151  }
   152  
   153  // ReadRuleSpecsFromReader reads rules from a reader.
   154  func (p *Profanity) ReadRuleSpecsFromReader(filename string, reader io.Reader) (rules []RuleSpec, err error) {
   155  	fileRules := make(RuleSpecFile)
   156  	decoder := yaml.NewDecoder(reader)
   157  	decoder.KnownFields(true)
   158  	yamlErr := decoder.Decode(&fileRules)
   159  	if yamlErr != nil {
   160  		err = ex.New("cannot unmarshal rules file", ex.OptMessagef("file: %s", filename), ex.OptInnerClass(yamlErr))
   161  		return
   162  	}
   163  	for _, rule := range fileRules.Rules() {
   164  		rule.SourceFile = filename
   165  		if validationErr := rule.Validate(); validationErr != nil {
   166  			p.Debugf("rule file %q fails validation", filename)
   167  			err = validationErr
   168  			return
   169  		}
   170  		rules = append(rules, rule)
   171  	}
   172  	return
   173  }
   174  
   175  // FormatRuleResultFailure formats a rule result with the rule that produced it.
   176  func (p Profanity) FormatRuleResultFailure(r RuleSpec, rr RuleResult) error {
   177  	if rr.OK {
   178  		return nil
   179  	}
   180  	var lines []string
   181  	lines = append(lines, fmt.Sprintf("%s:%d", ansi.Bold(ansi.ColorWhite, rr.File), rr.Line))
   182  	lines = append(lines, fmt.Sprintf("\t%s: %s", ansi.LightBlack("id"), r.ID))
   183  	if r.Description != "" {
   184  		lines = append(lines, fmt.Sprintf("\t%s: %s", ansi.LightBlack("description"), r.Description))
   185  	}
   186  	lines = append(lines, fmt.Sprintf("\t%s: %s", ansi.LightBlack("status"), ansi.Red("failed")))
   187  	lines = append(lines, fmt.Sprintf("\t%s: %s", ansi.LightBlack("rule"), rr.Message))
   188  	return fmt.Errorf(strings.Join(lines, "\n"))
   189  }
   190  
   191  // Verbosef prints a verbose message.
   192  func (p *Profanity) Verbosef(format string, args ...interface{}) {
   193  	if p.Config.VerboseOrDefault() {
   194  		p.Printf("[VERBOSE] "+format+"\n", args...)
   195  	}
   196  }
   197  
   198  // Debugf prints a debug message.
   199  func (p *Profanity) Debugf(format string, args ...interface{}) {
   200  	if p.Config.DebugOrDefault() {
   201  		p.Printf("[DEBUG] "+format+"\n", args...)
   202  	}
   203  }
   204  
   205  // Printf writes to the output stream.
   206  func (p *Profanity) Printf(format string, args ...interface{}) {
   207  	if p.Stdout != nil {
   208  		fmt.Fprintf(p.Stdout, format, args...)
   209  	}
   210  }
   211  
   212  // Errorf writes to the error output stream.
   213  func (p *Profanity) Errorf(format string, args ...interface{}) {
   214  	if p.Stderr != nil {
   215  		fmt.Fprintf(p.Stderr, format, args...)
   216  	}
   217  }