├── .github └── workflows │ └── go.yml ├── .gitignore ├── .goreleaser.yml ├── .ignore ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── gocodewalker │ └── main.go ├── gocodewalkerexample │ └── main.go └── gocodewalkerperformance │ └── main.go ├── dir_suffix.go ├── dir_suffix_test.go ├── file.go ├── file_test.go ├── gitignore_test.go ├── gitmodule.go ├── gitmodule_test.go ├── go-gitignore ├── LICENSE ├── README.md ├── cache.go ├── cache_test.go ├── defn_test.go ├── doc.go ├── error.go ├── errors.go ├── example_test.go ├── exclude.go ├── gitignore.go ├── gitignore_test.go ├── lexer.go ├── lexer_test.go ├── match.go ├── match_test.go ├── parser.go ├── parser_test.go ├── pattern.go ├── position.go ├── position_test.go ├── repository.go ├── repository_test.go ├── rune.go ├── token.go ├── token_test.go ├── tokenset.go ├── tokentype.go └── util_test.go ├── go.mod ├── go.sum ├── hidden.go ├── hidden_windows.go └── vendor ├── github.com └── danwakefield │ └── fnmatch │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ └── fnmatch.go ├── golang.org └── x │ └── sync │ ├── LICENSE │ ├── PATENTS │ └── errgroup │ └── errgroup.go └── modules.txt /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | strategy: 13 | matrix: 14 | go: ['1.23', '1.24'] 15 | 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go }} 24 | id: go 25 | 26 | - name: Test 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .idea 18 | dist/ 19 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | builds: 10 | - skip: true 11 | 12 | archives: 13 | - format: tar.gz 14 | # this name template makes the OS and Arch compatible with the results of uname. 15 | name_template: >- 16 | {{ .ProjectName }}_ 17 | {{- title .Os }}_ 18 | {{- if eq .Arch "amd64" }}x86_64 19 | {{- else if eq .Arch "386" }}i386 20 | {{- else }}{{ .Arch }}{{ end }} 21 | {{- if .Arm }}v{{ .Arm }}{{ end }} 22 | # use zip for windows archives 23 | format_overrides: 24 | - goos: windows 25 | format: zip 26 | checksum: 27 | name_template: 'checksums.txt' 28 | snapshot: 29 | name_template: "{{ incpatch .Version }}-next" 30 | changelog: 31 | sort: asc 32 | filters: 33 | exclude: 34 | - '^docs:' 35 | - '^test:' 36 | 37 | # The lines beneath this are called `modelines`. See `:help modeline` 38 | # Feel free to remove those if you don't want/use them. 39 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 40 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 41 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | / 2 | go-gitignore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2021 Ben Boyter 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Some people have gotestsum installed and like it so use it if it exists 2 | HAS_GOTESTSUM := $(shell which gotestsum) 3 | ifdef HAS_GOTESTSUM 4 | TEST_CMD = gotestsum --format testname --packages="./..." -- -count=1 -tags=integration -v -p 1 5 | else 6 | TEST_CMD = go test ./... --count=1 -tags=integration 7 | endif 8 | 9 | lint: 10 | @golangci-lint run --fix 11 | @golangci-lint run 12 | 13 | test: 14 | @$(TEST_CMD) 15 | 16 | test-run: 17 | @$(TEST_CMD) -run=$(RUN) 18 | 19 | fuzz: 20 | go test -fuzz=FuzzTestGitIgnore -fuzztime 30s 21 | 22 | test-coverage: 23 | go test ./... -coverprofile coverage.out && go tool cover -html=coverage.out -o coverage.html 24 | 25 | mod: 26 | @go mod tidy 27 | @go mod vendor 28 | 29 | clean: 30 | go clean -modcache 31 | 32 | all: mod lint test fuzz 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gocodewalker 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/boyter/gocodewalker)](https://goreportcard.com/report/github.com/boyter/gocodewalker) 4 | [![Str Count Badge](https://sloc.xyz/github/boyter/gocodewalker/)](https://github.com/boyter/gocodewalker/) 5 | 6 | Library to help with walking of code directories in Go. 7 | 8 | The problem. You want to walk the directories of a code repository. You want to respect .gitignore and .ignore files, and 9 | some are nested. This library is the answer. 10 | 11 | - Designed to walk code repositories or find the root of them. 12 | - By default, respects both .gitignore and .ignore files (can be disabled) and nested ones for accuracy 13 | - Has configurable options for skipping files based on regex, extension or general match 14 | - Uses readdir to provide as fast as possible file walking 15 | 16 | NB this was moved from go-code-walker due to the name being annoying and to ensure it has a unique package name. Should still be drop in replaceable 17 | so long as you refer to the new package name. 18 | 19 | https://pkg.go.dev/github.com/boyter/gocodewalker 20 | 21 | Package provides file operations specific to code repositories such as walking the file tree obeying .ignore and .gitignore files 22 | or looking for the root directory assuming already in a git project. 23 | 24 | Example of usage, 25 | 26 | ```go 27 | fileListQueue := make(chan *gocodewalker.File, 100) 28 | 29 | fileWalker := gocodewalker.NewFileWalker(".", fileListQueue) 30 | 31 | // restrict to only process files that have the .go extension 32 | fileWalker.AllowListExtensions = append(fileWalker.AllowListExtensions, "go") 33 | 34 | // handle the errors by printing them out and then ignore 35 | errorHandler := func(e error) bool { 36 | fmt.Println("ERR", e.Error()) 37 | return true 38 | } 39 | fileWalker.SetErrorHandler(errorHandler) 40 | 41 | go fileWalker.Start() 42 | 43 | for f := range fileListQueue { 44 | fmt.Println(f.Location) 45 | } 46 | ``` 47 | 48 | The above by default will recursively add files to the fileListQueue respecting both .ignore and .gitignore files if found, and 49 | only adding files with the go extension into the queue. 50 | 51 | You can also run the walker in parallel with the results intermixed if required, 52 | 53 | ```go 54 | fileListQueue := make(chan *gocodewalker.File, 100) 55 | 56 | fileWalker := gocodewalker.NewParallelFileWalker([]string{".", "someotherdir"}, fileListQueue) 57 | go fileWalker.Start() 58 | 59 | for f := range fileListQueue { 60 | fmt.Println(f.Location) 61 | } 62 | ``` 63 | 64 | All code is licenced as MIT. 65 | 66 | ### Error Handler 67 | 68 | You can supply your own error handler when walking. This allows you to perform an action when there is an error 69 | and decide if the walker should continue to process, or return. 70 | 71 | The simplest handler is the below, which if set will swallow all errors and continue as best it can. 72 | 73 | ```go 74 | errorHandler := func(e error) bool { 75 | return true 76 | } 77 | fileWalker.SetErrorHandler(errorHandler) 78 | ``` 79 | 80 | If you wanted to return on errors you could use the following. 81 | 82 | ```go 83 | errorHandler := func(e error) bool { 84 | return false 85 | } 86 | fileWalker.SetErrorHandler(errorHandler) 87 | ``` 88 | 89 | If you wanted to terminate walking on an error you could use the following, which would cause it to return the error, 90 | and then terminate all walking. This might be desirable where any error indicates a total failure. 91 | 92 | ```go 93 | errorHandler := func(e error) bool { 94 | fileWalker.Terminate() 95 | return false 96 | } 97 | fileWalker.SetErrorHandler(errorHandler) 98 | ``` 99 | 100 | ### Binary Checking 101 | 102 | You can ask it to ignore binary files for you by setting `IgnoreBinaryFiles` to true and optionally 103 | `IgnoreBinaryFileBytes` to the number of bytes you want to check which by default is set to 1,000. 104 | 105 | This will have a performance impact as gocodewalker will open each file, so you may want to do this check yourself 106 | if performance is your goal. 107 | 108 | ```go 109 | fileListQueue := make(chan *gocodewalker.File, 100) 110 | 111 | fileWalker := gocodewalker.NewFileWalker(".", fileListQueue) 112 | 113 | // set to ignore binary files 114 | fileWalker.IgnoreBinaryFiles = true 115 | fileWalker.IgnoreBinaryFileBytes = 500 116 | ``` 117 | 118 | The check itself looks for a null byte `if b == 0 {` which is a fast mostly accurate way of checking for 119 | a binary file. 120 | 121 | ### Testing 122 | 123 | Done through unit/integration tests. Otherwise see https://github.com/svent/gitignore-test 124 | 125 | See `./cmd/gocodewalker/main.go` for an example of how to implement and validate 126 | 127 | ### Info 128 | 129 | Details on how gitignores work 130 | 131 | https://stackoverflow.com/questions/71735516/proper-way-to-setup-multiple-gitignore-files-in-nested-folders-of-a-repository 132 | -------------------------------------------------------------------------------- /cmd/gocodewalker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/boyter/gocodewalker" 9 | ) 10 | 11 | // Proper test designed to confirm that .gitignores work as expected with globs 12 | // Designed to work against https://github.com/svent/gitignore-test 13 | // If you compile and run this it should produce the same output as the following tools 14 | // when run from the directory you check it out into 15 | // 16 | // rg ^foo: | sort 17 | // git grep ^foo: | sort 18 | // gocodewalker | sort 19 | func main() { 20 | fileListQueue := make(chan *gocodewalker.File, 10_000) 21 | fileWalker := gocodewalker.NewParallelFileWalker([]string{"./cmd/", "./go-gitignore/"}, fileListQueue) 22 | 23 | // handle the errors by printing them out and then ignore 24 | errorHandler := func(e error) bool { 25 | fmt.Println("ERR", e.Error()) 26 | return true 27 | } 28 | fileWalker.SetErrorHandler(errorHandler) 29 | 30 | go func() { 31 | err := fileWalker.Start() 32 | if err != nil { 33 | fmt.Println("ERR", err.Error()) 34 | } 35 | }() 36 | 37 | for f := range fileListQueue { 38 | contents, _ := os.ReadFile(f.Location) 39 | if len(contents) > 10 { 40 | contents = contents[:10] 41 | } 42 | fmt.Printf("%v:%v\n", f.Location, strings.TrimSpace(string(contents))) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/gocodewalkerexample/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "regexp" 8 | 9 | "github.com/boyter/gocodewalker" 10 | ) 11 | 12 | func main() { 13 | fileListQueue := make(chan *gocodewalker.File, 100) 14 | fileWalker := gocodewalker.NewFileWalker(".", fileListQueue) 15 | 16 | fileWalker.AllowListExtensions = []string{"go", "sh"} 17 | fileWalker.ExcludeListExtensions = []string{"sh"} 18 | fileWalker.IncludeFilenameRegex = []*regexp.Regexp{regexp.MustCompile(".*")} 19 | 20 | // handle the error by printing it out and terminating the walker and returning 21 | // false which should cause continued processing to error 22 | errorHandler := func(e error) bool { 23 | fmt.Println("ERR", e.Error()) 24 | fileWalker.Terminate() 25 | return false 26 | } 27 | fileWalker.SetErrorHandler(errorHandler) 28 | 29 | go func() { 30 | err := fileWalker.Start() 31 | if err != nil { 32 | fmt.Println("ERROR", err.Error()) 33 | } 34 | }() 35 | 36 | for f := range fileListQueue { 37 | fmt.Println(f.Location) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /cmd/gocodewalkerperformance/main.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | "github.com/boyter/gocodewalker" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) != 2 { 14 | fmt.Println("Usage: gocodewalkerperformance ") 15 | os.Exit(1) 16 | } 17 | 18 | // useful for improving performance, then go tool pprof -http=localhost:8090 profile.pprof 19 | //f, _ := os.Create("profile.pprof") 20 | //_ = pprof.StartCPUProfile(f) 21 | //defer pprof.StopCPUProfile() 22 | 23 | fileListQueue := make(chan *gocodewalker.File, 100) 24 | fileWalker := gocodewalker.NewFileWalker(os.Args[1], fileListQueue) 25 | 26 | // handle the error by printing it out and terminating the walker and returning 27 | // false which should cause continued processing to error 28 | errorHandler := func(e error) bool { 29 | fmt.Println("ERR", e.Error()) 30 | return true 31 | } 32 | fileWalker.SetErrorHandler(errorHandler) 33 | 34 | go func() { 35 | err := fileWalker.Start() 36 | if err != nil { 37 | fmt.Println("ERROR", err.Error()) 38 | } 39 | }() 40 | 41 | count := 0 42 | for f := range fileListQueue { 43 | fmt.Println(f.Location) 44 | count++ 45 | } 46 | fmt.Println(count) 47 | } 48 | -------------------------------------------------------------------------------- /dir_suffix.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gocodewalker 4 | 5 | import ( 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // isSuffixDir returns true if base ends with suffix. Suffix "/" will be trimmed. 11 | // suffix must be a valid sub dir of base. 12 | // For examples: 13 | // - isSuffixDir("a", "a") returns true 14 | // - isSuffixDir("a/b/c", "c") returns true 15 | // - isSuffixDir("a/b/c", "b/c") returns true 16 | // - isSuffixDir("a/b/c", "b") returns false 17 | // - isSuffixDir("a/b/c", "a/b") returns false, "a/b" is a valid sub dir but not at the end of "a/b/c" 18 | // - isSuffixDir("a/bb/c", "b/c") returns false 19 | func isSuffixDir(base string, suffix string) bool { 20 | if base == "" || suffix == "" { 21 | return false 22 | } 23 | base = strings.TrimSuffix(filepath.ToSlash(base), "/") 24 | suffix = strings.TrimSuffix(filepath.ToSlash(suffix), "/") 25 | newBase := strings.TrimSuffix(base, suffix) 26 | if newBase == base { 27 | return false 28 | } 29 | return strings.HasSuffix(newBase, "/") || newBase == "" 30 | } 31 | -------------------------------------------------------------------------------- /dir_suffix_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gocodewalker 4 | 5 | import "testing" 6 | 7 | func TestIsSuffixDir(t *testing.T) { 8 | testCases := []struct { 9 | base string 10 | suffix string 11 | expect bool 12 | }{ 13 | { 14 | base: "", 15 | suffix: "", 16 | expect: false, 17 | }, 18 | { 19 | base: "a", 20 | suffix: "", 21 | expect: false, 22 | }, 23 | { 24 | base: "", 25 | suffix: "a", 26 | expect: false, 27 | }, 28 | { 29 | base: "a", 30 | suffix: "a", 31 | expect: true, 32 | }, 33 | { 34 | base: "a/b/c", 35 | suffix: "a/b/c", 36 | expect: true, 37 | }, 38 | { 39 | base: "a/b/c", 40 | suffix: "c", 41 | expect: true, 42 | }, 43 | { 44 | base: "c", 45 | suffix: "a/b/c", 46 | expect: false, 47 | }, 48 | { 49 | base: "a/b/c", 50 | suffix: "b/c", 51 | expect: true, 52 | }, 53 | { 54 | base: "/a/b/c", 55 | suffix: "b/c", 56 | expect: true, 57 | }, 58 | { 59 | base: "/a/b/c", 60 | suffix: "/b/c", 61 | expect: false, 62 | }, 63 | { 64 | base: "a/b/c", 65 | suffix: "/b/c", 66 | expect: false, 67 | }, 68 | { 69 | base: "a/b/c", 70 | suffix: "b", 71 | expect: false, 72 | }, 73 | { 74 | base: "a/b/c", 75 | suffix: "a/b", 76 | expect: false, 77 | }, 78 | { 79 | base: "a/b/c/d", 80 | suffix: "b/c", 81 | expect: false, 82 | }, 83 | { 84 | base: "a/bb/c", 85 | suffix: "b/c", 86 | expect: false, 87 | }, 88 | { 89 | base: "C:/a/b", 90 | suffix: "a/b", 91 | expect: true, 92 | }, 93 | { 94 | base: "C:/a/b", 95 | suffix: "/a/b", 96 | expect: false, 97 | }, 98 | { 99 | base: "C:/a/b", 100 | suffix: "D:/a/b", 101 | expect: false, 102 | }, 103 | { 104 | base: "b/b/c", 105 | suffix: "b/b/c/", 106 | expect: true, 107 | }, 108 | } 109 | for _, tc := range testCases { 110 | res := isSuffixDir(tc.base, tc.suffix) 111 | if res != tc.expect { 112 | t.Errorf("base: %s, suffix: %s, got: %v, want: %v", tc.base, tc.suffix, res, tc.expect) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | // Package file provides file operations specific to code repositories 2 | // such as walking the file tree obeying .ignore and .gitignore files 3 | // or looking for the root directory assuming already in a git project 4 | 5 | // SPDX-License-Identifier: MIT 6 | 7 | package gocodewalker 8 | 9 | import ( 10 | "bytes" 11 | "errors" 12 | "io" 13 | "io/fs" 14 | "os" 15 | "path" 16 | "path/filepath" 17 | "regexp" 18 | "slices" 19 | "strings" 20 | "sync" 21 | 22 | "github.com/boyter/gocodewalker/go-gitignore" 23 | "golang.org/x/sync/errgroup" 24 | ) 25 | 26 | const ( 27 | GitIgnore = ".gitignore" 28 | Ignore = ".ignore" 29 | GitModules = ".gitmodules" 30 | IgnoreBinaryFileBytes = 1000 31 | ) 32 | 33 | // ErrTerminateWalk error which indicates that the walker was terminated 34 | var ErrTerminateWalk = errors.New("gocodewalker terminated") 35 | 36 | // File is a struct returned which contains the location and the filename of the file that passed all exclusion rules 37 | type File struct { 38 | Location string 39 | Filename string 40 | } 41 | 42 | var semaphoreCount = 8 43 | 44 | type FileWalker struct { 45 | fileListQueue chan<- *File 46 | errorsHandler func(error) bool // If returns true will continue to process where possible, otherwise returns if possible 47 | directory string 48 | directories []string 49 | LocationExcludePattern []string // Case-sensitive patterns which exclude directory/file matches 50 | IncludeDirectory []string 51 | ExcludeDirectory []string // Paths to always ignore such as .git,.svn and .hg 52 | IncludeFilename []string 53 | ExcludeFilename []string 54 | IncludeDirectoryRegex []*regexp.Regexp // Must match regex as logical OR IE can match any of them 55 | ExcludeDirectoryRegex []*regexp.Regexp 56 | IncludeFilenameRegex []*regexp.Regexp 57 | ExcludeFilenameRegex []*regexp.Regexp 58 | AllowListExtensions []string // Which extensions should be allowed case sensitive 59 | ExcludeListExtensions []string // Which extensions should be excluded case sensitive 60 | walkMutex sync.Mutex 61 | terminateWalking bool 62 | isWalking bool 63 | IgnoreIgnoreFile bool // Should .ignore files be respected? 64 | IgnoreGitIgnore bool // Should .gitignore files be respected? 65 | IgnoreGitModules bool // Should .gitmodules files be respected? 66 | CustomIgnore []string // Custom ignore files 67 | CustomIgnorePatterns []string //Custom ignore patterns 68 | IncludeHidden bool // Should hidden files and directories be included/walked 69 | osOpen func(name string) (*os.File, error) 70 | osReadFile func(name string) ([]byte, error) 71 | countingSemaphore chan bool 72 | semaphoreCount int 73 | MaxDepth int 74 | IgnoreBinaryFiles bool // Should we open the file and try to determine if it is binary? 75 | IgnoreBinaryFileBytes int // How many bytes should be used 76 | } 77 | 78 | // NewFileWalker constructs a filewalker, which will walk the supplied directory 79 | // and output File results to the supplied queue as it finds them 80 | func NewFileWalker(directory string, fileListQueue chan<- *File) *FileWalker { 81 | return &FileWalker{ 82 | fileListQueue: fileListQueue, 83 | errorsHandler: func(e error) bool { return true }, // a generic one that just swallows everything 84 | directory: directory, 85 | LocationExcludePattern: nil, 86 | IncludeDirectory: nil, 87 | ExcludeDirectory: nil, 88 | IncludeFilename: nil, 89 | ExcludeFilename: nil, 90 | IncludeDirectoryRegex: nil, 91 | ExcludeDirectoryRegex: nil, 92 | IncludeFilenameRegex: nil, 93 | ExcludeFilenameRegex: nil, 94 | AllowListExtensions: nil, 95 | ExcludeListExtensions: nil, 96 | walkMutex: sync.Mutex{}, 97 | terminateWalking: false, 98 | isWalking: false, 99 | IgnoreIgnoreFile: false, 100 | IgnoreGitIgnore: false, 101 | CustomIgnore: []string{}, 102 | CustomIgnorePatterns: []string{}, 103 | IgnoreGitModules: false, 104 | IncludeHidden: false, 105 | osOpen: os.Open, 106 | osReadFile: os.ReadFile, 107 | countingSemaphore: make(chan bool, semaphoreCount), 108 | semaphoreCount: semaphoreCount, 109 | MaxDepth: -1, 110 | IgnoreBinaryFiles: false, 111 | IgnoreBinaryFileBytes: IgnoreBinaryFileBytes, 112 | } 113 | } 114 | 115 | // NewParallelFileWalker constructs a filewalker, which will walk the supplied directories in parallel 116 | // and output File results to the supplied queue as it finds them 117 | func NewParallelFileWalker(directories []string, fileListQueue chan<- *File) *FileWalker { 118 | return &FileWalker{ 119 | fileListQueue: fileListQueue, 120 | errorsHandler: func(e error) bool { return true }, // a generic one that just swallows everything 121 | directories: directories, 122 | LocationExcludePattern: nil, 123 | IncludeDirectory: nil, 124 | ExcludeDirectory: nil, 125 | IncludeFilename: nil, 126 | ExcludeFilename: nil, 127 | IncludeDirectoryRegex: nil, 128 | ExcludeDirectoryRegex: nil, 129 | IncludeFilenameRegex: nil, 130 | ExcludeFilenameRegex: nil, 131 | AllowListExtensions: nil, 132 | ExcludeListExtensions: nil, 133 | walkMutex: sync.Mutex{}, 134 | terminateWalking: false, 135 | isWalking: false, 136 | IgnoreIgnoreFile: false, 137 | IgnoreGitIgnore: false, 138 | CustomIgnore: []string{}, 139 | CustomIgnorePatterns: []string{}, 140 | IgnoreGitModules: false, 141 | IncludeHidden: false, 142 | osOpen: os.Open, 143 | osReadFile: os.ReadFile, 144 | countingSemaphore: make(chan bool, semaphoreCount), 145 | semaphoreCount: semaphoreCount, 146 | MaxDepth: -1, 147 | IgnoreBinaryFiles: false, 148 | IgnoreBinaryFileBytes: IgnoreBinaryFileBytes, 149 | } 150 | } 151 | 152 | // SetConcurrency sets the concurrency when walking 153 | // which controls the number of goroutines that 154 | // walk directories concurrently 155 | // by default it is set to 8 156 | // must be a whole integer greater than 0 157 | func (f *FileWalker) SetConcurrency(i int) { 158 | f.walkMutex.Lock() 159 | defer f.walkMutex.Unlock() 160 | if i >= 1 { 161 | f.semaphoreCount = i 162 | } 163 | } 164 | 165 | // Walking gets the state of the file walker and determine 166 | // if we are walking or not 167 | func (f *FileWalker) Walking() bool { 168 | f.walkMutex.Lock() 169 | defer f.walkMutex.Unlock() 170 | return f.isWalking 171 | } 172 | 173 | // Terminate have the walker break out of walking and return as 174 | // soon as it possibly can. This is needed because 175 | // this walker needs to work in a TUI interactive mode and 176 | // as such we need to be able to end old processes 177 | func (f *FileWalker) Terminate() { 178 | f.walkMutex.Lock() 179 | defer f.walkMutex.Unlock() 180 | f.terminateWalking = true 181 | } 182 | 183 | // SetErrorHandler sets the function that is called on processing any error 184 | // where if you return true it will attempt to continue processing, and if false 185 | // will return the error instantly 186 | func (f *FileWalker) SetErrorHandler(errors func(error) bool) { 187 | if errors != nil { 188 | f.errorsHandler = errors 189 | } 190 | } 191 | 192 | // Start will start walking the supplied directory with the supplied settings 193 | // and putting files that mach into the supplied channel. 194 | // Returns usual ioutil errors if there is a file issue 195 | // and a ErrTerminateWalk if terminate is called while walking 196 | func (f *FileWalker) Start() error { 197 | f.walkMutex.Lock() 198 | f.isWalking = true 199 | f.walkMutex.Unlock() 200 | 201 | // we now set the counting semaphore based on the count 202 | // done here because it should not change while walking 203 | f.countingSemaphore = make(chan bool, semaphoreCount) 204 | 205 | var err error 206 | if len(f.directories) != 0 { 207 | eg := errgroup.Group{} 208 | for _, directory := range f.directories { 209 | d := directory // capture var 210 | eg.Go(func() error { 211 | return f.walkDirectoryRecursive(0, d, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}) 212 | }) 213 | } 214 | 215 | err = eg.Wait() 216 | } else { 217 | if f.directory != "" { 218 | err = f.walkDirectoryRecursive(0, f.directory, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}, []gitignore.GitIgnore{}) 219 | } 220 | } 221 | 222 | close(f.fileListQueue) 223 | 224 | f.walkMutex.Lock() 225 | f.isWalking = false 226 | f.walkMutex.Unlock() 227 | 228 | return err 229 | } 230 | 231 | func (f *FileWalker) walkDirectoryRecursive(iteration int, 232 | directory string, 233 | gitignores []gitignore.GitIgnore, 234 | ignores []gitignore.GitIgnore, 235 | moduleIgnores []gitignore.GitIgnore, 236 | customIgnores []gitignore.GitIgnore) error { 237 | 238 | // implement max depth option 239 | if f.MaxDepth != -1 && iteration >= f.MaxDepth { 240 | return nil 241 | } 242 | 243 | if iteration == 1 { 244 | f.countingSemaphore <- true 245 | defer func() { 246 | <-f.countingSemaphore 247 | }() 248 | } 249 | 250 | // NB have to call unlock not using defer because method is recursive 251 | // and will deadlock if not done manually 252 | f.walkMutex.Lock() 253 | if f.terminateWalking { 254 | f.walkMutex.Unlock() 255 | return ErrTerminateWalk 256 | } 257 | f.walkMutex.Unlock() 258 | 259 | d, err := f.osOpen(directory) 260 | if err != nil { 261 | // nothing we can do with this so return nil and process as best we can 262 | if f.errorsHandler(err) { 263 | return nil 264 | } 265 | return err 266 | } 267 | defer func(d *os.File) { 268 | err := d.Close() 269 | if err != nil { 270 | f.errorsHandler(err) 271 | } 272 | }(d) 273 | 274 | foundFiles, err := d.ReadDir(-1) 275 | if err != nil { 276 | // nothing we can do with this so return nil and process as best we can 277 | if f.errorsHandler(err) { 278 | return nil 279 | } 280 | return err 281 | } 282 | 283 | files := []fs.DirEntry{} 284 | dirs := []fs.DirEntry{} 285 | 286 | // We want to break apart the files and directories from the 287 | // return as we loop over them differently and this avoids some 288 | // nested if logic at the expense of a "redundant" loop 289 | for _, file := range foundFiles { 290 | if file.IsDir() { 291 | dirs = append(dirs, file) 292 | } else { 293 | files = append(files, file) 294 | } 295 | } 296 | 297 | // Pull out all ignore, gitignore and gitmodule files and add them 298 | // to out collection of gitignores to be applied for this pass 299 | // and any subdirectories 300 | // Since they can apply to the current list of files we need to ensure 301 | // we do this before processing files themselves 302 | for _, file := range files { 303 | if !f.IgnoreGitIgnore { 304 | if file.Name() == GitIgnore { 305 | c, err := f.osReadFile(filepath.Join(directory, file.Name())) 306 | if err != nil { 307 | if f.errorsHandler(err) { 308 | continue // if asked to ignore it lets continue 309 | } 310 | return err 311 | } 312 | 313 | abs, err := filepath.Abs(directory) 314 | if err != nil { 315 | if f.errorsHandler(err) { 316 | continue // if asked to ignore it lets continue 317 | } 318 | return err 319 | } 320 | 321 | gitIgnore := gitignore.New(bytes.NewReader(c), abs, nil) 322 | gitignores = append(gitignores, gitIgnore) 323 | } 324 | } 325 | 326 | if !f.IgnoreIgnoreFile { 327 | if file.Name() == Ignore { 328 | c, err := f.osReadFile(filepath.Join(directory, file.Name())) 329 | if err != nil { 330 | if f.errorsHandler(err) { 331 | continue // if asked to ignore it lets continue 332 | } 333 | return err 334 | } 335 | 336 | abs, err := filepath.Abs(directory) 337 | if err != nil { 338 | if f.errorsHandler(err) { 339 | continue // if asked to ignore it lets continue 340 | } 341 | return err 342 | } 343 | 344 | gitIgnore := gitignore.New(bytes.NewReader(c), abs, nil) 345 | ignores = append(ignores, gitIgnore) 346 | } 347 | } 348 | 349 | // this should only happen on the first iteration 350 | // because there should be one .gitmodules file per repository 351 | // however we also need to support someone running in a directory of 352 | // projects that have multiple repositories or in a go vendor 353 | // repository etc... hence check every time 354 | if !f.IgnoreGitModules { 355 | if file.Name() == GitModules { 356 | // now we need to open and parse the file 357 | c, err := f.osReadFile(filepath.Join(directory, file.Name())) 358 | if err != nil { 359 | if f.errorsHandler(err) { 360 | continue // if asked to ignore it lets continue 361 | } 362 | return err 363 | } 364 | 365 | abs, err := filepath.Abs(directory) 366 | if err != nil { 367 | if f.errorsHandler(err) { 368 | continue // if asked to ignore it lets continue 369 | } 370 | return err 371 | } 372 | 373 | for _, gm := range extractGitModuleFolders(string(c)) { 374 | gitIgnore := gitignore.New(strings.NewReader(gm), abs, nil) 375 | moduleIgnores = append(moduleIgnores, gitIgnore) 376 | } 377 | } 378 | } 379 | 380 | for _, ci := range f.CustomIgnore { 381 | if file.Name() == ci { 382 | c, err := f.osReadFile(filepath.Join(directory, file.Name())) 383 | if err != nil { 384 | if f.errorsHandler(err) { 385 | continue // if asked to ignore it lets continue 386 | } 387 | return err 388 | } 389 | 390 | abs, err := filepath.Abs(directory) 391 | if err != nil { 392 | if f.errorsHandler(err) { 393 | continue // if asked to ignore it lets continue 394 | } 395 | return err 396 | } 397 | 398 | gitIgnore := gitignore.New(bytes.NewReader(c), abs, nil) 399 | customIgnores = append(customIgnores, gitIgnore) 400 | } 401 | } 402 | } 403 | 404 | // If we have custom ignore patterns defined we should concatenate them and treat them as a single gitignore file 405 | if len(f.CustomIgnorePatterns) > 0 { 406 | customIgnorePatternsCombined := strings.Join(f.CustomIgnorePatterns, "\n") 407 | 408 | abs, err := filepath.Abs(directory) 409 | if err != nil { 410 | if !f.errorsHandler(err) { 411 | return err 412 | } 413 | } 414 | 415 | gitIgnore := gitignore.New(bytes.NewReader([]byte(customIgnorePatternsCombined)), abs, nil) 416 | customIgnores = append(customIgnores, gitIgnore) 417 | } 418 | 419 | // Process files first to start feeding whatever process is consuming 420 | // the output before traversing into directories for more files 421 | for _, file := range files { 422 | shouldIgnore := false 423 | joined := filepath.Join(directory, file.Name()) 424 | 425 | for _, ignore := range gitignores { 426 | // we have the following situations 427 | // 1. none of the gitignores match 428 | // 2. one or more match 429 | // for #1 this means we should include the file 430 | // for #2 this means the last one wins since it should be the most correct 431 | if ignore.MatchIsDir(joined, false) != nil { 432 | shouldIgnore = ignore.Ignore(joined) 433 | } 434 | } 435 | 436 | for _, ignore := range ignores { 437 | // same rules as above 438 | if ignore.MatchIsDir(joined, false) != nil { 439 | shouldIgnore = ignore.Ignore(joined) 440 | } 441 | } 442 | 443 | for _, ignore := range customIgnores { 444 | // same rules as above 445 | if ignore.MatchIsDir(joined, false) != nil { 446 | shouldIgnore = ignore.Ignore(joined) 447 | } 448 | } 449 | 450 | if len(f.IncludeFilename) != 0 { 451 | // include files 452 | shouldIgnore = !slices.ContainsFunc(f.IncludeFilename, func(allow string) bool { 453 | return file.Name() == allow 454 | }) 455 | } 456 | // Exclude comes after include as it takes precedence 457 | for _, deny := range f.ExcludeFilename { 458 | if file.Name() == deny { 459 | shouldIgnore = true 460 | break 461 | } 462 | } 463 | 464 | if len(f.IncludeFilenameRegex) != 0 { 465 | shouldIgnore = !slices.ContainsFunc(f.IncludeFilenameRegex, func(allow *regexp.Regexp) bool { 466 | return allow.MatchString(file.Name()) 467 | }) 468 | } 469 | // Exclude comes after include as it takes precedence 470 | for _, deny := range f.ExcludeFilenameRegex { 471 | if deny.MatchString(file.Name()) { 472 | shouldIgnore = true 473 | break 474 | } 475 | } 476 | 477 | // Ignore hidden files 478 | if !f.IncludeHidden { 479 | s, err := IsHiddenDirEntry(file, directory) 480 | if err != nil { 481 | if !f.errorsHandler(err) { 482 | return err 483 | } 484 | } 485 | 486 | if s { 487 | shouldIgnore = true 488 | } 489 | } 490 | 491 | // Check against extensions 492 | if len(f.AllowListExtensions) != 0 { 493 | ext := GetExtension(file.Name()) 494 | // try again because we could have one of those pesky ones such as something.spec.tsx 495 | // but only if we didn't already find something to save on a bit of processing 496 | if !slices.Contains(f.AllowListExtensions, ext) && !slices.Contains(f.AllowListExtensions, GetExtension(ext)) { 497 | shouldIgnore = true 498 | } 499 | } 500 | 501 | if len(f.ExcludeListExtensions) != 0 { 502 | ext := GetExtension(file.Name()) 503 | shouldIgnore = slices.ContainsFunc(f.ExcludeListExtensions, func(deny string) bool { 504 | return ext == deny || GetExtension(ext) == deny 505 | }) 506 | } 507 | 508 | for _, p := range f.LocationExcludePattern { 509 | if strings.Contains(joined, p) { 510 | shouldIgnore = true 511 | break 512 | } 513 | } 514 | 515 | if f.IgnoreBinaryFiles { 516 | fi, err := os.Open(filepath.Join(directory, file.Name())) 517 | if err != nil { 518 | if !f.errorsHandler(err) { 519 | return err 520 | } 521 | } 522 | defer func(fi *os.File) { 523 | _ = fi.Close() 524 | }(fi) 525 | 526 | buffer := make([]byte, f.IgnoreBinaryFileBytes) 527 | 528 | // Read up to buffer size 529 | _, err = io.ReadFull(fi, buffer) 530 | if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) { 531 | if !f.errorsHandler(err) { 532 | return err 533 | } 534 | } 535 | 536 | // cheaply check if is binary file by checking for null byte. 537 | // note that this could be improved later on by checking for magic numbers and the like 538 | // but that should probably be its own package 539 | for _, b := range buffer { 540 | if b == 0 { 541 | shouldIgnore = true 542 | break 543 | } 544 | } 545 | } 546 | 547 | if !shouldIgnore { 548 | f.fileListQueue <- &File{ 549 | Location: joined, 550 | Filename: file.Name(), 551 | } 552 | } 553 | } 554 | 555 | // if we are the 1st iteration IE not the root, we run in parallel 556 | wg := sync.WaitGroup{} 557 | 558 | // Now we process the directories after hopefully giving the 559 | // channel some files to process 560 | for _, dir := range dirs { 561 | var shouldIgnore bool 562 | joined := filepath.Join(directory, dir.Name()) 563 | 564 | // Check against the ignore files we have if the file we are looking at 565 | // should be ignored 566 | // It is safe to always call this because the gitignores will not be added 567 | // in previous steps 568 | for _, ignore := range gitignores { 569 | // we have the following situations 570 | // 1. none of the gitignores match 571 | // 2. one or more match 572 | // for #1 this means we should include the file 573 | // for #2 this means the last one wins since it should be the most correct 574 | if ignore.MatchIsDir(joined, true) != nil { 575 | shouldIgnore = ignore.Ignore(joined) 576 | } 577 | } 578 | for _, ignore := range ignores { 579 | // same rules as above 580 | if ignore.MatchIsDir(joined, true) != nil { 581 | shouldIgnore = ignore.Ignore(joined) 582 | } 583 | } 584 | for _, ignore := range customIgnores { 585 | // same rules as above 586 | if ignore.MatchIsDir(joined, true) != nil { 587 | shouldIgnore = ignore.Ignore(joined) 588 | } 589 | } 590 | for _, ignore := range moduleIgnores { 591 | // same rules as above 592 | if ignore.MatchIsDir(joined, true) != nil { 593 | shouldIgnore = ignore.Ignore(joined) 594 | } 595 | } 596 | 597 | // start by saying we didn't find it then check each possible 598 | // choice to see if we did find it 599 | // if we didn't find it then we should ignore 600 | if len(f.IncludeDirectory) != 0 { 601 | shouldIgnore = !slices.ContainsFunc(f.IncludeDirectory, func(allow string) bool { 602 | return dir.Name() == allow 603 | }) 604 | } 605 | // Confirm if there are any files in the path deny list which usually includes 606 | // things like .git .hg and .svn 607 | // Comes after include as it takes precedence 608 | for _, deny := range f.ExcludeDirectory { 609 | if isSuffixDir(joined, deny) { 610 | shouldIgnore = true 611 | break 612 | } 613 | } 614 | 615 | if len(f.IncludeDirectoryRegex) != 0 { 616 | shouldIgnore = !slices.ContainsFunc(f.IncludeDirectoryRegex, func(allow *regexp.Regexp) bool { 617 | return allow.MatchString(dir.Name()) 618 | }) 619 | } 620 | // Exclude comes after include as it takes precedence 621 | for _, deny := range f.ExcludeDirectoryRegex { 622 | if deny.MatchString(dir.Name()) { 623 | shouldIgnore = true 624 | break 625 | } 626 | } 627 | 628 | // Ignore hidden directories 629 | if !f.IncludeHidden { 630 | s, err := IsHiddenDirEntry(dir, directory) 631 | if err != nil { 632 | if !f.errorsHandler(err) { 633 | return err 634 | } 635 | } 636 | 637 | if s { 638 | shouldIgnore = true 639 | } 640 | } 641 | 642 | for _, p := range f.LocationExcludePattern { 643 | if strings.Contains(joined, p) { 644 | shouldIgnore = true 645 | break 646 | } 647 | } 648 | 649 | if !shouldIgnore { 650 | if iteration == 0 { 651 | wg.Add(1) 652 | go func(iteration int, directory string, gitignores []gitignore.GitIgnore, ignores []gitignore.GitIgnore) { 653 | _ = f.walkDirectoryRecursive(iteration+1, joined, gitignores, ignores, moduleIgnores, customIgnores) 654 | wg.Done() 655 | }(iteration, joined, gitignores, ignores) 656 | } else { 657 | err = f.walkDirectoryRecursive(iteration+1, joined, gitignores, ignores, moduleIgnores, customIgnores) 658 | if err != nil { 659 | return err 660 | } 661 | } 662 | } 663 | } 664 | 665 | wg.Wait() 666 | 667 | return nil 668 | } 669 | 670 | // FindRepositoryRoot given the supplied directory backwards looking for .git or .hg 671 | // directories indicating we should start our search from that 672 | // location as it's the root. 673 | // Returns the first directory below supplied with .git or .hg in it 674 | // otherwise the supplied directory 675 | func FindRepositoryRoot(startDirectory string) string { 676 | // Firstly try to determine our real location 677 | curdir, err := os.Getwd() 678 | if err != nil { 679 | return startDirectory 680 | } 681 | 682 | // Check if we have .git or .hg where we are and if 683 | // so just return because we are already there 684 | if checkForGitOrMercurial(curdir) { 685 | return startDirectory 686 | } 687 | 688 | // We did not find something, so now we need to walk the file tree 689 | // backwards in a cross platform way and if we find 690 | // a match we return that 691 | lastIndex := strings.LastIndex(curdir, string(os.PathSeparator)) 692 | for lastIndex != -1 { 693 | curdir = curdir[:lastIndex] 694 | 695 | if checkForGitOrMercurial(curdir) { 696 | return curdir 697 | } 698 | 699 | lastIndex = strings.LastIndex(curdir, string(os.PathSeparator)) 700 | } 701 | 702 | // If we didn't find a good match return the supplied directory 703 | // so that we start the search from where we started at least 704 | // rather than the root 705 | return startDirectory 706 | } 707 | 708 | // Check if there is a .git or .hg folder in the supplied directory 709 | func checkForGitOrMercurial(curdir string) bool { 710 | if stat, err := os.Stat(filepath.Join(curdir, ".git")); err == nil && stat.IsDir() { 711 | return true 712 | } 713 | 714 | if stat, err := os.Stat(filepath.Join(curdir, ".hg")); err == nil && stat.IsDir() { 715 | return true 716 | } 717 | 718 | return false 719 | } 720 | 721 | // GetExtension is a custom version of extracting extensions for a file 722 | // which deals with extensions specific to code such as 723 | // .travis.yml and the like 724 | func GetExtension(name string) string { 725 | name = strings.ToLower(name) 726 | if !strings.Contains(name, ".") { 727 | return name 728 | } 729 | 730 | if strings.LastIndex(name, ".") == 0 { 731 | return name 732 | } 733 | 734 | return path.Ext(name)[1:] 735 | } 736 | -------------------------------------------------------------------------------- /gitignore_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gocodewalker 4 | 5 | import ( 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/boyter/gocodewalker/go-gitignore" 11 | ) 12 | 13 | func TestGitIgnore(t *testing.T) { 14 | testcases := []string{ 15 | `/`, 16 | `\`, 17 | `"`, 18 | `.`, 19 | } 20 | 21 | abs, _ := filepath.Abs(".") 22 | for _, te := range testcases { 23 | gitignore.New(strings.NewReader(te), abs, nil) 24 | } 25 | } 26 | 27 | func FuzzTestGitIgnore(f *testing.F) { 28 | testcases := []string{ 29 | "", 30 | `\`, 31 | `'`, 32 | `#`, 33 | "/", 34 | "README.md", 35 | `README.md 36 | /`, 37 | `*.[oa] 38 | *.html 39 | *.min.js 40 | 41 | !foo*.html 42 | foo-excl.html 43 | 44 | vmlinux* 45 | 46 | \!important!.txt 47 | 48 | log/*.log 49 | !/log/foo.log 50 | 51 | **/logdir/log 52 | **/foodir/bar 53 | exclude/** 54 | 55 | !findthis* 56 | 57 | **/hide/** 58 | subdir/subdir2/ 59 | 60 | /rootsubdir/ 61 | 62 | dirpattern/ 63 | 64 | README.md 65 | `, 66 | } 67 | 68 | for _, tc := range testcases { 69 | f.Add(tc) // Use f.Add to provide a seed corpus 70 | } 71 | 72 | abs, _ := filepath.Abs(".") 73 | f.Fuzz(func(t *testing.T, c string) { 74 | gitignore.New(strings.NewReader(c), abs, nil) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /gitmodule.go: -------------------------------------------------------------------------------- 1 | package gocodewalker 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | func extractGitModuleFolders(input string) []string { 9 | // Compile a regular expression to match lines starting with "path =" 10 | re := regexp.MustCompile(`^\s*path\s*=\s*(.*)`) 11 | output := []string{} 12 | 13 | for _, line := range strings.Split(input, "\n") { 14 | // Check if the line matches the "path = " pattern 15 | if matches := re.FindStringSubmatch(line); matches != nil { 16 | // Extract the submodule path (which is captured in the regex group) 17 | submodulePath := strings.TrimSpace(matches[1]) 18 | output = append(output, submodulePath) 19 | } 20 | } 21 | 22 | return output 23 | } 24 | -------------------------------------------------------------------------------- /gitmodule_test.go: -------------------------------------------------------------------------------- 1 | package gocodewalker 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_extractGitModuleFolders(t *testing.T) { 9 | type args struct { 10 | input string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want []string 16 | }{ 17 | { 18 | name: "", 19 | args: args{ 20 | input: `[submodule "contracts/lib/forge-std"] 21 | path = contracts/lib/forge-std 22 | url = https://github.com/foundry-rs/forge-std 23 | tag = v1.8.2 24 | [submodule "contracts/lib/sp1-contracts"] 25 | path = contracts/lib/sp1-contracts 26 | url = https://github.com/succinctlabs/sp1-contracts 27 | tag = main 28 | [submodule "contracts/lib/openzeppelin-contracts"] 29 | path = contracts/lib/openzeppelin-contracts 30 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 31 | [submodule "lib/java-tron"] 32 | path = lib/java-tron 33 | url = https://github.com/tronprotocol/java-tron 34 | [submodule "lib/googleapis"] 35 | path = lib/googleapis 36 | url = https://github.com/googleapis/googleapis 37 | [submodule "contracts/lib/openzeppelin-contracts-upgradeable"] 38 | path = contracts/lib/openzeppelin-contracts-upgradeable 39 | url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable 40 | [submodule "contracts/lib/v2-testnet-contracts"] 41 | path = contracts/lib/v2-testnet-contracts 42 | url = https://github.com/matter-labs/v2-testnet-contracts 43 | branch = beta`, 44 | }, 45 | want: []string{ 46 | "contracts/lib/forge-std", 47 | "contracts/lib/sp1-contracts", 48 | "contracts/lib/openzeppelin-contracts", 49 | "lib/java-tron", 50 | "lib/googleapis", 51 | "contracts/lib/openzeppelin-contracts-upgradeable", 52 | "contracts/lib/v2-testnet-contracts", 53 | }, 54 | }, 55 | } 56 | for _, tt := range tests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | if got := extractGitModuleFolders(tt.args.input); !reflect.DeepEqual(got, tt.want) { 59 | t.Errorf("extractGitModuleFolders() = %v, want %v", got, tt.want) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /go-gitignore/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Denormal Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go-gitignore/README.md: -------------------------------------------------------------------------------- 1 | # go-gitignore 2 | 3 | Package `go-gitignore` provides an interface for parsing `.gitignore` files, 4 | either individually, or within a repository, and 5 | matching paths against the retrieved patterns. Path matching is done using 6 | [fnmatch](https://github.com/danwakefield/fnmatch) as specified by 7 | [git](https://git-scm.com/docs/gitignore), with 8 | support for recursive matching via the `**` pattern. 9 | 10 | ```go 11 | import "github.com/denormal/go-gitignore" 12 | 13 | // match a file against a particular .gitignore 14 | ignore, err := gitignore.NewFromFile("/my/.gitignore") 15 | if err != nil { 16 | panic(err) 17 | } 18 | match := ignore.Match("/my/file/to.check") 19 | if match != nil { 20 | if match.Ignore() { 21 | return true 22 | } 23 | } 24 | 25 | // or match against a repository 26 | // - here we match a directory path relative to the repository 27 | ignore, err := gitignore.NewRepository( "/my/git/repository" ) 28 | if err != nil { 29 | panic(err) 30 | } 31 | match := ignore.Relative("src/examples", true) 32 | if match != nil { 33 | if match.Include() { 34 | fmt.Printf( 35 | "include src/examples/ because of pattern %q at %s", 36 | match, match.Position(), 37 | ) 38 | } 39 | } 40 | 41 | // if it's not important whether a path matches, but whether it is 42 | // ignored or included... 43 | if ignore.Ignore("src/test") { 44 | fmt.Println("ignore src/test") 45 | } else if ignore.Include("src/github.com") { 46 | fmt.Println("include src/github.com") 47 | } 48 | ``` 49 | 50 | For more information see `godoc github.com/denormal/go-gitignore`. 51 | 52 | ## Patterns 53 | 54 | `go-gitignore` supports the same `.gitignore` pattern format and matching rules as defined by [git](https://git-scm.com/docs/gitignore): 55 | 56 | * A blank line matches no files, so it can serve as a separator for readability. 57 | 58 | * A line starting with `#` serves as a comment. Put a backslash `\` in front of the first hash for patterns that begin with a hash. 59 | 60 | * Trailing spaces are ignored unless they are quoted with backslash `\`. 61 | 62 | * An optional prefix `!` which negates the pattern; any matching file excluded by a previous pattern will become included again. It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn’t list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined. Put a backslash `\` in front of the first `!` for patterns that begin with a literal `!`, for example, `\!important!.txt`. 63 | 64 | * If the pattern ends with a slash, it is removed for the purpose of the following description, but it would only find a match with a directory. In other words, `foo/` will match a directory foo and paths underneath it, but will not match a regular file or a symbolic link `foo` (this is consistent with the way how pathspec works in general in Git). 65 | 66 | * If the pattern does not contain a slash `/`, Git treats it as a shell glob pattern and checks for a match against the pathname relative to the location of the `.gitignore` file (relative to the toplevel of the work tree if not from a `.gitignore` file). 67 | 68 | * Otherwise, Git treats the pattern as a shell glob suitable for consumption by `fnmatch(3)` with the `FNM_PATHNAME` flag: wildcards in the pattern will not match a `/` in the pathname. For example, `Documentation/*.html` matches `Documentation/git.html` but not `Documentation/ppc/ppc.html` or `tools/perf/Documentation/perf.html`. 69 | 70 | * A leading slash matches the beginning of the pathname. For example, `/*.c` matches `cat-file.c` but not `mozilla-sha1/sha1.c`. 71 | 72 | Two consecutive asterisks `**` in patterns matched against full pathname may have special meaning: 73 | 74 | * A leading `**` followed by a slash means match in all directories. For example, `**/foo` matches file or directory `foo` anywhere, the same as pattern `foo`. `**/foo/bar` matches file or directory `bar` anywhere that is directly under directory `foo`. 75 | 76 | * A trailing `/**` matches everything inside. For example, `abc/**` matches all files inside directory `abc`, relative to the location of the `.gitignore` file, with infinite depth. 77 | 78 | * A slash followed by two consecutive asterisks then a slash matches zero or more directories. For example, `a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b` and so on. 79 | 80 | * Other consecutive asterisks are considered invalid. 81 | 82 | ## Installation 83 | 84 | `go-gitignore` can be installed using the standard Go approach: 85 | 86 | ```go 87 | go get github.com/denormal/go-gitignore 88 | ``` 89 | 90 | ## License 91 | 92 | Copyright (c) 2016 Denormal Limited 93 | 94 | [MIT License](LICENSE) 95 | -------------------------------------------------------------------------------- /go-gitignore/cache.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "sync" 7 | ) 8 | 9 | // Cache is the interface for the GitIgnore cache 10 | type Cache interface { 11 | // Set stores the GitIgnore ignore against its path. 12 | Set(path string, ig GitIgnore) 13 | 14 | // Get attempts to retrieve an GitIgnore instance associated with the given 15 | // path. If the path is not known nil is returned. 16 | Get(path string) GitIgnore 17 | } 18 | 19 | // cache is the default thread-safe cache implementation 20 | type cache struct { 21 | _i map[string]GitIgnore 22 | _lock sync.Mutex 23 | } 24 | 25 | // NewCache returns a Cache instance. This is a thread-safe, in-memory cache 26 | // for GitIgnore instances. 27 | func NewCache() Cache { 28 | return &cache{} 29 | } // Cache() 30 | 31 | // Set stores the GitIgnore ignore against its path. 32 | func (c *cache) Set(path string, ignore GitIgnore) { 33 | if ignore == nil { 34 | return 35 | } 36 | 37 | // ensure the map is defined 38 | if c._i == nil { 39 | c._i = make(map[string]GitIgnore) 40 | } 41 | 42 | // set the cache item 43 | c._lock.Lock() 44 | c._i[path] = ignore 45 | c._lock.Unlock() 46 | } // Set() 47 | 48 | // Get attempts to retrieve an GitIgnore instance associated with the given 49 | // path. If the path is not known nil is returned. 50 | func (c *cache) Get(path string) GitIgnore { 51 | c._lock.Lock() 52 | _ignore, _ok := c._i[path] 53 | c._lock.Unlock() 54 | if _ok { 55 | return _ignore 56 | } else { 57 | return nil 58 | } 59 | } // Get() 60 | 61 | // ensure cache supports the Cache interface 62 | var _ Cache = &cache{} 63 | -------------------------------------------------------------------------------- /go-gitignore/cache_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/boyter/gocodewalker/go-gitignore" 9 | ) 10 | 11 | func TestCache(t *testing.T) { 12 | // populate the cache with the defined tests 13 | _cache := gitignore.NewCache() 14 | for _k, _v := range _CACHETEST { 15 | _cache.Set(_k, _v) 16 | } 17 | 18 | // attempt to retrieve the values from the cache 19 | // - if a GitIgnore instance is returned, ensure it is the correct 20 | // instance, and not some other instance 21 | for _k, _v := range _CACHETEST { 22 | _found := _cache.Get(_k) 23 | if _found != _v { 24 | t.Errorf("cache Get() mismatch; expected %v, got %v", 25 | _v, _found, 26 | ) 27 | } 28 | } 29 | 30 | // ensure unknown cache keys return nil 31 | for _, _k := range _CACHEUNKNOWN { 32 | _found := _cache.Get(_k) 33 | if _found != nil { 34 | t.Errorf("cache.Get() unexpected return for key %q; "+ 35 | "expected nil, got %v", 36 | _k, _found, 37 | ) 38 | } 39 | } 40 | 41 | // ensure we can update the cache 42 | _ignore := null() 43 | for _k := range _CACHETEST { 44 | _cache.Set(_k, _ignore) 45 | } 46 | for _k := range _CACHETEST { 47 | _found := _cache.Get(_k) 48 | if _found != _ignore { 49 | t.Errorf("cache Get() mismatch; expected %v, got %v", 50 | _ignore, _found, 51 | ) 52 | } 53 | } 54 | } // TestCache() 55 | -------------------------------------------------------------------------------- /go-gitignore/doc.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | /* 4 | Package gitignore provides an interface for parsing .gitignore files, 5 | either individually, or within a repository, and 6 | matching paths against the retrieved patterns. Path matching is done using 7 | fnmatch as specified by git (see https://git-scm.com/docs/gitignore), with 8 | support for recursive matching via the "**" pattern. 9 | */ 10 | package gitignore 11 | -------------------------------------------------------------------------------- /go-gitignore/error.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | type Error interface { 6 | error 7 | 8 | // Position returns the position of the error within the .gitignore file 9 | // (if any) 10 | Position() Position 11 | 12 | // Underlying returns the underlying error, permitting direct comparison 13 | // against the wrapped error. 14 | Underlying() error 15 | } 16 | 17 | type err struct { 18 | error 19 | _position Position 20 | } // err() 21 | 22 | // NewError returns a new Error instance for the given error e and position p. 23 | func NewError(e error, p Position) Error { 24 | return &err{error: e, _position: p} 25 | } // NewError() 26 | 27 | func (e *err) Position() Position { return e._position } 28 | 29 | func (e *err) Underlying() error { return e.error } 30 | 31 | // ensure err satisfies the Error interface 32 | var _ Error = &err{} 33 | -------------------------------------------------------------------------------- /go-gitignore/errors.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "errors" 7 | ) 8 | 9 | var ( 10 | ErrCarriageReturnError = errors.New("unexpected carriage return '\\r'") 11 | ErrInvalidPatternError = errors.New("invalid pattern") 12 | ErrInvalidDirectoryError = errors.New("invalid directory") 13 | ) 14 | -------------------------------------------------------------------------------- /go-gitignore/example_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/boyter/gocodewalker/go-gitignore" 9 | ) 10 | 11 | func ExampleNewFromFile() { 12 | ignore, err := gitignore.NewFromFile("/my/project/.gitignore") 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | // attempt to match an absolute path 18 | match := ignore.Match("/my/project/src/file.go") 19 | if match != nil { 20 | if match.Ignore() { 21 | fmt.Println("ignore file.go") 22 | } 23 | } 24 | 25 | // attempt to match a relative path 26 | // - this is equivalent to the call above 27 | match = ignore.Relative("src/file.go", false) 28 | if match != nil { 29 | if match.Include() { 30 | fmt.Println("include file.go") 31 | } 32 | } 33 | } // ExampleNewFromFile() 34 | 35 | func ExampleNewRepository() { 36 | ignore, err := gitignore.NewRepository("/my/project") 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | // attempt to match a directory in the repository 42 | match := ignore.Relative("src/examples", true) 43 | if match != nil { 44 | if match.Ignore() { 45 | fmt.Printf( 46 | "ignore src/examples because of pattern %q at %s", 47 | match, match.Position(), 48 | ) 49 | } 50 | } 51 | 52 | // if we have an absolute path, or a path relative to the current 53 | // working directory we can use the short-hand methods 54 | if ignore.Include("/my/project/etc/service.conf") { 55 | fmt.Println("include the service configuration") 56 | } 57 | } // ExampleNewRepository() 58 | -------------------------------------------------------------------------------- /go-gitignore/exclude.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // exclude attempts to return the GitIgnore instance for the 11 | // $GIT_DIR/info/exclude from the working copy to which path belongs. 12 | func exclude(path string) (GitIgnore, error) { 13 | // attempt to locate GIT_DIR 14 | _gitdir := os.Getenv("GIT_DIR") 15 | if _gitdir == "" { 16 | _gitdir = filepath.Join(path, ".git") 17 | } 18 | _info, _err := os.Stat(_gitdir) 19 | if _err != nil { 20 | if os.IsNotExist(_err) { 21 | return nil, nil 22 | } else { 23 | return nil, _err 24 | } 25 | } else if !_info.IsDir() { 26 | return nil, nil 27 | } 28 | 29 | // is there an info/exclude file within this directory? 30 | _file := filepath.Join(_gitdir, "info", "exclude") 31 | _, _err = os.Stat(_file) 32 | if _err != nil { 33 | if os.IsNotExist(_err) { 34 | return nil, nil 35 | } else { 36 | return nil, _err 37 | } 38 | } 39 | 40 | // attempt to load the exclude file 41 | return NewFromFile(_file) 42 | } // exclude() 43 | -------------------------------------------------------------------------------- /go-gitignore/gitignore.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // use an empty GitIgnore for cached lookups 15 | var empty = &ignore{} 16 | 17 | // GitIgnore is the interface to .gitignore files and repositories. It defines 18 | // methods for testing files for matching the .gitignore file, and then 19 | // determining whether a file should be ignored or included. 20 | type GitIgnore interface { 21 | // Base returns the directory containing the .gitignore file. 22 | Base() string 23 | 24 | // Match attempts to match the path against this GitIgnore, and will 25 | // return its Match if successful. Match will invoke the GitIgnore error 26 | // handler (if defined) if it is not possible to determine the absolute 27 | // path of the given path, or if its not possible to determine if the 28 | // path represents a file or a directory. If an error occurs, Match 29 | // returns nil and the error handler (if defined via New, NewWithErrors 30 | // or NewWithCache) will be invoked. 31 | Match(path string) Match 32 | 33 | MatchIsDir(path string, _isdir bool) Match 34 | 35 | // Absolute attempts to match an absolute path against this GitIgnore. If 36 | // the path is not located under the base directory of this GitIgnore, or 37 | // is not matched by this GitIgnore, nil is returned. 38 | Absolute(string, bool) Match 39 | 40 | // Relative attempts to match a path relative to the GitIgnore base 41 | // directory. isdir is used to indicate whether the path represents a file 42 | // or a directory. If the path is not matched by the GitIgnore, nil is 43 | // returned. 44 | Relative(path string, isdir bool) Match 45 | 46 | // Ignore returns true if the path is ignored by this GitIgnore. Paths 47 | // that are not matched by this GitIgnore are not ignored. Internally, 48 | // Ignore uses Match, and will return false if Match() returns nil for path. 49 | Ignore(path string) bool 50 | 51 | // Include returns true if the path is included by this GitIgnore. Paths 52 | // that are not matched by this GitIgnore are always included. Internally, 53 | // Include uses Match, and will return true if Match() returns nil for path. 54 | Include(path string) bool 55 | } 56 | 57 | // ignore is the implementation of a .gitignore file. 58 | type ignore struct { 59 | _base string 60 | _pattern []Pattern 61 | _errors func(Error) bool 62 | } 63 | 64 | // NewGitIgnore creates a new GitIgnore instance from the patterns listed in t, 65 | // representing a .gitignore file in the base directory. If errors is given, it 66 | // will be invoked for every error encountered when parsing the .gitignore 67 | // patterns. Parsing will terminate if errors is called and returns false, 68 | // otherwise, parsing will continue until end of file has been reached. 69 | func New(r io.Reader, base string, errors func(Error) bool) GitIgnore { 70 | // do we have an error handler? 71 | _errors := errors 72 | if _errors == nil { 73 | _errors = func(e Error) bool { return true } 74 | } 75 | 76 | // extract the patterns from the reader 77 | _parser := NewParser(r, _errors) 78 | _patterns := _parser.Parse() 79 | 80 | return &ignore{_base: base, _pattern: _patterns, _errors: _errors} 81 | } // New() 82 | 83 | // NewFromFile creates a GitIgnore instance from the given file. An error 84 | // will be returned if file cannot be opened or its absolute path determined. 85 | func NewFromFile(file string) (GitIgnore, error) { 86 | // define an error handler to catch any file access errors 87 | // - record the first encountered error 88 | var _error Error 89 | _errors := func(e Error) bool { 90 | if _error == nil { 91 | _error = e 92 | } 93 | return true 94 | } 95 | 96 | // attempt to retrieve the GitIgnore represented by this file 97 | _ignore := NewWithErrors(file, _errors) 98 | 99 | // did we encounter an error? 100 | // - if the error has a zero Position then it was encountered 101 | // before parsing was attempted, so we return that error 102 | if _error != nil { 103 | if _error.Position().Zero() { 104 | return nil, _error.Underlying() 105 | } 106 | } 107 | 108 | // otherwise, we ignore the parser errors 109 | return _ignore, nil 110 | } // NewFromFile() 111 | 112 | // NewWithErrors creates a GitIgnore instance from the given file. 113 | // If errors is given, it will be invoked for every error encountered when 114 | // parsing the .gitignore patterns. Parsing will terminate if errors is called 115 | // and returns false, otherwise, parsing will continue until end of file has 116 | // been reached. NewWithErrors returns nil if the .gitignore could not be read. 117 | func NewWithErrors(file string, errors func(Error) bool) GitIgnore { 118 | var _err error 119 | 120 | // do we have an error handler? 121 | _file := file 122 | _errors := errors 123 | if _errors == nil { 124 | _errors = func(e Error) bool { return true } 125 | } else { 126 | // augment the error handler to include the .gitignore file name 127 | // - we do this here since the parser and lexer interfaces are 128 | // not aware of file names 129 | _errors = func(e Error) bool { 130 | // augment the position with the file name 131 | _position := e.Position() 132 | _position.File = _file 133 | 134 | // create a new error with the updated Position 135 | _error := NewError(e.Underlying(), _position) 136 | 137 | // invoke the original error handler 138 | return errors(_error) 139 | } 140 | } 141 | 142 | // we need the absolute path for the GitIgnore base 143 | _file, _err = filepath.Abs(file) 144 | if _err != nil { 145 | _errors(NewError(_err, Position{})) 146 | return nil 147 | } 148 | _base := filepath.Dir(_file) 149 | 150 | // attempt to open the ignore file to create the io.Reader 151 | _fh, _err := os.Open(_file) 152 | if _err != nil { 153 | _errors(NewError(_err, Position{})) 154 | return nil 155 | } 156 | 157 | // return the GitIgnore instance 158 | return New(_fh, _base, _errors) 159 | } // NewWithErrors() 160 | 161 | // NewWithCache returns a GitIgnore instance (using NewWithErrors) 162 | // for the given file. If the file has been loaded before, its GitIgnore 163 | // instance will be returned from the cache rather than being reloaded. If 164 | // cache is not defined, NewWithCache will behave as NewWithErrors 165 | // 166 | // If NewWithErrors returns nil, NewWithCache will store an empty 167 | // GitIgnore (i.e. no patterns) against the file to prevent repeated parse 168 | // attempts on subsequent requests for the same file. Subsequent calls to 169 | // NewWithCache for a file that could not be loaded due to an error will 170 | // return nil. 171 | // 172 | // If errors is given, it will be invoked for every error encountered when 173 | // parsing the .gitignore patterns. Parsing will terminate if errors is called 174 | // and returns false, otherwise, parsing will continue until end of file has 175 | // been reached. 176 | func NewWithCache(file string, cache Cache, errors func(Error) bool) GitIgnore { 177 | // do we have an error handler? 178 | _errors := errors 179 | if _errors == nil { 180 | _errors = func(e Error) bool { return true } 181 | } 182 | 183 | // use the file absolute path as its key into the cache 184 | _abs, _err := filepath.Abs(file) 185 | if _err != nil { 186 | _errors(NewError(_err, Position{})) 187 | return nil 188 | } 189 | 190 | var _ignore GitIgnore 191 | if cache != nil { 192 | _ignore = cache.Get(_abs) 193 | } 194 | if _ignore == nil { 195 | _ignore = NewWithErrors(file, _errors) 196 | if _ignore == nil { 197 | // if the load failed, cache an empty GitIgnore to prevent 198 | // further attempts to load this file 199 | _ignore = empty 200 | } 201 | if cache != nil { 202 | cache.Set(_abs, _ignore) 203 | } 204 | } 205 | 206 | // return the ignore (if we have it) 207 | if _ignore == empty { 208 | return nil 209 | } else { 210 | return _ignore 211 | } 212 | } // NewWithCache() 213 | 214 | // Base returns the directory containing the .gitignore file for this GitIgnore. 215 | func (i *ignore) Base() string { 216 | return i._base 217 | } // Base() 218 | 219 | // Match attempts to match the path against this GitIgnore, and will 220 | // return its Match if successful. Match will invoke the GitIgnore error 221 | // handler (if defined) if it is not possible to determine the absolute 222 | // path of the given path, or if its not possible to determine if the 223 | // path represents a file or a directory. If an error occurs, Match 224 | // returns nil and the error handler (if defined via New, NewWithErrors 225 | // or NewWithCache) will be invoked. 226 | func (i *ignore) Match(path string) Match { 227 | // ensure we have the absolute path for the given file 228 | _path, _err := filepath.Abs(path) 229 | if _err != nil { 230 | i._errors(NewError(_err, Position{})) 231 | return nil 232 | } 233 | 234 | // is the path a file or a directory? 235 | _info, _err := os.Stat(_path) 236 | if _err != nil { 237 | i._errors(NewError(_err, Position{})) 238 | return nil 239 | } 240 | _isdir := _info.IsDir() 241 | 242 | // attempt to match the absolute path 243 | return i.Absolute(_path, _isdir) 244 | } // Match() 245 | 246 | var matchIsDirMutex = sync.Mutex{} 247 | var matchIsDirCache = map[string]string{} 248 | 249 | func (i *ignore) MatchIsDir(path string, _isdir bool) Match { 250 | 251 | // ensure we have the absolute path for the given file 252 | matchIsDirMutex.Lock() 253 | _path, ok := matchIsDirCache[path] 254 | matchIsDirMutex.Unlock() 255 | if !ok { 256 | var _err error 257 | _path, _err = filepath.Abs(path) 258 | if _err != nil { 259 | i._errors(NewError(_err, Position{})) 260 | return nil 261 | } 262 | matchIsDirMutex.Lock() 263 | matchIsDirCache[path] = _path 264 | matchIsDirMutex.Unlock() 265 | } 266 | 267 | // attempt to match the absolute path 268 | return i.Absolute(_path, _isdir) 269 | } // Match() 270 | 271 | // Absolute attempts to match an absolute path against this GitIgnore. If 272 | // the path is not located under the base directory of this GitIgnore, or 273 | // is not matched by this GitIgnore, nil is returned. 274 | func (i *ignore) Absolute(path string, isdir bool) Match { 275 | // does the file share the same directory as this ignore file? 276 | if !strings.HasPrefix(path, i._base) { 277 | return nil 278 | } 279 | 280 | // extract the relative path of this file 281 | _prefix := len(i._base) + 1 // BOYTERWASHERE 282 | //_prefix := len(i._base) 283 | _rel := string(path[_prefix:]) 284 | return i.Relative(_rel, isdir) 285 | } // Absolute() 286 | 287 | // Relative attempts to match a path relative to the GitIgnore base 288 | // directory. isdir is used to indicate whether the path represents a file 289 | // or a directory. If the path is not matched by the GitIgnore, nil is 290 | // returned. 291 | func (i *ignore) Relative(path string, isdir bool) Match { 292 | // if we are on Windows, then translate the path to Unix form 293 | _rel := path 294 | if runtime.GOOS == "windows" { 295 | _rel = filepath.ToSlash(_rel) 296 | } 297 | 298 | // iterate over the patterns for this ignore file 299 | // - iterate in reverse, since later patterns overwrite earlier 300 | for _i := len(i._pattern) - 1; _i >= 0; _i-- { 301 | _pattern := i._pattern[_i] 302 | if _pattern.Match(_rel, isdir) { 303 | return _pattern 304 | } 305 | } 306 | 307 | // we don't match this file 308 | return nil 309 | } // Relative() 310 | 311 | // Ignore returns true if the path is ignored by this GitIgnore. Paths 312 | // that are not matched by this GitIgnore are not ignored. Internally, 313 | // Ignore uses Match, and will return false if Match() returns nil for path. 314 | func (i *ignore) Ignore(path string) bool { 315 | _match := i.Match(path) 316 | if _match != nil { 317 | return _match.Ignore() 318 | } 319 | 320 | // we didn't match this path, so we don't ignore it 321 | return false 322 | } // Ignore() 323 | 324 | // Include returns true if the path is included by this GitIgnore. Paths 325 | // that are not matched by this GitIgnore are always included. Internally, 326 | // Include uses Match, and will return true if Match() returns nil for path. 327 | func (i *ignore) Include(path string) bool { 328 | _match := i.Match(path) 329 | if _match != nil { 330 | return _match.Include() 331 | } 332 | 333 | // we didn't match this path, so we include it 334 | return true 335 | } // Include() 336 | 337 | // ensure Ignore satisfies the GitIgnore interface 338 | var _ GitIgnore = &ignore{} 339 | -------------------------------------------------------------------------------- /go-gitignore/gitignore_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/boyter/gocodewalker/go-gitignore" 11 | ) 12 | 13 | type gitignoretest struct { 14 | errors []gitignore.Error 15 | error func(gitignore.Error) bool 16 | cache gitignore.Cache 17 | cached bool 18 | instance func(string) (gitignore.GitIgnore, error) 19 | } // gitignoretest{} 20 | 21 | func TestNewFromFile(t *testing.T) { 22 | _test := &gitignoretest{} 23 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 24 | return gitignore.NewFromFile(file) 25 | } 26 | 27 | // perform the gitignore test 28 | withfile(t, _test, _GITIGNORE) 29 | } // TestNewFromFile() 30 | 31 | func TestNewFromWhitespaceFile(t *testing.T) { 32 | _test := &gitignoretest{} 33 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 34 | return gitignore.NewFromFile(file) 35 | } 36 | 37 | // perform the gitignore test 38 | withfile(t, _test, _GITIGNORE_WHITESPACE) 39 | } // TestNewFromWhitespaceFile() 40 | 41 | func TestNewFromEmptyFile(t *testing.T) { 42 | _test := &gitignoretest{} 43 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 44 | return gitignore.NewFromFile(file) 45 | } 46 | 47 | // perform the gitignore test 48 | withfile(t, _test, "") 49 | } // TestNewFromEmptyFile() 50 | 51 | func TestNewWithErrors(t *testing.T) { 52 | _test := &gitignoretest{} 53 | _test.error = func(e gitignore.Error) bool { 54 | _test.errors = append(_test.errors, e) 55 | return true 56 | } 57 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 58 | // reset the error slice 59 | _test.errors = make([]gitignore.Error, 0) 60 | 61 | // attempt to create the GitIgnore instance 62 | _ignore := gitignore.NewWithErrors(file, _test.error) 63 | 64 | // if we encountered errors, and the first error has a zero position 65 | // then it represents a file access error 66 | // - extract the error and return it 67 | // - remove it from the list of errors 68 | var _err error 69 | if len(_test.errors) > 0 { 70 | if _test.errors[0].Position().Zero() { 71 | _err = _test.errors[0].Underlying() 72 | _test.errors = _test.errors[1:] 73 | } 74 | } 75 | 76 | // return the GitIgnore instance 77 | return _ignore, _err 78 | } 79 | 80 | // perform the gitignore test 81 | withfile(t, _test, _GITIGNORE) 82 | 83 | _test.error = nil 84 | withfile(t, _test, _GITIGNORE) 85 | } // TestNewWithErrors() 86 | 87 | func TestNewWithCache(t *testing.T) { 88 | // perform the gitignore test with a custom cache 89 | _test := &gitignoretest{} 90 | _test.cached = true 91 | _test.cache = gitignore.NewCache() 92 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 93 | // reset the error slice 94 | _test.errors = make([]gitignore.Error, 0) 95 | 96 | // attempt to create the GitIgnore instance 97 | _ignore := gitignore.NewWithCache(file, _test.cache, _test.error) 98 | 99 | // if we encountered errors, and the first error has a zero position 100 | // then it represents a file access error 101 | // - extract the error and return it 102 | // - remove it from the list of errors 103 | var _err error 104 | if len(_test.errors) > 0 { 105 | if _test.errors[0].Position().Zero() { 106 | _err = _test.errors[0].Underlying() 107 | _test.errors = _test.errors[1:] 108 | } 109 | } 110 | 111 | // return the GitIgnore instance 112 | return _ignore, _err 113 | } 114 | 115 | // perform the gitignore test 116 | withfile(t, _test, _GITIGNORE) 117 | 118 | // repeat the tests while accumulating errors 119 | _test.error = func(e gitignore.Error) bool { 120 | _test.errors = append(_test.errors, e) 121 | return true 122 | } 123 | withfile(t, _test, _GITIGNORE) 124 | 125 | // create a temporary .gitignore 126 | _file, _err := file(_GITIGNORE) 127 | if _err != nil { 128 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 129 | } 130 | defer func(name string) { 131 | _ = os.Remove(name) 132 | }(_file.Name()) 133 | 134 | // attempt to load the .gitignore file 135 | _ignore, _err := _test.instance(_file.Name()) 136 | if _err != nil { 137 | t.Fatalf("unable to open temporary .gitignore: %s", _err.Error()) 138 | } 139 | 140 | // remove the .gitignore and try again 141 | err := os.Remove(_file.Name()) 142 | if err != nil { 143 | t.Fatalf("unable to remove temporary .gitignore: %s", _err.Error()) 144 | return 145 | } 146 | 147 | // ensure the retrieved GitIgnore matches the stored instance 148 | _new, _err := _test.instance(_file.Name()) 149 | if _err != nil { 150 | t.Fatalf( 151 | "unexpected error retrieving cached .gitignore: %s", _err.Error(), 152 | ) 153 | } else if _new != _ignore { 154 | t.Fatalf( 155 | "gitignore.NewWithCache() mismatch; expected %v, got %v", 156 | _ignore, _new, 157 | ) 158 | } 159 | } // TestNewWithCache() 160 | 161 | func TestNew(t *testing.T) { 162 | // create a temporary .gitignore 163 | _file, _err := file(_GITIGNORE) 164 | if _err != nil { 165 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 166 | } 167 | defer func(name string) { 168 | _ = os.Remove(name) 169 | }(_file.Name()) 170 | 171 | // ensure we can run NewGitIgnore() 172 | // - ensure we encounter the expected errors 173 | _position := []gitignore.Position{} 174 | _error := func(e gitignore.Error) bool { 175 | _position = append(_position, e.Position()) 176 | return true 177 | } 178 | 179 | _dir := filepath.Dir(_file.Name()) 180 | _ignore := gitignore.New(_file, _dir, _error) 181 | 182 | // ensure we have a non-nil GitIgnore instance 183 | if _ignore == nil { 184 | t.Error("expected non-nil GitIgnore instance; nil found") 185 | } 186 | 187 | // ensure the base of the ignore is the directory of the temporary file 188 | if _ignore.Base() != _dir { 189 | t.Errorf( 190 | "gitignore.Base() mismatch; expected %q, got %q", 191 | _dir, _ignore.Base(), 192 | ) 193 | } 194 | 195 | // ensure we encountered the right number of errors 196 | if len(_position) != _GITBADPATTERNS { 197 | t.Errorf( 198 | "parse error mismatch; expected %d errors, got %d", 199 | _GITBADPATTERNS, len(_position), 200 | ) 201 | } else { 202 | // ensure the error positions are correct 203 | for _i := 0; _i < _GITBADPATTERNS; _i++ { 204 | _got := _position[_i] 205 | _expected := _GITBADPOSITION[_i] 206 | 207 | // ensure the positions are correct 208 | if !coincident(_got, _expected) { 209 | t.Errorf("bad pattern position mismatch; expected %q, got %q", 210 | pos(_expected), pos(_got), 211 | ) 212 | } 213 | } 214 | } 215 | } // TestNew() 216 | 217 | func withfile(t *testing.T, test *gitignoretest, content string) { 218 | // create a temporary .gitignore 219 | _file, _err := file(content) 220 | if _err != nil { 221 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 222 | } 223 | defer func(name string) { 224 | _ = os.Remove(name) 225 | }(_file.Name()) 226 | 227 | // attempt to retrieve the GitIgnore instance 228 | _ignore, _err := test.instance(_file.Name()) 229 | if _err != nil { 230 | t.Fatalf("unable to open temporary .gitignore: %s", _err.Error()) 231 | } 232 | 233 | // ensure we have a non-nil GitIgnore instance 234 | if _ignore == nil { 235 | t.Error("expected non-nil GitIgnore instance; nil found") 236 | } 237 | 238 | // ensure the base of the ignore is the directory of the temporary file 239 | _dir := filepath.Dir(_file.Name()) 240 | if _ignore.Base() != _dir { 241 | t.Errorf( 242 | "gitignore.Base() mismatch; expected %q, got %q", 243 | _dir, _ignore.Base(), 244 | ) 245 | } 246 | 247 | // ensure we encountered the right number of errors 248 | // - only do this if we are configured to record bad patterns 249 | if test.error != nil { 250 | if len(test.errors) != _GITBADPATTERNS { 251 | t.Errorf( 252 | "parse error mismatch; expected %d errors, got %d", 253 | _GITBADPATTERNS, len(test.errors), 254 | ) 255 | } else { 256 | // ensure the error positions are correct 257 | for _i := 0; _i < _GITBADPATTERNS; _i++ { 258 | _got := test.errors[_i].Position() 259 | _expected := _GITBADPOSITION[_i] 260 | 261 | // augment the expected position with the test file name 262 | _expected.File = _file.Name() 263 | 264 | // ensure the positions are correct 265 | if !coincident(_got, _expected) { 266 | t.Errorf( 267 | "bad pattern position mismatch; expected %q, got %q", 268 | pos(_expected), pos(_got), 269 | ) 270 | } 271 | } 272 | } 273 | } 274 | 275 | // test NewFromFile() behaves as expected if the .gitgnore file does 276 | // not exist 277 | _err = os.Remove(_file.Name()) 278 | if _err != nil { 279 | t.Fatalf( 280 | "unable to remove temporary .gitignore %s: %s", 281 | _file.Name(), _err.Error(), 282 | ) 283 | } 284 | _ignore, _err = test.instance(_file.Name()) 285 | if _err == nil { 286 | // if we are using a cache in this test, then no error is acceptable 287 | // as long as a GitIgnore instance is retrieved 288 | if test.cached { 289 | if _ignore == nil { 290 | t.Fatal("expected non-nil GitIgnore, nil found") 291 | } 292 | } else if test.error != nil { 293 | t.Fatalf( 294 | "expected error loading deleted file %s; none found", 295 | _file.Name(), 296 | ) 297 | } 298 | } else if !os.IsNotExist(_err) { 299 | t.Fatalf( 300 | "unexpected error attempting to load non-existant .gitignore: %s", 301 | _err.Error(), 302 | ) 303 | } else if _ignore != nil { 304 | t.Fatalf("expected nil GitIgnore, got %v", _ignore) 305 | } 306 | 307 | // test NewFromFile() behaves as expected if absolute path of the 308 | // .gitignore cannot be determined 309 | _map := map[string]string{gitignore.File: content} 310 | _dir, _err = dir(_map) 311 | if _err != nil { 312 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 313 | } 314 | defer func(path string) { 315 | _ = os.RemoveAll(path) 316 | }(_dir) 317 | 318 | // change into the temporary directory 319 | _cwd, _err := os.Getwd() 320 | if _err != nil { 321 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 322 | } 323 | _err = os.Chdir(_dir) 324 | if _err != nil { 325 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 326 | } 327 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 328 | 329 | // remove permission from the temporary directory 330 | _err = os.Chmod(_dir, 0) 331 | if _err != nil { 332 | t.Fatalf( 333 | "unable to remove temporary directory permissions: %s: %s", 334 | _dir, _err.Error(), 335 | ) 336 | } 337 | 338 | // attempt to load the .gitignore using a relative path 339 | _ignore, _err = test.instance(gitignore.File) 340 | if test.error != nil && _err == nil { 341 | _git := filepath.Join(_dir, gitignore.File) 342 | t.Fatalf( 343 | "%s: expected error for inaccessible .gitignore; none found", 344 | _git, 345 | ) 346 | } else if _ignore != nil { 347 | t.Fatalf("expected nil GitIgnore, got %v", _ignore) 348 | } 349 | } // withfile() 350 | -------------------------------------------------------------------------------- /go-gitignore/lexer.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "bufio" 7 | "io" 8 | ) 9 | 10 | // 11 | // inspired by https://blog.gopheracademy.com/advent-2014/parsers-lexers/ 12 | // 13 | 14 | // lexer is the implementation of the .gitignore lexical analyser 15 | type lexer struct { 16 | _r *bufio.Reader 17 | _unread []rune 18 | _offset int 19 | _line int 20 | _column int 21 | _previous []int 22 | } // lexer{} 23 | 24 | // Lexer is the interface to the lexical analyser for .gitignore files 25 | type Lexer interface { 26 | // Next returns the next Token from the Lexer reader. If an error is 27 | // encountered, it will be returned as an Error instance, detailing the 28 | // error and its position within the stream. 29 | Next() (*Token, Error) 30 | 31 | // Position returns the current position of the Lexer. 32 | Position() Position 33 | 34 | // String returns the string representation of the current position of the 35 | // Lexer. 36 | String() string 37 | } 38 | 39 | // NewLexer returns a Lexer instance for the io.Reader r. 40 | func NewLexer(r io.Reader) Lexer { 41 | return &lexer{_r: bufio.NewReader(r), _line: 1, _column: 1} 42 | } // NewLexer() 43 | 44 | // Next returns the next Token from the Lexer reader. If an error is 45 | // encountered, it will be returned as an Error instance, detailing the error 46 | // and its position within the stream. 47 | func (l *lexer) Next() (*Token, Error) { 48 | // are we at the beginning of the line? 49 | _beginning := l.beginning() 50 | 51 | // read the next rune 52 | _r, _err := l.read() 53 | if _err != nil { 54 | return nil, _err 55 | } 56 | 57 | switch _r { 58 | // end of file 59 | case _EOF: 60 | return l.token(EOF, nil, nil) 61 | 62 | // whitespace ' ', '\t' 63 | case _SPACE: 64 | fallthrough 65 | case _TAB: 66 | l.unread(_r) 67 | _rtn, _err := l.whitespace() 68 | return l.token(WHITESPACE, _rtn, _err) 69 | 70 | // end of line '\n' or '\r\n' 71 | case _CR: 72 | fallthrough 73 | case _NEWLINE: 74 | l.unread(_r) 75 | _rtn, _err := l.eol() 76 | return l.token(EOL, _rtn, _err) 77 | 78 | // separator '/' 79 | case _SEPARATOR: 80 | return l.token(SEPARATOR, []rune{_r}, nil) 81 | 82 | // '*' or any '**' 83 | case _WILDCARD: 84 | // is the wildcard followed by another wildcard? 85 | // - does this represent the "any" token (i.e. "**") 86 | _next, _err := l.peek() 87 | if _err != nil { 88 | return nil, _err 89 | } else if _next == _WILDCARD { 90 | // we know read() will succeed here since we used peek() above 91 | _, _ = l.read() 92 | return l.token(ANY, []rune{_WILDCARD, _WILDCARD}, nil) 93 | } 94 | 95 | // we have a single wildcard, so treat this as a pattern 96 | l.unread(_r) 97 | _rtn, _err := l.pattern() 98 | return l.token(PATTERN, _rtn, _err) 99 | 100 | // comment '#' 101 | case _COMMENT: 102 | l.unread(_r) 103 | 104 | // if we are at the start of the line, then we treat this as a comment 105 | if _beginning { 106 | _rtn, _err := l.comment() 107 | return l.token(COMMENT, _rtn, _err) 108 | } 109 | 110 | // otherwise, we regard this as a pattern 111 | _rtn, _err := l.pattern() 112 | return l.token(PATTERN, _rtn, _err) 113 | 114 | // negation '!' 115 | case _NEGATION: 116 | if _beginning { 117 | return l.token(NEGATION, []rune{_r}, nil) 118 | } 119 | fallthrough 120 | 121 | // pattern 122 | default: 123 | l.unread(_r) 124 | _rtn, _err := l.pattern() 125 | return l.token(PATTERN, _rtn, _err) 126 | } 127 | } // Next() 128 | 129 | // Position returns the current position of the Lexer. 130 | func (l *lexer) Position() Position { 131 | return Position{"", l._line, l._column, l._offset} 132 | } // Position() 133 | 134 | // String returns the string representation of the current position of the 135 | // Lexer. 136 | func (l *lexer) String() string { 137 | return l.Position().String() 138 | } // String() 139 | 140 | // 141 | // private methods 142 | // 143 | 144 | // read the next rune from the stream. Return an Error if there is a problem 145 | // reading from the stream. If the end of stream is reached, return the EOF 146 | // Token. 147 | func (l *lexer) read() (rune, Error) { 148 | var _r rune 149 | var _err error 150 | 151 | // do we have any unread runes to read? 152 | _length := len(l._unread) 153 | if _length > 0 { 154 | _r = l._unread[_length-1] 155 | l._unread = l._unread[:_length-1] 156 | 157 | // otherwise, attempt to read a new rune 158 | } else { 159 | _r, _, _err = l._r.ReadRune() 160 | if _err == io.EOF { 161 | return _EOF, nil 162 | } 163 | } 164 | 165 | // increment the offset and column counts 166 | l._offset++ 167 | l._column++ 168 | 169 | return _r, l.err(_err) 170 | } // read() 171 | 172 | // unread returns the given runes to the stream, making them eligible to be 173 | // read again. The runes are returned in the order given, so the last rune 174 | // specified will be the next rune read from the stream. 175 | func (l *lexer) unread(r ...rune) { 176 | // ignore EOF runes 177 | _r := make([]rune, 0) 178 | for _, _rune := range r { 179 | if _rune != _EOF { 180 | _r = append(_r, _rune) 181 | } 182 | } 183 | 184 | // initialise the unread rune list if necessary 185 | if l._unread == nil { 186 | l._unread = make([]rune, 0) 187 | } 188 | if len(_r) != 0 { 189 | l._unread = append(l._unread, _r...) 190 | } 191 | 192 | // decrement the offset and column counts 193 | // - we have to take care of column being 0 194 | // - at present we can only unwind across a single line boundary 195 | _length := len(_r) 196 | for ; _length > 0; _length-- { 197 | l._offset-- 198 | if l._column == 1 { 199 | _length := len(l._previous) 200 | if _length > 0 { 201 | l._column = l._previous[_length-1] 202 | l._previous = l._previous[:_length-1] 203 | l._line-- 204 | } 205 | } else { 206 | l._column-- 207 | } 208 | } 209 | } // unread() 210 | 211 | // peek returns the next rune in the stream without consuming it (i.e. it will 212 | // be returned by the next call to read or peek). peek will return an error if 213 | // there is a problem reading from the stream. 214 | func (l *lexer) peek() (rune, Error) { 215 | // read the next rune 216 | _r, _err := l.read() 217 | if _err != nil { 218 | return _r, _err 219 | } 220 | 221 | // unread & return the rune 222 | l.unread(_r) 223 | return _r, _err 224 | } // peek() 225 | 226 | // newline adjusts the positional counters when an end of line is reached 227 | func (l *lexer) newline() { 228 | // adjust the counters for the new line 229 | if l._previous == nil { 230 | l._previous = make([]int, 0) 231 | } 232 | l._previous = append(l._previous, l._column) 233 | l._column = 1 234 | l._line++ 235 | } // newline() 236 | 237 | // comment reads all runes until a newline or end of file is reached. An 238 | // error is returned if an error is encountered reading from the stream. 239 | func (l *lexer) comment() ([]rune, Error) { 240 | _comment := make([]rune, 0) 241 | 242 | // read until we reach end of line or end of file 243 | // - as we are in a comment, we ignore escape characters 244 | for { 245 | _next, _err := l.read() 246 | if _err != nil { 247 | return _comment, _err 248 | } 249 | 250 | // read until we have end of line or end of file 251 | switch _next { 252 | case _CR: 253 | fallthrough 254 | case _NEWLINE: 255 | fallthrough 256 | case _EOF: 257 | // return the read run to the stream and stop 258 | l.unread(_next) 259 | return _comment, nil 260 | } 261 | 262 | // otherwise, add this run to the comment 263 | _comment = append(_comment, _next) 264 | } 265 | } // comment() 266 | 267 | // escape attempts to read an escape sequence (e.g. '\ ') form the input 268 | // stream. An error will be returned if there is an error reading from the 269 | // stream. escape returns just the escape rune if the following rune is either 270 | // end of line or end of file (since .gitignore files do not support line 271 | // continuations). 272 | func (l *lexer) escape() ([]rune, Error) { 273 | // attempt to process the escape sequence 274 | _peek, _err := l.peek() 275 | if _err != nil { 276 | return nil, _err 277 | } 278 | 279 | // what is the next rune after the escape? 280 | switch _peek { 281 | // are we at the end of the line or file? 282 | // - we return just the escape rune 283 | case _CR: 284 | fallthrough 285 | case _NEWLINE: 286 | fallthrough 287 | case _EOF: 288 | return []rune{_ESCAPE}, nil 289 | } 290 | 291 | // otherwise, return the escape and the next rune 292 | // - we know read() will succeed here since we used peek() above 293 | _, _ = l.read() 294 | return []rune{_ESCAPE, _peek}, nil 295 | } // escape() 296 | 297 | // eol returns all runes from the current position to the end of the line. An 298 | // error is returned if there is a problem reading from the stream, or if a 299 | // carriage return character '\r' is encountered that is not followed by a 300 | // newline '\n'. 301 | func (l *lexer) eol() ([]rune, Error) { 302 | // read the to the end of the line 303 | // - we should only be called here when we encounter an end of line 304 | // sequence 305 | _line := make([]rune, 0, 1) 306 | 307 | // loop until there's nothing more to do 308 | for { 309 | _next, _err := l.read() 310 | if _err != nil { 311 | return _line, _err 312 | } 313 | 314 | // read until we have a newline or we're at end of file 315 | switch _next { 316 | // end of file 317 | case _EOF: 318 | return _line, nil 319 | 320 | // carriage return - we expect to see a newline next 321 | case _CR: 322 | _line = append(_line, _next) 323 | _next, _err = l.read() 324 | if _err != nil { 325 | return _line, _err 326 | } else if _next != _NEWLINE { 327 | l.unread(_next) 328 | return _line, l.err(ErrCarriageReturnError) 329 | } 330 | fallthrough 331 | 332 | // newline 333 | case _NEWLINE: 334 | _line = append(_line, _next) 335 | return _line, nil 336 | } 337 | } 338 | } // eol() 339 | 340 | // whitespace returns all whitespace (i.e. ' ' and '\t') runes in a sequence, 341 | // or an error if there is a problem reading the next runes. 342 | func (l *lexer) whitespace() ([]rune, Error) { 343 | // read until we hit the first non-whitespace rune 344 | _ws := make([]rune, 0, 1) 345 | 346 | // loop until there's nothing more to do 347 | for { 348 | _next, _err := l.read() 349 | if _err != nil { 350 | return _ws, _err 351 | } 352 | 353 | // what is this next rune? 354 | switch _next { 355 | // space or tab is consumed 356 | case _SPACE: 357 | fallthrough 358 | case _TAB: 359 | //nolint:staticcheck // SA4011: ineffective break statement (deliberate) 360 | break 361 | 362 | // non-whitespace rune 363 | default: 364 | // return the rune to the buffer and we're done 365 | l.unread(_next) 366 | return _ws, nil 367 | } 368 | 369 | // add this rune to the whitespace 370 | _ws = append(_ws, _next) 371 | } 372 | } // whitespace() 373 | 374 | // pattern returns all runes representing a file or path pattern, delimited 375 | // either by unescaped whitespace, a path separator '/' or enf of file. An 376 | // error is returned if a problem is encountered reading from the stream. 377 | func (l *lexer) pattern() ([]rune, Error) { 378 | // read until we hit the first whitespace/end of line/eof rune 379 | _pattern := make([]rune, 0, 1) 380 | 381 | // loop until there's nothing more to do 382 | for { 383 | _r, _err := l.read() 384 | if _err != nil { 385 | return _pattern, _err 386 | } 387 | 388 | // what is the next rune? 389 | switch _r { 390 | // whitespace, newline, end of file, separator 391 | // - this is the end of the pattern 392 | case _SPACE: 393 | fallthrough 394 | case _TAB: 395 | fallthrough 396 | case _CR: 397 | fallthrough 398 | case _NEWLINE: 399 | fallthrough 400 | case _SEPARATOR: 401 | fallthrough 402 | case _EOF: 403 | // return what we have 404 | l.unread(_r) 405 | return _pattern, nil 406 | 407 | // a wildcard is the end of the pattern if it is part of any '**' 408 | case _WILDCARD: 409 | _next, _err := l.peek() 410 | if _err != nil { 411 | return _pattern, _err 412 | } else if _next == _WILDCARD { 413 | l.unread(_r) 414 | return _pattern, _err 415 | } else { 416 | _pattern = append(_pattern, _r) 417 | } 418 | 419 | // escape sequence - consume the next rune 420 | case _ESCAPE: 421 | _escape, _err := l.escape() 422 | if _err != nil { 423 | return _pattern, _err 424 | } 425 | 426 | // add the escape sequence as part of the pattern 427 | _pattern = append(_pattern, _escape...) 428 | 429 | // any other character, we add to the pattern 430 | default: 431 | _pattern = append(_pattern, _r) 432 | } 433 | } 434 | } // pattern() 435 | 436 | // token returns a Token instance of the given type_ represented by word runes. 437 | func (l *lexer) token(type_ TokenType, word []rune, e Error) (*Token, Error) { 438 | // if we have an error, then we return a BAD token 439 | if e != nil { 440 | type_ = BAD 441 | } 442 | 443 | // extract the lexer position 444 | // - the column is taken from the current column position 445 | // minus the length of the consumed "word" 446 | _word := len(word) 447 | _column := l._column - _word 448 | _offset := l._offset - _word 449 | position := Position{"", l._line, _column, _offset} 450 | 451 | // if this is a newline token, we adjust the line & column counts 452 | if type_ == EOL { 453 | l.newline() 454 | } 455 | 456 | // return the Token 457 | return NewToken(type_, word, position), e 458 | } // token() 459 | 460 | // err returns an Error encapsulating the error e and the current Lexer 461 | // position. 462 | func (l *lexer) err(e error) Error { 463 | // do we have an error? 464 | if e == nil { 465 | return nil 466 | } else { 467 | return NewError(e, l.Position()) 468 | } 469 | } // err() 470 | 471 | // beginning returns true if the Lexer is at the start of a new line. 472 | func (l *lexer) beginning() bool { 473 | return l._column == 1 474 | } // beginning() 475 | 476 | // ensure the lexer conforms to the lexer interface 477 | var _ Lexer = &lexer{} 478 | -------------------------------------------------------------------------------- /go-gitignore/lexer_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/boyter/gocodewalker/go-gitignore" 11 | ) 12 | 13 | // TestLexerNewLne tests the behavour of the gitignore.Lexer when the input 14 | // data explicitly uses "\n" as the line separator 15 | func TestLexerNewLine(t *testing.T) { 16 | // split the test content into lines 17 | // - ensure we handle "\n" and "\r" correctly 18 | // - since this test file is written on a system that uses "\n" 19 | // to designate end of line, this should be unnecessary, but it's 20 | // possible for the file line endings to be converted outside of 21 | // this repository, so we are thorough here to ensure the test 22 | // works as expected everywhere 23 | _content := strings.Split(_GITIGNORE, "\n") 24 | for _i := 0; _i < len(_content); _i++ { 25 | _content[_i] = strings.TrimSuffix(_content[_i], "\r") 26 | } 27 | 28 | // perform the Lexer test with input explicitly separated by "\n" 29 | lexer(t, _content, "\n", _GITTOKENS, nil) 30 | } // TestLexerNewLine() 31 | 32 | // TestLexerCarriageReturn tests the behavour of the gitignore.Lexer when the 33 | // input data explicitly uses "\r\n" as the line separator 34 | func TestLexerCarriageReturn(t *testing.T) { 35 | // split the test content into lines 36 | // - see above 37 | _content := strings.Split(_GITIGNORE, "\n") 38 | for _i := 0; _i < len(_content); _i++ { 39 | _content[_i] = strings.TrimSuffix(_content[_i], "\r") 40 | } 41 | 42 | // perform the Lexer test with input explicitly separated by "\r\n" 43 | lexer(t, _content, "\r\n", _GITTOKENS, nil) 44 | } // TestLexerCarriageReturn() 45 | 46 | func TestLexerInvalidNewLine(t *testing.T) { 47 | // perform the Lexer test with invalid input separated by "\n" 48 | // - the source content is manually constructed with "\n" as EOL 49 | _content := strings.Split(_GITINVALID, "\n") 50 | lexer(t, _content, "\n", _TOKENSINVALID, gitignore.ErrCarriageReturnError) 51 | } // TestLexerInvalidNewLine() 52 | 53 | func TestLexerInvalidCarriageReturn(t *testing.T) { 54 | // perform the Lexer test with invalid input separated by "\n" 55 | // - the source content is manually constructed with "\n" as EOL 56 | _content := strings.Split(_GITINVALID, "\n") 57 | lexer(t, _content, "\r\n", _TOKENSINVALID, gitignore.ErrCarriageReturnError) 58 | } // TestLexerInvalidCarriageReturn() 59 | 60 | func lexer(t *testing.T, lines []string, eol string, tokens []token, e error) { 61 | // create a temporary .gitignore 62 | _buffer, _err := buffer(strings.Join(lines, eol)) 63 | if _err != nil { 64 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 65 | } 66 | 67 | // ensure we have a non-nil Lexer instance 68 | _lexer := gitignore.NewLexer(_buffer) 69 | if _lexer == nil { 70 | t.Error("expected non-nil Lexer instance; nil found") 71 | } 72 | 73 | // ensure the stream of tokens is as we expect 74 | for _, _expected := range tokens { 75 | _position := _lexer.Position() 76 | 77 | // ensure the string form of the Lexer reports the correct position 78 | _string := fmt.Sprintf("%d:%d", _position.Line, _position.Column) 79 | if _lexer.String() != _string { 80 | t.Errorf( 81 | "lexer string mismatch; expected %q, got %q", 82 | _string, _position.String(), 83 | ) 84 | } 85 | 86 | // extract the next token from the lexer 87 | _got, _err := _lexer.Next() 88 | 89 | // ensure we did not receive an error and the token is as expected 90 | if _err != nil { 91 | // if we expect an error during processing, check to see if 92 | // the received error is as expected 93 | // if !_err.Is(e) { 94 | if _err.Underlying() != e { 95 | t.Fatalf( 96 | "unable to retrieve expected token; %s at %s", 97 | _err.Error(), pos(_err.Position()), 98 | ) 99 | } 100 | } 101 | 102 | // did we receive a token? 103 | if _got == nil { 104 | t.Fatalf("expected token at %s; none found", _lexer) 105 | } else if _got.Type != _expected.Type { 106 | t.Fatalf( 107 | "token type mismatch; expected type %d, got %d [%s]", 108 | _expected.Type, _got.Type, _got, 109 | ) 110 | } else if _got.Name() != _expected.Name { 111 | t.Fatalf( 112 | "token name mismatch; expected name %q, got %q [%s]", 113 | _expected.Name, _got.Name(), _got, 114 | ) 115 | } else { 116 | // ensure the extracted token string matches expectation 117 | // - we handle EOL separately, since it can change based 118 | // on the end of line sequence of the input file 119 | _same := _got.Token() == _expected.Token 120 | if _got.Type == gitignore.EOL { 121 | _same = _got.Token() == eol 122 | } 123 | if !_same { 124 | t.Fatalf( 125 | "token value mismatch; expected name %q, got %q [%s]", 126 | _expected.Token, _got.Token(), _got, 127 | ) 128 | } 129 | 130 | // ensure the token position matches the original lexer position 131 | if !coincident(_got.Position, _position) { 132 | t.Fatalf( 133 | "token position mismatch for %s; expected %s, got %s", 134 | _got, pos(_position), pos(_got.Position), 135 | ) 136 | } 137 | 138 | // ensure the token position matches the expected position 139 | // - since we will be testing with different line endings, we 140 | // have to choose the correct offset 141 | _position := gitignore.Position{ 142 | File: "", 143 | Line: _expected.Line, 144 | Column: _expected.Column, 145 | Offset: _expected.NewLine, 146 | } 147 | if eol == "\r\n" { 148 | _position.Offset = _expected.CarriageReturn 149 | } 150 | if !coincident(_got.Position, _position) { 151 | t.Log(pos(_got.Position) + "\t" + _got.String()) 152 | t.Fatalf( 153 | "token position mismatch; expected %s, got %s", 154 | pos(_position), pos(_got.Position), 155 | ) 156 | } 157 | } 158 | } 159 | 160 | // ensure there are no more tokens 161 | _next, _err := _lexer.Next() 162 | if _err != nil { 163 | t.Errorf("unexpected error on end of token test: %s", _err.Error()) 164 | } else if _next == nil { 165 | t.Errorf("unexpected nil token at end of test") 166 | } else if _next.Type != gitignore.EOF { 167 | t.Errorf( 168 | "token type mismatch; expected type %d, got %d [%s]", 169 | gitignore.EOF, _next.Type, _next, 170 | ) 171 | } 172 | } // TestLexer() 173 | -------------------------------------------------------------------------------- /go-gitignore/match.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | // Match represents the interface of successful matches against a .gitignore 6 | // pattern set. A Match can be queried to determine whether the matched path 7 | // should be ignored or included (i.e. was the path matched by a negated 8 | // pattern), and to extract the position of the pattern within the .gitignore, 9 | // and a string representation of the pattern. 10 | type Match interface { 11 | // Ignore returns true if the match pattern describes files or paths that 12 | // should be ignored. 13 | Ignore() bool 14 | 15 | // Include returns true if the match pattern describes files or paths that 16 | // should be included. 17 | Include() bool 18 | 19 | // String returns a string representation of the matched pattern. 20 | String() string 21 | 22 | // Position returns the position in the .gitignore file at which the 23 | // matching pattern was defined. 24 | Position() Position 25 | } 26 | -------------------------------------------------------------------------------- /go-gitignore/match_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/boyter/gocodewalker/go-gitignore" 11 | ) 12 | 13 | func TestMatch(t *testing.T) { 14 | // we need to populate a directory with the match test files 15 | // - this is to permit GitIgnore.Match() to correctly resolve 16 | // absolute path names 17 | _dir, _ignore := directory(t) 18 | defer func(path string) { 19 | _ = os.RemoveAll(path) 20 | }(_dir) 21 | 22 | // perform the path matching 23 | // - first we test absolute paths 24 | _cb := func(path string, isdir bool) gitignore.Match { 25 | _path := filepath.Join(_dir, path) 26 | return _ignore.Match(_path) 27 | } 28 | for _, _test := range _GITMATCHES { 29 | do(t, _cb, _test) 30 | } 31 | 32 | // now, attempt relative path matching 33 | // - to do this, we need to change the working directory 34 | _cwd, _err := os.Getwd() 35 | if _err != nil { 36 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 37 | } 38 | _err = os.Chdir(_dir) 39 | if _err != nil { 40 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 41 | } 42 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 43 | 44 | // perform the relative path tests 45 | _cb = func(path string, isdir bool) gitignore.Match { 46 | return _ignore.Match(path) 47 | } 48 | for _, _test := range _GITMATCHES { 49 | do(t, _cb, _test) 50 | } 51 | 52 | // perform absolute path tests with paths not under the same root 53 | // directory as the GitIgnore we are testing 54 | _new, _ := directory(t) 55 | defer func(path string) { 56 | _ = os.RemoveAll(path) 57 | }(_new) 58 | 59 | for _, _test := range _GITMATCHES { 60 | _path := filepath.Join(_new, _test.Local()) 61 | _match := _ignore.Match(_path) 62 | if _match != nil { 63 | t.Fatalf("unexpected match; expected nil, got %v", _match) 64 | } 65 | } 66 | 67 | // ensure Match() behaves as expected if the absolute path cannot 68 | // be determined 69 | // - we do this by choosing as our working directory a path 70 | // that this process does not have permission to 71 | _dir, _err = dir(nil) 72 | if _err != nil { 73 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 74 | } 75 | defer func(path string) { 76 | _ = os.RemoveAll(path) 77 | }(_dir) 78 | 79 | _err = os.Chdir(_dir) 80 | if _err != nil { 81 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 82 | } 83 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 84 | 85 | // remove permission from the temporary directory 86 | _err = os.Chmod(_dir, 0) 87 | if _err != nil { 88 | t.Fatalf( 89 | "unable to modify temporary directory permissions: %s: %s", 90 | _dir, _err.Error(), 91 | ) 92 | } 93 | 94 | // now perform the match tests and ensure an error is returned 95 | for _, _test := range _GITMATCHES { 96 | _match := _ignore.Match(_test.Local()) 97 | if _match != nil { 98 | t.Fatalf("unexpected match; expected nil, got %v", _match) 99 | } 100 | } 101 | } // TestMatch() 102 | 103 | func TestIgnore(t *testing.T) { 104 | // we need to populate a directory with the match test files 105 | // - this is to permit GitIgnore.Ignore() to correctly resolve 106 | // absolute path names 107 | _dir, _ignore := directory(t) 108 | defer func(path string) { 109 | _ = os.RemoveAll(path) 110 | }(_dir) 111 | 112 | // perform the path matching 113 | // - first we test absolute paths 114 | for _, _test := range _GITMATCHES { 115 | _path := filepath.Join(_dir, _test.Local()) 116 | _rtn := _ignore.Ignore(_path) 117 | if _rtn != _test.Ignore { 118 | t.Errorf( 119 | "ignore mismatch for %q; expected %v, got %v", 120 | _path, _test.Ignore, _rtn, 121 | ) 122 | } 123 | } 124 | 125 | // now, attempt relative path matching 126 | // - to do this, we need to change the working directory 127 | _cwd, _err := os.Getwd() 128 | if _err != nil { 129 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 130 | } 131 | _err = os.Chdir(_dir) 132 | if _err != nil { 133 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 134 | } 135 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 136 | 137 | // perform the relative path tests 138 | for _, _test := range _GITMATCHES { 139 | _rtn := _ignore.Ignore(_test.Local()) 140 | if _rtn != _test.Ignore { 141 | t.Errorf( 142 | "ignore mismatch for %q; expected %v, got %v", 143 | _test.Path, _test.Ignore, _rtn, 144 | ) 145 | } 146 | } 147 | 148 | // perform absolute path tests with paths not under the same root 149 | // directory as the GitIgnore we are testing 150 | _new, _ := directory(t) 151 | defer func(path string) { 152 | _ = os.RemoveAll(path) 153 | }(_new) 154 | 155 | for _, _test := range _GITMATCHES { 156 | _path := filepath.Join(_new, _test.Local()) 157 | _ignore := _ignore.Ignore(_path) 158 | if _ignore { 159 | t.Fatalf("unexpected ignore for %q", _path) 160 | } 161 | } 162 | } // TestIgnore() 163 | 164 | func TestInclude(t *testing.T) { 165 | // we need to populate a directory with the match test files 166 | // - this is to permit GitIgnore.Include() to correctly resolve 167 | // absolute path names 168 | _dir, _ignore := directory(t) 169 | defer func(path string) { 170 | _ = os.RemoveAll(path) 171 | }(_dir) 172 | 173 | // perform the path matching 174 | // - first we test absolute paths 175 | for _, _test := range _GITMATCHES { 176 | _path := filepath.Join(_dir, _test.Local()) 177 | _rtn := _ignore.Include(_path) 178 | if _rtn == _test.Ignore { 179 | t.Errorf( 180 | "include mismatch for %q; expected %v, got %v", 181 | _path, !_test.Ignore, _rtn, 182 | ) 183 | } 184 | } 185 | 186 | // now, attempt relative path matching 187 | // - to do this, we need to change the working directory 188 | _cwd, _err := os.Getwd() 189 | if _err != nil { 190 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 191 | } 192 | _err = os.Chdir(_dir) 193 | if _err != nil { 194 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 195 | } 196 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 197 | 198 | // perform the relative path tests 199 | for _, _test := range _GITMATCHES { 200 | _rtn := _ignore.Include(_test.Local()) 201 | if _rtn == _test.Ignore { 202 | t.Errorf( 203 | "include mismatch for %q; expected %v, got %v", 204 | _test.Path, !_test.Ignore, _rtn, 205 | ) 206 | } 207 | } 208 | 209 | // perform absolute path tests with paths not under the same root 210 | // directory as the GitIgnore we are testing 211 | _new, _ := directory(t) 212 | defer func(path string) { 213 | _ = os.RemoveAll(path) 214 | }(_new) 215 | 216 | for _, _test := range _GITMATCHES { 217 | _path := filepath.Join(_new, _test.Local()) 218 | _include := _ignore.Include(_path) 219 | if !_include { 220 | t.Fatalf("unexpected include for %q", _path) 221 | } 222 | } 223 | } // TestInclude() 224 | 225 | func TestMatchAbsolute(t *testing.T) { 226 | // create a temporary .gitignore 227 | _buffer, _err := buffer(_GITMATCH) 228 | if _err != nil { 229 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 230 | } 231 | 232 | // ensure we can run New() 233 | // - ensure we encounter no errors 234 | _position := []gitignore.Position{} 235 | _error := func(e gitignore.Error) bool { 236 | _position = append(_position, e.Position()) 237 | return true 238 | } 239 | 240 | // ensure we have a non-nil GitIgnore instance 241 | _ignore := gitignore.New(_buffer, _GITBASE, _error) 242 | if _ignore == nil { 243 | t.Error("expected non-nil GitIgnore instance; nil found") 244 | } 245 | 246 | // ensure we encountered the right number of errors 247 | if len(_position) != _GITBADMATCHPATTERNS { 248 | t.Errorf( 249 | "match error mismatch; expected %d errors, got %d", 250 | _GITBADMATCHPATTERNS, len(_position), 251 | ) 252 | } 253 | 254 | // perform the absolute path matching 255 | _cb := func(path string, isdir bool) gitignore.Match { 256 | _path := filepath.Join(_GITBASE, path) 257 | return _ignore.Absolute(_path, isdir) 258 | } 259 | for _, _test := range _GITMATCHES { 260 | do(t, _cb, _test) 261 | } 262 | 263 | // perform absolute path tests with paths not under the same root 264 | // directory as the GitIgnore we are testing 265 | _new, _ := directory(t) 266 | defer func(path string) { 267 | _ = os.RemoveAll(path) 268 | }(_new) 269 | 270 | for _, _test := range _GITMATCHES { 271 | _path := filepath.Join(_new, _test.Local()) 272 | _match := _ignore.Match(_path) 273 | if _match != nil { 274 | t.Fatalf("unexpected match; expected nil, got %v", _match) 275 | } 276 | } 277 | } // TestMatchAbsolute() 278 | 279 | func TestMatchRelative(t *testing.T) { 280 | // create a temporary .gitignore 281 | _buffer, _err := buffer(_GITMATCH) 282 | if _err != nil { 283 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 284 | } 285 | 286 | // ensure we can run New() 287 | // - ensure we encounter no errors 288 | _position := []gitignore.Position{} 289 | _error := func(e gitignore.Error) bool { 290 | _position = append(_position, e.Position()) 291 | return true 292 | } 293 | 294 | // ensure we have a non-nil GitIgnore instance 295 | _ignore := gitignore.New(_buffer, _GITBASE, _error) 296 | if _ignore == nil { 297 | t.Error("expected non-nil GitIgnore instance; nil found") 298 | } 299 | 300 | // ensure we encountered the right number of errors 301 | if len(_position) != _GITBADMATCHPATTERNS { 302 | t.Errorf( 303 | "match error mismatch; expected %d errors, got %d", 304 | _GITBADMATCHPATTERNS, len(_position), 305 | ) 306 | } 307 | 308 | // perform the relative path matching 309 | _cb := func(path string, isdir bool) gitignore.Match { 310 | return _ignore.Relative(path, isdir) 311 | } 312 | for _, _test := range _GITMATCHES { 313 | do(t, _cb, _test) 314 | } 315 | } // TestMatchRelative() 316 | 317 | func do(t *testing.T, cb func(string, bool) gitignore.Match, m match) { 318 | // attempt to match this path 319 | _match := cb(m.Local(), m.IsDir()) 320 | if _match == nil { 321 | // we have no match, is this expected? 322 | // - a test that matches will list the expected pattern 323 | if m.Pattern != "" { 324 | t.Errorf( 325 | "failed match; expected match for %q by %q", 326 | m.Path, m.Pattern, 327 | ) 328 | return 329 | } 330 | 331 | // since we have no match, ensure this path is not ignored 332 | if m.Ignore { 333 | t.Errorf( 334 | "failed ignore; no match for %q but expected to be ignored", 335 | m.Path, 336 | ) 337 | } 338 | } else { 339 | // we have a match, is this expected? 340 | // - a test that matches will list the expected pattern 341 | if m.Pattern == "" { 342 | t.Errorf( 343 | "unexpected match by %q; expected no match for %q", 344 | _match, m.Path, 345 | ) 346 | return 347 | } else if m.Pattern != _match.String() { 348 | t.Errorf( 349 | "mismatch for %q; expected match pattern %q, got %q", 350 | m.Path, m.Pattern, _match.String(), 351 | ) 352 | return 353 | } 354 | 355 | // since we have a match, are we expected to ignore this file? 356 | if m.Ignore != _match.Ignore() { 357 | t.Errorf( 358 | "ignore mismatch; expected %v for %q Ignore(), "+ 359 | "got %v from pattern %q", 360 | m.Ignore, m.Path, _match.Ignore(), _match, 361 | ) 362 | } 363 | } 364 | } // do() 365 | 366 | func directory(t *testing.T) (string, gitignore.GitIgnore) { 367 | // we need to populate a directory with the match test files 368 | // - this is to permit GitIgnore.Match() to correctly resolve 369 | // absolute path names 370 | // - populate the directory by passing a map of file names and their 371 | // contents 372 | // - the content is not important, it just can't be empty 373 | // - use this mechanism to also populate the .gitignore file 374 | _map := map[string]string{gitignore.File: _GITMATCH} 375 | for _, _test := range _GITMATCHES { 376 | _map[_test.Path] = " " // this is the file contents 377 | } 378 | 379 | // create the temporary directory 380 | _dir, _err := dir(_map) 381 | if _err != nil { 382 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 383 | } 384 | 385 | // ensure we can run New() 386 | // - ensure we encounter no errors 387 | _position := []gitignore.Position{} 388 | _error := func(e gitignore.Error) bool { 389 | _position = append(_position, e.Position()) 390 | return true 391 | } 392 | 393 | // ensure we have a non-nil GitIgnore instance 394 | _file := filepath.Join(_dir, gitignore.File) 395 | _ignore := gitignore.NewWithErrors(_file, _error) 396 | if _ignore == nil { 397 | t.Fatalf("expected non-nil GitIgnore instance; nil found") 398 | } 399 | 400 | // ensure we encountered the right number of errors 401 | if len(_position) != _GITBADMATCHPATTERNS { 402 | t.Errorf( 403 | "match error mismatch; expected %d errors, got %d", 404 | _GITBADMATCHPATTERNS, len(_position), 405 | ) 406 | } 407 | 408 | // return the directory name and the GitIgnore instance 409 | return _dir, _ignore 410 | } // directory() 411 | -------------------------------------------------------------------------------- /go-gitignore/parser.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "io" 7 | ) 8 | 9 | // Parser is the interface for parsing .gitignore files and extracting the set 10 | // of patterns specified in the .gitignore file. 11 | type Parser interface { 12 | // Parse returns all well-formed .gitignore Patterns contained within the 13 | // parser stream. Parsing will terminate at the end of the stream, or if 14 | // the parser error handler returns false. 15 | Parse() []Pattern 16 | 17 | // Next returns the next well-formed .gitignore Pattern from the parser 18 | // stream. If an error is encountered, and the error handler is either 19 | // not defined, or returns true, Next will skip to the end of the current 20 | // line and attempt to parse the next Pattern. If the error handler 21 | // returns false, or the parser reaches the end of the stream, Next 22 | // returns nil. 23 | Next() Pattern 24 | 25 | // Position returns the current position of the parser in the input stream. 26 | Position() Position 27 | } // Parser{} 28 | 29 | // parser is the implementation of the .gitignore parser 30 | type parser struct { 31 | _lexer Lexer 32 | _undo []*Token 33 | _error func(Error) bool 34 | } // parser{} 35 | 36 | // NewParser returns a new Parser instance for the given stream r. 37 | // If err is not nil, it will be called for every error encountered during 38 | // parsing. Parsing will terminate at the end of the stream, or if err 39 | // returns false. 40 | func NewParser(r io.Reader, err func(Error) bool) Parser { 41 | return &parser{_lexer: NewLexer(r), _error: err} 42 | } // NewParser() 43 | 44 | // Parse returns all well-formed .gitignore Patterns contained within the 45 | // parser stream. Parsing will terminate at the end of the stream, or if 46 | // the parser error handler returns false. 47 | func (p *parser) Parse() []Pattern { 48 | // keep parsing until there's no more patterns 49 | _patterns := make([]Pattern, 0) 50 | for { 51 | _pattern := p.Next() 52 | if _pattern == nil { 53 | return _patterns 54 | } 55 | _patterns = append(_patterns, _pattern) 56 | } 57 | } // Parse() 58 | 59 | // Next returns the next well-formed .gitignore Pattern from the parser stream. 60 | // If an error is encountered, and the error handler is either not defined, or 61 | // returns true, Next will skip to the end of the current line and attempt to 62 | // parse the next Pattern. If the error handler returns false, or the parser 63 | // reaches the end of the stream, Next returns nil. 64 | func (p *parser) Next() Pattern { 65 | // keep searching until we find the next pattern, or until we 66 | // reach the end of the file 67 | for { 68 | _token, _err := p.next() 69 | if _err != nil { 70 | if !p.errors(_err) { 71 | return nil 72 | } 73 | 74 | // we got an error from the lexer, so skip the remainder 75 | // of this line and try again from the next line 76 | for _err != nil { 77 | _err = p.skip() 78 | if _err != nil { 79 | if !p.errors(_err) { 80 | return nil 81 | } 82 | } 83 | } 84 | continue 85 | } 86 | 87 | switch _token.Type { 88 | // we're at the end of the file 89 | case EOF: 90 | return nil 91 | 92 | // we have a blank line or comment 93 | case EOL: 94 | continue 95 | case COMMENT: 96 | continue 97 | 98 | // otherwise, attempt to build the next pattern 99 | default: 100 | _pattern, _err := p.build(_token) 101 | if _err != nil { 102 | if !p.errors(_err) { 103 | return nil 104 | } 105 | 106 | // we encountered an error parsing the retrieved tokens 107 | // - skip to the end of the line 108 | for _err != nil { 109 | _err = p.skip() 110 | if _err != nil { 111 | if !p.errors(_err) { 112 | return nil 113 | } 114 | } 115 | } 116 | 117 | // skip to the next token 118 | continue 119 | } else if _pattern != nil { 120 | return _pattern 121 | } 122 | } 123 | } 124 | } // Next() 125 | 126 | // Position returns the current position of the parser in the input stream. 127 | func (p *parser) Position() Position { 128 | // if we have any previously read tokens, then the token at 129 | // the end of the "undo" list (most recently "undone") gives the 130 | // position of the parser 131 | _length := len(p._undo) 132 | if _length != 0 { 133 | return p._undo[_length-1].Position 134 | } 135 | 136 | // otherwise, return the position of the lexer 137 | return p._lexer.Position() 138 | } // Position() 139 | 140 | // 141 | // private methods 142 | // 143 | 144 | // build attempts to build a well-formed .gitignore Pattern starting from the 145 | // given Token t. An Error will be returned if the sequence of tokens returned 146 | // by the Lexer does not represent a valid Pattern. 147 | func (p *parser) build(t *Token) (Pattern, Error) { 148 | // attempt to create a valid pattern 149 | switch t.Type { 150 | // we have a negated pattern 151 | case NEGATION: 152 | return p.negation(t) 153 | 154 | // attempt to build a path specification 155 | default: 156 | return p.path(t) 157 | } 158 | } // build() 159 | 160 | // negation attempts to build a well-formed negated .gitignore Pattern starting 161 | // from the negation Token t. As with build, negation returns an Error if the 162 | // sequence of tokens returned by the Lexer does not represent a valid Pattern. 163 | func (p *parser) negation(t *Token) (Pattern, Error) { 164 | // a negation appears before a path specification, so 165 | // skip the negation token 166 | _next, _err := p.next() 167 | if _err != nil { 168 | return nil, _err 169 | } 170 | 171 | // extract the sequence of tokens for this path 172 | _tokens, _err := p.sequence(_next) 173 | if _err != nil { 174 | return nil, _err 175 | } 176 | 177 | // include the "negation" token at the front of the sequence 178 | _tokens = append([]*Token{t}, _tokens...) 179 | 180 | // return the Pattern instance 181 | return NewPattern(_tokens), nil 182 | } // negation() 183 | 184 | // path attempts to build a well-formed .gitignore Pattern representing a path 185 | // specification, starting with the Token t. If the sequence of tokens returned 186 | // by the Lexer does not represent a valid Pattern, path returns an Error. 187 | // Trailing whitespace is dropped from the sequence of pattern tokens. 188 | func (p *parser) path(t *Token) (Pattern, Error) { 189 | // extract the sequence of tokens for this path 190 | _tokens, _err := p.sequence(t) 191 | if _err != nil { 192 | return nil, _err 193 | } 194 | 195 | // remove trailing whitespace tokens 196 | _length := len(_tokens) 197 | for _length > 0 { 198 | // if we have a non-whitespace token, we can stop 199 | _length-- 200 | if _tokens[_length].Type != WHITESPACE { 201 | break 202 | } 203 | 204 | // otherwise, truncate the token list 205 | _tokens = _tokens[:_length] 206 | } 207 | 208 | // return the Pattern instance 209 | return NewPattern(_tokens), nil 210 | } // path() 211 | 212 | // sequence attempts to extract a well-formed Token sequence from the Lexer 213 | // representing a .gitignore Pattern. sequence returns an Error if the 214 | // retrieved sequence of tokens does not represent a valid Pattern. 215 | func (p *parser) sequence(t *Token) ([]*Token, Error) { 216 | // extract the sequence of tokens for a valid path 217 | // - this excludes the negation token, which is handled as 218 | // a special case before sequence() is called 219 | switch t.Type { 220 | // the path starts with a separator 221 | case SEPARATOR: 222 | return p.separator(t) 223 | 224 | // the path starts with the "any" pattern ("**") 225 | case ANY: 226 | return p.any(t) 227 | 228 | // the path starts with whitespace, wildcard or a pattern 229 | case WHITESPACE: 230 | fallthrough 231 | case PATTERN: 232 | return p.pattern(t) 233 | } 234 | 235 | // otherwise, we have an invalid specification 236 | p.undo(t) 237 | return nil, p.err(ErrInvalidPatternError) 238 | } // sequence() 239 | 240 | // separator attempts to retrieve a valid sequence of tokens that may appear 241 | // after the path separator '/' Token t. An Error is returned if the sequence if 242 | // tokens is not valid, or if there is an error extracting tokens from the 243 | // input stream. 244 | func (p *parser) separator(t *Token) ([]*Token, Error) { 245 | // build a list of tokens that may appear after a separator 246 | _tokens := []*Token{t} 247 | _token, _err := p.next() 248 | if _err != nil { 249 | return _tokens, _err 250 | } 251 | 252 | // what tokens are we allowed to have follow a separator? 253 | switch _token.Type { 254 | // a separator can be followed by a pattern or 255 | // an "any" pattern (i.e. "**") 256 | case ANY: 257 | _next, _err := p.any(_token) 258 | return append(_tokens, _next...), _err 259 | 260 | case WHITESPACE: 261 | fallthrough 262 | case PATTERN: 263 | _next, _err := p.pattern(_token) 264 | return append(_tokens, _next...), _err 265 | 266 | // if we encounter end of line or file we are done 267 | case EOL: 268 | fallthrough 269 | case EOF: 270 | return _tokens, nil 271 | 272 | // a separator can be followed by another separator 273 | // - it's not ideal, and not very useful, but it's interpreted 274 | // as a single separator 275 | // - we could clean it up here, but instead we pass 276 | // everything down to the matching later on 277 | case SEPARATOR: 278 | _next, _err := p.separator(_token) 279 | return append(_tokens, _next...), _err 280 | } 281 | 282 | // any other token is invalid 283 | p.undo(_token) 284 | return _tokens, p.err(ErrInvalidPatternError) 285 | } // separator() 286 | 287 | // any attempts to retrieve a valid sequence of tokens that may appear 288 | // after the any '**' Token t. An Error is returned if the sequence if 289 | // tokens is not valid, or if there is an error extracting tokens from the 290 | // input stream. 291 | func (p *parser) any(t *Token) ([]*Token, Error) { 292 | // build the list of tokens that may appear after "any" (i.e. "**") 293 | _tokens := []*Token{t} 294 | _token, _err := p.next() 295 | if _err != nil { 296 | return _tokens, _err 297 | } 298 | 299 | // what tokens are we allowed to have follow an "any" symbol? 300 | switch _token.Type { 301 | // an "any" token may only be followed by a separator 302 | case SEPARATOR: 303 | _next, _err := p.separator(_token) 304 | return append(_tokens, _next...), _err 305 | 306 | // whitespace is acceptable if it takes us to the end of the line 307 | case WHITESPACE: 308 | return _tokens, p.eol() 309 | 310 | // if we encounter end of line or file we are done 311 | case EOL: 312 | fallthrough 313 | case EOF: 314 | return _tokens, nil 315 | } 316 | 317 | // any other token is invalid 318 | p.undo(_token) 319 | return _tokens, p.err(ErrInvalidPatternError) 320 | } // any() 321 | 322 | // pattern attempts to retrieve a valid sequence of tokens that may appear 323 | // after the path pattern Token t. An Error is returned if the sequence if 324 | // tokens is not valid, or if there is an error extracting tokens from the 325 | // input stream. 326 | func (p *parser) pattern(t *Token) ([]*Token, Error) { 327 | // build the list of tokens that may appear after a pattern 328 | _tokens := []*Token{t} 329 | _token, _err := p.next() 330 | if _err != nil { 331 | return _tokens, _err 332 | } 333 | 334 | // what tokens are we allowed to have follow a pattern? 335 | var _next []*Token 336 | switch _token.Type { 337 | case SEPARATOR: 338 | _next, _err = p.separator(_token) 339 | return append(_tokens, _next...), _err 340 | 341 | case WHITESPACE: 342 | fallthrough 343 | case PATTERN: 344 | _next, _err = p.pattern(_token) 345 | return append(_tokens, _next...), _err 346 | 347 | // if we encounter end of line or file we are done 348 | case EOL: 349 | fallthrough 350 | case EOF: 351 | return _tokens, nil 352 | } 353 | 354 | // any other token is invalid 355 | p.undo(_token) 356 | return _tokens, p.err(ErrInvalidPatternError) 357 | } // pattern() 358 | 359 | // eol attempts to consume the next Lexer token to read the end of line or end 360 | // of file. If a EOL or EOF is not reached , eol will return an error. 361 | func (p *parser) eol() Error { 362 | // are we at the end of the line? 363 | _token, _err := p.next() 364 | if _err != nil { 365 | return _err 366 | } 367 | 368 | // have we encountered whitespace only? 369 | switch _token.Type { 370 | // if we're at the end of the line or file, we're done 371 | case EOL: 372 | fallthrough 373 | case EOF: 374 | p.undo(_token) 375 | return nil 376 | } 377 | 378 | // otherwise, we have an invalid pattern 379 | p.undo(_token) 380 | return p.err(ErrInvalidPatternError) 381 | } // eol() 382 | 383 | // next returns the next token from the Lexer, or an error if there is a 384 | // problem reading from the input stream. 385 | func (p *parser) next() (*Token, Error) { 386 | // do we have any previously read tokens? 387 | _length := len(p._undo) 388 | if _length > 0 { 389 | _token := p._undo[_length-1] 390 | p._undo = p._undo[:_length-1] 391 | return _token, nil 392 | } 393 | 394 | // otherwise, attempt to retrieve the next token from the lexer 395 | return p._lexer.Next() 396 | } // next() 397 | 398 | // skip reads Tokens from the input until the end of line or end of file is 399 | // reached. If there is a problem reading tokens, an Error is returned. 400 | func (p *parser) skip() Error { 401 | // skip to the next end of line or end of file token 402 | for { 403 | _token, _err := p.next() 404 | if _err != nil { 405 | return _err 406 | } 407 | 408 | // if we have an end of line or file token, then we can stop 409 | switch _token.Type { 410 | case EOL: 411 | fallthrough 412 | case EOF: 413 | return nil 414 | } 415 | } 416 | } // skip() 417 | 418 | // undo returns the given Token t to the parser input stream to be retrieved 419 | // again on a subsequent call to next. 420 | func (p *parser) undo(t *Token) { 421 | // add this token to the list of previously read tokens 422 | // - initialise the undo list if required 423 | if p._undo == nil { 424 | p._undo = make([]*Token, 0, 1) 425 | } 426 | p._undo = append(p._undo, t) 427 | } // undo() 428 | 429 | // err returns an Error for the error e, capturing the current parser Position. 430 | func (p *parser) err(e error) Error { 431 | // convert the error to include the parser position 432 | return NewError(e, p.Position()) 433 | } // err() 434 | 435 | // errors returns the response from the parser error handler to the Error e. If 436 | // no error handler has been configured for this parser, errors returns true. 437 | func (p *parser) errors(e Error) bool { 438 | // do we have an error handler? 439 | if p._error == nil { 440 | return true 441 | } 442 | 443 | // pass the error through to the error handler 444 | // - if this returns false, parsing will stop 445 | return p._error(e) 446 | } // errors() 447 | -------------------------------------------------------------------------------- /go-gitignore/parser_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "strings" 7 | "testing" 8 | 9 | "github.com/boyter/gocodewalker/go-gitignore" 10 | ) 11 | 12 | type parsetest struct { 13 | good int 14 | bad int 15 | position []gitignore.Position 16 | failures []gitignore.Error 17 | errors func(gitignore.Error) bool 18 | } // parsetest{} 19 | 20 | // TestParser tests the behaviour of gitignore.Parser 21 | func TestParser(t *testing.T) { 22 | _test := &parsetest{good: _GITPATTERNS, bad: _GITBADPATTERNS} 23 | _test.position = make([]gitignore.Position, 0) 24 | 25 | // record the position of encountered errors 26 | _test.errors = func(e gitignore.Error) bool { 27 | _test.position = append(_test.position, e.Position()) 28 | return true 29 | } 30 | 31 | // run this parser test 32 | parse(t, _test) 33 | } // TestParser() 34 | 35 | // TestParserError tests the behaviour of the gitignore.Parser with an error 36 | // handler that returns false on receiving an error 37 | func TestParserError(t *testing.T) { 38 | _test := &parsetest{good: _GITPATTERNSFALSE, bad: _GITBADPATTERNSFALSE} 39 | _test.position = make([]gitignore.Position, 0) 40 | 41 | // record the position of encountered errors 42 | // - return false to stop parsing 43 | _test.errors = func(e gitignore.Error) bool { 44 | _test.position = append(_test.position, e.Position()) 45 | return false 46 | } 47 | 48 | // run this parser test 49 | parse(t, _test) 50 | } // TestParserError() 51 | 52 | func TestParserInvalid(t *testing.T) { 53 | _test := &parsetest{good: _GITINVALIDPATTERNS, bad: _GITINVALIDERRORS} 54 | _test.position = make([]gitignore.Position, 0) 55 | 56 | // record the position of encountered errors 57 | _test.errors = func(e gitignore.Error) bool { 58 | _test.position = append(_test.position, e.Position()) 59 | _test.failures = append(_test.failures, e) 60 | return true 61 | } 62 | 63 | // run this parser test 64 | invalidparse(t, _test) 65 | } // TestParserInvalid() 66 | 67 | func TestParserInvalidFalse(t *testing.T) { 68 | _test := &parsetest{ 69 | good: _GITINVALIDPATTERNSFALSE, 70 | bad: _GITINVALIDERRORSFALSE, 71 | } 72 | _test.position = make([]gitignore.Position, 0) 73 | 74 | // record the position of encountered errors 75 | _test.errors = func(e gitignore.Error) bool { 76 | _test.position = append(_test.position, e.Position()) 77 | _test.failures = append(_test.failures, e) 78 | return false 79 | } 80 | 81 | // run this parser test 82 | invalidparse(t, _test) 83 | } // TestParserInvalidFalse() 84 | 85 | func parse(t *testing.T, test *parsetest) { 86 | // create a temporary .gitignore 87 | _buffer, _err := buffer(_GITIGNORE) 88 | if _err != nil { 89 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 90 | } 91 | 92 | // ensure we have a non-nil Parser instance 93 | _parser := gitignore.NewParser(_buffer, test.errors) 94 | if _parser == nil { 95 | t.Error("expected non-nil Parser instance; nil found") 96 | } 97 | 98 | // before we parse, what position do we have? 99 | _position := _parser.Position() 100 | if !coincident(_position, _BEGINNING) { 101 | t.Errorf( 102 | "beginning position mismatch; expected %s, got %s", 103 | pos(_BEGINNING), pos(_position), 104 | ) 105 | } 106 | 107 | // attempt to parse the .gitignore 108 | _patterns := _parser.Parse() 109 | 110 | // ensure we encountered the expected bad patterns 111 | if len(test.position) != test.bad { 112 | t.Errorf( 113 | "parse error mismatch; expected %d errors, got %d", 114 | test.bad, len(test.position), 115 | ) 116 | } else { 117 | // ensure the bad pattern positions are correct 118 | for _i := 0; _i < test.bad; _i++ { 119 | _got := test.position[_i] 120 | _expected := _GITBADPOSITION[_i] 121 | 122 | if !coincident(_got, _expected) { 123 | t.Errorf( 124 | "bad pattern position mismatch; expected %q, got %q", 125 | pos(_expected), pos(_got), 126 | ) 127 | } 128 | } 129 | } 130 | 131 | // ensure we encountered the right number of good patterns 132 | if len(_patterns) != test.good { 133 | t.Errorf( 134 | "parse pattern mismatch; expected %d patterns, got %d", 135 | test.good, len(_patterns), 136 | ) 137 | } else { 138 | // ensure the good pattern positions are correct 139 | for _i := 0; _i < len(_patterns); _i++ { 140 | _got := _patterns[_i].Position() 141 | _expected := _GITPOSITION[_i] 142 | 143 | if !coincident(_got, _expected) { 144 | t.Errorf( 145 | "pattern position mismatch; expected %q, got %q", 146 | pos(_expected), pos(_got), 147 | ) 148 | } 149 | } 150 | 151 | // ensure the retrieved patterns are correct 152 | // - we check the string form of the pattern against the respective 153 | // lines from the .gitignore 154 | // - we must special-case patterns that end in whitespace that 155 | // can be ignored (i.e. it's not escaped) 156 | _lines := strings.Split(_GITIGNORE, "\n") 157 | for _i := 0; _i < len(_patterns); _i++ { 158 | _pattern := _patterns[_i] 159 | _got := _pattern.String() 160 | _line := _pattern.Position().Line 161 | _expected := _lines[_line-1] 162 | 163 | if _got != _expected { 164 | // if the two strings aren't the same, then check to see if 165 | // the difference is trailing whitespace 166 | // - the expected string may have whitespace, while the 167 | // pattern string does not 168 | // - patterns have their trailing whitespace removed, so 169 | // - we perform this check here, since it's possible for 170 | // a pattern to end in a whitespace character (e.g. '\ ') 171 | // and we don't want to be too heavy handed with our 172 | // removal of whitespace 173 | // - only do this check for non-comments 174 | if !strings.HasPrefix(_expected, "#") { 175 | _new := strings.TrimRight(_expected, " \t") 176 | if _new == _got { 177 | continue 178 | } 179 | } 180 | t.Errorf( 181 | "pattern mismatch; expected %q, got %q at %s", 182 | _expected, _got, pos(_pattern.Position()), 183 | ) 184 | } 185 | } 186 | } 187 | } // parse() 188 | 189 | func invalidparse(t *testing.T, test *parsetest) { 190 | _buffer, _err := buffer(_GITINVALID) 191 | if _err != nil { 192 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 193 | } 194 | 195 | // create the parser instance 196 | _parser := gitignore.NewParser(_buffer, test.errors) 197 | if _parser == nil { 198 | t.Error("expected non-nil Parser instance; nil found") 199 | } 200 | 201 | // attempt to parse the .gitignore 202 | _patterns := _parser.Parse() 203 | 204 | // ensure we have the correct number of errors encountered 205 | if len(test.failures) != test.bad { 206 | t.Fatalf( 207 | "unexpected invalid parse errors; expected %d, got %d", 208 | test.bad, len(test.failures), 209 | ) 210 | } else { 211 | for _i := 0; _i < test.bad; _i++ { 212 | _expected := _GITINVALIDERROR[_i] 213 | _got := test.failures[_i] 214 | 215 | // is this error the same as expected? 216 | // if !_got.Is(_expected) { 217 | if _got.Underlying() != _expected { 218 | t.Fatalf( 219 | "unexpected invalid parse error; expected %q, got %q", 220 | _expected.Error(), _got.Error(), 221 | ) 222 | } 223 | } 224 | } 225 | 226 | // ensure we have the correct number of patterns 227 | if len(_patterns) != test.good { 228 | t.Fatalf( 229 | "unexpected invalid parse patterns; expected %d, got %d", 230 | test.good, len(_patterns), 231 | ) 232 | } else { 233 | for _i := 0; _i < test.good; _i++ { 234 | _expected := _GITINVALIDPATTERN[_i] 235 | _got := _patterns[_i] 236 | 237 | // is this pattern the same as expected? 238 | if _got.String() != _expected { 239 | t.Fatalf( 240 | "unexpected invalid parse pattern; expected %q, got %q", 241 | _expected, _got, 242 | ) 243 | } 244 | } 245 | } 246 | } // invalidparse() 247 | -------------------------------------------------------------------------------- /go-gitignore/pattern.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/danwakefield/fnmatch" 10 | ) 11 | 12 | // Pattern represents per-line patterns within a .gitignore file 13 | type Pattern interface { 14 | Match 15 | 16 | // Match returns true if the given path matches the name pattern. If the 17 | // pattern is meant for directories only, and the path is not a directory, 18 | // Match will return false. The matching is performed by fnmatch(). It 19 | // is assumed path is relative to the base path of the owning GitIgnore. 20 | Match(string, bool) bool 21 | } 22 | 23 | // pattern is the base implementation of a .gitignore pattern 24 | type pattern struct { 25 | _negated bool 26 | _anchored bool 27 | _directory bool 28 | _string string 29 | _fnmatch string 30 | _position Position 31 | } // pattern() 32 | 33 | // name represents patterns matching a file or path name (i.e. the last 34 | // component of a path) 35 | type name struct { 36 | pattern 37 | } // name{} 38 | 39 | // path represents a pattern that contains at least one path separator within 40 | // the pattern (i.e. not at the start or end of the pattern) 41 | type path struct { 42 | pattern 43 | _depth int 44 | } // path{} 45 | 46 | // any represents a pattern that contains at least one "any" token "**" 47 | // allowing for recursive matching. 48 | type any struct { 49 | pattern 50 | _tokens []*Token 51 | } // any{} 52 | 53 | // NewPattern returns a Pattern from the ordered slice of Tokens. The tokens are 54 | // assumed to represent a well-formed .gitignore pattern. A Pattern may be 55 | // negated, anchored to the start of the path (relative to the base directory 56 | // of tie containing .gitignore), or match directories only. 57 | func NewPattern(tokens []*Token) Pattern { 58 | // if we have no tokens there is no pattern 59 | if len(tokens) == 0 { 60 | return nil 61 | } 62 | 63 | // extract the pattern position from first token 64 | _position := tokens[0].Position 65 | _string := tokenset(tokens).String() 66 | 67 | // is this a negated pattern? 68 | _negated := false 69 | if tokens[0].Type == NEGATION { 70 | _negated = true 71 | tokens = tokens[1:] 72 | } 73 | 74 | // is this pattern anchored to the start of the path? 75 | _anchored := false 76 | if tokens[0].Type == SEPARATOR { 77 | _anchored = true 78 | tokens = tokens[1:] 79 | } 80 | 81 | // is this pattern for directories only? 82 | _directory := false 83 | _last := len(tokens) - 1 84 | if len(tokens) != 0 { 85 | if tokens[_last].Type == SEPARATOR { 86 | _directory = true 87 | tokens = tokens[:_last] 88 | } 89 | } 90 | 91 | // build the pattern expression 92 | _fnmatch := tokenset(tokens).String() 93 | _pattern := &pattern{ 94 | _negated: _negated, 95 | _anchored: _anchored, 96 | _position: _position, 97 | _directory: _directory, 98 | _string: _string, 99 | _fnmatch: _fnmatch, 100 | } 101 | return _pattern.compile(tokens) 102 | } // NewPattern() 103 | 104 | // compile generates a specific Pattern (i.e. name, path or any) 105 | // represented by the list of tokens. 106 | func (p *pattern) compile(tokens []*Token) Pattern { 107 | // what tokens do we have in this pattern? 108 | // - ANY token means we can match to any depth 109 | // - SEPARATOR means we have path rather than file matching 110 | _separator := false 111 | for _, _token := range tokens { 112 | switch _token.Type { 113 | case ANY: 114 | return p.any(tokens) 115 | case SEPARATOR: 116 | _separator = true 117 | } 118 | } 119 | 120 | // should we perform path or name/file matching? 121 | if _separator { 122 | return p.path(tokens) 123 | } else { 124 | return p.name(tokens) 125 | } 126 | } // compile() 127 | 128 | // Ignore returns true if the pattern describes files or paths that should be 129 | // ignored. 130 | func (p *pattern) Ignore() bool { return !p._negated } 131 | 132 | // Include returns true if the pattern describes files or paths that should be 133 | // included (i.e. not ignored) 134 | func (p *pattern) Include() bool { return p._negated } 135 | 136 | // Position returns the position of the first token of this pattern. 137 | func (p *pattern) Position() Position { return p._position } 138 | 139 | // String returns the string representation of the pattern. 140 | func (p *pattern) String() string { return p._string } 141 | 142 | // 143 | // name patterns 144 | // - designed to match trailing file/directory names only 145 | // 146 | 147 | // name returns a Pattern designed to match file or directory names, with no 148 | // path elements. 149 | func (p *pattern) name(tokens []*Token) Pattern { 150 | return &name{*p} 151 | } // name() 152 | 153 | // Match returns true if the given path matches the name pattern. If the 154 | // pattern is meant for directories only, and the path is not a directory, 155 | // Match will return false. The matching is performed by fnmatch(). It 156 | // is assumed path is relative to the base path of the owning GitIgnore. 157 | func (n *name) Match(path string, isdir bool) bool { 158 | // are we expecting a directory? 159 | if n._directory && !isdir { 160 | return false 161 | } 162 | 163 | // should we match the whole path, or just the last component? 164 | if n._anchored { 165 | return fnmatch.Match(n._fnmatch, path, 0) 166 | } else { 167 | _, _base := filepath.Split(path) 168 | return fnmatch.Match(n._fnmatch, _base, 0) 169 | } 170 | } // Match() 171 | 172 | // 173 | // path patterns 174 | // - designed to match complete or partial paths (not just filenames) 175 | // 176 | 177 | // path returns a Pattern designed to match paths that include at least one 178 | // path separator '/' neither at the end nor the start of the pattern. 179 | func (p *pattern) path(tokens []*Token) Pattern { 180 | // how many directory components are we expecting? 181 | _depth := 0 182 | for _, _token := range tokens { 183 | if _token.Type == SEPARATOR { 184 | _depth++ 185 | } 186 | } 187 | 188 | // return the pattern instance 189 | return &path{pattern: *p, _depth: _depth} 190 | } // path() 191 | 192 | // Match returns true if the given path matches the path pattern. If the 193 | // pattern is meant for directories only, and the path is not a directory, 194 | // Match will return false. The matching is performed by fnmatch() 195 | // with flags set to FNM_PATHNAME. It is assumed path is relative to the 196 | // base path of the owning GitIgnore. 197 | func (p *path) Match(path string, isdir bool) bool { 198 | // are we expecting a directory 199 | if p._directory && !isdir { 200 | return false 201 | } 202 | 203 | if fnmatch.Match(p._fnmatch, path, fnmatch.FNM_PATHNAME) { 204 | return true 205 | } else if p._anchored { 206 | return false 207 | } 208 | 209 | // match against the trailing path elements 210 | return fnmatch.Match(p._fnmatch, path, fnmatch.FNM_PATHNAME) 211 | } // Match() 212 | 213 | // 214 | // "any" patterns 215 | // 216 | 217 | // any returns a Pattern designed to match paths that include at least one 218 | // any pattern '**', specifying recursive matching. 219 | func (p *pattern) any(tokens []*Token) Pattern { 220 | // consider only the non-SEPARATOR tokens, as these will be matched 221 | // against the path components 222 | _tokens := make([]*Token, 0) 223 | for _, _token := range tokens { 224 | if _token.Type != SEPARATOR { 225 | _tokens = append(_tokens, _token) 226 | } 227 | } 228 | 229 | return &any{*p, _tokens} 230 | } // any() 231 | 232 | // Match returns true if the given path matches the any pattern. If the 233 | // pattern is meant for directories only, and the path is not a directory, 234 | // Match will return false. The matching is performed by recursively applying 235 | // fnmatch() with flags set to FNM_PATHNAME. It is assumed path is relative to 236 | // the base path of the owning GitIgnore. 237 | func (a *any) Match(path string, isdir bool) bool { 238 | // are we expecting a directory? 239 | if a._directory && !isdir { 240 | return false 241 | } 242 | 243 | // split the path into components 244 | _parts := strings.Split(path, string(_SEPARATOR)) 245 | 246 | // attempt to match the parts against the pattern tokens 247 | return a.match(_parts, a._tokens) 248 | } // Match() 249 | 250 | // match performs the recursive matching for 'any' patterns. An 'any' 251 | // token '**' may match any path component, or no path component. 252 | func (a *any) match(path []string, tokens []*Token) bool { 253 | // if we have no more tokens, then we have matched this path 254 | // if there are also no more path elements, otherwise there's no match 255 | if len(tokens) == 0 { 256 | return len(path) == 0 257 | } 258 | 259 | // what token are we trying to match? 260 | _token := tokens[0] 261 | switch _token.Type { 262 | case ANY: 263 | if len(path) == 0 { 264 | return a.match(path, tokens[1:]) 265 | } else { 266 | return a.match(path, tokens[1:]) || a.match(path[1:], tokens) 267 | } 268 | 269 | default: 270 | // if we have a non-ANY token, then we must have a non-empty path 271 | if len(path) != 0 { 272 | // if the current path element matches this token, 273 | // we match if the remainder of the path matches the 274 | // remaining tokens 275 | if fnmatch.Match(_token.Token(), path[0], fnmatch.FNM_PATHNAME) { 276 | return a.match(path[1:], tokens[1:]) 277 | } 278 | } 279 | } 280 | 281 | // if we are here, then we have no match 282 | return false 283 | } // match() 284 | 285 | // ensure the patterns confirm to the Pattern interface 286 | var _ Pattern = &name{} 287 | var _ Pattern = &path{} 288 | var _ Pattern = &any{} 289 | -------------------------------------------------------------------------------- /go-gitignore/position.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | // Position represents the position of the .gitignore parser, and the position 10 | // of a .gitignore pattern within the parsed stream. 11 | type Position struct { 12 | File string 13 | Line int 14 | Column int 15 | Offset int 16 | } 17 | 18 | // String returns a string representation of the current position. 19 | func (p Position) String() string { 20 | _prefix := "" 21 | if p.File != "" { 22 | _prefix = p.File + ": " 23 | } 24 | 25 | if p.Line == 0 { 26 | return fmt.Sprintf("%s+%d", _prefix, p.Offset) 27 | } else if p.Column == 0 { 28 | return fmt.Sprintf("%s%d", _prefix, p.Line) 29 | } else { 30 | return fmt.Sprintf("%s%d:%d", _prefix, p.Line, p.Column) 31 | } 32 | } // String() 33 | 34 | // Zero returns true if the Position represents the zero Position 35 | func (p Position) Zero() bool { 36 | return p.Line+p.Column+p.Offset == 0 37 | } // Zero() 38 | -------------------------------------------------------------------------------- /go-gitignore/position_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/boyter/gocodewalker/go-gitignore" 9 | ) 10 | 11 | func TestPosition(t *testing.T) { 12 | // test the conversion of Positions to strings 13 | for _, _p := range _POSITIONS { 14 | _position := gitignore.Position{ 15 | File: _p.File, 16 | Line: _p.Line, 17 | Column: _p.Column, 18 | Offset: _p.Offset, 19 | } 20 | 21 | // ensure the string representation of the Position is as expected 22 | _rtn := _position.String() 23 | if _rtn != _p.String { 24 | t.Errorf( 25 | "position mismatch; expected %q, got %q", 26 | _p.String, _rtn, 27 | ) 28 | } 29 | } 30 | } // TestPosition() 31 | -------------------------------------------------------------------------------- /go-gitignore/repository.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | const File = ".gitignore" 12 | 13 | // repository is the implementation of the set of .gitignore files within a 14 | // repository hierarchy 15 | type repository struct { 16 | ignore 17 | _errors func(e Error) bool 18 | _cache Cache 19 | _file string 20 | _exclude GitIgnore 21 | } // repository{} 22 | 23 | // NewRepository returns a GitIgnore instance representing a git repository 24 | // with root directory base. If base is not a directory, or base cannot be 25 | // read, NewRepository will return an error. 26 | // 27 | // Internally, NewRepository uses NewRepositoryWithFile. 28 | func NewRepository(base string) (GitIgnore, error) { 29 | return NewRepositoryWithFile(base, File) 30 | } // NewRepository() 31 | 32 | // NewRepositoryWithFile returns a GitIgnore instance representing a git 33 | // repository with root directory base. The repository will use file as 34 | // the name of the files within the repository from which to load the 35 | // .gitignore patterns. If file is the empty string, NewRepositoryWithFile 36 | // uses ".gitignore". If the ignore file name is ".gitignore", the returned 37 | // GitIgnore instance will also consider patterns listed in 38 | // $GIT_DIR/info/exclude when performing repository matching. 39 | // 40 | // Internally, NewRepositoryWithFile uses NewRepositoryWithErrors. 41 | func NewRepositoryWithFile(base, file string) (GitIgnore, error) { 42 | // define an error handler to catch any file access errors 43 | // - record the first encountered error 44 | var _error Error 45 | _errors := func(e Error) bool { 46 | if _error == nil { 47 | _error = e 48 | } 49 | return true 50 | } 51 | 52 | // attempt to retrieve the repository represented by this file 53 | _repository := NewRepositoryWithErrors(base, file, _errors) 54 | 55 | // did we encounter an error? 56 | // - if the error has a zero Position then it was encountered 57 | // before parsing was attempted, so we return that error 58 | if _error != nil { 59 | if _error.Position().Zero() { 60 | return nil, _error.Underlying() 61 | } 62 | } 63 | 64 | // otherwise, we ignore the parser errors 65 | return _repository, nil 66 | } // NewRepositoryWithFile() 67 | 68 | // NewRepositoryWithErrors returns a GitIgnore instance representing a git 69 | // repository with a root directory base. As with NewRepositoryWithFile, file 70 | // specifies the name of the files within the repository containing the 71 | // .gitignore patterns, and defaults to ".gitignore" if file is not specified. 72 | // If the ignore file name is ".gitignore", the returned GitIgnore instance 73 | // will also consider patterns listed in $GIT_DIR/info/exclude when performing 74 | // repository matching. 75 | // 76 | // If errors is given, it will be invoked for each error encountered while 77 | // matching a path against the repository GitIgnore (such as file permission 78 | // denied, or errors during .gitignore parsing). See Match below. 79 | // 80 | // Internally, NewRepositoryWithErrors uses NewRepositoryWithCache. 81 | func NewRepositoryWithErrors(base, file string, errors func(e Error) bool) GitIgnore { 82 | return NewRepositoryWithCache(base, file, NewCache(), errors) 83 | } // NewRepositoryWithErrors() 84 | 85 | // NewRepositoryWithCache returns a GitIgnore instance representing a git 86 | // repository with a root directory base. As with NewRepositoryWithErrors, 87 | // file specifies the name of the files within the repository containing the 88 | // .gitignore patterns, and defaults to ".gitignore" if file is not specified. 89 | // If the ignore file name is ".gitignore", the returned GitIgnore instance 90 | // will also consider patterns listed in $GIT_DIR/info/exclude when performing 91 | // repository matching. 92 | // 93 | // NewRepositoryWithCache will attempt to load each .gitignore within the 94 | // repository only once, using NewWithCache to store the corresponding 95 | // GitIgnore instance in cache. If cache is given as nil, 96 | // NewRepositoryWithCache will create a Cache instance for this repository. 97 | // 98 | // If errors is given, it will be invoked for each error encountered while 99 | // matching a path against the repository GitIgnore (such as file permission 100 | // denied, or errors during .gitignore parsing). See Match below. 101 | func NewRepositoryWithCache(base, file string, cache Cache, errors func(e Error) bool) GitIgnore { 102 | // do we have an error handler? 103 | _errors := errors 104 | if _errors == nil { 105 | _errors = func(e Error) bool { return true } 106 | } 107 | 108 | // extract the absolute path of the base directory 109 | _base, _err := filepath.Abs(base) 110 | if _err != nil { 111 | _errors(NewError(_err, Position{})) 112 | return nil 113 | } 114 | 115 | // ensure the given base is a directory 116 | _info, _err := os.Stat(_base) 117 | if _info != nil { 118 | if !_info.IsDir() { 119 | _err = ErrInvalidDirectoryError 120 | } 121 | } 122 | if _err != nil { 123 | _errors(NewError(_err, Position{})) 124 | return nil 125 | } 126 | 127 | // if we haven't been given a base file name, use the default 128 | if file == "" { 129 | file = File 130 | } 131 | 132 | // are we matching .gitignore files? 133 | // - if we are, we also consider $GIT_DIR/info/exclude 134 | var _exclude GitIgnore 135 | if file == File { 136 | _exclude, _err = exclude(_base) 137 | if _err != nil { 138 | _errors(NewError(_err, Position{})) 139 | return nil 140 | } 141 | } 142 | 143 | // create the repository instance 144 | _ignore := ignore{_base: _base} 145 | _repository := &repository{ 146 | ignore: _ignore, 147 | _errors: _errors, 148 | _exclude: _exclude, 149 | _cache: cache, 150 | _file: file, 151 | } 152 | 153 | return _repository 154 | } // NewRepositoryWithCache() 155 | 156 | // Match attempts to match the path against this repository. Matching proceeds 157 | // according to normal gitignore rules, where .gtignore files in the same 158 | // directory as path, take precedence over .gitignore files higher up the 159 | // path hierarchy, and child files and directories are ignored if the parent 160 | // is ignored. If the path is matched by a gitignore pattern in the repository, 161 | // a Match is returned detailing the matched pattern. The returned Match 162 | // can be used to determine if the path should be ignored or included according 163 | // to the repository. 164 | // 165 | // If an error is encountered during matching, the repository error handler 166 | // (if configured via NewRepositoryWithErrors or NewRepositoryWithCache), will 167 | // be called. If the error handler returns false, matching will terminate and 168 | // Match will return nil. If handler returns true, Match will continue 169 | // processing in an attempt to match path. 170 | // 171 | // Match will raise an error and return nil if the absolute path cannot be 172 | // determined, or if its not possible to determine if path represents a file 173 | // or a directory. 174 | // 175 | // If path is not located under the root of this repository, Match returns nil. 176 | func (r *repository) Match(path string) Match { 177 | // ensure we have the absolute path for the given file 178 | _path, _err := filepath.Abs(path) 179 | if _err != nil { 180 | r._errors(NewError(_err, Position{})) 181 | return nil 182 | } 183 | 184 | // is the path a file or a directory? 185 | _info, _err := os.Stat(_path) 186 | if _err != nil { 187 | r._errors(NewError(_err, Position{})) 188 | return nil 189 | } 190 | _isdir := _info.IsDir() 191 | 192 | // attempt to match the absolute path 193 | return r.Absolute(_path, _isdir) 194 | } // Match() 195 | 196 | // Absolute attempts to match an absolute path against this repository. If the 197 | // path is not located under the base directory of this repository, or is not 198 | // matched by this repository, nil is returned. 199 | func (r *repository) Absolute(path string, isdir bool) Match { 200 | // does the file share the same directory as this ignore file? 201 | if !strings.HasPrefix(path, r.Base()) { 202 | return nil 203 | } 204 | 205 | // extract the relative path of this file 206 | _prefix := len(r.Base()) + 1 207 | _rel := string(path[_prefix:]) 208 | return r.Relative(_rel, isdir) 209 | } // Absolute() 210 | 211 | // Relative attempts to match a path relative to the repository base directory. 212 | // If the path is not matched by the repository, nil is returned. 213 | func (r *repository) Relative(path string, isdir bool) Match { 214 | // if there's no path, then there's nothing to match 215 | _path := filepath.Clean(path) 216 | if _path == "." { 217 | return nil 218 | } 219 | 220 | // repository matching: 221 | // - a child path cannot be considered if its parent is ignored 222 | // - a .gitignore in a lower directory overrides a .gitignore in a 223 | // higher directory 224 | 225 | // first, is the parent directory ignored? 226 | // - extract the parent directory from the current path 227 | _parent, _local := filepath.Split(_path) 228 | _match := r.Relative(_parent, true) 229 | if _match != nil { 230 | if _match.Ignore() { 231 | return _match 232 | } 233 | } 234 | _parent = filepath.Clean(_parent) 235 | 236 | // the parent directory isn't ignored, so we now look at the original path 237 | // - we consider .gitignore files in the current directory first, then 238 | // move up the path hierarchy 239 | var _last string 240 | for { 241 | _file := filepath.Join(r._base, _parent, r._file) 242 | _ignore := NewWithCache(_file, r._cache, r._errors) 243 | if _ignore != nil { 244 | _match := _ignore.Relative(_local, isdir) 245 | if _match != nil { 246 | return _match 247 | } 248 | } 249 | 250 | // if there's no parent, then we're done 251 | // - since we use filepath.Clean() we look for "." 252 | if _parent == "." { 253 | break 254 | } 255 | 256 | // we don't have a match for this file, so we progress up the 257 | // path hierarchy 258 | // - we are manually building _local using the .gitignore 259 | // separator "/", which is how we handle operating system 260 | // file system differences 261 | _parent, _last = filepath.Split(_parent) 262 | _parent = filepath.Clean(_parent) 263 | _local = _last + string(_SEPARATOR) + _local 264 | } 265 | 266 | // do we have a global exclude file? (i.e. GIT_DIR/info/exclude) 267 | if r._exclude != nil { 268 | return r._exclude.Relative(path, isdir) 269 | } 270 | 271 | // we have no match 272 | return nil 273 | } // Relative() 274 | 275 | // ensure repository satisfies the GitIgnore interface 276 | var _ GitIgnore = &repository{} 277 | -------------------------------------------------------------------------------- /go-gitignore/repository_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/boyter/gocodewalker/go-gitignore" 11 | ) 12 | 13 | type repositorytest struct { 14 | file string 15 | directory string 16 | cache gitignore.Cache 17 | cached bool 18 | error func(e gitignore.Error) bool 19 | errors []gitignore.Error 20 | bad int 21 | instance func(string) (gitignore.GitIgnore, error) 22 | exclude string 23 | gitdir string 24 | } // repostorytest{} 25 | 26 | func (r *repositorytest) create(path string, gitdir bool) (gitignore.GitIgnore, error) { 27 | // if we have an error handler, reset the list of errors 28 | if r.error != nil { 29 | r.errors = make([]gitignore.Error, 0) 30 | } 31 | 32 | if r.file == gitignore.File || r.file == "" { 33 | // should we create the global exclude file 34 | r.gitdir = os.Getenv("GIT_DIR") 35 | if gitdir { 36 | // create a temporary file for the global exclude file 37 | _exclude, _err := exclude(_GITEXCLUDE) 38 | if _err != nil { 39 | return nil, _err 40 | } 41 | 42 | // extract the current value of the GIT_DIR environment variable 43 | // and set the value to be that of the temporary file 44 | r.exclude = _exclude 45 | _err = os.Setenv("GIT_DIR", r.exclude) 46 | if _err != nil { 47 | return nil, _err 48 | } 49 | } else { 50 | _err := os.Unsetenv("GIT_DIR") 51 | if _err != nil { 52 | return nil, _err 53 | } 54 | } 55 | } 56 | 57 | // attempt to create the GitIgnore instance 58 | _repository, _err := r.instance(path) 59 | 60 | // if we encountered errors, and the first error has a zero position 61 | // then it represents a file access error 62 | // - extract the error and return it 63 | // - remove it from the list of errors 64 | if len(r.errors) > 0 { 65 | if r.errors[0].Position().Zero() { 66 | _err = r.errors[0].Underlying() 67 | r.errors = r.errors[1:] 68 | } 69 | } 70 | 71 | // return the GitIgnore instance 72 | return _repository, _err 73 | } // create() 74 | 75 | func (r *repositorytest) destroy() { 76 | // remove the temporary files and directories 77 | for _, _path := range []string{r.directory, r.exclude} { 78 | if _path != "" { 79 | defer func(path string) { 80 | _ = os.RemoveAll(path) 81 | }(_path) 82 | } 83 | } 84 | 85 | if r.file == gitignore.File || r.file == "" { 86 | // reset the GIT_DIR environment variable 87 | if r.gitdir == "" { 88 | defer func() { 89 | _ = os.Unsetenv("GIT_DIR") 90 | }() 91 | } else { 92 | defer func(key, value string) { 93 | _ = os.Setenv(key, value) 94 | }("GIT_DIR", r.gitdir) 95 | } 96 | } 97 | } // destroy() 98 | 99 | type invalidtest struct { 100 | *repositorytest 101 | tag string 102 | match func() gitignore.Match 103 | } // invalidtest{} 104 | 105 | func TestRepository(t *testing.T) { 106 | _test := &repositorytest{} 107 | _test.bad = _GITREPOSITORYERRORS 108 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 109 | return gitignore.NewRepository(path) 110 | } 111 | 112 | // perform the repository tests 113 | repository(t, _test, _REPOSITORYMATCHES) 114 | 115 | // remove the temporary directory used for this test 116 | defer _test.destroy() 117 | } // TestRepository() 118 | 119 | func TestRepositoryWithFile(t *testing.T) { 120 | _test := &repositorytest{} 121 | _test.bad = _GITREPOSITORYERRORS 122 | _test.file = gitignore.File + "-with-file" 123 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 124 | return gitignore.NewRepositoryWithFile(path, _test.file) 125 | } 126 | 127 | // perform the repository tests 128 | repository(t, _test, _REPOSITORYMATCHES) 129 | 130 | // remove the temporary directory used for this test 131 | defer _test.destroy() 132 | } // TestRepositoryWithFile() 133 | 134 | func TestRepositoryWithErrors(t *testing.T) { 135 | _test := &repositorytest{} 136 | _test.bad = _GITREPOSITORYERRORS 137 | _test.file = gitignore.File + "-with-errors" 138 | _test.error = func(e gitignore.Error) bool { 139 | _test.errors = append(_test.errors, e) 140 | return true 141 | } 142 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 143 | return gitignore.NewRepositoryWithErrors( 144 | path, _test.file, _test.error, 145 | ), nil 146 | } 147 | 148 | // perform the repository tests 149 | repository(t, _test, _REPOSITORYMATCHES) 150 | 151 | // remove the temporary directory used for this test 152 | defer _test.destroy() 153 | } // TestRepositoryWithErrors() 154 | 155 | func TestRepositoryWithErrorsFalse(t *testing.T) { 156 | _test := &repositorytest{} 157 | _test.bad = _GITREPOSITORYERRORSFALSE 158 | _test.file = gitignore.File + "-with-errors-false" 159 | _test.error = func(e gitignore.Error) bool { 160 | _test.errors = append(_test.errors, e) 161 | return false 162 | } 163 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 164 | return gitignore.NewRepositoryWithErrors( 165 | path, _test.file, _test.error, 166 | ), nil 167 | } 168 | 169 | // perform the repository tests 170 | repository(t, _test, _REPOSITORYMATCHESFALSE) 171 | 172 | // remove the temporary directory used for this test 173 | defer _test.destroy() 174 | } // TestRepositoryWithErrorsFalse() 175 | 176 | func TestRepositoryWithCache(t *testing.T) { 177 | _test := &repositorytest{} 178 | _test.bad = _GITREPOSITORYERRORS 179 | _test.cache = gitignore.NewCache() 180 | _test.cached = true 181 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 182 | return gitignore.NewRepositoryWithCache( 183 | path, _test.file, _test.cache, _test.error, 184 | ), nil 185 | } 186 | 187 | // perform the repository tests 188 | repository(t, _test, _REPOSITORYMATCHES) 189 | 190 | // clean up 191 | defer _test.destroy() 192 | 193 | // rerun the tests while accumulating errors 194 | _test.directory = "" 195 | _test.file = gitignore.File + "-with-cache" 196 | _test.error = func(e gitignore.Error) bool { 197 | _test.errors = append(_test.errors, e) 198 | return true 199 | } 200 | repository(t, _test, _REPOSITORYMATCHES) 201 | 202 | // remove the temporary directory used for this test 203 | _err := os.RemoveAll(_test.directory) 204 | if _err != nil { 205 | t.Fatalf( 206 | "unable to remove temporary directory %s: %s", 207 | _test.directory, _err.Error(), 208 | ) 209 | } 210 | 211 | // recreate the temporary directory 212 | // - this remove & recreate gives us an empty directory for the 213 | // repository test 214 | // - this lets us test the caching 215 | _err = os.MkdirAll(_test.directory, _GITMASK) 216 | if _err != nil { 217 | t.Fatalf( 218 | "unable to recreate temporary directory %s: %s", 219 | _test.directory, _err.Error(), 220 | ) 221 | } 222 | defer _test.destroy() 223 | 224 | // repeat the repository tests 225 | // - these should succeed using just the cache data 226 | repository(t, _test, _REPOSITORYMATCHES) 227 | } // TestRepositoryWithCache() 228 | 229 | func TestInvalidRepository(t *testing.T) { 230 | _test := &repositorytest{} 231 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 232 | return gitignore.NewRepository(path) 233 | } 234 | 235 | // perform the invalid repository tests 236 | invalid(t, _test) 237 | } // TestInvalidRepository() 238 | 239 | func TestInvalidRepositoryWithFile(t *testing.T) { 240 | _test := &repositorytest{} 241 | _test.file = gitignore.File + "-invalid-with-file" 242 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 243 | return gitignore.NewRepositoryWithFile(path, _test.file) 244 | } 245 | 246 | // perform the invalid repository tests 247 | invalid(t, _test) 248 | } // TestInvalidRepositoryWithFile() 249 | 250 | func TestInvalidRepositoryWithErrors(t *testing.T) { 251 | _test := &repositorytest{} 252 | _test.file = gitignore.File + "-invalid-with-errors" 253 | _test.error = func(e gitignore.Error) bool { 254 | _test.errors = append(_test.errors, e) 255 | return true 256 | } 257 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 258 | return gitignore.NewRepositoryWithErrors( 259 | path, _test.file, _test.error, 260 | ), nil 261 | } 262 | 263 | // perform the invalid repository tests 264 | invalid(t, _test) 265 | } // TestInvalidRepositoryWithErrors() 266 | 267 | func TestInvalidRepositoryWithErrorsFalse(t *testing.T) { 268 | _test := &repositorytest{} 269 | _test.file = gitignore.File + "-invalid-with-errors-false" 270 | _test.error = func(e gitignore.Error) bool { 271 | _test.errors = append(_test.errors, e) 272 | return false 273 | } 274 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 275 | return gitignore.NewRepositoryWithErrors( 276 | path, _test.file, _test.error, 277 | ), nil 278 | } 279 | 280 | // perform the invalid repository tests 281 | invalid(t, _test) 282 | } // TestInvalidRepositoryWithErrorsFalse() 283 | 284 | func TestInvalidRepositoryWithCache(t *testing.T) { 285 | _test := &repositorytest{} 286 | _test.file = gitignore.File + "-invalid-with-cache" 287 | _test.cache = gitignore.NewCache() 288 | _test.cached = true 289 | _test.error = func(e gitignore.Error) bool { 290 | _test.errors = append(_test.errors, e) 291 | return true 292 | } 293 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 294 | return gitignore.NewRepositoryWithCache( 295 | path, _test.file, _test.cache, _test.error, 296 | ), nil 297 | } 298 | 299 | // perform the invalid repository tests 300 | invalid(t, _test) 301 | 302 | // repeat the tests using a default cache 303 | _test.cache = nil 304 | invalid(t, _test) 305 | } // TestInvalidRepositoryWithCache() 306 | 307 | // 308 | // helper functions 309 | // 310 | 311 | func repository(t *testing.T, test *repositorytest, m []match) { 312 | // if the test has no configured directory, then create a new 313 | // directory with the required .gitignore files 314 | if test.directory == "" { 315 | // what name should we use for the .gitignore file? 316 | // - if none is given, use the default 317 | _file := test.file 318 | if _file == "" { 319 | _file = gitignore.File 320 | } 321 | 322 | // create a temporary directory populated with sample .gitignore files 323 | // - first, augment the test data to include file names 324 | _map := make(map[string]string) 325 | for _k, _content := range _GITREPOSITORY { 326 | _map[_k+"/"+_file] = _content 327 | } 328 | _dir, _err := dir(_map) 329 | if _err != nil { 330 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 331 | } 332 | test.directory = _dir 333 | } 334 | 335 | // create the repository 336 | _repository, _err := test.create(test.directory, true) 337 | if _err != nil { 338 | t.Fatalf("unable to create repository: %s", _err.Error()) 339 | } 340 | 341 | // ensure we have a non-nill repository returned 342 | if _repository == nil { 343 | t.Error("expected non-nill GitIgnore repository instance; nil found") 344 | } 345 | 346 | // ensure the base of the repository is correct 347 | if _repository.Base() != test.directory { 348 | t.Errorf( 349 | "repository.Base() mismatch; expected %q, got %q", 350 | test.directory, _repository.Base(), 351 | ) 352 | } 353 | 354 | // we need to check each test to see if it's matching against a 355 | // GIT_DIR/info/exclude 356 | // - we only do this if the target does not use .gitignore 357 | // as the name of the ignore file 358 | _prepare := func(m match) match { 359 | if test.file == "" || test.file == gitignore.File { 360 | return m 361 | } else if m.Exclude { 362 | return match{m.Path, "", false, m.Exclude} 363 | } else { 364 | return m 365 | } 366 | } // _prepare() 367 | 368 | // perform the repository matching using absolute paths 369 | _cb := func(path string, isdir bool) gitignore.Match { 370 | _path := filepath.Join(_repository.Base(), path) 371 | return _repository.Absolute(_path, isdir) 372 | } 373 | for _, _test := range m { 374 | do(t, _cb, _prepare(_test)) 375 | } 376 | 377 | // repeat the tests using relative paths 378 | _repository, _err = test.create(test.directory, true) 379 | if _err != nil { 380 | t.Fatalf("unable to create repository: %s", _err.Error()) 381 | } 382 | _cb = func(path string, isdir bool) gitignore.Match { 383 | return _repository.Relative(path, isdir) 384 | } 385 | for _, _test := range m { 386 | do(t, _cb, _prepare(_test)) 387 | } 388 | 389 | // perform absolute path tests with paths not under the same repository 390 | _map := make(map[string]string) 391 | for _, _test := range m { 392 | _map[_test.Path] = " " 393 | } 394 | _new, _err := dir(_map) 395 | if _err != nil { 396 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 397 | } 398 | defer func(path string) { 399 | _ = os.RemoveAll(path) 400 | }(_new) 401 | 402 | // first, perform Match() tests 403 | _repository, _err = test.create(test.directory, true) 404 | if _err != nil { 405 | t.Fatalf("unable to create repository: %s", _err.Error()) 406 | } 407 | for _, _test := range m { 408 | _path := filepath.Join(_new, _test.Local()) 409 | _match := _repository.Match(_path) 410 | if _match != nil { 411 | t.Fatalf("unexpected match; expected nil, got %v", _match) 412 | } 413 | } 414 | 415 | // next, perform Absolute() tests 416 | _repository, _err = test.create(test.directory, true) 417 | if _err != nil { 418 | t.Fatalf("unable to create repository: %s", _err.Error()) 419 | } 420 | for _, _test := range m { 421 | // build the absolute path 422 | _path := filepath.Join(_new, _test.Local()) 423 | 424 | // we don't expect to match paths not under this repository 425 | _match := _repository.Absolute(_path, _test.IsDir()) 426 | if _match != nil { 427 | t.Fatalf("unexpected match; expected nil, got %v", _match) 428 | } 429 | } 430 | 431 | // now, repeat the Match() test after having first removed the 432 | // temporary directory 433 | // - we are testing correct handling of missing files 434 | _err = os.RemoveAll(_new) 435 | if _err != nil { 436 | t.Fatalf( 437 | "unable to remove temporary directory %s: %s", 438 | _new, _err.Error(), 439 | ) 440 | } 441 | _repository, _err = test.create(test.directory, true) 442 | if _err != nil { 443 | t.Fatalf("unable to create repository: %s", _err.Error()) 444 | } 445 | for _, _test := range m { 446 | _path := filepath.Join(_new, _test.Local()) 447 | 448 | // if we have an error handler configured, we should be recording 449 | // and error in this call to Match() 450 | _before := len(test.errors) 451 | 452 | // perform the match 453 | _match := _repository.Match(_path) 454 | if _match != nil { 455 | t.Fatalf("unexpected match; expected nil, got %v", _match) 456 | } 457 | 458 | // were we recording errors? 459 | if test.error != nil { 460 | _after := len(test.errors) 461 | if _after <= _before { 462 | t.Fatalf( 463 | "expected Match() error; none found for %s", 464 | _path, 465 | ) 466 | } 467 | 468 | // ensure the most recent error is "not exists" 469 | _latest := test.errors[_after-1] 470 | _underlying := _latest.Underlying() 471 | if !os.IsNotExist(_underlying) { 472 | t.Fatalf( 473 | "unexpected Match() error for %s; expected %q, got %q", 474 | _path, os.ErrNotExist.Error(), _underlying.Error(), 475 | ) 476 | } 477 | } 478 | } 479 | 480 | // ensure Match() behaves as expected if the absolute path cannot 481 | // be determined 482 | // - we do this by choosing as our working directory a path 483 | // that this process does not have permission to 484 | _dir, _err := dir(nil) 485 | if _err != nil { 486 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 487 | } 488 | defer func(path string) { 489 | _ = os.RemoveAll(path) 490 | }(_dir) 491 | 492 | _cwd, _err := os.Getwd() 493 | if _err != nil { 494 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 495 | } 496 | _err = os.Chdir(_dir) 497 | if _err != nil { 498 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 499 | } 500 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 501 | 502 | // remove permission from the temporary directory 503 | _err = os.Chmod(_dir, 0) 504 | if _err != nil { 505 | t.Fatalf( 506 | "unable to remove temporary directory %s: %s", 507 | _dir, _err.Error(), 508 | ) 509 | } 510 | 511 | // perform the repository tests 512 | _repository, _err = test.create(test.directory, true) 513 | if _err != nil { 514 | t.Fatalf("unable to create repository: %s", _err.Error()) 515 | } 516 | for _, _test := range m { 517 | _match := _repository.Match(_test.Local()) 518 | if _match != nil { 519 | t.Fatalf("unexpected match; expected nil, not %v", _match) 520 | } 521 | } 522 | 523 | if test.errors != nil { 524 | // ensure the number of errors is expected 525 | if len(test.errors) != test.bad { 526 | t.Fatalf( 527 | "unexpected repository errors; expected %d, got %d", 528 | test.bad, len(test.errors), 529 | ) 530 | } else { 531 | // if we're here, then we intended to record errors 532 | // - ensure we recorded the expected errors 533 | for _i := 0; _i < len(test.errors); _i++ { 534 | _got := test.errors[_i] 535 | _underlying := _got.Underlying() 536 | if os.IsNotExist(_underlying) || 537 | os.IsPermission(_underlying) { 538 | continue 539 | } else { 540 | t.Log(_i) 541 | t.Fatalf("unexpected repository error: %s", _got.Error()) 542 | } 543 | } 544 | } 545 | } 546 | } // repository() 547 | 548 | func invalid(t *testing.T, test *repositorytest) { 549 | // create a temporary file to use as the repository 550 | _file, _err := file("") 551 | if _err != nil { 552 | t.Fatalf("unable to create temporary file: %s", _err.Error()) 553 | } 554 | defer func(name string) { 555 | _ = os.Remove(name) 556 | }(_file.Name()) 557 | 558 | // test repository instance creation against a file 559 | _repository, _err := test.create(_file.Name(), false) 560 | if _err == nil { 561 | t.Errorf( 562 | "invalid repository error; expected %q, got nil", 563 | gitignore.ErrInvalidDirectoryError.Error(), 564 | ) 565 | } else if _err != gitignore.ErrInvalidDirectoryError { 566 | t.Errorf( 567 | "invalid repository mismatch; expected %q, got %q", 568 | gitignore.ErrInvalidDirectoryError.Error(), _err.Error(), 569 | ) 570 | } 571 | 572 | // ensure no repository is returned 573 | if _repository != nil { 574 | t.Errorf( 575 | "invalid repository; expected nil, got %v", 576 | _repository, 577 | ) 578 | } 579 | 580 | // now, remove the temporary file and repeat the tests 581 | _err = os.Remove(_file.Name()) 582 | if _err != nil { 583 | t.Fatalf( 584 | "unable to remove temporary file %s: %s", 585 | _file.Name(), _err.Error(), 586 | ) 587 | } 588 | 589 | // test repository instance creating against a missing file 590 | _repository, _err = test.create(_file.Name(), false) 591 | if _err == nil { 592 | t.Errorf( 593 | "invalid repository error; expected %q, got nil", 594 | gitignore.ErrInvalidDirectoryError.Error(), 595 | ) 596 | } else if !os.IsNotExist(_err) { 597 | t.Errorf( 598 | "invalid repository mismatch; "+ 599 | "expected no such file or directory, got %q", 600 | _err.Error(), 601 | ) 602 | } 603 | 604 | // ensure no repository is returned 605 | if _repository != nil { 606 | t.Errorf( 607 | "invalid repository; expected nil, got %v", 608 | _repository, 609 | ) 610 | } 611 | 612 | // ensure we can't create a repository instance where the absolute path 613 | // of the repository cannot be determined 614 | // - we do this by choosing a working directory this process does 615 | // not have access to and using a relative path 616 | _map := map[string]string{gitignore.File: _GITIGNORE} 617 | _dir, _err := dir(_map) 618 | if _err != nil { 619 | t.Fatalf("unable to create a temporary directory: %s", _err.Error()) 620 | } 621 | defer func(path string) { 622 | _ = os.RemoveAll(path) 623 | }(_dir) 624 | 625 | // now change the working directory 626 | _cwd, _err := os.Getwd() 627 | if _err != nil { 628 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 629 | } 630 | _err = os.Chdir(_dir) 631 | if _err != nil { 632 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 633 | } 634 | defer func(dir string) { _ = os.Chdir(dir) }(_cwd) 635 | 636 | // remove permissions from the working directory 637 | _err = os.Chmod(_dir, 0) 638 | if _err != nil { 639 | t.Fatalf("unable remove temporary directory permissions: %s: %s", 640 | _dir, _err.Error(), 641 | ) 642 | } 643 | 644 | // test repository instance creating against a relative path 645 | // - the relative path exists 646 | _, _err = test.create(gitignore.File, false) 647 | if _err == nil { 648 | t.Errorf("expected repository error, got nil") 649 | } else if os.IsNotExist(_err) { 650 | t.Errorf( 651 | "unexpected repository error; file exists, but %q returned", 652 | _err.Error(), 653 | ) 654 | } 655 | 656 | // next, create a repository where we do not have read permission 657 | // to a .gitignore file within the repository 658 | // - this should trigger a panic() when attempting a file match 659 | for _, _test := range _REPOSITORYMATCHES { 660 | _map[_test.Path] = " " 661 | } 662 | _dir, _err = dir(_map) 663 | if _err != nil { 664 | t.Fatalf("unable to create a temporary directory: %s", _err.Error()) 665 | } 666 | defer func(path string) { 667 | _ = os.RemoveAll(path) 668 | }(_dir) 669 | 670 | _git := filepath.Join(_dir, gitignore.File) 671 | _err = os.Chmod(_git, 0) 672 | if _err != nil { 673 | t.Fatalf("unable remove temporary .gitignore permissions: %s: %s", 674 | _git, _err.Error(), 675 | ) 676 | } 677 | 678 | // attempt to match a path in this repository 679 | // - it can be anything, so we just use the .gitignore itself 680 | // - between each test we recreate the repository instance to 681 | // remove the effect of any caching 682 | _instance := func() gitignore.GitIgnore { 683 | // reset the cache 684 | if test.cached { 685 | if test.cache != nil { 686 | test.cache = gitignore.NewCache() 687 | } 688 | } 689 | 690 | // create the new repository 691 | _repository, _err := test.create(_dir, false) 692 | if _err != nil { 693 | t.Fatalf("unable to create repository: %s", _err.Error()) 694 | } 695 | 696 | // return the repository 697 | return _repository 698 | } 699 | for _, _match := range _REPOSITORYMATCHES { 700 | _local := _match.Local() 701 | _isdir := _match.IsDir() 702 | _path := filepath.Join(_dir, _local) 703 | 704 | // try Match() with an absolute path 705 | _test := &invalidtest{repositorytest: test} 706 | _test.tag = "Match()" 707 | _test.match = func() gitignore.Match { 708 | return _instance().Match(_path) 709 | } 710 | run(t, _test) 711 | 712 | // try Absolute() with an absolute path 713 | _test = &invalidtest{repositorytest: test} 714 | _test.tag = "Absolute()" 715 | _test.match = func() gitignore.Match { 716 | return _instance().Absolute(_path, _isdir) 717 | } 718 | run(t, _test) 719 | 720 | // try Absolute() with an absolute path 721 | _test = &invalidtest{repositorytest: test} 722 | _test.tag = "Relative()" 723 | _test.match = func() gitignore.Match { 724 | return _instance().Relative(_local, _isdir) 725 | } 726 | run(t, _test) 727 | } 728 | } // invalid() 729 | 730 | func run(t *testing.T, test *invalidtest) { 731 | // perform the match, and ensure it returns nil, nil 732 | _match := test.match() 733 | if _match != nil { 734 | t.Fatalf("%s: unexpected match: %v", test.tag, _match) 735 | } else if test.errors == nil { 736 | return 737 | } 738 | 739 | // if we're here, then we intended to record errors 740 | // - ensure we recorded the expected errors 741 | for _i := 0; _i < len(test.errors); _i++ { 742 | _got := test.errors[_i] 743 | _underlying := _got.Underlying() 744 | if os.IsNotExist(_underlying) || 745 | os.IsPermission(_underlying) { 746 | continue 747 | } else { 748 | t.Fatalf( 749 | "%s: unexpected error: %q", 750 | test.tag, _got.Error(), 751 | ) 752 | } 753 | } 754 | } // run() 755 | -------------------------------------------------------------------------------- /go-gitignore/rune.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | const ( 6 | // define the sentinel runes of the lexer 7 | _EOF = rune(0) 8 | _CR = rune('\r') 9 | _NEWLINE = rune('\n') 10 | _COMMENT = rune('#') 11 | _SEPARATOR = rune('/') 12 | _ESCAPE = rune('\\') 13 | _SPACE = rune(' ') 14 | _TAB = rune('\t') 15 | _NEGATION = rune('!') 16 | _WILDCARD = rune('*') 17 | ) 18 | -------------------------------------------------------------------------------- /go-gitignore/token.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | // Token represents a parsed token from a .gitignore stream, encapsulating the 10 | // token type, the runes comprising the token, and the position within the 11 | // stream of the first rune of the token. 12 | type Token struct { 13 | Type TokenType 14 | Word []rune 15 | Position 16 | } 17 | 18 | // NewToken returns a Token instance of the given t, represented by the 19 | // word runes, at the stream position pos. If the token type is not know, the 20 | // returned instance will have type BAD. 21 | func NewToken(t TokenType, word []rune, pos Position) *Token { 22 | // ensure the type is valid 23 | if t < ILLEGAL || t > BAD { 24 | t = BAD 25 | } 26 | 27 | // return the token 28 | return &Token{Type: t, Word: word, Position: pos} 29 | } // NewToken() 30 | 31 | // Name returns a string representation of the Token type. 32 | func (t *Token) Name() string { 33 | return t.Type.String() 34 | } // Name() 35 | 36 | // Token returns the string representation of the Token word. 37 | func (t *Token) Token() string { 38 | return string(t.Word) 39 | } // Token() 40 | 41 | // String returns a string representation of the Token, encapsulating its 42 | // position in the input stream, its name (i.e. type), and its runes. 43 | func (t *Token) String() string { 44 | return fmt.Sprintf("%s: %s %q", t.Position.String(), t.Name(), t.Token()) 45 | } // String() 46 | -------------------------------------------------------------------------------- /go-gitignore/token_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/boyter/gocodewalker/go-gitignore" 10 | ) 11 | 12 | func TestToken(t *testing.T) { 13 | for _, _test := range _TOKENS { 14 | // create the token 15 | _position := gitignore.Position{ 16 | File: "file", 17 | Line: _test.Line, 18 | Column: _test.Column, 19 | Offset: _test.NewLine, 20 | } 21 | _token := gitignore.NewToken( 22 | _test.Type, []rune(_test.Token), _position, 23 | ) 24 | 25 | // ensure we have a non-nil token 26 | if _token == nil { 27 | t.Errorf( 28 | "unexpected nil Token for type %d %q", _test.Type, _test.Name, 29 | ) 30 | continue 31 | } 32 | 33 | // ensure the token type match 34 | if _token.Type != _test.Type { 35 | // if we have a bad token, then we accept token types that 36 | // are outside the range of permitted token values 37 | if _token.Type == gitignore.BAD { 38 | if _test.Type < gitignore.ILLEGAL || 39 | _test.Type > gitignore.BAD { 40 | goto NAME 41 | } 42 | } 43 | 44 | // otherwise, we have a type mismatch 45 | t.Errorf( 46 | "token type mismatch for %q; expected %d, got %d", 47 | _test.Name, _test.Type, _token.Type, 48 | ) 49 | continue 50 | } 51 | 52 | NAME: 53 | // ensure the token name match 54 | if _token.Name() != _test.Name { 55 | t.Errorf( 56 | "token name mismatch for type %d; expected %s, got %s", 57 | _test.Type, _test.Name, _token.Name(), 58 | ) 59 | continue 60 | } 61 | 62 | // ensure the positions are the same 63 | if !coincident(_position, _token.Position) { 64 | t.Errorf( 65 | "token position mismatch; expected %s, got %s", 66 | pos(_position), pos(_token.Position), 67 | ) 68 | continue 69 | } 70 | 71 | // ensure the string form of the token is as expected 72 | _string := fmt.Sprintf( 73 | "%s: %s %q", _position, _test.Name, _test.Token, 74 | ) 75 | if _string != _token.String() { 76 | t.Errorf( 77 | "token string mismatch; expected %q, got %q", 78 | _string, _token.String(), 79 | ) 80 | } 81 | } 82 | } // TestToken() 83 | -------------------------------------------------------------------------------- /go-gitignore/tokenset.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | // tokenset represents an ordered list of Tokens 6 | type tokenset []*Token 7 | 8 | // String() returns a concatenated string of all runes represented by the 9 | // list of tokens. 10 | func (t tokenset) String() string { 11 | // concatenate the tokens into a single string 12 | _rtn := "" 13 | for _, _t := range []*Token(t) { 14 | _rtn = _rtn + _t.Token() 15 | } 16 | return _rtn 17 | } // String() 18 | -------------------------------------------------------------------------------- /go-gitignore/tokentype.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore 4 | 5 | type TokenType int 6 | 7 | const ( 8 | ILLEGAL TokenType = iota 9 | EOF 10 | EOL 11 | WHITESPACE 12 | COMMENT 13 | SEPARATOR 14 | NEGATION 15 | PATTERN 16 | ANY 17 | BAD 18 | ) 19 | 20 | // String returns a string representation of the Token type. 21 | func (t TokenType) String() string { 22 | switch t { 23 | case ILLEGAL: 24 | return "ILLEGAL" 25 | case EOF: 26 | return "EOF" 27 | case EOL: 28 | return "EOL" 29 | case WHITESPACE: 30 | return "WHITESPACE" 31 | case COMMENT: 32 | return "COMMENT" 33 | case SEPARATOR: 34 | return "SEPARATOR" 35 | case NEGATION: 36 | return "NEGATION" 37 | case PATTERN: 38 | return "PATTERN" 39 | case ANY: 40 | return "ANY" 41 | default: 42 | return "BAD TOKEN" 43 | } 44 | } // String() 45 | -------------------------------------------------------------------------------- /go-gitignore/util_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | package gitignore_test 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/boyter/gocodewalker/go-gitignore" 14 | ) 15 | 16 | func file(content string) (*os.File, error) { 17 | // create a temporary file 18 | _file, _err := os.CreateTemp("", "gitignore") 19 | if _err != nil { 20 | return nil, _err 21 | } 22 | 23 | // populate this file with the example .gitignore 24 | _, _err = _file.WriteString(content) 25 | if _err != nil { 26 | defer func(name string) { 27 | _ = os.Remove(name) 28 | }(_file.Name()) 29 | return nil, _err 30 | } 31 | _, _err = _file.Seek(0, io.SeekStart) 32 | if _err != nil { 33 | defer func(name string) { 34 | _ = os.Remove(name) 35 | }(_file.Name()) 36 | return nil, _err 37 | } 38 | 39 | // we have a temporary file containing the .gitignore 40 | return _file, nil 41 | } // file() 42 | 43 | func dir(content map[string]string) (string, error) { 44 | // create a temporary directory 45 | _dir, _err := os.MkdirTemp("", "") 46 | if _err != nil { 47 | return "", _err 48 | } 49 | 50 | // resolve the path of this directory 51 | // - we do this to handle systems with a temporary directory 52 | // that is a symbolic link 53 | _dir, _err = filepath.EvalSymlinks(_dir) 54 | if _err != nil { 55 | defer func(path string) { 56 | _ = os.RemoveAll(path) 57 | }(_dir) 58 | return "", _err 59 | } 60 | 61 | // populate the temporary directory with the content map 62 | // - each key of the map is a file name 63 | // - each value of the map is the file content 64 | // - file names are relative to the temporary directory 65 | 66 | for _key, _content := range content { 67 | // ensure we have content to store 68 | if _content == "" { 69 | continue 70 | } 71 | 72 | // should we create a directory or a file? 73 | _isdir := false 74 | _path := _key 75 | if strings.HasSuffix(_path, "/") { 76 | _path = strings.TrimSuffix(_path, "/") 77 | _isdir = true 78 | } 79 | 80 | // construct the absolute path (according to the local file system) 81 | _abs := _dir 82 | _parts := strings.Split(_path, "/") 83 | _last := len(_parts) - 1 84 | if _isdir { 85 | _abs = filepath.Join(_abs, filepath.Join(_parts...)) 86 | } else if _last > 0 { 87 | _abs = filepath.Join(_abs, filepath.Join(_parts[:_last]...)) 88 | } 89 | 90 | // ensure this directory exists 91 | _err = os.MkdirAll(_abs, _GITMASK) 92 | if _err != nil { 93 | defer func(path string) { 94 | _ = os.RemoveAll(path) 95 | }(_dir) 96 | return "", _err 97 | } else if _isdir { 98 | continue 99 | } 100 | 101 | // create the absolute path for the target file 102 | _abs = filepath.Join(_abs, _parts[_last]) 103 | 104 | // write the contents to this file 105 | _file, _err := os.Create(_abs) 106 | if _err != nil { 107 | defer func(path string) { 108 | _ = os.RemoveAll(path) 109 | }(_dir) 110 | return "", _err 111 | } 112 | _, _err = _file.WriteString(_content) 113 | if _err != nil { 114 | defer func(path string) { 115 | _ = os.RemoveAll(path) 116 | }(_dir) 117 | return "", _err 118 | } 119 | _err = _file.Close() 120 | if _err != nil { 121 | defer func(path string) { 122 | _ = os.RemoveAll(path) 123 | }(_dir) 124 | return "", _err 125 | } 126 | } 127 | 128 | // return the temporary directory name 129 | return _dir, nil 130 | } // dir() 131 | 132 | func exclude(content string) (string, error) { 133 | // create a temporary folder with the info/ subfolder 134 | _dir, _err := dir(nil) 135 | if _err != nil { 136 | return "", _err 137 | } 138 | _info := filepath.Join(_dir, "info") 139 | _err = os.MkdirAll(_info, _GITMASK) 140 | if _err != nil { 141 | defer func(path string) { 142 | _ = os.RemoveAll(path) 143 | }(_dir) 144 | return "", _err 145 | } 146 | 147 | // create the exclude file 148 | _exclude := filepath.Join(_info, "exclude") 149 | _err = os.WriteFile(_exclude, []byte(content), _GITMASK) 150 | if _err != nil { 151 | defer func(path string) { 152 | _ = os.RemoveAll(path) 153 | }(_dir) 154 | return "", _err 155 | } 156 | 157 | // return the temporary directory name 158 | return _dir, nil 159 | } // exclude() 160 | 161 | func coincident(a, b gitignore.Position) bool { 162 | return a.File == b.File && 163 | a.Line == b.Line && 164 | a.Column == b.Column && 165 | a.Offset == b.Offset 166 | } // coincident() 167 | 168 | func pos(p gitignore.Position) string { 169 | _prefix := p.File 170 | if _prefix != "" { 171 | _prefix = _prefix + ": " 172 | } 173 | 174 | return fmt.Sprintf("%s%d:%d [%d]", _prefix, p.Line, p.Column, p.Offset) 175 | } // pos() 176 | 177 | func buffer(content string) (*bytes.Buffer, error) { 178 | // return a buffered .gitignore 179 | return bytes.NewBufferString(content), nil 180 | } // buffer() 181 | 182 | func null() gitignore.GitIgnore { 183 | // return an empty GitIgnore instance 184 | return gitignore.New(bytes.NewBuffer(nil), "", nil) 185 | } // null() 186 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/boyter/gocodewalker 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 7 | golang.org/x/sync v0.12.0 8 | ) 9 | 10 | retract v1.2.0 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= 2 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= 3 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 4 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 5 | -------------------------------------------------------------------------------- /hidden.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | //go:build !windows 3 | 4 | package gocodewalker 5 | 6 | import ( 7 | "io/fs" 8 | "os" 9 | ) 10 | 11 | // IsHidden Returns true if file is hidden 12 | func IsHidden(file os.FileInfo, directory string) (bool, error) { 13 | return IsHiddenDirEntry(fs.FileInfoToDirEntry(file), directory) 14 | } 15 | 16 | // IsHiddenDirEntry is similar to [IsHidden], excepts it accepts [fs.DirEntry] as its argument 17 | func IsHiddenDirEntry(file fs.DirEntry, directory string) (bool, error) { 18 | return file.Name()[0:1] == ".", nil 19 | } 20 | -------------------------------------------------------------------------------- /hidden_windows.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | //go:build windows 3 | 4 | package gocodewalker 5 | 6 | import ( 7 | "io/fs" 8 | "os" 9 | "path" 10 | "syscall" 11 | ) 12 | 13 | // IsHidden Returns true if file is hidden 14 | func IsHidden(file os.FileInfo, directory string) (bool, error) { 15 | return IsHiddenDirEntry(fs.FileInfoToDirEntry(file), directory) 16 | } 17 | 18 | // IsHiddenDirEntry is similar to [IsHidden], excepts it accepts [fs.DirEntry] as its argument 19 | func IsHiddenDirEntry(file fs.DirEntry, directory string) (bool, error) { 20 | fullpath := path.Join(directory, file.Name()) 21 | pointer, err := syscall.UTF16PtrFromString(fullpath) 22 | if err != nil { 23 | return false, err 24 | } 25 | attributes, err := syscall.GetFileAttributes(pointer) 26 | if err != nil { 27 | return false, err 28 | } 29 | return attributes&syscall.FILE_ATTRIBUTE_HIDDEN != 0, nil 30 | } 31 | -------------------------------------------------------------------------------- /vendor/github.com/danwakefield/fnmatch/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /vendor/github.com/danwakefield/fnmatch/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Daniel Wakefield 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /vendor/github.com/danwakefield/fnmatch/README.md: -------------------------------------------------------------------------------- 1 | # fnmatch 2 | Updated clone of kballards golang fnmatch gist (https://gist.github.com/kballard/272720) 3 | 4 | 5 | -------------------------------------------------------------------------------- /vendor/github.com/danwakefield/fnmatch/fnmatch.go: -------------------------------------------------------------------------------- 1 | // Provide string-matching based on fnmatch.3 2 | package fnmatch 3 | 4 | // There are a few issues that I believe to be bugs, but this implementation is 5 | // based as closely as possible on BSD fnmatch. These bugs are present in the 6 | // source of BSD fnmatch, and so are replicated here. The issues are as follows: 7 | // 8 | // * FNM_PERIOD is no longer observed after the first * in a pattern 9 | // This only applies to matches done with FNM_PATHNAME as well 10 | // * FNM_PERIOD doesn't apply to ranges. According to the documentation, 11 | // a period must be matched explicitly, but a range will match it too 12 | 13 | import ( 14 | "unicode" 15 | "unicode/utf8" 16 | ) 17 | 18 | const ( 19 | FNM_NOESCAPE = (1 << iota) 20 | FNM_PATHNAME 21 | FNM_PERIOD 22 | 23 | FNM_LEADING_DIR 24 | FNM_CASEFOLD 25 | 26 | FNM_IGNORECASE = FNM_CASEFOLD 27 | FNM_FILE_NAME = FNM_PATHNAME 28 | ) 29 | 30 | func unpackRune(str *string) rune { 31 | rune, size := utf8.DecodeRuneInString(*str) 32 | *str = (*str)[size:] 33 | return rune 34 | } 35 | 36 | // Matches the pattern against the string, with the given flags, 37 | // and returns true if the match is successful. 38 | // This function should match fnmatch.3 as closely as possible. 39 | func Match(pattern, s string, flags int) bool { 40 | // The implementation for this function was patterned after the BSD fnmatch.c 41 | // source found at http://src.gnu-darwin.org/src/contrib/csup/fnmatch.c.html 42 | noescape := (flags&FNM_NOESCAPE != 0) 43 | pathname := (flags&FNM_PATHNAME != 0) 44 | period := (flags&FNM_PERIOD != 0) 45 | leadingdir := (flags&FNM_LEADING_DIR != 0) 46 | casefold := (flags&FNM_CASEFOLD != 0) 47 | // the following is some bookkeeping that the original fnmatch.c implementation did not do 48 | // We are forced to do this because we're not keeping indexes into C strings but rather 49 | // processing utf8-encoded strings. Use a custom unpacker to maintain our state for us 50 | sAtStart := true 51 | sLastAtStart := true 52 | sLastSlash := false 53 | sLastUnpacked := rune(0) 54 | unpackS := func() rune { 55 | sLastSlash = (sLastUnpacked == '/') 56 | sLastUnpacked = unpackRune(&s) 57 | sLastAtStart = sAtStart 58 | sAtStart = false 59 | return sLastUnpacked 60 | } 61 | for len(pattern) > 0 { 62 | c := unpackRune(&pattern) 63 | switch c { 64 | case '?': 65 | if len(s) == 0 { 66 | return false 67 | } 68 | sc := unpackS() 69 | if pathname && sc == '/' { 70 | return false 71 | } 72 | if period && sc == '.' && (sLastAtStart || (pathname && sLastSlash)) { 73 | return false 74 | } 75 | case '*': 76 | // collapse multiple *'s 77 | // don't use unpackRune here, the only char we care to detect is ASCII 78 | for len(pattern) > 0 && pattern[0] == '*' { 79 | pattern = pattern[1:] 80 | } 81 | if period && s[0] == '.' && (sAtStart || (pathname && sLastUnpacked == '/')) { 82 | return false 83 | } 84 | // optimize for patterns with * at end or before / 85 | if len(pattern) == 0 { 86 | if pathname { 87 | return leadingdir || (strchr(s, '/') == -1) 88 | } else { 89 | return true 90 | } 91 | return !(pathname && strchr(s, '/') >= 0) 92 | } else if pathname && pattern[0] == '/' { 93 | offset := strchr(s, '/') 94 | if offset == -1 { 95 | return false 96 | } else { 97 | // we already know our pattern and string have a /, skip past it 98 | s = s[offset:] // use unpackS here to maintain our bookkeeping state 99 | unpackS() 100 | pattern = pattern[1:] // we know / is one byte long 101 | break 102 | } 103 | } 104 | // general case, recurse 105 | for test := s; len(test) > 0; unpackRune(&test) { 106 | // I believe the (flags &^ FNM_PERIOD) is a bug when FNM_PATHNAME is specified 107 | // but this follows exactly from how fnmatch.c implements it 108 | if Match(pattern, test, (flags &^ FNM_PERIOD)) { 109 | return true 110 | } else if pathname && test[0] == '/' { 111 | break 112 | } 113 | } 114 | return false 115 | case '[': 116 | if len(s) == 0 { 117 | return false 118 | } 119 | if pathname && s[0] == '/' { 120 | return false 121 | } 122 | sc := unpackS() 123 | if !rangematch(&pattern, sc, flags) { 124 | return false 125 | } 126 | case '\\': 127 | if !noescape { 128 | if len(pattern) > 0 { 129 | c = unpackRune(&pattern) 130 | } 131 | } 132 | fallthrough 133 | default: 134 | if len(s) == 0 { 135 | return false 136 | } 137 | sc := unpackS() 138 | switch { 139 | case sc == c: 140 | case casefold && unicode.ToLower(sc) == unicode.ToLower(c): 141 | default: 142 | return false 143 | } 144 | } 145 | } 146 | return len(s) == 0 || (leadingdir && s[0] == '/') 147 | } 148 | 149 | func rangematch(pattern *string, test rune, flags int) bool { 150 | if len(*pattern) == 0 { 151 | return false 152 | } 153 | casefold := (flags&FNM_CASEFOLD != 0) 154 | noescape := (flags&FNM_NOESCAPE != 0) 155 | if casefold { 156 | test = unicode.ToLower(test) 157 | } 158 | var negate, matched bool 159 | if (*pattern)[0] == '^' || (*pattern)[0] == '!' { 160 | negate = true 161 | (*pattern) = (*pattern)[1:] 162 | } 163 | for !matched && len(*pattern) > 1 && (*pattern)[0] != ']' { 164 | c := unpackRune(pattern) 165 | if !noescape && c == '\\' { 166 | if len(*pattern) > 1 { 167 | c = unpackRune(pattern) 168 | } else { 169 | return false 170 | } 171 | } 172 | if casefold { 173 | c = unicode.ToLower(c) 174 | } 175 | if (*pattern)[0] == '-' && len(*pattern) > 1 && (*pattern)[1] != ']' { 176 | unpackRune(pattern) // skip the - 177 | c2 := unpackRune(pattern) 178 | if !noescape && c2 == '\\' { 179 | if len(*pattern) > 0 { 180 | c2 = unpackRune(pattern) 181 | } else { 182 | return false 183 | } 184 | } 185 | if casefold { 186 | c2 = unicode.ToLower(c2) 187 | } 188 | // this really should be more intelligent, but it looks like 189 | // fnmatch.c does simple int comparisons, therefore we will as well 190 | if c <= test && test <= c2 { 191 | matched = true 192 | } 193 | } else if c == test { 194 | matched = true 195 | } 196 | } 197 | // skip past the rest of the pattern 198 | ok := false 199 | for !ok && len(*pattern) > 0 { 200 | c := unpackRune(pattern) 201 | if c == '\\' && len(*pattern) > 0 { 202 | unpackRune(pattern) 203 | } else if c == ']' { 204 | ok = true 205 | } 206 | } 207 | return ok && matched != negate 208 | } 209 | 210 | // define strchr because strings.Index() seems a bit overkill 211 | // returns the index of c in s, or -1 if there is no match 212 | func strchr(s string, c rune) int { 213 | for i, sc := range s { 214 | if sc == c { 215 | return i 216 | } 217 | } 218 | return -1 219 | } 220 | -------------------------------------------------------------------------------- /vendor/golang.org/x/sync/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2009 The Go Authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /vendor/golang.org/x/sync/PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Google as part of the Go project. 5 | 6 | Google hereby grants to You a perpetual, worldwide, non-exclusive, 7 | no-charge, royalty-free, irrevocable (except as stated in this section) 8 | patent license to make, have made, use, offer to sell, sell, import, 9 | transfer and otherwise run, modify and propagate the contents of this 10 | implementation of Go, where such license applies only to those patent 11 | claims, both currently owned or controlled by Google and acquired in 12 | the future, licensable by Google that are necessarily infringed by this 13 | implementation of Go. This grant does not include claims that would be 14 | infringed only as a consequence of further modification of this 15 | implementation. If you or your agent or exclusive licensee institute or 16 | order or agree to the institution of patent litigation against any 17 | entity (including a cross-claim or counterclaim in a lawsuit) alleging 18 | that this implementation of Go or any code incorporated within this 19 | implementation of Go constitutes direct or contributory patent 20 | infringement, or inducement of patent infringement, then any patent 21 | rights granted to you under this License for this implementation of Go 22 | shall terminate as of the date such litigation is filed. 23 | -------------------------------------------------------------------------------- /vendor/golang.org/x/sync/errgroup/errgroup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package errgroup provides synchronization, error propagation, and Context 6 | // cancelation for groups of goroutines working on subtasks of a common task. 7 | // 8 | // [errgroup.Group] is related to [sync.WaitGroup] but adds handling of tasks 9 | // returning errors. 10 | package errgroup 11 | 12 | import ( 13 | "context" 14 | "fmt" 15 | "sync" 16 | ) 17 | 18 | type token struct{} 19 | 20 | // A Group is a collection of goroutines working on subtasks that are part of 21 | // the same overall task. 22 | // 23 | // A zero Group is valid, has no limit on the number of active goroutines, 24 | // and does not cancel on error. 25 | type Group struct { 26 | cancel func(error) 27 | 28 | wg sync.WaitGroup 29 | 30 | sem chan token 31 | 32 | errOnce sync.Once 33 | err error 34 | } 35 | 36 | func (g *Group) done() { 37 | if g.sem != nil { 38 | <-g.sem 39 | } 40 | g.wg.Done() 41 | } 42 | 43 | // WithContext returns a new Group and an associated Context derived from ctx. 44 | // 45 | // The derived Context is canceled the first time a function passed to Go 46 | // returns a non-nil error or the first time Wait returns, whichever occurs 47 | // first. 48 | func WithContext(ctx context.Context) (*Group, context.Context) { 49 | ctx, cancel := context.WithCancelCause(ctx) 50 | return &Group{cancel: cancel}, ctx 51 | } 52 | 53 | // Wait blocks until all function calls from the Go method have returned, then 54 | // returns the first non-nil error (if any) from them. 55 | func (g *Group) Wait() error { 56 | g.wg.Wait() 57 | if g.cancel != nil { 58 | g.cancel(g.err) 59 | } 60 | return g.err 61 | } 62 | 63 | // Go calls the given function in a new goroutine. 64 | // It blocks until the new goroutine can be added without the number of 65 | // active goroutines in the group exceeding the configured limit. 66 | // 67 | // The first call to return a non-nil error cancels the group's context, if the 68 | // group was created by calling WithContext. The error will be returned by Wait. 69 | func (g *Group) Go(f func() error) { 70 | if g.sem != nil { 71 | g.sem <- token{} 72 | } 73 | 74 | g.wg.Add(1) 75 | go func() { 76 | defer g.done() 77 | 78 | if err := f(); err != nil { 79 | g.errOnce.Do(func() { 80 | g.err = err 81 | if g.cancel != nil { 82 | g.cancel(g.err) 83 | } 84 | }) 85 | } 86 | }() 87 | } 88 | 89 | // TryGo calls the given function in a new goroutine only if the number of 90 | // active goroutines in the group is currently below the configured limit. 91 | // 92 | // The return value reports whether the goroutine was started. 93 | func (g *Group) TryGo(f func() error) bool { 94 | if g.sem != nil { 95 | select { 96 | case g.sem <- token{}: 97 | // Note: this allows barging iff channels in general allow barging. 98 | default: 99 | return false 100 | } 101 | } 102 | 103 | g.wg.Add(1) 104 | go func() { 105 | defer g.done() 106 | 107 | if err := f(); err != nil { 108 | g.errOnce.Do(func() { 109 | g.err = err 110 | if g.cancel != nil { 111 | g.cancel(g.err) 112 | } 113 | }) 114 | } 115 | }() 116 | return true 117 | } 118 | 119 | // SetLimit limits the number of active goroutines in this group to at most n. 120 | // A negative value indicates no limit. 121 | // A limit of zero will prevent any new goroutines from being added. 122 | // 123 | // Any subsequent call to the Go method will block until it can add an active 124 | // goroutine without exceeding the configured limit. 125 | // 126 | // The limit must not be modified while any goroutines in the group are active. 127 | func (g *Group) SetLimit(n int) { 128 | if n < 0 { 129 | g.sem = nil 130 | return 131 | } 132 | if len(g.sem) != 0 { 133 | panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", len(g.sem))) 134 | } 135 | g.sem = make(chan token, n) 136 | } 137 | -------------------------------------------------------------------------------- /vendor/modules.txt: -------------------------------------------------------------------------------- 1 | # github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 2 | ## explicit 3 | github.com/danwakefield/fnmatch 4 | # golang.org/x/sync v0.12.0 5 | ## explicit; go 1.23.0 6 | golang.org/x/sync/errgroup 7 | --------------------------------------------------------------------------------