diff --git a/README.md b/README.md index 8950522..070fbaf 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ usage: codeowners ... -h, --help show this help message -o, --owner strings filter results by owner -u, --unowned only show unowned files (can be combined with -o) + -c, --check exit with a non-zero status code if unowned files exist $ ls CODEOWNERS DOCUMENTATION.md README.md example.go example_test.go diff --git a/cmd/codeowners/main.go b/cmd/codeowners/main.go index 8a9e7e1..9ef2af4 100644 --- a/cmd/codeowners/main.go +++ b/cmd/codeowners/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "errors" "fmt" "io" "os" @@ -12,17 +13,29 @@ import ( flag "github.com/spf13/pflag" ) +var ErrCheck = errors.New("unowned files exist") + +type Codeowners struct { + ownerFilters []string + showUnowned bool + checkMode bool + codeownersPath string + helpFlag bool + sections bool +} + func main() { var ( - ownerFilters []string - showUnowned bool - codeownersPath string - helpFlag bool + c Codeowners + helpFlag bool ) - flag.StringSliceVarP(&ownerFilters, "owner", "o", nil, "filter results by owner") - flag.BoolVarP(&showUnowned, "unowned", "u", false, "only show unowned files (can be combined with -o)") - flag.StringVarP(&codeownersPath, "file", "f", "", "CODEOWNERS file path") + + flag.StringSliceVarP(&c.ownerFilters, "owner", "o", nil, "filter results by owner") + flag.BoolVarP(&c.showUnowned, "unowned", "u", false, "only show unowned files (can be combined with -o)") + flag.StringVarP(&c.codeownersPath, "file", "f", "", "CODEOWNERS file path") flag.BoolVarP(&helpFlag, "help", "h", false, "show this help message") + flag.BoolVarP(&c.checkMode, "check", "c", false, "exit with a non-zero status code if unowned files exist") + flag.BoolVarP(&c.sections, "sections", "", false, "support sections and inheritance") flag.Usage = func() { fmt.Fprintf(os.Stderr, "usage: codeowners ...\n") @@ -35,7 +48,7 @@ func main() { os.Exit(0) } - ruleset, err := loadCodeowners(codeownersPath) + ruleset, err := c.loadCodeowners() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -47,17 +60,23 @@ func main() { } // Make the @ optional for GitHub teams and usernames - for i := range ownerFilters { - ownerFilters[i] = strings.TrimLeft(ownerFilters[i], "@") + for i := range c.ownerFilters { + c.ownerFilters[i] = strings.TrimLeft(c.ownerFilters[i], "@") } out := bufio.NewWriter(os.Stdout) defer out.Flush() + var checkError bool for _, startPath := range paths { // godirwalk only accepts directories, so we need to handle files separately if !isDir(startPath) { - if err := printFileOwners(out, ruleset, startPath, ownerFilters, showUnowned); err != nil { + if err := c.printFileOwners(out, ruleset, startPath); err != nil { + if errors.Is(err, ErrCheck) { + checkError = true + continue + } + fmt.Fprintf(os.Stderr, "error: %v", err) os.Exit(1) } @@ -71,19 +90,44 @@ func main() { // Only show code owners for files, not directories if !d.IsDir() { - return printFileOwners(out, ruleset, path, ownerFilters, showUnowned) + err := c.printFileOwners(out, ruleset, path) + if err != nil { + if errors.Is(err, ErrCheck) { + checkError = true + return nil + } + } + + return err } return nil }) if err != nil { + if errors.Is(err, ErrCheck) { + checkError = true + continue + } + fmt.Fprintf(os.Stderr, "error: %v", err) os.Exit(1) } } + + if checkError { + if c.showUnowned { + out.Flush() + } + + fmt.Fprintf(os.Stderr, "error: %v\n", ErrCheck.Error()) + + os.Exit(1) + } } -func printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string, ownerFilters []string, showUnowned bool) error { +func (c Codeowners) printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string) error { + hasUnowned := false + rule, err := ruleset.Match(path) if err != nil { return err @@ -91,9 +135,18 @@ func printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string, own // If we didn't get a match, the file is unowned if rule == nil || rule.Owners == nil { // Unless explicitly requested, don't show unowned files if we're filtering by owner - if len(ownerFilters) == 0 || showUnowned { + if len(c.ownerFilters) == 0 || c.showUnowned || c.checkMode { fmt.Fprintf(out, "%-70s (unowned)\n", path) + + if c.checkMode { + hasUnowned = true + } } + + if hasUnowned { + return ErrCheck + } + return nil } @@ -101,8 +154,8 @@ func printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string, own ownersToShow := make([]string, 0, len(rule.Owners)) for _, o := range rule.Owners { // If there are no filters, show all owners - filterMatch := len(ownerFilters) == 0 && !showUnowned - for _, filter := range ownerFilters { + filterMatch := len(c.ownerFilters) == 0 && !c.showUnowned + for _, filter := range c.ownerFilters { if filter == o.Value { filterMatch = true } @@ -119,11 +172,16 @@ func printFileOwners(out io.Writer, ruleset codeowners.Ruleset, path string, own return nil } -func loadCodeowners(path string) (codeowners.Ruleset, error) { - if path == "" { - return codeowners.LoadFileFromStandardLocation() +func (c Codeowners) loadCodeowners() (codeowners.Ruleset, error) { + var parseOptions []codeowners.ParseOption + if c.sections { + parseOptions = append(parseOptions, codeowners.WithSectionSupport()) + } + + if c.codeownersPath == "" { + return codeowners.LoadFileFromStandardLocation(parseOptions...) } - return codeowners.LoadFile(path) + return codeowners.LoadFile(c.codeownersPath, parseOptions...) } // isDir checks if there's a directory at the path specified. diff --git a/codeowners.go b/codeowners.go index 5aef80b..239dbdb 100644 --- a/codeowners.go +++ b/codeowners.go @@ -5,27 +5,28 @@ // the CODEOWNERS file format into rulesets, which may then be used to determine // the ownership of files. // -// Usage +// # Usage // // To find the owner of a given file, parse a CODEOWNERS file and call Match() // on the resulting ruleset. -// ruleset, err := codeowners.ParseFile(file) -// if err != nil { -// log.Fatal(err) -// } // -// rule, err := ruleset.Match("path/to/file") -// if err != nil { -// log.Fatal(err) -// } +// ruleset, err := codeowners.ParseFile(file) +// if err != nil { +// log.Fatal(err) +// } // -// Command line interface +// rule, err := ruleset.Match("path/to/file") +// if err != nil { +// log.Fatal(err) +// } +// +// # Command line interface // // A command line interface is also available in the cmd/codeowners package. // When run, it will walk the directory tree showing the code owners for each // file encountered. The help flag lists available options. // -// $ codeowners --help +// $ codeowners --help package codeowners import ( @@ -39,21 +40,21 @@ import ( // LoadFileFromStandardLocation loads and parses a CODEOWNERS file at one of the // standard locations for CODEOWNERS files (./, .github/, docs/). If run from a // git repository, all paths are relative to the repository root. -func LoadFileFromStandardLocation() (Ruleset, error) { +func LoadFileFromStandardLocation(options ...ParseOption) (Ruleset, error) { path := findFileAtStandardLocation() if path == "" { return nil, fmt.Errorf("could not find CODEOWNERS file at any of the standard locations") } - return LoadFile(path) + return LoadFile(path, options...) } // LoadFile loads and parses a CODEOWNERS file at the path specified. -func LoadFile(path string) (Ruleset, error) { +func LoadFile(path string, options ...ParseOption) (Ruleset, error) { f, err := os.Open(path) if err != nil { return nil, err } - return ParseFile(f) + return ParseFile(f, options...) } // findFileAtStandardLocation loops through the standard locations for @@ -122,6 +123,14 @@ type Rule struct { pattern pattern } +type Section struct { + Name string + Owners []Owner + Comment string + ApprovalOptional bool + ApprovalCount int +} + // RawPattern returns the rule's gitignore-style path pattern. func (r Rule) RawPattern() string { return r.pattern.pattern @@ -139,6 +148,8 @@ const ( TeamOwner string = "team" // UsernameOwner is the owner type for GitHub usernames. UsernameOwner string = "username" + // GroupOwner is the owner type for Gitlab groups. + GroupOwner string = "group" ) // Owner represents an owner found in a rule. diff --git a/example_test.go b/example_test.go index a30c6b7..3e47904 100644 --- a/example_test.go +++ b/example_test.go @@ -3,12 +3,15 @@ package codeowners_test import ( "bytes" "fmt" + "regexp" "github.com/hmarr/codeowners" ) func Example() { - f := bytes.NewBufferString("src/**/*.c @acme/c-developers") + f := bytes.NewBufferString(`src/**/*.c @acme/c-developers +# The following line should be ignored; it contains only spaces and tabs` + + " \t\nsrc/**/*.go @acme/go-developers") ruleset, err := codeowners.ParseFile(f) if err != nil { panic(err) @@ -19,9 +22,13 @@ func Example() { match, err = ruleset.Match("src/foo.rs") fmt.Println(match) + + match, err = ruleset.Match("src/go/bar/bar.go") + fmt.Println(match.Owners) // Output: // [@acme/c-developers] // + // [@acme/go-developers] } func ExampleParseFile() { @@ -41,6 +48,43 @@ func ExampleParseFile() { // Go code } +func ExampleParseFile_customOwnerMatchers() { + validUsernames := []string{"the-a-team", "the-b-team"} + usernameRegexp := regexp.MustCompile(`\A@([a-zA-Z0-9\-]+)\z`) + + f := bytes.NewBufferString("src/**/*.go @the-a-team # Go code") + ownerMatchers := []codeowners.OwnerMatcher{ + codeowners.OwnerMatchFunc(codeowners.MatchEmailOwner), + codeowners.OwnerMatchFunc(func(s string) (codeowners.Owner, error) { + // Custom owner matcher that only matches valid usernames + match := usernameRegexp.FindStringSubmatch(s) + if match == nil { + return codeowners.Owner{}, codeowners.ErrNoMatch + } + + for _, t := range validUsernames { + if t == match[1] { + return codeowners.Owner{Value: match[1], Type: codeowners.TeamOwner}, nil + } + } + return codeowners.Owner{}, codeowners.ErrNoMatch + }), + } + ruleset, err := codeowners.ParseFile(f, codeowners.WithOwnerMatchers(ownerMatchers)) + if err != nil { + panic(err) + } + fmt.Println(len(ruleset)) + fmt.Println(ruleset[0].RawPattern()) + fmt.Println(ruleset[0].Owners[0].String()) + fmt.Println(ruleset[0].Comment) + // Output: + // 1 + // src/**/*.go + // @the-a-team + // Go code +} + func ExampleRuleset_Match() { f := bytes.NewBufferString("src/**/*.go @acme/go-developers # Go code") ruleset, _ := codeowners.ParseFile(f) @@ -62,3 +106,77 @@ func ExampleRuleset_Match() { // src/foo/bar.go true // src/foo.rs false } + +func ExampleRuleset_Match_section() { + f := bytes.NewBufferString(`[SECTION] @the-a-team +src +src-b @user-b +`) + ruleset, _ := codeowners.ParseFile(f, codeowners.WithSectionSupport()) + match, _ := ruleset.Match("src") + fmt.Println("src", match != nil) + fmt.Println(ruleset[0].Owners[0].String()) + match, _ = ruleset.Match("src-b") + fmt.Println("src-b", match != nil) + fmt.Println(ruleset[1].Owners[0].String()) + // Output: + // src true + // @the-a-team + // src-b true + // @user-b +} + +func ExampleRuleset_Match_section_groups() { + f := bytes.NewBufferString(`[SECTION] @the/a/group +src +src-b @user-b +src-c @the/c/group +`) + ruleset, _ := codeowners.ParseFile(f, codeowners.WithSectionSupport()) + match, _ := ruleset.Match("src") + fmt.Println("src", match != nil) + fmt.Println(ruleset[0].Owners[0].String()) + match, _ = ruleset.Match("src-b") + fmt.Println("src-b", match != nil) + fmt.Println(ruleset[1].Owners[0].String()) + match, _ = ruleset.Match("src-c") + fmt.Println("src-c", match != nil) + fmt.Println(ruleset[2].Owners[0].String()) + // Output: + // src true + // @the/a/group + // src-b true + // @user-b + // src-c true + // @the/c/group +} + +func ExampleRuleset_Match_section_groups_multiple() { + f := bytes.NewBufferString(`[SECTION] @the/a/group +* @other + +[SECTION-B] @the/b/group +b-src +b-src-b @user-b +b-src-c @the/c/group + +[SECTION-C] +`) + ruleset, _ := codeowners.ParseFile(f, codeowners.WithSectionSupport()) + match, _ := ruleset.Match("b-src") + fmt.Println("b-src", match != nil) + fmt.Println(ruleset[1].Owners[0].String()) + match, _ = ruleset.Match("b-src-b") + fmt.Println("b-src-b", match != nil) + fmt.Println(ruleset[2].Owners[0].String()) + match, _ = ruleset.Match("b-src-c") + fmt.Println("b-src-c", match != nil) + fmt.Println(ruleset[3].Owners[0].String()) + // Output: + // b-src true + // @the/b/group + // b-src-b true + // @user-b + // b-src-c true + // @the/c/group +} diff --git a/go.mod b/go.mod index f1dafa2..e342c54 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,14 @@ module github.com/hmarr/codeowners -go 1.14 +go 1.22 require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.0 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/match_test.go b/match_test.go index bd012ef..eb3a3ca 100644 --- a/match_test.go +++ b/match_test.go @@ -2,7 +2,7 @@ package codeowners import ( "encoding/json" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -17,7 +17,7 @@ type patternTest struct { } func TestMatch(t *testing.T) { - data, err := ioutil.ReadFile("testdata/patterns.json") + data, err := os.ReadFile("testdata/patterns.json") require.NoError(t, err) var tests []patternTest diff --git a/parse.go b/parse.go index cef7a8e..d502898 100644 --- a/parse.go +++ b/parse.go @@ -3,25 +3,103 @@ package codeowners import ( "bufio" "bytes" + "errors" "fmt" "io" "regexp" + "strconv" "strings" ) +type ParseOption func(*parseOptions) + +type parseOptions struct { + ownerMatchers []OwnerMatcher + sectionSupport bool +} + +func WithSectionSupport() ParseOption { + return func(opts *parseOptions) { + opts.sectionSupport = true + } +} + +func WithOwnerMatchers(mm []OwnerMatcher) ParseOption { + return func(opts *parseOptions) { + opts.ownerMatchers = mm + } +} + +type OwnerMatcher interface { + // Matches give string agains a pattern e.g. a regexp. + // Should return ErrNoMatch if the pattern doesn't match. + Match(s string) (Owner, error) +} + +type ErrInvalidOwnerFormat struct { + Owner string +} + +func (err ErrInvalidOwnerFormat) Error() string { + return fmt.Sprintf("invalid owner format '%s'", err.Owner) +} + +var ErrNoMatch = errors.New("no match") + var ( emailRegexp = regexp.MustCompile(`\A[A-Z0-9a-z\._%\+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,6}\z`) - teamRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-]+\/[a-zA-Z0-9_\-]+)\z`) - usernameRegexp = regexp.MustCompile(`\A@([a-zA-Z0-9\-]+)\z`) + teamRegexp = regexp.MustCompile(`\A@(([a-zA-Z0-9\-_]+)([\/][a-zA-Z0-9\-_]+)+)\z`) + usernameRegexp = regexp.MustCompile(`\A@(([a-zA-Z0-9\-_]+)([\._][a-zA-Z0-9\-_]+)*)\z`) ) -const ( - statePattern = iota + 1 - stateOwners -) +var DefaultOwnerMatchers = []OwnerMatcher{ + OwnerMatchFunc(MatchEmailOwner), + OwnerMatchFunc(MatchTeamOwner), + OwnerMatchFunc(MatchUsernameOwner), +} + +type OwnerMatchFunc func(s string) (Owner, error) + +func (f OwnerMatchFunc) Match(s string) (Owner, error) { + return f(s) +} + +func MatchEmailOwner(s string) (Owner, error) { + match := emailRegexp.FindStringSubmatch(s) + if match == nil { + return Owner{}, ErrNoMatch + } + + return Owner{Value: match[0], Type: EmailOwner}, nil +} + +func MatchTeamOwner(s string) (Owner, error) { + match := teamRegexp.FindStringSubmatch(s) + if match == nil { + return Owner{}, ErrNoMatch + } + + return Owner{Value: match[1], Type: TeamOwner}, nil +} + +func MatchUsernameOwner(s string) (Owner, error) { + match := usernameRegexp.FindStringSubmatch(s) + if match == nil { + return Owner{}, ErrNoMatch + } + + return Owner{Value: match[1], Type: UsernameOwner}, nil +} // ParseFile parses a CODEOWNERS file, returning a set of rules. -func ParseFile(f io.Reader) (Ruleset, error) { +// To override the default owner matchers, pass WithOwnerMatchers() as an option. +func ParseFile(f io.Reader, options ...ParseOption) (Ruleset, error) { + opts := parseOptions{ownerMatchers: DefaultOwnerMatchers} + for _, opt := range options { + opt(&opts) + } + + sectionOwners := []Owner{} rules := Ruleset{} scanner := bufio.NewScanner(f) lineNo := 0 @@ -30,11 +108,22 @@ func ParseFile(f io.Reader) (Ruleset, error) { line := scanner.Text() // Ignore blank lines and comments - if len(line) == 0 || line[0] == '#' { + if len(strings.TrimSpace(line)) == 0 || line[0] == '#' { continue } - rule, err := parseRule(line) + if opts.sectionSupport && isSectionStart(rune(line[0])) { + section, err := parseSection(line, opts) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + + sectionOwners = section.Owners + + continue + } + + rule, err := parseRule(line, opts, sectionOwners) if err != nil { return nil, fmt.Errorf("line %d: %w", lineNo, err) } @@ -44,8 +133,155 @@ func ParseFile(f io.Reader) (Ruleset, error) { return rules, nil } +const ( + statePattern = iota + 1 + stateOwners + stateSection + stateSectionBrace + stateSectionApprovalCount +) + +// parseSection parses a single line of a CODEOWNERS file, returning a Rule struct +func parseSection(ruleStr string, opts parseOptions) (Section, error) { + s := Section{} + + state := stateSection + escaped := false + buf := bytes.Buffer{} + for i, ch := range strings.TrimSpace(ruleStr) { + // Comments consume the rest of the line and stop further parsing + if ch == '#' { + s.Comment = strings.TrimSpace(ruleStr[i+1:]) + break + } + + switch state { + case stateSection: + switch { + case ch == '\\': + // Escape the next character (important for whitespace while parsing), but + // don't lose the backslash as it's part of the pattern + escaped = true + buf.WriteRune(ch) + continue + + case isSectionStart(ch): + if ch == '^' { + s.ApprovalOptional = true + + continue + } + + state = stateSectionBrace + continue + + case isSectionChar(ch): + buf.WriteRune(ch) + + case isSectionEnd(ch) || isWhitespace(ch) && !escaped: + buf.Reset() + + state = stateOwners + + default: + return s, fmt.Errorf("section: unexpected character '%c' at position %d", ch, i+1) + } + + case stateSectionBrace: + switch { + case ch == '\\': + // Escape the next character (important for whitespace while parsing), but + // don't lose the backslash as it's part of the pattern + escaped = true + buf.WriteRune(ch) + continue + + case isSectionEnd(ch): + s.Name = buf.String() + + buf.Reset() + + state = stateOwners + continue + + case isSectionChar(ch): + // Keep any valid pattern characters and escaped whitespace + buf.WriteRune(ch) + + default: + return s, fmt.Errorf("section: unexpected character '%c' at position %d", ch, i+1) + } + + case stateSectionApprovalCount: + switch { + case isSectionEnd(ch): + approvalCount := buf.String() + approvalCountInt, err := strconv.Atoi(approvalCount) + if err != nil { + return s, fmt.Errorf("section: invalid approval count %w at position %d", err, i+1) + } + s.ApprovalCount = approvalCountInt + + buf.Reset() + state = stateOwners + + default: + buf.WriteRune(ch) + } + + case stateOwners: + switch { + case isSectionStart(ch): + state = stateSectionApprovalCount + + case isWhitespace(ch): + // Whitespace means we've reached the end of the owner or we're just chomping + // through whitespace before or after owner declarations + if buf.Len() > 0 { + ownerStr := buf.String() + owner, err := newOwner(ownerStr, opts.ownerMatchers) + if err != nil { + return s, fmt.Errorf("section: %w at position %d", err, i+1-len(ownerStr)) + } + + s.Owners = append(s.Owners, owner) + buf.Reset() + } + + case isOwnersChar(ch): + // Write valid owner characters to the buffer + buf.WriteRune(ch) + + default: + return s, fmt.Errorf("section: unexpected character '%c' at position %d", ch, i+1) + } + } + } + + escaped = false + + // We've finished consuming the line, but we might still have content in the buffer + // if the line didn't end with a separator (whitespace) + switch state { + case stateOwners: + // If there's an owner left in the buffer, don't leave it behind + if buf.Len() > 0 { + ownerStr := buf.String() + owner, err := newOwner(ownerStr, opts.ownerMatchers) + if err != nil { + return s, fmt.Errorf("%s at position %d", err.Error(), len(ruleStr)+1-len(ownerStr)) + } + + s.Owners = append(s.Owners, owner) + } + + } + + return s, nil +} + // parseRule parses a single line of a CODEOWNERS file, returning a Rule struct -func parseRule(ruleStr string) (Rule, error) { +func parseRule(ruleStr string, opts parseOptions, inheritedOwners []Owner) (Rule, error) { r := Rule{} state := statePattern @@ -95,9 +331,9 @@ func parseRule(ruleStr string) (Rule, error) { // through whitespace before or after owner declarations if buf.Len() > 0 { ownerStr := buf.String() - owner, err := newOwner(ownerStr) + owner, err := newOwner(ownerStr, opts.ownerMatchers) if err != nil { - return r, fmt.Errorf("%s at position %d", err.Error(), i+1-len(ownerStr)) + return r, fmt.Errorf("%w at position %d", err, i+1-len(ownerStr)) } r.Owners = append(r.Owners, owner) buf.Reset() @@ -131,7 +367,7 @@ func parseRule(ruleStr string) (Rule, error) { // If there's an owner left in the buffer, don't leave it behind if buf.Len() > 0 { ownerStr := buf.String() - owner, err := newOwner(ownerStr) + owner, err := newOwner(ownerStr, opts.ownerMatchers) if err != nil { return r, fmt.Errorf("%s at position %d", err.Error(), len(ruleStr)+1-len(ownerStr)) } @@ -139,27 +375,29 @@ func parseRule(ruleStr string) (Rule, error) { } } + if len(r.Owners) == 0 { + r.Owners = inheritedOwners + } + return r, nil } // newOwner figures out which kind of owner this is and returns an Owner struct -func newOwner(s string) (Owner, error) { - match := emailRegexp.FindStringSubmatch(s) - if match != nil { - return Owner{Value: match[0], Type: EmailOwner}, nil - } +func newOwner(s string, mm []OwnerMatcher) (Owner, error) { + for _, m := range mm { + o, err := m.Match(s) + if errors.Is(err, ErrNoMatch) { + continue + } else if err != nil { + return Owner{}, err + } - match = teamRegexp.FindStringSubmatch(s) - if match != nil { - return Owner{Value: match[1], Type: TeamOwner}, nil + return o, nil } - match = usernameRegexp.FindStringSubmatch(s) - if match != nil { - return Owner{Value: match[1], Type: UsernameOwner}, nil + return Owner{}, ErrInvalidOwnerFormat{ + Owner: s, } - - return Owner{}, fmt.Errorf("invalid owner format '%s'", s) } func isWhitespace(ch rune) bool { @@ -187,3 +425,32 @@ func isOwnersChar(ch rune) bool { } return isAlphanumeric(ch) } + +// isSectionChar matches characters that are allowed for section names +func isSectionChar(ch rune) bool { + switch ch { + case '.', '@', '/', '_', '%', '+', '-', ' ': + return true + } + return isAlphanumeric(ch) +} + +// isSectionEnd matches characters ends each section block +// e.g. [Section Name][] +func isSectionEnd(ch rune) bool { + switch ch { + case ']': + return true + } + return false +} + +// isSectionStart defines characters starting the beginning of a section +// - `^` starts an optional section +func isSectionStart(ch rune) bool { + switch ch { + case '[', '^': + return true + } + return false +} diff --git a/parse_test.go b/parse_test.go index fbc2c63..cdecfbc 100644 --- a/parse_test.go +++ b/parse_test.go @@ -8,12 +8,29 @@ import ( func TestParseRule(t *testing.T) { examples := []struct { - name string - rule string - expected Rule - err string + name string + rule string + ownerMatchers []OwnerMatcher + expected Rule + err string }{ // Success cases + { + name: "username with dots", + rule: "file.txt @user.name", + expected: Rule{ + pattern: mustBuildPattern(t, "file.txt"), + Owners: []Owner{{Value: "user.name", Type: "username"}}, + }, + }, + { + name: "username with underscore", + rule: "file.txt @user_name", + expected: Rule{ + pattern: mustBuildPattern(t, "file.txt"), + Owners: []Owner{{Value: "user_name", Type: "username"}}, + }, + }, { name: "username owners", rule: "file.txt @user", @@ -142,11 +159,147 @@ func TestParseRule(t *testing.T) { rule: "file.txt missing-at-sign", err: "invalid owner format 'missing-at-sign' at position 10", }, + { + name: "malformed owners trailing dot", + rule: "file.txt @trailing-dot.", + err: "invalid owner format '@trailing-dot.' at position 10", + }, + { + name: "email owners without email matcher", + rule: "file.txt foo@example.com", + ownerMatchers: []OwnerMatcher{ + OwnerMatchFunc(MatchTeamOwner), + OwnerMatchFunc(MatchUsernameOwner), + }, + err: "invalid owner format 'foo@example.com' at position 10", + }, + { + name: "team owners without team matcher", + rule: "file.txt @org/team", + ownerMatchers: []OwnerMatcher{ + OwnerMatchFunc(MatchEmailOwner), + OwnerMatchFunc(MatchUsernameOwner), + }, + err: "invalid owner format '@org/team' at position 10", + }, + { + name: "username owners without username matcher", + rule: "file.txt @user", + ownerMatchers: []OwnerMatcher{ + OwnerMatchFunc(MatchEmailOwner), + OwnerMatchFunc(MatchTeamOwner), + }, + err: "invalid owner format '@user' at position 10", + }, } for _, e := range examples { t.Run("parses "+e.name, func(t *testing.T) { - actual, err := parseRule(e.rule) + opts := parseOptions{ownerMatchers: DefaultOwnerMatchers} + if e.ownerMatchers != nil { + opts.ownerMatchers = e.ownerMatchers + } + actual, err := parseRule(e.rule, opts, nil) + if e.err != "" { + assert.EqualError(t, err, e.err) + } else { + assert.NoError(t, err) + assert.Equal(t, e.expected, actual) + } + }) + } +} + +func TestParseSection(t *testing.T) { + examples := []struct { + name string + rule string + ownerMatchers []OwnerMatcher + expected Section + err string + }{ + // Success cases + { + name: "match sections", + rule: "[Section]", + expected: Section{ + Name: "Section", + Owners: nil, + Comment: "", + }, + }, + { + name: "match sections with spaces", + rule: "[Section Spaces]", + expected: Section{ + Name: "Section Spaces", + Owners: nil, + Comment: "", + }, + }, + { + name: "match sections with optional approval", + rule: "^[Section]", + expected: Section{ + Name: "Section", + Owners: nil, + Comment: "", + ApprovalOptional: true, + }, + }, + { + name: "match sections with approval count", + rule: "^[Section][2]", + expected: Section{ + Name: "Section", + Owners: nil, + Comment: "", + ApprovalOptional: true, + ApprovalCount: 2, + }, + }, + { + name: "match sections with owner", + rule: "[Section-B-User] @the-b-user", + expected: Section{ + Name: "Section-B-User", + Owners: []Owner{{Value: "the-b-user", Type: "username"}}, + Comment: "", + }, + }, + { + name: "match sections with comment", + rule: "[Section] # some comment", + expected: Section{ + Name: "Section", + Owners: nil, + Comment: "some comment", + }, + }, + { + name: "match sections with owner and comment", + rule: "[Section] @the/a/team # some comment", + expected: Section{ + Name: "Section", + Owners: []Owner{{Value: "the/a/team", Type: "team"}}, + Comment: "some comment", + }, + ownerMatchers: []OwnerMatcher{ + OwnerMatchFunc(MatchTeamOwner), + }, + }, + + // Error cases + // TODO + } + + for _, e := range examples { + t.Run("parses Sections "+e.name, func(t *testing.T) { + opts := parseOptions{ownerMatchers: DefaultOwnerMatchers} + if e.ownerMatchers != nil { + opts.ownerMatchers = e.ownerMatchers + } + actual, err := parseSection(e.rule, opts) if e.err != "" { assert.EqualError(t, err, e.err) } else {