├── .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 /.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 | 26 | # ignore directories 27 | bin/ 28 | pkg/ 29 | **/github.com/ 30 | 31 | # ignore edit files 32 | .*~ 33 | .*.sw? 34 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Cache is the interface for the GitIgnore cache 8 | type Cache interface { 9 | // Set stores the GitIgnore ignore against its path. 10 | Set(path string, ig GitIgnore) 11 | 12 | // Get attempts to retrieve an GitIgnore instance associated with the given 13 | // path. If the path is not known nil is returned. 14 | Get(path string) GitIgnore 15 | } 16 | 17 | // cache is the default thread-safe cache implementation 18 | type cache struct { 19 | _i map[string]GitIgnore 20 | _lock sync.Mutex 21 | } 22 | 23 | // NewCache returns a Cache instance. This is a thread-safe, in-memory cache 24 | // for GitIgnore instances. 25 | func NewCache() Cache { 26 | return &cache{} 27 | } // Cache() 28 | 29 | // Set stores the GitIgnore ignore against its path. 30 | func (c *cache) Set(path string, ignore GitIgnore) { 31 | if ignore == nil { 32 | return 33 | } 34 | 35 | // ensure the map is defined 36 | if c._i == nil { 37 | c._i = make(map[string]GitIgnore) 38 | } 39 | 40 | // set the cache item 41 | c._lock.Lock() 42 | c._i[path] = ignore 43 | c._lock.Unlock() 44 | } // Set() 45 | 46 | // Get attempts to retrieve an GitIgnore instance associated with the given 47 | // path. If the path is not known nil is returned. 48 | func (c *cache) Get(path string) GitIgnore { 49 | c._lock.Lock() 50 | _ignore, _ok := c._i[path] 51 | c._lock.Unlock() 52 | if _ok { 53 | return _ignore 54 | } else { 55 | return nil 56 | } 57 | } // Get() 58 | 59 | // ensure cache supports the Cache interface 60 | var _ Cache = &cache{} 61 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/denormal/go-gitignore" 7 | ) 8 | 9 | func TestCache(t *testing.T) { 10 | // populate the cache with the defined tests 11 | _cache := gitignore.NewCache() 12 | for _k, _v := range _CACHETEST { 13 | _cache.Set(_k, _v) 14 | } 15 | 16 | // attempt to retrieve the values from the cache 17 | // - if a GitIgnore instance is returned, ensure it is the correct 18 | // instance, and not some other instance 19 | for _k, _v := range _CACHETEST { 20 | _found := _cache.Get(_k) 21 | if _found != _v { 22 | t.Errorf("cache Get() mismatch; expected %v, got %v", 23 | _v, _found, 24 | ) 25 | } 26 | } 27 | 28 | // ensure unknown cache keys return nil 29 | for _, _k := range _CACHEUNKNOWN { 30 | _found := _cache.Get(_k) 31 | if _found != nil { 32 | t.Errorf("cache.Get() unexpected return for key %q; "+ 33 | "expected nil, got %v", 34 | _k, _found, 35 | ) 36 | } 37 | } 38 | 39 | // ensure we can update the cache 40 | _ignore := null() 41 | for _k, _ := range _CACHETEST { 42 | _cache.Set(_k, _ignore) 43 | } 44 | for _k, _ := range _CACHETEST { 45 | _found := _cache.Get(_k) 46 | if _found != _ignore { 47 | t.Errorf("cache Get() mismatch; expected %v, got %v", 48 | _ignore, _found, 49 | ) 50 | } 51 | } 52 | } // TestCache() 53 | -------------------------------------------------------------------------------- /defn_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/denormal/go-gitignore" 8 | ) 9 | 10 | type token struct { 11 | Type gitignore.TokenType 12 | Name string 13 | Token string 14 | Line int 15 | Column int 16 | NewLine int // token offset for newline end of line 17 | CarriageReturn int // token offset for carriage return end of line 18 | } // token{} 19 | 20 | type match struct { 21 | Path string // test path 22 | Pattern string // matching pattern (if any) 23 | Ignore bool // whether the path is ignored or included 24 | Exclude bool // whether the match comes from the GIT_DIR/info/exclude file 25 | } // match{} 26 | 27 | func (m match) Local() string { 28 | _path := m.Path 29 | if m.IsDir() { 30 | _path = strings.TrimSuffix(m.Path, "/") 31 | } 32 | 33 | // generate the local representation of the match path 34 | return filepath.Join(strings.Split(_path, "/")...) 35 | } // Local() 36 | 37 | func (m match) IsDir() bool { 38 | return strings.HasSuffix(m.Path, "/") 39 | } // IsDir() 40 | 41 | type position struct { 42 | File string 43 | Line int 44 | Column int 45 | Offset int 46 | String string 47 | } // position{} 48 | 49 | // define the constants for the unit tests 50 | const ( 51 | // define the example .gitignore file contents 52 | _GITIGNORE = ` 53 | # example .gitignore 54 | 55 | !*.go 56 | 57 | *.o 58 | *.a 59 | 60 | /ignore/this/path/ 61 | 62 | # the following line has trailing whitespace 63 | /and/**/all/**/these/** 64 | !/but/not/this\ 65 | 66 | we support spaces 67 | 68 | /**/this.is.not/a ** valid/pattern 69 | /**/nor/is/***/this 70 | /nor/is***this 71 | northis** x 72 | 73 | but \this\ is / valid\# 74 | \ 75 | 76 | so is this# 77 | and this is #3 ok too 78 | / // 79 | ` 80 | 81 | // define the example .gitignore file contents for the Match tests 82 | // these tests have been taken from 83 | // https://github.com/sdobz/backup/gitignore 84 | // 85 | // https://github.com/sdobz/backup/blob/master/gitignore/gitignore_test.go 86 | _GITMATCH = ` 87 | 88 | *.[oa] 89 | *.html 90 | *.min.js 91 | 92 | !foo*.html 93 | foo-excl.html 94 | 95 | vmlinux* 96 | 97 | \!important!.txt 98 | 99 | log/*.log 100 | !/log/foo.log 101 | 102 | **/logdir/log 103 | **/foodir/bar 104 | exclude/** 105 | 106 | !findthis* 107 | 108 | **/hide/** 109 | subdir/subdir2/ 110 | 111 | /rootsubdir/ 112 | 113 | dirpattern/ 114 | 115 | README.md 116 | 117 | # arch/foo/kernel/.gitignore 118 | !arch/foo/kernel/vmlinux* 119 | 120 | # htmldoc/.gitignore 121 | !htmldoc/*.html 122 | 123 | # git-sample-3/.gitignore 124 | git-sample-3/* 125 | !git-sample-3/foo 126 | git-sample-3/foo/* 127 | !git-sample-3/foo/bar 128 | 129 | Documentation/*.pdf 130 | Documentation/**/p*.pdf 131 | ` 132 | 133 | // define the number of good & bad patterns in the .gitignore above 134 | _GITPATTERNS = 12 135 | _GITBADPATTERNS = 4 136 | 137 | // define the number of good & bad patterns in the match .gitignore above 138 | _GITMATCHPATTERNS = 24 139 | _GITBADMATCHPATTERNS = 0 140 | 141 | // define the number of good and bad patterns returned when the 142 | // gitignore.Parser error handler returns false upon receiving an error 143 | _GITPATTERNSFALSE = 7 144 | _GITBADPATTERNSFALSE = 1 145 | 146 | // define the base path for a git repository 147 | _GITBASE = "/my/git/repository" 148 | 149 | // define the directory mask for any directories created during testing 150 | _GITMASK = 0700 151 | 152 | // define a .gitignore that will trigger lexer errors 153 | _GITINVALID = "" + 154 | "# the following two lines will trigger repeated lexer errors\n" + 155 | "x\rx\rx\rx\n" + 156 | "\rx\rx\rx\n" + 157 | "!\rx\n" + 158 | "/my/valid/pattern\n" + 159 | "!\n" + 160 | "** *\n" + 161 | "/\r" 162 | 163 | // define the number of invalid patterns and errors 164 | _GITINVALIDERRORS = 10 165 | _GITINVALIDERRORSFALSE = 1 166 | _GITINVALIDPATTERNS = 1 167 | _GITINVALIDPATTERNSFALSE = 0 168 | 169 | // define the expected number of errors during repository matching 170 | _GITREPOSITORYERRORS = 38 171 | _GITREPOSITORYERRORSFALSE = 1 172 | 173 | // define a .gitignore file the contains just whitespace & comments 174 | _GITIGNORE_WHITESPACE = ` 175 | # this is an empty .gitignore file 176 | # - the following lines contains just whitespace 177 | 178 | 179 | ` 180 | ) 181 | 182 | var ( 183 | // define the positions of the bad patterns 184 | _GITBADPOSITION = []gitignore.Position{ 185 | gitignore.Position{File: "", Line: 17, Column: 19, Offset: 189}, 186 | gitignore.Position{File: "", Line: 18, Column: 14, Offset: 219}, 187 | gitignore.Position{File: "", Line: 19, Column: 8, Offset: 233}, 188 | gitignore.Position{File: "", Line: 20, Column: 8, Offset: 248}, 189 | } 190 | 191 | // define the positions of the good patterns 192 | _GITPOSITION = []gitignore.Position{ 193 | gitignore.Position{File: "", Line: 4, Column: 1, Offset: 23}, 194 | gitignore.Position{File: "", Line: 6, Column: 1, Offset: 30}, 195 | gitignore.Position{File: "", Line: 7, Column: 1, Offset: 34}, 196 | gitignore.Position{File: "", Line: 9, Column: 1, Offset: 39}, 197 | gitignore.Position{File: "", Line: 12, Column: 1, Offset: 104}, 198 | gitignore.Position{File: "", Line: 13, Column: 1, Offset: 132}, 199 | gitignore.Position{File: "", Line: 15, Column: 1, Offset: 150}, 200 | gitignore.Position{File: "", Line: 22, Column: 1, Offset: 256}, 201 | gitignore.Position{File: "", Line: 23, Column: 1, Offset: 280}, 202 | gitignore.Position{File: "", Line: 25, Column: 1, Offset: 283}, 203 | gitignore.Position{File: "", Line: 26, Column: 1, Offset: 295}, 204 | gitignore.Position{File: "", Line: 27, Column: 1, Offset: 317}, 205 | } 206 | 207 | // define the token stream for the _GITIGNORE .gitignore 208 | _GITTOKENS = []token{ 209 | // 1: 210 | {gitignore.EOL, "EOL", "\n", 1, 1, 0, 0}, 211 | // 2: # example .gitignore contents 212 | {gitignore.COMMENT, "COMMENT", "# example .gitignore", 2, 1, 1, 2}, 213 | {gitignore.EOL, "EOL", "\n", 2, 21, 21, 22}, 214 | // 3: 215 | {gitignore.EOL, "EOL", "\n", 3, 1, 22, 24}, 216 | // 4: !*.go 217 | {gitignore.NEGATION, "NEGATION", "!", 4, 1, 23, 26}, 218 | {gitignore.PATTERN, "PATTERN", "*.go", 4, 2, 24, 27}, 219 | {gitignore.EOL, "EOL", "\n", 4, 6, 28, 31}, 220 | // 5: 221 | {gitignore.EOL, "EOL", "\n", 5, 1, 29, 33}, 222 | // 6: *.o 223 | {gitignore.PATTERN, "PATTERN", "*.o", 6, 1, 30, 35}, 224 | {gitignore.EOL, "EOL", "\n", 6, 4, 33, 38}, 225 | // 7: *.a 226 | {gitignore.PATTERN, "PATTERN", "*.a", 7, 1, 34, 40}, 227 | {gitignore.EOL, "EOL", "\n", 7, 4, 37, 43}, 228 | // 8: 229 | {gitignore.EOL, "EOL", "\n", 8, 1, 38, 45}, 230 | // 9: /ignore/this/path/ 231 | {gitignore.SEPARATOR, "SEPARATOR", "/", 9, 1, 39, 47}, 232 | {gitignore.PATTERN, "PATTERN", "ignore", 9, 2, 40, 48}, 233 | {gitignore.SEPARATOR, "SEPARATOR", "/", 9, 8, 46, 54}, 234 | {gitignore.PATTERN, "PATTERN", "this", 9, 9, 47, 55}, 235 | {gitignore.SEPARATOR, "SEPARATOR", "/", 9, 13, 51, 59}, 236 | {gitignore.PATTERN, "PATTERN", "path", 9, 14, 52, 60}, 237 | {gitignore.SEPARATOR, "SEPARATOR", "/", 9, 18, 56, 64}, 238 | {gitignore.EOL, "EOL", "\n", 9, 19, 57, 65}, 239 | // 10: 240 | {gitignore.EOL, "EOL", "\n", 10, 1, 58, 67}, 241 | // 11: # the following line has trailing whitespace 242 | {gitignore.COMMENT, "COMMENT", 243 | "# the following line has trailing whitespace", 244 | 11, 1, 59, 69}, 245 | {gitignore.EOL, "EOL", "\n", 11, 45, 103, 113}, 246 | // 12: /and/**/all/**/these/** 247 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 1, 104, 115}, 248 | {gitignore.PATTERN, "PATTERN", "and", 12, 2, 105, 116}, 249 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 5, 108, 119}, 250 | {gitignore.ANY, "ANY", "**", 12, 6, 109, 120}, 251 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 8, 111, 122}, 252 | {gitignore.PATTERN, "PATTERN", "all", 12, 9, 112, 123}, 253 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 12, 115, 126}, 254 | {gitignore.ANY, "ANY", "**", 12, 13, 116, 127}, 255 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 15, 118, 129}, 256 | {gitignore.PATTERN, "PATTERN", "these", 12, 16, 119, 130}, 257 | {gitignore.SEPARATOR, "SEPARATOR", "/", 12, 21, 124, 135}, 258 | {gitignore.ANY, "ANY", "**", 12, 22, 125, 136}, 259 | {gitignore.WHITESPACE, "WHITESPACE", " \t ", 12, 24, 127, 138}, 260 | {gitignore.EOL, "EOL", "\n", 12, 28, 131, 142}, 261 | // 13: !/but/not/this\ 262 | {gitignore.NEGATION, "NEGATION", "!", 13, 1, 132, 144}, 263 | {gitignore.SEPARATOR, "SEPARATOR", "/", 13, 2, 133, 145}, 264 | {gitignore.PATTERN, "PATTERN", "but", 13, 3, 134, 146}, 265 | {gitignore.SEPARATOR, "SEPARATOR", "/", 13, 6, 137, 149}, 266 | {gitignore.PATTERN, "PATTERN", "not", 13, 7, 138, 150}, 267 | {gitignore.SEPARATOR, "SEPARATOR", "/", 13, 10, 141, 153}, 268 | {gitignore.PATTERN, "PATTERN", "this\\ ", 13, 11, 142, 154}, 269 | {gitignore.EOL, "EOL", "\n", 13, 17, 148, 160}, 270 | // 14: 271 | {gitignore.EOL, "EOL", "\n", 14, 1, 149, 162}, 272 | // 15: we support spaces 273 | {gitignore.PATTERN, "PATTERN", "we", 15, 1, 150, 164}, 274 | {gitignore.WHITESPACE, "WHITESPACE", " ", 15, 3, 152, 166}, 275 | {gitignore.PATTERN, "PATTERN", "support", 15, 4, 153, 167}, 276 | {gitignore.WHITESPACE, "WHITESPACE", " ", 15, 11, 160, 174}, 277 | {gitignore.PATTERN, "PATTERN", "spaces", 15, 14, 163, 177}, 278 | {gitignore.EOL, "EOL", "\n", 15, 20, 169, 183}, 279 | // 16: 280 | {gitignore.EOL, "EOL", "\n", 16, 1, 170, 185}, 281 | // 17: /**/this.is.not/a ** valid/pattern 282 | {gitignore.SEPARATOR, "SEPARATOR", "/", 17, 1, 171, 187}, 283 | {gitignore.ANY, "ANY", "**", 17, 2, 172, 188}, 284 | {gitignore.SEPARATOR, "SEPARATOR", "/", 17, 4, 174, 190}, 285 | {gitignore.PATTERN, "PATTERN", "this.is.not", 17, 5, 175, 191}, 286 | {gitignore.SEPARATOR, "SEPARATOR", "/", 17, 16, 186, 202}, 287 | {gitignore.PATTERN, "PATTERN", "a", 17, 17, 187, 203}, 288 | {gitignore.WHITESPACE, "WHITESPACE", " ", 17, 18, 188, 204}, 289 | {gitignore.ANY, "ANY", "**", 17, 19, 189, 205}, 290 | {gitignore.WHITESPACE, "WHITESPACE", " ", 17, 21, 191, 207}, 291 | {gitignore.PATTERN, "PATTERN", "valid", 17, 22, 192, 208}, 292 | {gitignore.SEPARATOR, "SEPARATOR", "/", 17, 27, 197, 213}, 293 | {gitignore.PATTERN, "PATTERN", "pattern", 17, 28, 198, 214}, 294 | {gitignore.EOL, "EOL", "\n", 17, 35, 205, 221}, 295 | // 18: /**/nor/is/***/this 296 | {gitignore.SEPARATOR, "SEPARATOR", "/", 18, 1, 206, 223}, 297 | {gitignore.ANY, "ANY", "**", 18, 2, 207, 224}, 298 | {gitignore.SEPARATOR, "SEPARATOR", "/", 18, 4, 209, 226}, 299 | {gitignore.PATTERN, "PATTERN", "nor", 18, 5, 210, 227}, 300 | {gitignore.SEPARATOR, "SEPARATOR", "/", 18, 8, 213, 230}, 301 | {gitignore.PATTERN, "PATTERN", "is", 18, 9, 214, 231}, 302 | {gitignore.SEPARATOR, "SEPARATOR", "/", 18, 11, 216, 233}, 303 | {gitignore.ANY, "ANY", "**", 18, 12, 217, 234}, 304 | {gitignore.PATTERN, "PATTERN", "*", 18, 14, 219, 236}, 305 | {gitignore.SEPARATOR, "SEPARATOR", "/", 18, 15, 220, 237}, 306 | {gitignore.PATTERN, "PATTERN", "this", 18, 16, 221, 238}, 307 | {gitignore.EOL, "EOL", "\n", 18, 20, 225, 242}, 308 | // 19: /nor/is***this 309 | {gitignore.SEPARATOR, "SEPARATOR", "/", 19, 1, 226, 244}, 310 | {gitignore.PATTERN, "PATTERN", "nor", 19, 2, 227, 245}, 311 | {gitignore.SEPARATOR, "SEPARATOR", "/", 19, 5, 230, 248}, 312 | {gitignore.PATTERN, "PATTERN", "is", 19, 6, 231, 249}, 313 | {gitignore.ANY, "ANY", "**", 19, 8, 233, 251}, 314 | {gitignore.PATTERN, "PATTERN", "*this", 19, 10, 235, 253}, 315 | {gitignore.EOL, "EOL", "\n", 19, 15, 240, 258}, 316 | // 20: northis** x 317 | {gitignore.PATTERN, "PATTERN", "northis", 20, 1, 241, 260}, 318 | {gitignore.ANY, "ANY", "**", 20, 8, 248, 267}, 319 | {gitignore.WHITESPACE, "WHITESPACE", " \t ", 20, 10, 250, 269}, 320 | {gitignore.PATTERN, "PATTERN", "x", 20, 13, 253, 272}, 321 | {gitignore.EOL, "EOL", "\n", 20, 14, 254, 273}, 322 | // 21: 323 | {gitignore.EOL, "EOL", "\n", 21, 1, 255, 275}, 324 | // 22: but \this\ is / valid 325 | {gitignore.PATTERN, "PATTERN", "but", 22, 1, 256, 277}, 326 | {gitignore.WHITESPACE, "WHITESPACE", " ", 22, 4, 259, 280}, 327 | {gitignore.PATTERN, "PATTERN", "\\this\\ is", 22, 5, 260, 281}, 328 | {gitignore.WHITESPACE, "WHITESPACE", " ", 22, 14, 269, 290}, 329 | {gitignore.SEPARATOR, "SEPARATOR", "/", 22, 15, 270, 291}, 330 | {gitignore.WHITESPACE, "WHITESPACE", " ", 22, 16, 271, 292}, 331 | {gitignore.PATTERN, "PATTERN", "valid\\#", 22, 17, 272, 293}, 332 | {gitignore.EOL, "EOL", "\n", 22, 24, 279, 300}, 333 | // 23: \ 334 | {gitignore.PATTERN, "PATTERN", "\\", 23, 1, 280, 302}, 335 | {gitignore.EOL, "EOL", "\n", 23, 2, 281, 303}, 336 | // 24: 337 | {gitignore.EOL, "EOL", "\n", 24, 1, 282, 305}, 338 | // 25: so is this# 339 | {gitignore.PATTERN, "PATTERN", "so", 25, 1, 283, 307}, 340 | {gitignore.WHITESPACE, "WHITESPACE", " ", 25, 3, 285, 309}, 341 | {gitignore.PATTERN, "PATTERN", "is", 25, 4, 286, 310}, 342 | {gitignore.WHITESPACE, "WHITESPACE", " ", 25, 6, 288, 312}, 343 | {gitignore.PATTERN, "PATTERN", "this#", 25, 7, 289, 313}, 344 | {gitignore.EOL, "EOL", "\n", 25, 12, 294, 318}, 345 | // 26: and this is #3 ok too 346 | {gitignore.PATTERN, "PATTERN", "and", 26, 1, 295, 320}, 347 | {gitignore.WHITESPACE, "WHITESPACE", " ", 26, 4, 298, 323}, 348 | {gitignore.PATTERN, "PATTERN", "this", 26, 5, 299, 324}, 349 | {gitignore.WHITESPACE, "WHITESPACE", " ", 26, 9, 303, 328}, 350 | {gitignore.PATTERN, "PATTERN", "is", 26, 10, 304, 329}, 351 | {gitignore.WHITESPACE, "WHITESPACE", " ", 26, 12, 306, 331}, 352 | {gitignore.PATTERN, "PATTERN", "#3", 26, 13, 307, 332}, 353 | {gitignore.WHITESPACE, "WHITESPACE", " ", 26, 15, 309, 334}, 354 | {gitignore.PATTERN, "PATTERN", "ok", 26, 16, 310, 335}, 355 | {gitignore.WHITESPACE, "WHITESPACE", " ", 26, 18, 312, 337}, 356 | {gitignore.PATTERN, "PATTERN", "too", 26, 19, 313, 338}, 357 | {gitignore.EOL, "EOL", "\n", 26, 22, 316, 341}, 358 | // 27: / // 359 | {gitignore.WHITESPACE, "WHITESPACE", " ", 27, 1, 317, 343}, 360 | {gitignore.SEPARATOR, "SEPARATOR", "/", 27, 2, 318, 344}, 361 | {gitignore.WHITESPACE, "WHITESPACE", " ", 27, 3, 319, 345}, 362 | {gitignore.SEPARATOR, "SEPARATOR", "/", 27, 4, 320, 346}, 363 | {gitignore.SEPARATOR, "SEPARATOR", "/", 27, 5, 321, 347}, 364 | {gitignore.EOL, "EOL", "\n", 27, 6, 322, 348}, 365 | 366 | {gitignore.EOF, "EOF", "", 28, 1, 323, 350}, 367 | } 368 | 369 | // define match tests and their expected results 370 | _GITMATCHES = []match{ 371 | {"!important!.txt", "\\!important!.txt", true, false}, 372 | {"arch/", "", false, false}, 373 | {"arch/foo/", "", false, false}, 374 | {"arch/foo/kernel/", "", false, false}, 375 | {"arch/foo/kernel/vmlinux.lds.S", "!arch/foo/kernel/vmlinux*", false, false}, 376 | {"arch/foo/vmlinux.lds.S", "vmlinux*", true, false}, 377 | {"bar/", "", false, false}, 378 | {"bar/testfile", "", false, false}, 379 | {"dirpattern", "", false, false}, 380 | {"my/other/path/to/dirpattern", "", false, false}, 381 | {"my/path/to/dirpattern/", "dirpattern/", true, false}, 382 | {"my/path/to/dirpattern/some_file.txt", "", false, false}, 383 | {"Documentation/", "", false, false}, 384 | {"Documentation/foo-excl.html", "foo-excl.html", true, false}, 385 | {"Documentation/foo.html", "!foo*.html", false, false}, 386 | {"Documentation/gitignore.html", "*.html", true, false}, 387 | {"Documentation/test.a.html", "*.html", true, false}, 388 | {"exclude/", "exclude/**", true, false}, 389 | {"exclude/dir1/", "exclude/**", true, false}, 390 | {"exclude/dir1/dir2/", "exclude/**", true, false}, 391 | {"exclude/dir1/dir2/dir3/", "exclude/**", true, false}, 392 | {"exclude/dir1/dir2/dir3/testfile", "exclude/**", true, false}, 393 | {"exclude/other_file.txt", "exclude/**", true, false}, 394 | {"file.o", "*.[oa]", true, false}, 395 | {"foo/exclude/some_file.txt", "", false, false}, 396 | {"foo/exclude/other/file.txt", "", false, false}, 397 | {"foodir/", "", false, false}, 398 | {"foodir/bar/", "**/foodir/bar", true, false}, 399 | {"foodir/bar/testfile", "", false, false}, 400 | {"git-sample-3/", "", false, false}, 401 | {"git-sample-3/foo/", "!git-sample-3/foo", false, false}, 402 | {"git-sample-3/foo/bar/", "!git-sample-3/foo/bar", false, false}, 403 | {"git-sample-3/foo/test/", "git-sample-3/foo/*", true, false}, 404 | {"git-sample-3/test/", "git-sample-3/*", true, false}, 405 | {"htmldoc/", "", false, false}, 406 | {"htmldoc/docs.html", "!htmldoc/*.html", false, false}, 407 | {"htmldoc/jslib.min.js", "*.min.js", true, false}, 408 | {"lib.a", "*.[oa]", true, false}, 409 | {"log/", "", false, false}, 410 | {"log/foo.log", "!/log/foo.log", false, false}, 411 | {"log/test.log", "log/*.log", true, false}, 412 | {"rootsubdir/", "/rootsubdir/", true, false}, 413 | {"rootsubdir/foo", "", false, false}, 414 | {"src/", "", false, false}, 415 | {"src/findthis.o", "!findthis*", false, false}, 416 | {"src/internal.o", "*.[oa]", true, false}, 417 | {"subdir/", "", false, false}, 418 | {"subdir/hide/", "**/hide/**", true, false}, 419 | {"subdir/hide/foo", "**/hide/**", true, false}, 420 | {"subdir/logdir/", "", false, false}, 421 | {"subdir/logdir/log/", "**/logdir/log", true, false}, 422 | {"subdir/logdir/log/findthis.log", "!findthis*", false, false}, 423 | {"subdir/logdir/log/foo.log", "", false, false}, 424 | {"subdir/logdir/log/test.log", "", false, false}, 425 | {"subdir/rootsubdir/", "", false, false}, 426 | {"subdir/rootsubdir/foo", "", false, false}, 427 | {"subdir/subdir2/", "subdir/subdir2/", true, false}, 428 | {"subdir/subdir2/bar", "", false, false}, 429 | {"README.md", "README.md", true, false}, 430 | {"my-path/README.md", "README.md", true, false}, 431 | {"my-path/also/README.md", "README.md", true, false}, 432 | {"Documentation/git.pdf", "Documentation/*.pdf", true, false}, 433 | {"Documentation/ppc/ppc.pdf", "Documentation/**/p*.pdf", true, false}, 434 | {"tools/perf/Documentation/perf.pdf", "", false, false}, 435 | } 436 | 437 | // define the cache tests 438 | _CACHETEST = map[string]gitignore.GitIgnore{ 439 | "a": null(), 440 | "a/b": null(), 441 | "a/b/c": nil, 442 | } 443 | 444 | // define a set of cache keys known not to be in the cache tests above 445 | _CACHEUNKNOWN = []string{ 446 | "b", 447 | "b/c", 448 | } 449 | 450 | // define the set of .gitignore files for a repository 451 | _GITREPOSITORY = map[string]string{ 452 | // define the top-level .gitignore file 453 | "": ` 454 | # ignore .bak files 455 | *.bak 456 | `, 457 | // define subdirectory .gitignore files 458 | "a": ` 459 | # ignore .go files 460 | *.go 461 | 462 | # ignore every c directory 463 | # - this should be the same as c/ 464 | **/c/ 465 | `, 466 | "a/b": ` 467 | # include .go files in this directory 468 | !*.go 469 | 470 | # include everything under e 471 | !**/e/** 472 | `, 473 | "a/b/d": ` 474 | # include c directories 475 | !c/ 476 | hidden/ 477 | `, 478 | } 479 | 480 | // define the patterns for $GIT_DIR/info/exclude 481 | _GITEXCLUDE = ` 482 | # exclude every file using 'exclude' in its name 483 | *exclude* 484 | ` 485 | 486 | // define repository match tests and their expected results 487 | _REPOSITORYMATCHES = []match{ 488 | // matching against the nested .gitignore files 489 | {"include.go", "", false, false}, 490 | {"ignore.go.bak", "*.bak", true, false}, 491 | {"a/ignore.go", "*.go", true, false}, 492 | {"a/ignore.go.bak", "*.bak", true, false}, 493 | {"a/include.sh", "", false, false}, 494 | {"a/c/ignore.go", "**/c/", true, false}, 495 | {"a/c/ignore.go.bak", "**/c/", true, false}, 496 | {"a/c/ignore.sh", "**/c/", true, false}, 497 | {"a/c/", "**/c/", true, false}, 498 | {"a/b/c/d/ignore.go", "**/c/", true, false}, 499 | {"a/b/c/d/ignore.go.bak", "**/c/", true, false}, 500 | {"a/b/c/d/ignore.sh", "**/c/", true, false}, 501 | {"a/b/c/d/", "**/c/", true, false}, 502 | {"a/b/c/", "**/c/", true, false}, 503 | {"a/b/include.go", "!*.go", false, false}, 504 | {"a/b/ignore.go.bak", "*.bak", true, false}, 505 | {"a/b/include.sh", "", false, false}, 506 | {"a/b/d/include.go", "!*.go", false, false}, 507 | {"a/b/d/ignore.go.bak", "*.bak", true, false}, 508 | {"a/b/d/include.sh", "", false, false}, 509 | {"a/b/d/c/", "!c/", false, false}, 510 | {"a/b/d/c/include.go", "!*.go", false, false}, 511 | {"a/b/d/c/ignore.go.bak", "*.bak", true, false}, 512 | {"a/b/d/c/include.sh", "", false, false}, 513 | {"a/b/e/c/", "!**/e/**", false, false}, 514 | {"a/b/e/c/include.go", "!**/e/**", false, false}, 515 | {"a/b/e/c/include.go.bak", "!**/e/**", false, false}, 516 | {"a/b/e/c/include.sh", "!**/e/**", false, false}, 517 | 518 | // matching against GIT_DIR/info/exclude 519 | {"exclude.me", "*exclude*", true, true}, 520 | {"a/exclude.me", "*exclude*", true, true}, 521 | {"a/b/exclude.me", "*exclude*", true, true}, 522 | {"a/b/c/exclude.me", "**/c/", true, false}, 523 | {"a/b/c/d/exclude.me", "**/c/", true, false}, 524 | {"a/c/exclude.me", "**/c/", true, false}, 525 | {"a/b/exclude.me", "*exclude*", true, true}, 526 | {"a/b/d/exclude.me", "*exclude*", true, true}, 527 | {"a/b/d/c/exclude.me", "*exclude*", true, true}, 528 | {"a/b/e/c/exclude.me", "!**/e/**", false, false}, 529 | } 530 | 531 | // define the repository match tests and their expected results when the 532 | // error handler returns false 533 | _REPOSITORYMATCHESFALSE = []match{ 534 | {"a/b/c_/d/e_/f/g/h/include.go~", "", false, false}, 535 | } 536 | 537 | // define the position tests 538 | _POSITIONS = []position{ 539 | {"", 0, 0, 0, "+0"}, 540 | {"", 1, 0, 0, "1"}, 541 | {"", 0, 1, 0, "+0"}, 542 | {"", 0, 0, 1, "+1"}, 543 | {"", 1, 2, 0, "1:2"}, 544 | {"", 1, 0, 3, "1"}, 545 | {"", 1, 2, 3, "1:2"}, 546 | {"file", 0, 0, 0, "file: +0"}, 547 | {"file", 1, 0, 0, "file: 1"}, 548 | {"file", 0, 1, 0, "file: +0"}, 549 | {"file", 0, 0, 1, "file: +1"}, 550 | {"file", 1, 2, 0, "file: 1:2"}, 551 | {"file", 1, 0, 3, "file: 1"}, 552 | {"file", 1, 2, 3, "file: 1:2"}, 553 | } 554 | 555 | // define the token tests 556 | // - we us the same position for all tokens, and ignore the 557 | // token string (i.e. the sequence of runes that comprise this 558 | // token), since we test the correctness of rune mappings to toknes 559 | // in the above tests of example .gitignore files 560 | _TOKENS = []token{ 561 | {gitignore.ILLEGAL, "ILLEGAL", "", 1, 2, 3, 4}, 562 | {gitignore.EOF, "EOF", "", 1, 2, 3, 4}, 563 | {gitignore.EOL, "EOL", "", 1, 2, 3, 4}, 564 | {gitignore.WHITESPACE, "WHITESPACE", "", 1, 2, 3, 4}, 565 | {gitignore.COMMENT, "COMMENT", "", 1, 2, 3, 4}, 566 | {gitignore.SEPARATOR, "SEPARATOR", "", 1, 2, 3, 4}, 567 | {gitignore.NEGATION, "NEGATION", "", 1, 2, 3, 4}, 568 | {gitignore.PATTERN, "PATTERN", "", 1, 2, 3, 4}, 569 | {gitignore.ANY, "ANY", "", 1, 2, 3, 4}, 570 | {gitignore.BAD, "BAD TOKEN", "", 1, 2, 3, 4}, 571 | 572 | // invalid tokens 573 | {-1, "BAD TOKEN", "", 1, 2, 3, 4}, 574 | {12345, "BAD TOKEN", "", 1, 2, 3, 4}, 575 | } 576 | 577 | // define the beginning position for the parser & lexer 578 | _BEGINNING = gitignore.Position{File: "", Line: 1, Column: 1, Offset: 0} 579 | 580 | // define the tokens from the invalid .gitignore above 581 | _TOKENSINVALID = []token{ 582 | // 1: # the following two lines will trigger repeated lexer errors 583 | {gitignore.COMMENT, 584 | "COMMENT", 585 | "# the following two lines will trigger repeated lexer errors", 586 | 1, 1, 0, 0}, 587 | {gitignore.EOL, "EOL", "\n", 1, 61, 60, 60}, 588 | // 2: x\rx\rx\rx 589 | {gitignore.PATTERN, "PATTERN", "x", 2, 1, 61, 62}, 590 | {gitignore.BAD, "BAD TOKEN", "\r", 2, 2, 62, 63}, 591 | {gitignore.PATTERN, "PATTERN", "x", 2, 3, 63, 64}, 592 | {gitignore.BAD, "BAD TOKEN", "\r", 2, 4, 64, 65}, 593 | {gitignore.PATTERN, "PATTERN", "x", 2, 5, 65, 66}, 594 | {gitignore.BAD, "BAD TOKEN", "\r", 2, 6, 66, 67}, 595 | {gitignore.PATTERN, "PATTERN", "x", 2, 7, 67, 68}, 596 | {gitignore.EOL, "EOL", "\n", 2, 8, 68, 69}, 597 | // 3: x\rx\rx\rx 598 | {gitignore.BAD, "BAD TOKEN", "\r", 3, 1, 69, 71}, 599 | {gitignore.PATTERN, "PATTERN", "x", 3, 2, 70, 72}, 600 | {gitignore.BAD, "BAD TOKEN", "\r", 3, 3, 71, 73}, 601 | {gitignore.PATTERN, "PATTERN", "x", 3, 4, 72, 74}, 602 | {gitignore.BAD, "BAD TOKEN", "\r", 3, 5, 73, 75}, 603 | {gitignore.PATTERN, "PATTERN", "x", 3, 6, 74, 76}, 604 | {gitignore.EOL, "EOL", "\n", 3, 7, 75, 77}, 605 | // 4: !\rx 606 | {gitignore.NEGATION, "NEGATION", "!", 4, 1, 76, 79}, 607 | {gitignore.BAD, "BAD TOKEN", "\r", 4, 2, 77, 80}, 608 | {gitignore.PATTERN, "PATTERN", "x", 4, 3, 78, 81}, 609 | {gitignore.EOL, "EOL", "\n", 4, 4, 79, 82}, 610 | // 5: /my/valid/pattern 611 | {gitignore.SEPARATOR, "SEPARATOR", "/", 5, 1, 80, 84}, 612 | {gitignore.PATTERN, "PATTERN", "my", 5, 2, 81, 85}, 613 | {gitignore.SEPARATOR, "SEPARATOR", "/", 5, 4, 83, 87}, 614 | {gitignore.PATTERN, "PATTERN", "valid", 5, 5, 84, 88}, 615 | {gitignore.SEPARATOR, "SEPARATOR", "/", 5, 10, 89, 93}, 616 | {gitignore.PATTERN, "PATTERN", "pattern", 5, 11, 90, 94}, 617 | {gitignore.EOL, "EOL", "\n", 5, 18, 97, 101}, 618 | // 6: ! 619 | {gitignore.NEGATION, "NEGATION", "!", 6, 1, 98, 103}, 620 | {gitignore.EOL, "EOL", "\n", 6, 2, 99, 104}, 621 | // 7: ** * 622 | {gitignore.ANY, "ANY", "**", 7, 1, 100, 106}, 623 | {gitignore.WHITESPACE, "WHITESPACE", " ", 7, 3, 102, 108}, 624 | {gitignore.PATTERN, "PATTERN", "*", 7, 4, 103, 109}, 625 | {gitignore.EOL, "EOL", "\n", 7, 5, 104, 110}, 626 | // 8: /\r 627 | {gitignore.SEPARATOR, "SEPARATOR", "/", 8, 1, 105, 112}, 628 | {gitignore.BAD, "BAD TOKEN", "\r", 8, 2, 106, 113}, 629 | 630 | {gitignore.EOF, "EOF", "", 8, 3, 107, 114}, 631 | } 632 | 633 | // define the patterns & errors expected during invalid content parsing 634 | _GITINVALIDPATTERN = []string{"/my/valid/pattern"} 635 | _GITINVALIDERROR = []error{ 636 | gitignore.CarriageReturnError, 637 | gitignore.CarriageReturnError, 638 | gitignore.CarriageReturnError, 639 | gitignore.CarriageReturnError, 640 | gitignore.CarriageReturnError, 641 | gitignore.CarriageReturnError, 642 | gitignore.CarriageReturnError, 643 | gitignore.InvalidPatternError, 644 | gitignore.InvalidPatternError, 645 | gitignore.CarriageReturnError, 646 | } 647 | ) 648 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package gitignore provides an interface for parsing .gitignore files, 3 | either individually, or within a repository, and 4 | matching paths against the retrieved patterns. Path matching is done using 5 | fnmatch as specified by git (see https://git-scm.com/docs/gitignore), with 6 | support for recursive matching via the "**" pattern. 7 | */ 8 | package gitignore 9 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | type Error interface { 4 | error 5 | 6 | // Position returns the position of the error within the .gitignore file 7 | // (if any) 8 | Position() Position 9 | 10 | // Underlying returns the underlying error, permitting direct comparison 11 | // against the wrapped error. 12 | Underlying() error 13 | } 14 | 15 | type err struct { 16 | error 17 | _position Position 18 | } // err() 19 | 20 | // NewError returns a new Error instance for the given error e and position p. 21 | func NewError(e error, p Position) Error { 22 | return &err{error: e, _position: p} 23 | } // NewError() 24 | 25 | func (e *err) Position() Position { return e._position } 26 | 27 | func (e *err) Underlying() error { return e.error } 28 | 29 | // ensure err satisfies the Error interface 30 | var _ Error = &err{} 31 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | CarriageReturnError = errors.New("unexpected carriage return '\\r'") 9 | InvalidPatternError = errors.New("invalid pattern") 10 | InvalidDirectoryError = errors.New("invalid directory") 11 | ) 12 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/denormal/go-gitignore" 7 | ) 8 | 9 | func ExampleNewFromFile() { 10 | ignore, err := gitignore.NewFromFile("/my/project/.gitignore") 11 | if err != nil { 12 | panic(err) 13 | } 14 | 15 | // attempt to match an absolute path 16 | match := ignore.Match("/my/project/src/file.go") 17 | if match != nil { 18 | if match.Ignore() { 19 | fmt.Println("ignore file.go") 20 | } 21 | } 22 | 23 | // attempt to match a relative path 24 | // - this is equivalent to the call above 25 | match = ignore.Relative("src/file.go", false) 26 | if match != nil { 27 | if match.Include() { 28 | fmt.Println("include file.go") 29 | } 30 | } 31 | } // ExampleNewFromFile() 32 | 33 | func ExampleNewRepository() { 34 | ignore, err := gitignore.NewRepository("/my/project") 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | // attempt to match a directory in the repository 40 | match := ignore.Relative("src/examples", true) 41 | if match != nil { 42 | if match.Ignore() { 43 | fmt.Printf( 44 | "ignore src/examples because of pattern %q at %s", 45 | match, match.Position(), 46 | ) 47 | } 48 | } 49 | 50 | // if we have an absolute path, or a path relative to the current 51 | // working directory we can use the short-hand methods 52 | if ignore.Include("/my/project/etc/service.conf") { 53 | fmt.Println("include the service configuration") 54 | } 55 | } // ExampleNewRepository() 56 | -------------------------------------------------------------------------------- /exclude.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // exclude attempts to return the GitIgnore instance for the 9 | // $GIT_DIR/info/exclude from the working copy to which path belongs. 10 | func exclude(path string) (GitIgnore, error) { 11 | // attempt to locate GIT_DIR 12 | _gitdir := os.Getenv("GIT_DIR") 13 | if _gitdir == "" { 14 | _gitdir = filepath.Join(path, ".git") 15 | } 16 | _info, _err := os.Stat(_gitdir) 17 | if _err != nil { 18 | if os.IsNotExist(_err) { 19 | return nil, nil 20 | } else { 21 | return nil, _err 22 | } 23 | } else if !_info.IsDir() { 24 | return nil, nil 25 | } 26 | 27 | // is there an info/exclude file within this directory? 28 | _file := filepath.Join(_gitdir, "info", "exclude") 29 | _, _err = os.Stat(_file) 30 | if _err != nil { 31 | if os.IsNotExist(_err) { 32 | return nil, nil 33 | } else { 34 | return nil, _err 35 | } 36 | } 37 | 38 | // attempt to load the exclude file 39 | return NewFromFile(_file) 40 | } // exclude() 41 | -------------------------------------------------------------------------------- /gitignore.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | // use an empty GitIgnore for cached lookups 12 | var empty = &ignore{} 13 | 14 | // GitIgnore is the interface to .gitignore files and repositories. It defines 15 | // methods for testing files for matching the .gitignore file, and then 16 | // determining whether a file should be ignored or included. 17 | type GitIgnore interface { 18 | // Base returns the directory containing the .gitignore file. 19 | Base() string 20 | 21 | // Match attempts to match the path against this GitIgnore, and will 22 | // return its Match if successful. Match will invoke the GitIgnore error 23 | // handler (if defined) if it is not possible to determine the absolute 24 | // path of the given path, or if its not possible to determine if the 25 | // path represents a file or a directory. If an error occurs, Match 26 | // returns nil and the error handler (if defined via New, NewWithErrors 27 | // or NewWithCache) will be invoked. 28 | Match(path string) Match 29 | 30 | // Absolute attempts to match an absolute path against this GitIgnore. If 31 | // the path is not located under the base directory of this GitIgnore, or 32 | // is not matched by this GitIgnore, nil is returned. 33 | Absolute(string, bool) Match 34 | 35 | // Relative attempts to match a path relative to the GitIgnore base 36 | // directory. isdir is used to indicate whether the path represents a file 37 | // or a directory. If the path is not matched by the GitIgnore, nil is 38 | // returned. 39 | Relative(path string, isdir bool) Match 40 | 41 | // Ignore returns true if the path is ignored by this GitIgnore. Paths 42 | // that are not matched by this GitIgnore are not ignored. Internally, 43 | // Ignore uses Match, and will return false if Match() returns nil for path. 44 | Ignore(path string) bool 45 | 46 | // Include returns true if the path is included by this GitIgnore. Paths 47 | // that are not matched by this GitIgnore are always included. Internally, 48 | // Include uses Match, and will return true if Match() returns nil for path. 49 | Include(path string) bool 50 | } 51 | 52 | // ignore is the implementation of a .gitignore file. 53 | type ignore struct { 54 | _base string 55 | _pattern []Pattern 56 | _errors func(Error) bool 57 | } 58 | 59 | // NewGitIgnore creates a new GitIgnore instance from the patterns listed in t, 60 | // representing a .gitignore file in the base directory. If errors is given, it 61 | // will be invoked for every error encountered when parsing the .gitignore 62 | // patterns. Parsing will terminate if errors is called and returns false, 63 | // otherwise, parsing will continue until end of file has been reached. 64 | func New(r io.Reader, base string, errors func(Error) bool) GitIgnore { 65 | // do we have an error handler? 66 | _errors := errors 67 | if _errors == nil { 68 | _errors = func(e Error) bool { return true } 69 | } 70 | 71 | // extract the patterns from the reader 72 | _parser := NewParser(r, _errors) 73 | _patterns := _parser.Parse() 74 | 75 | return &ignore{_base: base, _pattern: _patterns, _errors: _errors} 76 | } // New() 77 | 78 | // NewFromFile creates a GitIgnore instance from the given file. An error 79 | // will be returned if file cannot be opened or its absolute path determined. 80 | func NewFromFile(file string) (GitIgnore, error) { 81 | // define an error handler to catch any file access errors 82 | // - record the first encountered error 83 | var _error Error 84 | _errors := func(e Error) bool { 85 | if _error == nil { 86 | _error = e 87 | } 88 | return true 89 | } 90 | 91 | // attempt to retrieve the GitIgnore represented by this file 92 | _ignore := NewWithErrors(file, _errors) 93 | 94 | // did we encounter an error? 95 | // - if the error has a zero Position then it was encountered 96 | // before parsing was attempted, so we return that error 97 | if _error != nil { 98 | if _error.Position().Zero() { 99 | return nil, _error.Underlying() 100 | } 101 | } 102 | 103 | // otherwise, we ignore the parser errors 104 | return _ignore, nil 105 | } // NewFromFile() 106 | 107 | // NewWithErrors creates a GitIgnore instance from the given file. 108 | // If errors is given, it will be invoked for every error encountered when 109 | // parsing the .gitignore patterns. Parsing will terminate if errors is called 110 | // and returns false, otherwise, parsing will continue until end of file has 111 | // been reached. NewWithErrors returns nil if the .gitignore could not be read. 112 | func NewWithErrors(file string, errors func(Error) bool) GitIgnore { 113 | var _err error 114 | 115 | // do we have an error handler? 116 | _file := file 117 | _errors := errors 118 | if _errors == nil { 119 | _errors = func(e Error) bool { return true } 120 | } else { 121 | // augment the error handler to include the .gitignore file name 122 | // - we do this here since the parser and lexer interfaces are 123 | // not aware of file names 124 | _errors = func(e Error) bool { 125 | // augment the position with the file name 126 | _position := e.Position() 127 | _position.File = _file 128 | 129 | // create a new error with the updated Position 130 | _error := NewError(e.Underlying(), _position) 131 | 132 | // invoke the original error handler 133 | return errors(_error) 134 | } 135 | } 136 | 137 | // we need the absolute path for the GitIgnore base 138 | _file, _err = filepath.Abs(file) 139 | if _err != nil { 140 | _errors(NewError(_err, Position{})) 141 | return nil 142 | } 143 | _base := filepath.Dir(_file) 144 | 145 | // attempt to open the ignore file to create the io.Reader 146 | _fh, _err := os.Open(_file) 147 | if _err != nil { 148 | _errors(NewError(_err, Position{})) 149 | return nil 150 | } 151 | 152 | // return the GitIgnore instance 153 | return New(_fh, _base, _errors) 154 | } // NewWithErrors() 155 | 156 | // NewWithCache returns a GitIgnore instance (using NewWithErrors) 157 | // for the given file. If the file has been loaded before, its GitIgnore 158 | // instance will be returned from the cache rather than being reloaded. If 159 | // cache is not defined, NewWithCache will behave as NewWithErrors 160 | // 161 | // If NewWithErrors returns nil, NewWithCache will store an empty 162 | // GitIgnore (i.e. no patterns) against the file to prevent repeated parse 163 | // attempts on subsequent requests for the same file. Subsequent calls to 164 | // NewWithCache for a file that could not be loaded due to an error will 165 | // return nil. 166 | // 167 | // If errors is given, it will be invoked for every error encountered when 168 | // parsing the .gitignore patterns. Parsing will terminate if errors is called 169 | // and returns false, otherwise, parsing will continue until end of file has 170 | // been reached. 171 | func NewWithCache(file string, cache Cache, errors func(Error) bool) GitIgnore { 172 | // do we have an error handler? 173 | _errors := errors 174 | if _errors == nil { 175 | _errors = func(e Error) bool { return true } 176 | } 177 | 178 | // use the file absolute path as its key into the cache 179 | _abs, _err := filepath.Abs(file) 180 | if _err != nil { 181 | _errors(NewError(_err, Position{})) 182 | return nil 183 | } 184 | 185 | var _ignore GitIgnore 186 | if cache != nil { 187 | _ignore = cache.Get(_abs) 188 | } 189 | if _ignore == nil { 190 | _ignore = NewWithErrors(file, _errors) 191 | if _ignore == nil { 192 | // if the load failed, cache an empty GitIgnore to prevent 193 | // further attempts to load this file 194 | _ignore = empty 195 | } 196 | if cache != nil { 197 | cache.Set(_abs, _ignore) 198 | } 199 | } 200 | 201 | // return the ignore (if we have it) 202 | if _ignore == empty { 203 | return nil 204 | } else { 205 | return _ignore 206 | } 207 | } // NewWithCache() 208 | 209 | // Base returns the directory containing the .gitignore file for this GitIgnore. 210 | func (i *ignore) Base() string { 211 | return i._base 212 | } // Base() 213 | 214 | // Match attempts to match the path against this GitIgnore, and will 215 | // return its Match if successful. Match will invoke the GitIgnore error 216 | // handler (if defined) if it is not possible to determine the absolute 217 | // path of the given path, or if its not possible to determine if the 218 | // path represents a file or a directory. If an error occurs, Match 219 | // returns nil and the error handler (if defined via New, NewWithErrors 220 | // or NewWithCache) will be invoked. 221 | func (i *ignore) Match(path string) Match { 222 | // ensure we have the absolute path for the given file 223 | _path, _err := filepath.Abs(path) 224 | if _err != nil { 225 | i._errors(NewError(_err, Position{})) 226 | return nil 227 | } 228 | 229 | // is the path a file or a directory? 230 | _info, _err := os.Stat(_path) 231 | if _err != nil { 232 | i._errors(NewError(_err, Position{})) 233 | return nil 234 | } 235 | _isdir := _info.IsDir() 236 | 237 | // attempt to match the absolute path 238 | return i.Absolute(_path, _isdir) 239 | } // Match() 240 | 241 | // Absolute attempts to match an absolute path against this GitIgnore. If 242 | // the path is not located under the base directory of this GitIgnore, or 243 | // is not matched by this GitIgnore, nil is returned. 244 | func (i *ignore) Absolute(path string, isdir bool) Match { 245 | // does the file share the same directory as this ignore file? 246 | if !strings.HasPrefix(path, i._base) { 247 | return nil 248 | } 249 | 250 | // extract the relative path of this file 251 | _prefix := len(i._base) + 1 252 | _rel := string(path[_prefix:]) 253 | return i.Relative(_rel, isdir) 254 | } // Absolute() 255 | 256 | // Relative attempts to match a path relative to the GitIgnore base 257 | // directory. isdir is used to indicate whether the path represents a file 258 | // or a directory. If the path is not matched by the GitIgnore, nil is 259 | // returned. 260 | func (i *ignore) Relative(path string, isdir bool) Match { 261 | // if we are on Windows, then translate the path to Unix form 262 | _rel := path 263 | if runtime.GOOS == "windows" { 264 | _rel = filepath.ToSlash(_rel) 265 | } 266 | 267 | // iterate over the patterns for this ignore file 268 | // - iterate in reverse, since later patterns overwrite earlier 269 | for _i := len(i._pattern) - 1; _i >= 0; _i-- { 270 | _pattern := i._pattern[_i] 271 | if _pattern.Match(_rel, isdir) { 272 | return _pattern 273 | } 274 | } 275 | 276 | // we don't match this file 277 | return nil 278 | } // Relative() 279 | 280 | // Ignore returns true if the path is ignored by this GitIgnore. Paths 281 | // that are not matched by this GitIgnore are not ignored. Internally, 282 | // Ignore uses Match, and will return false if Match() returns nil for path. 283 | func (i *ignore) Ignore(path string) bool { 284 | _match := i.Match(path) 285 | if _match != nil { 286 | return _match.Ignore() 287 | } 288 | 289 | // we didn't match this path, so we don't ignore it 290 | return false 291 | } // Ignore() 292 | 293 | // Include returns true if the path is included by this GitIgnore. Paths 294 | // that are not matched by this GitIgnore are always included. Internally, 295 | // Include uses Match, and will return true if Match() returns nil for path. 296 | func (i *ignore) Include(path string) bool { 297 | _match := i.Match(path) 298 | if _match != nil { 299 | return _match.Include() 300 | } 301 | 302 | // we didn't match this path, so we include it 303 | return true 304 | } // Include() 305 | 306 | // ensure Ignore satisfies the GitIgnore interface 307 | var _ GitIgnore = &ignore{} 308 | -------------------------------------------------------------------------------- /gitignore_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/denormal/go-gitignore" 10 | ) 11 | 12 | type gitignoretest struct { 13 | errors []gitignore.Error 14 | error func(gitignore.Error) bool 15 | cache gitignore.Cache 16 | cached bool 17 | instance func(string) (gitignore.GitIgnore, error) 18 | } // gitignoretest{} 19 | 20 | func TestNewFromFile(t *testing.T) { 21 | _test := &gitignoretest{} 22 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 23 | return gitignore.NewFromFile(file) 24 | } 25 | 26 | // perform the gitignore test 27 | withfile(t, _test, _GITIGNORE) 28 | } // TestNewFromFile() 29 | 30 | func TestNewFromWhitespaceFile(t *testing.T) { 31 | _test := &gitignoretest{} 32 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 33 | return gitignore.NewFromFile(file) 34 | } 35 | 36 | // perform the gitignore test 37 | withfile(t, _test, _GITIGNORE_WHITESPACE) 38 | } // TestNewFromWhitespaceFile() 39 | 40 | func TestNewFromEmptyFile(t *testing.T) { 41 | _test := &gitignoretest{} 42 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 43 | return gitignore.NewFromFile(file) 44 | } 45 | 46 | // perform the gitignore test 47 | withfile(t, _test, "") 48 | } // TestNewFromEmptyFile() 49 | 50 | func TestNewWithErrors(t *testing.T) { 51 | _test := &gitignoretest{} 52 | _test.error = func(e gitignore.Error) bool { 53 | _test.errors = append(_test.errors, e) 54 | return true 55 | } 56 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 57 | // reset the error slice 58 | _test.errors = make([]gitignore.Error, 0) 59 | 60 | // attempt to create the GitIgnore instance 61 | _ignore := gitignore.NewWithErrors(file, _test.error) 62 | 63 | // if we encountered errors, and the first error has a zero position 64 | // then it represents a file access error 65 | // - extract the error and return it 66 | // - remove it from the list of errors 67 | var _err error 68 | if len(_test.errors) > 0 { 69 | if _test.errors[0].Position().Zero() { 70 | _err = _test.errors[0].Underlying() 71 | _test.errors = _test.errors[1:] 72 | } 73 | } 74 | 75 | // return the GitIgnore instance 76 | return _ignore, _err 77 | } 78 | 79 | // perform the gitignore test 80 | withfile(t, _test, _GITIGNORE) 81 | 82 | _test.error = nil 83 | withfile(t, _test, _GITIGNORE) 84 | } // TestNewWithErrors() 85 | 86 | func TestNewWithCache(t *testing.T) { 87 | // perform the gitignore test with a custom cache 88 | _test := &gitignoretest{} 89 | _test.cached = true 90 | _test.cache = gitignore.NewCache() 91 | _test.instance = func(file string) (gitignore.GitIgnore, error) { 92 | // reset the error slice 93 | _test.errors = make([]gitignore.Error, 0) 94 | 95 | // attempt to create the GitIgnore instance 96 | _ignore := gitignore.NewWithCache(file, _test.cache, _test.error) 97 | 98 | // if we encountered errors, and the first error has a zero position 99 | // then it represents a file access error 100 | // - extract the error and return it 101 | // - remove it from the list of errors 102 | var _err error 103 | if len(_test.errors) > 0 { 104 | if _test.errors[0].Position().Zero() { 105 | _err = _test.errors[0].Underlying() 106 | _test.errors = _test.errors[1:] 107 | } 108 | } 109 | 110 | // return the GitIgnore instance 111 | return _ignore, _err 112 | } 113 | 114 | // perform the gitignore test 115 | withfile(t, _test, _GITIGNORE) 116 | 117 | // repeat the tests while accumulating errors 118 | _test.error = func(e gitignore.Error) bool { 119 | _test.errors = append(_test.errors, e) 120 | return true 121 | } 122 | withfile(t, _test, _GITIGNORE) 123 | 124 | // create a temporary .gitignore 125 | _file, _err := file(_GITIGNORE) 126 | if _err != nil { 127 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 128 | } 129 | defer os.Remove(_file.Name()) 130 | 131 | // attempt to load the .gitignore file 132 | _ignore, _err := _test.instance(_file.Name()) 133 | if _err != nil { 134 | t.Fatalf("unable to open temporary .gitignore: %s", _err.Error()) 135 | } 136 | 137 | // remove the .gitignore and try again 138 | os.Remove(_file.Name()) 139 | 140 | // ensure the retrieved GitIgnore matches the stored instance 141 | _new, _err := _test.instance(_file.Name()) 142 | if _err != nil { 143 | t.Fatalf( 144 | "unexpected error retrieving cached .gitignore: %s", _err.Error(), 145 | ) 146 | } else if _new != _ignore { 147 | t.Fatalf( 148 | "gitignore.NewWithCache() mismatch; expected %v, got %v", 149 | _ignore, _new, 150 | ) 151 | } 152 | } // TestNewWithCache() 153 | 154 | func TestNew(t *testing.T) { 155 | // create a temporary .gitignore 156 | _file, _err := file(_GITIGNORE) 157 | if _err != nil { 158 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 159 | } 160 | defer os.Remove(_file.Name()) 161 | 162 | // ensure we can run NewGitIgnore() 163 | // - ensure we encounter the expected errors 164 | _position := []gitignore.Position{} 165 | _error := func(e gitignore.Error) bool { 166 | _position = append(_position, e.Position()) 167 | return true 168 | } 169 | 170 | _dir := filepath.Dir(_file.Name()) 171 | _ignore := gitignore.New(_file, _dir, _error) 172 | 173 | // ensure we have a non-nil GitIgnore instance 174 | if _ignore == nil { 175 | t.Error("expected non-nil GitIgnore instance; nil found") 176 | } 177 | 178 | // ensure the base of the ignore is the directory of the temporary file 179 | if _ignore.Base() != _dir { 180 | t.Errorf( 181 | "gitignore.Base() mismatch; expected %q, got %q", 182 | _dir, _ignore.Base(), 183 | ) 184 | } 185 | 186 | // ensure we encountered the right number of errors 187 | if len(_position) != _GITBADPATTERNS { 188 | t.Errorf( 189 | "parse error mismatch; expected %d errors, got %d", 190 | _GITBADPATTERNS, len(_position), 191 | ) 192 | } else { 193 | // ensure the error positions are correct 194 | for _i := 0; _i < _GITBADPATTERNS; _i++ { 195 | _got := _position[_i] 196 | _expected := _GITBADPOSITION[_i] 197 | 198 | // ensure the positions are correct 199 | if !coincident(_got, _expected) { 200 | t.Errorf("bad pattern position mismatch; expected %q, got %q", 201 | pos(_expected), pos(_got), 202 | ) 203 | } 204 | } 205 | } 206 | } // TestNew() 207 | 208 | func withfile(t *testing.T, test *gitignoretest, content string) { 209 | // create a temporary .gitignore 210 | _file, _err := file(content) 211 | if _err != nil { 212 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 213 | } 214 | defer os.Remove(_file.Name()) 215 | 216 | // attempt to retrieve the GitIgnore instance 217 | _ignore, _err := test.instance(_file.Name()) 218 | if _err != nil { 219 | t.Fatalf("unable to open temporary .gitignore: %s", _err.Error()) 220 | } 221 | 222 | // ensure we have a non-nil GitIgnore instance 223 | if _ignore == nil { 224 | t.Error("expected non-nil GitIgnore instance; nil found") 225 | } 226 | 227 | // ensure the base of the ignore is the directory of the temporary file 228 | _dir := filepath.Dir(_file.Name()) 229 | if _ignore.Base() != _dir { 230 | t.Errorf( 231 | "gitignore.Base() mismatch; expected %q, got %q", 232 | _dir, _ignore.Base(), 233 | ) 234 | } 235 | 236 | // ensure we encountered the right number of errors 237 | // - only do this if we are configured to record bad patterns 238 | if test.error != nil { 239 | if len(test.errors) != _GITBADPATTERNS { 240 | t.Errorf( 241 | "parse error mismatch; expected %d errors, got %d", 242 | _GITBADPATTERNS, len(test.errors), 243 | ) 244 | } else { 245 | // ensure the error positions are correct 246 | for _i := 0; _i < _GITBADPATTERNS; _i++ { 247 | _got := test.errors[_i].Position() 248 | _expected := _GITBADPOSITION[_i] 249 | 250 | // augment the expected position with the test file name 251 | _expected.File = _file.Name() 252 | 253 | // ensure the positions are correct 254 | if !coincident(_got, _expected) { 255 | t.Errorf( 256 | "bad pattern position mismatch; expected %q, got %q", 257 | pos(_expected), pos(_got), 258 | ) 259 | } 260 | } 261 | } 262 | } 263 | 264 | // test NewFromFile() behaves as expected if the .gitgnore file does 265 | // not exist 266 | _err = os.Remove(_file.Name()) 267 | if _err != nil { 268 | t.Fatalf( 269 | "unable to remove temporary .gitignore %s: %s", 270 | _file.Name(), _err.Error(), 271 | ) 272 | } 273 | _ignore, _err = test.instance(_file.Name()) 274 | if _err == nil { 275 | // if we are using a cache in this test, then no error is acceptable 276 | // as long as a GitIgnore instance is retrieved 277 | if test.cached { 278 | if _ignore == nil { 279 | t.Fatal("expected non-nil GitIgnore, nil found") 280 | } 281 | } else if test.error != nil { 282 | t.Fatalf( 283 | "expected error loading deleted file %s; none found", 284 | _file.Name(), 285 | ) 286 | } 287 | } else if !os.IsNotExist(_err) { 288 | t.Fatalf( 289 | "unexpected error attempting to load non-existant .gitignore: %s", 290 | _err.Error(), 291 | ) 292 | } else if _ignore != nil { 293 | t.Fatalf("expected nil GitIgnore, got %v", _ignore) 294 | } 295 | 296 | // test NewFromFile() behaves as expected if absolute path of the 297 | // .gitignore cannot be determined 298 | _map := map[string]string{gitignore.File: content} 299 | _dir, _err = dir(_map) 300 | if _err != nil { 301 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 302 | } 303 | defer os.RemoveAll(_dir) 304 | 305 | // change into the temporary directory 306 | _cwd, _err := os.Getwd() 307 | if _err != nil { 308 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 309 | } 310 | _err = os.Chdir(_dir) 311 | if _err != nil { 312 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 313 | } 314 | defer os.Chdir(_cwd) 315 | 316 | // remove permission from the temporary directory 317 | _err = os.Chmod(_dir, 0) 318 | if _err != nil { 319 | t.Fatalf( 320 | "unable to remove temporary directory permissions: %s: %s", 321 | _dir, _err.Error(), 322 | ) 323 | } 324 | 325 | // attempt to load the .gitignore using a relative path 326 | _ignore, _err = test.instance(gitignore.File) 327 | if test.error != nil && _err == nil { 328 | _git := filepath.Join(_dir, gitignore.File) 329 | t.Fatalf( 330 | "%s: expected error for inaccessible .gitignore; none found", 331 | _git, 332 | ) 333 | } else if _ignore != nil { 334 | t.Fatalf("expected nil GitIgnore, got %v", _ignore) 335 | } 336 | } // withfile() 337 | -------------------------------------------------------------------------------- /lexer.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | ) 7 | 8 | // 9 | // inspired by https://blog.gopheracademy.com/advent-2014/parsers-lexers/ 10 | // 11 | 12 | // lexer is the implementation of the .gitignore lexical analyser 13 | type lexer struct { 14 | _r *bufio.Reader 15 | _unread []rune 16 | _offset int 17 | _line int 18 | _column int 19 | _previous []int 20 | } // lexer{} 21 | 22 | // Lexer is the interface to the lexical analyser for .gitignore files 23 | type Lexer interface { 24 | // Next returns the next Token from the Lexer reader. If an error is 25 | // encountered, it will be returned as an Error instance, detailing the 26 | // error and its position within the stream. 27 | Next() (*Token, Error) 28 | 29 | // Position returns the current position of the Lexer. 30 | Position() Position 31 | 32 | // String returns the string representation of the current position of the 33 | // Lexer. 34 | String() string 35 | } 36 | 37 | // NewLexer returns a Lexer instance for the io.Reader r. 38 | func NewLexer(r io.Reader) Lexer { 39 | return &lexer{_r: bufio.NewReader(r), _line: 1, _column: 1} 40 | } // NewLexer() 41 | 42 | // Next returns the next Token from the Lexer reader. If an error is 43 | // encountered, it will be returned as an Error instance, detailing the error 44 | // and its position within the stream. 45 | func (l *lexer) Next() (*Token, Error) { 46 | // are we at the beginning of the line? 47 | _beginning := l.beginning() 48 | 49 | // read the next rune 50 | _r, _err := l.read() 51 | if _err != nil { 52 | return nil, _err 53 | } 54 | 55 | switch _r { 56 | // end of file 57 | case _EOF: 58 | return l.token(EOF, nil, nil) 59 | 60 | // whitespace ' ', '\t' 61 | case _SPACE: 62 | fallthrough 63 | case _TAB: 64 | l.unread(_r) 65 | _rtn, _err := l.whitespace() 66 | return l.token(WHITESPACE, _rtn, _err) 67 | 68 | // end of line '\n' or '\r\n' 69 | case _CR: 70 | fallthrough 71 | case _NEWLINE: 72 | l.unread(_r) 73 | _rtn, _err := l.eol() 74 | return l.token(EOL, _rtn, _err) 75 | 76 | // separator '/' 77 | case _SEPARATOR: 78 | return l.token(SEPARATOR, []rune{_r}, nil) 79 | 80 | // '*' or any '**' 81 | case _WILDCARD: 82 | // is the wildcard followed by another wildcard? 83 | // - does this represent the "any" token (i.e. "**") 84 | _next, _err := l.peek() 85 | if _err != nil { 86 | return nil, _err 87 | } else if _next == _WILDCARD { 88 | // we know read() will succeed here since we used peek() above 89 | l.read() 90 | return l.token(ANY, []rune{_WILDCARD, _WILDCARD}, nil) 91 | } 92 | 93 | // we have a single wildcard, so treat this as a pattern 94 | l.unread(_r) 95 | _rtn, _err := l.pattern() 96 | return l.token(PATTERN, _rtn, _err) 97 | 98 | // comment '#' 99 | case _COMMENT: 100 | l.unread(_r) 101 | 102 | // if we are at the start of the line, then we treat this as a comment 103 | if _beginning { 104 | _rtn, _err := l.comment() 105 | return l.token(COMMENT, _rtn, _err) 106 | } 107 | 108 | // otherwise, we regard this as a pattern 109 | _rtn, _err := l.pattern() 110 | return l.token(PATTERN, _rtn, _err) 111 | 112 | // negation '!' 113 | case _NEGATION: 114 | if _beginning { 115 | return l.token(NEGATION, []rune{_r}, nil) 116 | } 117 | fallthrough 118 | 119 | // pattern 120 | default: 121 | l.unread(_r) 122 | _rtn, _err := l.pattern() 123 | return l.token(PATTERN, _rtn, _err) 124 | } 125 | } // Next() 126 | 127 | // Position returns the current position of the Lexer. 128 | func (l *lexer) Position() Position { 129 | return Position{"", l._line, l._column, l._offset} 130 | } // Position() 131 | 132 | // String returns the string representation of the current position of the 133 | // Lexer. 134 | func (l *lexer) String() string { 135 | return l.Position().String() 136 | } // String() 137 | 138 | // 139 | // private methods 140 | // 141 | 142 | // read the next rune from the stream. Return an Error if there is a problem 143 | // reading from the stream. If the end of stream is reached, return the EOF 144 | // Token. 145 | func (l *lexer) read() (rune, Error) { 146 | var _r rune 147 | var _err error 148 | 149 | // do we have any unread runes to read? 150 | _length := len(l._unread) 151 | if _length > 0 { 152 | _r = l._unread[_length-1] 153 | l._unread = l._unread[:_length-1] 154 | 155 | // otherwise, attempt to read a new rune 156 | } else { 157 | _r, _, _err = l._r.ReadRune() 158 | if _err == io.EOF { 159 | return _EOF, nil 160 | } 161 | } 162 | 163 | // increment the offset and column counts 164 | l._offset++ 165 | l._column++ 166 | 167 | return _r, l.err(_err) 168 | } // read() 169 | 170 | // unread returns the given runes to the stream, making them eligible to be 171 | // read again. The runes are returned in the order given, so the last rune 172 | // specified will be the next rune read from the stream. 173 | func (l *lexer) unread(r ...rune) { 174 | // ignore EOF runes 175 | _r := make([]rune, 0) 176 | for _, _rune := range r { 177 | if _rune != _EOF { 178 | _r = append(_r, _rune) 179 | } 180 | } 181 | 182 | // initialise the unread rune list if necessary 183 | if l._unread == nil { 184 | l._unread = make([]rune, 0) 185 | } 186 | if len(_r) != 0 { 187 | l._unread = append(l._unread, _r...) 188 | } 189 | 190 | // decrement the offset and column counts 191 | // - we have to take care of column being 0 192 | // - at present we can only unwind across a single line boundary 193 | _length := len(_r) 194 | for ; _length > 0; _length-- { 195 | l._offset-- 196 | if l._column == 1 { 197 | _length := len(l._previous) 198 | if _length > 0 { 199 | l._column = l._previous[_length-1] 200 | l._previous = l._previous[:_length-1] 201 | l._line-- 202 | } 203 | } else { 204 | l._column-- 205 | } 206 | } 207 | } // unread() 208 | 209 | // peek returns the next rune in the stream without consuming it (i.e. it will 210 | // be returned by the next call to read or peek). peek will return an error if 211 | // there is a problem reading from the stream. 212 | func (l *lexer) peek() (rune, Error) { 213 | // read the next rune 214 | _r, _err := l.read() 215 | if _err != nil { 216 | return _r, _err 217 | } 218 | 219 | // unread & return the rune 220 | l.unread(_r) 221 | return _r, _err 222 | } // peek() 223 | 224 | // newline adjusts the positional counters when an end of line is reached 225 | func (l *lexer) newline() { 226 | // adjust the counters for the new line 227 | if l._previous == nil { 228 | l._previous = make([]int, 0) 229 | } 230 | l._previous = append(l._previous, l._column) 231 | l._column = 1 232 | l._line++ 233 | } // newline() 234 | 235 | // comment reads all runes until a newline or end of file is reached. An 236 | // error is returned if an error is encountered reading from the stream. 237 | func (l *lexer) comment() ([]rune, Error) { 238 | _comment := make([]rune, 0) 239 | 240 | // read until we reach end of line or end of file 241 | // - as we are in a comment, we ignore escape characters 242 | for { 243 | _next, _err := l.read() 244 | if _err != nil { 245 | return _comment, _err 246 | } 247 | 248 | // read until we have end of line or end of file 249 | switch _next { 250 | case _CR: 251 | fallthrough 252 | case _NEWLINE: 253 | fallthrough 254 | case _EOF: 255 | // return the read run to the stream and stop 256 | l.unread(_next) 257 | return _comment, nil 258 | } 259 | 260 | // otherwise, add this run to the comment 261 | _comment = append(_comment, _next) 262 | } 263 | } // comment() 264 | 265 | // escape attempts to read an escape sequence (e.g. '\ ') form the input 266 | // stream. An error will be returned if there is an error reading from the 267 | // stream. escape returns just the escape rune if the following rune is either 268 | // end of line or end of file (since .gitignore files do not support line 269 | // continuations). 270 | func (l *lexer) escape() ([]rune, Error) { 271 | // attempt to process the escape sequence 272 | _peek, _err := l.peek() 273 | if _err != nil { 274 | return nil, _err 275 | } 276 | 277 | // what is the next rune after the escape? 278 | switch _peek { 279 | // are we at the end of the line or file? 280 | // - we return just the escape rune 281 | case _CR: 282 | fallthrough 283 | case _NEWLINE: 284 | fallthrough 285 | case _EOF: 286 | return []rune{_ESCAPE}, nil 287 | } 288 | 289 | // otherwise, return the escape and the next rune 290 | // - we know read() will succeed here since we used peek() above 291 | l.read() 292 | return []rune{_ESCAPE, _peek}, nil 293 | } // escape() 294 | 295 | // eol returns all runes from the current position to the end of the line. An 296 | // error is returned if there is a problem reading from the stream, or if a 297 | // carriage return character '\r' is encountered that is not followed by a 298 | // newline '\n'. 299 | func (l *lexer) eol() ([]rune, Error) { 300 | // read the to the end of the line 301 | // - we should only be called here when we encounter an end of line 302 | // sequence 303 | _line := make([]rune, 0, 1) 304 | 305 | // loop until there's nothing more to do 306 | for { 307 | _next, _err := l.read() 308 | if _err != nil { 309 | return _line, _err 310 | } 311 | 312 | // read until we have a newline or we're at end of file 313 | switch _next { 314 | // end of file 315 | case _EOF: 316 | return _line, nil 317 | 318 | // carriage return - we expect to see a newline next 319 | case _CR: 320 | _line = append(_line, _next) 321 | _next, _err = l.read() 322 | if _err != nil { 323 | return _line, _err 324 | } else if _next != _NEWLINE { 325 | l.unread(_next) 326 | return _line, l.err(CarriageReturnError) 327 | } 328 | fallthrough 329 | 330 | // newline 331 | case _NEWLINE: 332 | _line = append(_line, _next) 333 | return _line, nil 334 | } 335 | } 336 | } // eol() 337 | 338 | // whitespace returns all whitespace (i.e. ' ' and '\t') runes in a sequence, 339 | // or an error if there is a problem reading the next runes. 340 | func (l *lexer) whitespace() ([]rune, Error) { 341 | // read until we hit the first non-whitespace rune 342 | _ws := make([]rune, 0, 1) 343 | 344 | // loop until there's nothing more to do 345 | for { 346 | _next, _err := l.read() 347 | if _err != nil { 348 | return _ws, _err 349 | } 350 | 351 | // what is this next rune? 352 | switch _next { 353 | // space or tab is consumed 354 | case _SPACE: 355 | fallthrough 356 | case _TAB: 357 | break 358 | 359 | // non-whitespace rune 360 | default: 361 | // return the rune to the buffer and we're done 362 | l.unread(_next) 363 | return _ws, nil 364 | } 365 | 366 | // add this rune to the whitespace 367 | _ws = append(_ws, _next) 368 | } 369 | } // whitespace() 370 | 371 | // pattern returns all runes representing a file or path pattern, delimited 372 | // either by unescaped whitespace, a path separator '/' or enf of file. An 373 | // error is returned if a problem is encountered reading from the stream. 374 | func (l *lexer) pattern() ([]rune, Error) { 375 | // read until we hit the first whitespace/end of line/eof rune 376 | _pattern := make([]rune, 0, 1) 377 | 378 | // loop until there's nothing more to do 379 | for { 380 | _r, _err := l.read() 381 | if _err != nil { 382 | return _pattern, _err 383 | } 384 | 385 | // what is the next rune? 386 | switch _r { 387 | // whitespace, newline, end of file, separator 388 | // - this is the end of the pattern 389 | case _SPACE: 390 | fallthrough 391 | case _TAB: 392 | fallthrough 393 | case _CR: 394 | fallthrough 395 | case _NEWLINE: 396 | fallthrough 397 | case _SEPARATOR: 398 | fallthrough 399 | case _EOF: 400 | // return what we have 401 | l.unread(_r) 402 | return _pattern, nil 403 | 404 | // a wildcard is the end of the pattern if it is part of any '**' 405 | case _WILDCARD: 406 | _next, _err := l.peek() 407 | if _err != nil { 408 | return _pattern, _err 409 | } else if _next == _WILDCARD { 410 | l.unread(_r) 411 | return _pattern, _err 412 | } else { 413 | _pattern = append(_pattern, _r) 414 | } 415 | 416 | // escape sequence - consume the next rune 417 | case _ESCAPE: 418 | _escape, _err := l.escape() 419 | if _err != nil { 420 | return _pattern, _err 421 | } 422 | 423 | // add the escape sequence as part of the pattern 424 | _pattern = append(_pattern, _escape...) 425 | 426 | // any other character, we add to the pattern 427 | default: 428 | _pattern = append(_pattern, _r) 429 | } 430 | } 431 | } // pattern() 432 | 433 | // token returns a Token instance of the given type_ represented by word runes. 434 | func (l *lexer) token(type_ TokenType, word []rune, e Error) (*Token, Error) { 435 | // if we have an error, then we return a BAD token 436 | if e != nil { 437 | type_ = BAD 438 | } 439 | 440 | // extract the lexer position 441 | // - the column is taken from the current column position 442 | // minus the length of the consumed "word" 443 | _word := len(word) 444 | _column := l._column - _word 445 | _offset := l._offset - _word 446 | position := Position{"", l._line, _column, _offset} 447 | 448 | // if this is a newline token, we adjust the line & column counts 449 | if type_ == EOL { 450 | l.newline() 451 | } 452 | 453 | // return the Token 454 | return NewToken(type_, word, position), e 455 | } // token() 456 | 457 | // err returns an Error encapsulating the error e and the current Lexer 458 | // position. 459 | func (l *lexer) err(e error) Error { 460 | // do we have an error? 461 | if e == nil { 462 | return nil 463 | } else { 464 | return NewError(e, l.Position()) 465 | } 466 | } // err() 467 | 468 | // beginning returns true if the Lexer is at the start of a new line. 469 | func (l *lexer) beginning() bool { 470 | return l._column == 1 471 | } // beginning() 472 | 473 | // ensure the lexer conforms to the lexer interface 474 | var _ Lexer = &lexer{} 475 | -------------------------------------------------------------------------------- /lexer_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/denormal/go-gitignore" 9 | ) 10 | 11 | // TestLexerNewLne tests the behavour of the gitignore.Lexer when the input 12 | // data explicitly uses "\n" as the line separator 13 | func TestLexerNewLine(t *testing.T) { 14 | // split the test content into lines 15 | // - ensure we handle "\n" and "\r" correctly 16 | // - since this test file is written on a system that uses "\n" 17 | // to designate end of line, this should be unnecessary, but it's 18 | // possible for the file line endings to be converted outside of 19 | // this repository, so we are thorough here to ensure the test 20 | // works as expected everywhere 21 | _content := strings.Split(_GITIGNORE, "\n") 22 | for _i := 0; _i < len(_content); _i++ { 23 | _content[_i] = strings.TrimSuffix(_content[_i], "\r") 24 | } 25 | 26 | // perform the Lexer test with input explicitly separated by "\n" 27 | lexer(t, _content, "\n", _GITTOKENS, nil) 28 | } // TestLexerNewLine() 29 | 30 | // TestLexerCarriageReturn tests the behavour of the gitignore.Lexer when the 31 | // input data explicitly uses "\r\n" as the line separator 32 | func TestLexerCarriageReturn(t *testing.T) { 33 | // split the test content into lines 34 | // - see above 35 | _content := strings.Split(_GITIGNORE, "\n") 36 | for _i := 0; _i < len(_content); _i++ { 37 | _content[_i] = strings.TrimSuffix(_content[_i], "\r") 38 | } 39 | 40 | // perform the Lexer test with input explicitly separated by "\r\n" 41 | lexer(t, _content, "\r\n", _GITTOKENS, nil) 42 | } // TestLexerCarriageReturn() 43 | 44 | func TestLexerInvalidNewLine(t *testing.T) { 45 | // perform the Lexer test with invalid input separated by "\n" 46 | // - the source content is manually constructed with "\n" as EOL 47 | _content := strings.Split(_GITINVALID, "\n") 48 | lexer(t, _content, "\n", _TOKENSINVALID, gitignore.CarriageReturnError) 49 | } // TestLexerInvalidNewLine() 50 | 51 | func TestLexerInvalidCarriageReturn(t *testing.T) { 52 | // perform the Lexer test with invalid input separated by "\n" 53 | // - the source content is manually constructed with "\n" as EOL 54 | _content := strings.Split(_GITINVALID, "\n") 55 | lexer(t, _content, "\r\n", _TOKENSINVALID, gitignore.CarriageReturnError) 56 | } // TestLexerInvalidCarriageReturn() 57 | 58 | func lexer(t *testing.T, lines []string, eol string, tokens []token, e error) { 59 | // create a temporary .gitignore 60 | _buffer, _err := buffer(strings.Join(lines, eol)) 61 | if _err != nil { 62 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 63 | } 64 | 65 | // ensure we have a non-nil Lexer instance 66 | _lexer := gitignore.NewLexer(_buffer) 67 | if _lexer == nil { 68 | t.Error("expected non-nil Lexer instance; nil found") 69 | } 70 | 71 | // ensure the stream of tokens is as we expect 72 | for _, _expected := range tokens { 73 | _position := _lexer.Position() 74 | 75 | // ensure the string form of the Lexer reports the correct position 76 | _string := fmt.Sprintf("%d:%d", _position.Line, _position.Column) 77 | if _lexer.String() != _string { 78 | t.Errorf( 79 | "lexer string mismatch; expected %q, got %q", 80 | _string, _position.String(), 81 | ) 82 | } 83 | 84 | // extract the next token from the lexer 85 | _got, _err := _lexer.Next() 86 | 87 | // ensure we did not receive an error and the token is as expected 88 | if _err != nil { 89 | // if we expect an error during processing, check to see if 90 | // the received error is as expected 91 | // if !_err.Is(e) { 92 | if _err.Underlying() != e { 93 | t.Fatalf( 94 | "unable to retrieve expected token; %s at %s", 95 | _err.Error(), pos(_err.Position()), 96 | ) 97 | } 98 | } 99 | 100 | // did we receive a token? 101 | if _got == nil { 102 | t.Fatalf("expected token at %s; none found", _lexer) 103 | } else if _got.Type != _expected.Type { 104 | t.Fatalf( 105 | "token type mismatch; expected type %d, got %d [%s]", 106 | _expected.Type, _got.Type, _got, 107 | ) 108 | } else if _got.Name() != _expected.Name { 109 | t.Fatalf( 110 | "token name mismatch; expected name %q, got %q [%s]", 111 | _expected.Name, _got.Name(), _got, 112 | ) 113 | } else { 114 | // ensure the extracted token string matches expectation 115 | // - we handle EOL separately, since it can change based 116 | // on the end of line sequence of the input file 117 | _same := _got.Token() == _expected.Token 118 | if _got.Type == gitignore.EOL { 119 | _same = _got.Token() == eol 120 | } 121 | if !_same { 122 | t.Fatalf( 123 | "token value mismatch; expected name %q, got %q [%s]", 124 | _expected.Token, _got.Token(), _got, 125 | ) 126 | } 127 | 128 | // ensure the token position matches the original lexer position 129 | if !coincident(_got.Position, _position) { 130 | t.Fatalf( 131 | "token position mismatch for %s; expected %s, got %s", 132 | _got, pos(_position), pos(_got.Position), 133 | ) 134 | } 135 | 136 | // ensure the token position matches the expected position 137 | // - since we will be testing with different line endings, we 138 | // have to choose the correct offset 139 | _position := gitignore.Position{ 140 | File: "", 141 | Line: _expected.Line, 142 | Column: _expected.Column, 143 | Offset: _expected.NewLine, 144 | } 145 | if eol == "\r\n" { 146 | _position.Offset = _expected.CarriageReturn 147 | } 148 | if !coincident(_got.Position, _position) { 149 | t.Log(pos(_got.Position) + "\t" + _got.String()) 150 | t.Fatalf( 151 | "token position mismatch; expected %s, got %s", 152 | pos(_position), pos(_got.Position), 153 | ) 154 | } 155 | } 156 | } 157 | 158 | // ensure there are no more tokens 159 | _next, _err := _lexer.Next() 160 | if _err != nil { 161 | t.Errorf("unexpected error on end of token test: %s", _err.Error()) 162 | } else if _next == nil { 163 | t.Errorf("unexpected nil token at end of test") 164 | } else if _next.Type != gitignore.EOF { 165 | t.Errorf( 166 | "token type mismatch; expected type %d, got %d [%s]", 167 | gitignore.EOF, _next.Type, _next, 168 | ) 169 | } 170 | } // TestLexer() 171 | -------------------------------------------------------------------------------- /match.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | // Match represents the interface of successful matches against a .gitignore 4 | // pattern set. A Match can be queried to determine whether the matched path 5 | // should be ignored or included (i.e. was the path matched by a negated 6 | // pattern), and to extract the position of the pattern within the .gitignore, 7 | // and a string representation of the pattern. 8 | type Match interface { 9 | // Ignore returns true if the match pattern describes files or paths that 10 | // should be ignored. 11 | Ignore() bool 12 | 13 | // Include returns true if the match pattern describes files or paths that 14 | // should be included. 15 | Include() bool 16 | 17 | // String returns a string representation of the matched pattern. 18 | String() string 19 | 20 | // Position returns the position in the .gitignore file at which the 21 | // matching pattern was defined. 22 | Position() Position 23 | } 24 | -------------------------------------------------------------------------------- /match_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/denormal/go-gitignore" 9 | ) 10 | 11 | func TestMatch(t *testing.T) { 12 | // we need to populate a directory with the match test files 13 | // - this is to permit GitIgnore.Match() to correctly resolve 14 | // absolute path names 15 | _dir, _ignore := directory(t) 16 | defer os.RemoveAll(_dir) 17 | 18 | // perform the path matching 19 | // - first we test absolute paths 20 | _cb := func(path string, isdir bool) gitignore.Match { 21 | _path := filepath.Join(_dir, path) 22 | return _ignore.Match(_path) 23 | } 24 | for _, _test := range _GITMATCHES { 25 | do(t, _cb, _test) 26 | } 27 | 28 | // now, attempt relative path matching 29 | // - to do this, we need to change the working directory 30 | _cwd, _err := os.Getwd() 31 | if _err != nil { 32 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 33 | } 34 | _err = os.Chdir(_dir) 35 | if _err != nil { 36 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 37 | } 38 | defer os.Chdir(_cwd) 39 | 40 | // perform the relative path tests 41 | _cb = func(path string, isdir bool) gitignore.Match { 42 | return _ignore.Match(path) 43 | } 44 | for _, _test := range _GITMATCHES { 45 | do(t, _cb, _test) 46 | } 47 | 48 | // perform absolute path tests with paths not under the same root 49 | // directory as the GitIgnore we are testing 50 | _new, _ := directory(t) 51 | defer os.RemoveAll(_new) 52 | 53 | for _, _test := range _GITMATCHES { 54 | _path := filepath.Join(_new, _test.Local()) 55 | _match := _ignore.Match(_path) 56 | if _match != nil { 57 | t.Fatalf("unexpected match; expected nil, got %v", _match) 58 | } 59 | } 60 | 61 | // ensure Match() behaves as expected if the absolute path cannot 62 | // be determined 63 | // - we do this by choosing as our working directory a path 64 | // that this process does not have permission to 65 | _dir, _err = dir(nil) 66 | if _err != nil { 67 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 68 | } 69 | defer os.RemoveAll(_dir) 70 | 71 | _err = os.Chdir(_dir) 72 | if _err != nil { 73 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 74 | } 75 | defer os.Chdir(_cwd) 76 | 77 | // remove permission from the temporary directory 78 | _err = os.Chmod(_dir, 0) 79 | if _err != nil { 80 | t.Fatalf( 81 | "unable to modify temporary directory permissions: %s: %s", 82 | _dir, _err.Error(), 83 | ) 84 | } 85 | 86 | // now perform the match tests and ensure an error is returned 87 | for _, _test := range _GITMATCHES { 88 | _match := _ignore.Match(_test.Local()) 89 | if _match != nil { 90 | t.Fatalf("unexpected match; expected nil, got %v", _match) 91 | } 92 | } 93 | } // TestMatch() 94 | 95 | func TestIgnore(t *testing.T) { 96 | // we need to populate a directory with the match test files 97 | // - this is to permit GitIgnore.Ignore() to correctly resolve 98 | // absolute path names 99 | _dir, _ignore := directory(t) 100 | defer os.RemoveAll(_dir) 101 | 102 | // perform the path matching 103 | // - first we test absolute paths 104 | for _, _test := range _GITMATCHES { 105 | _path := filepath.Join(_dir, _test.Local()) 106 | _rtn := _ignore.Ignore(_path) 107 | if _rtn != _test.Ignore { 108 | t.Errorf( 109 | "ignore mismatch for %q; expected %v, got %v", 110 | _path, _test.Ignore, _rtn, 111 | ) 112 | } 113 | } 114 | 115 | // now, attempt relative path matching 116 | // - to do this, we need to change the working directory 117 | _cwd, _err := os.Getwd() 118 | if _err != nil { 119 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 120 | } 121 | _err = os.Chdir(_dir) 122 | if _err != nil { 123 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 124 | } 125 | defer os.Chdir(_cwd) 126 | 127 | // perform the relative path tests 128 | for _, _test := range _GITMATCHES { 129 | _rtn := _ignore.Ignore(_test.Local()) 130 | if _rtn != _test.Ignore { 131 | t.Errorf( 132 | "ignore mismatch for %q; expected %v, got %v", 133 | _test.Path, _test.Ignore, _rtn, 134 | ) 135 | } 136 | } 137 | 138 | // perform absolute path tests with paths not under the same root 139 | // directory as the GitIgnore we are testing 140 | _new, _ := directory(t) 141 | defer os.RemoveAll(_new) 142 | 143 | for _, _test := range _GITMATCHES { 144 | _path := filepath.Join(_new, _test.Local()) 145 | _ignore := _ignore.Ignore(_path) 146 | if _ignore { 147 | t.Fatalf("unexpected ignore for %q", _path) 148 | } 149 | } 150 | } // TestIgnore() 151 | 152 | func TestInclude(t *testing.T) { 153 | // we need to populate a directory with the match test files 154 | // - this is to permit GitIgnore.Include() to correctly resolve 155 | // absolute path names 156 | _dir, _ignore := directory(t) 157 | defer os.RemoveAll(_dir) 158 | 159 | // perform the path matching 160 | // - first we test absolute paths 161 | for _, _test := range _GITMATCHES { 162 | _path := filepath.Join(_dir, _test.Local()) 163 | _rtn := _ignore.Include(_path) 164 | if _rtn == _test.Ignore { 165 | t.Errorf( 166 | "include mismatch for %q; expected %v, got %v", 167 | _path, !_test.Ignore, _rtn, 168 | ) 169 | } 170 | } 171 | 172 | // now, attempt relative path matching 173 | // - to do this, we need to change the working directory 174 | _cwd, _err := os.Getwd() 175 | if _err != nil { 176 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 177 | } 178 | _err = os.Chdir(_dir) 179 | if _err != nil { 180 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 181 | } 182 | defer os.Chdir(_cwd) 183 | 184 | // perform the relative path tests 185 | for _, _test := range _GITMATCHES { 186 | _rtn := _ignore.Include(_test.Local()) 187 | if _rtn == _test.Ignore { 188 | t.Errorf( 189 | "include mismatch for %q; expected %v, got %v", 190 | _test.Path, !_test.Ignore, _rtn, 191 | ) 192 | } 193 | } 194 | 195 | // perform absolute path tests with paths not under the same root 196 | // directory as the GitIgnore we are testing 197 | _new, _ := directory(t) 198 | defer os.RemoveAll(_new) 199 | 200 | for _, _test := range _GITMATCHES { 201 | _path := filepath.Join(_new, _test.Local()) 202 | _include := _ignore.Include(_path) 203 | if !_include { 204 | t.Fatalf("unexpected include for %q", _path) 205 | } 206 | } 207 | } // TestInclude() 208 | 209 | func TestMatchAbsolute(t *testing.T) { 210 | // create a temporary .gitignore 211 | _buffer, _err := buffer(_GITMATCH) 212 | if _err != nil { 213 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 214 | } 215 | 216 | // ensure we can run New() 217 | // - ensure we encounter no errors 218 | _position := []gitignore.Position{} 219 | _error := func(e gitignore.Error) bool { 220 | _position = append(_position, e.Position()) 221 | return true 222 | } 223 | 224 | // ensure we have a non-nil GitIgnore instance 225 | _ignore := gitignore.New(_buffer, _GITBASE, _error) 226 | if _ignore == nil { 227 | t.Error("expected non-nil GitIgnore instance; nil found") 228 | } 229 | 230 | // ensure we encountered the right number of errors 231 | if len(_position) != _GITBADMATCHPATTERNS { 232 | t.Errorf( 233 | "match error mismatch; expected %d errors, got %d", 234 | _GITBADMATCHPATTERNS, len(_position), 235 | ) 236 | } 237 | 238 | // perform the absolute path matching 239 | _cb := func(path string, isdir bool) gitignore.Match { 240 | _path := filepath.Join(_GITBASE, path) 241 | return _ignore.Absolute(_path, isdir) 242 | } 243 | for _, _test := range _GITMATCHES { 244 | do(t, _cb, _test) 245 | } 246 | 247 | // perform absolute path tests with paths not under the same root 248 | // directory as the GitIgnore we are testing 249 | _new, _ := directory(t) 250 | defer os.RemoveAll(_new) 251 | 252 | for _, _test := range _GITMATCHES { 253 | _path := filepath.Join(_new, _test.Local()) 254 | _match := _ignore.Match(_path) 255 | if _match != nil { 256 | t.Fatalf("unexpected match; expected nil, got %v", _match) 257 | } 258 | } 259 | } // TestMatchAbsolute() 260 | 261 | func TestMatchRelative(t *testing.T) { 262 | // create a temporary .gitignore 263 | _buffer, _err := buffer(_GITMATCH) 264 | if _err != nil { 265 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 266 | } 267 | 268 | // ensure we can run New() 269 | // - ensure we encounter no errors 270 | _position := []gitignore.Position{} 271 | _error := func(e gitignore.Error) bool { 272 | _position = append(_position, e.Position()) 273 | return true 274 | } 275 | 276 | // ensure we have a non-nil GitIgnore instance 277 | _ignore := gitignore.New(_buffer, _GITBASE, _error) 278 | if _ignore == nil { 279 | t.Error("expected non-nil GitIgnore instance; nil found") 280 | } 281 | 282 | // ensure we encountered the right number of errors 283 | if len(_position) != _GITBADMATCHPATTERNS { 284 | t.Errorf( 285 | "match error mismatch; expected %d errors, got %d", 286 | _GITBADMATCHPATTERNS, len(_position), 287 | ) 288 | } 289 | 290 | // perform the relative path matching 291 | _cb := func(path string, isdir bool) gitignore.Match { 292 | return _ignore.Relative(path, isdir) 293 | } 294 | for _, _test := range _GITMATCHES { 295 | do(t, _cb, _test) 296 | } 297 | } // TestMatchRelative() 298 | 299 | func do(t *testing.T, cb func(string, bool) gitignore.Match, m match) { 300 | // attempt to match this path 301 | _match := cb(m.Local(), m.IsDir()) 302 | if _match == nil { 303 | // we have no match, is this expected? 304 | // - a test that matches will list the expected pattern 305 | if m.Pattern != "" { 306 | t.Errorf( 307 | "failed match; expected match for %q by %q", 308 | m.Path, m.Pattern, 309 | ) 310 | return 311 | } 312 | 313 | // since we have no match, ensure this path is not ignored 314 | if m.Ignore { 315 | t.Errorf( 316 | "failed ignore; no match for %q but expected to be ignored", 317 | m.Path, 318 | ) 319 | } 320 | } else { 321 | // we have a match, is this expected? 322 | // - a test that matches will list the expected pattern 323 | if m.Pattern == "" { 324 | t.Errorf( 325 | "unexpected match by %q; expected no match for %q", 326 | _match, m.Path, 327 | ) 328 | return 329 | } else if m.Pattern != _match.String() { 330 | t.Errorf( 331 | "mismatch for %q; expected match pattern %q, got %q", 332 | m.Path, m.Pattern, _match.String(), 333 | ) 334 | return 335 | } 336 | 337 | // since we have a match, are we expected to ignore this file? 338 | if m.Ignore != _match.Ignore() { 339 | t.Errorf( 340 | "ignore mismatch; expected %v for %q Ignore(), "+ 341 | "got %v from pattern %q", 342 | m.Ignore, m.Path, _match.Ignore(), _match, 343 | ) 344 | } 345 | } 346 | } // do() 347 | 348 | func directory(t *testing.T) (string, gitignore.GitIgnore) { 349 | // we need to populate a directory with the match test files 350 | // - this is to permit GitIgnore.Match() to correctly resolve 351 | // absolute path names 352 | // - populate the directory by passing a map of file names and their 353 | // contents 354 | // - the content is not important, it just can't be empty 355 | // - use this mechanism to also populate the .gitignore file 356 | _map := map[string]string{gitignore.File: _GITMATCH} 357 | for _, _test := range _GITMATCHES { 358 | _map[_test.Path] = " " // this is the file contents 359 | } 360 | 361 | // create the temporary directory 362 | _dir, _err := dir(_map) 363 | if _err != nil { 364 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 365 | } 366 | 367 | // ensure we can run New() 368 | // - ensure we encounter no errors 369 | _position := []gitignore.Position{} 370 | _error := func(e gitignore.Error) bool { 371 | _position = append(_position, e.Position()) 372 | return true 373 | } 374 | 375 | // ensure we have a non-nil GitIgnore instance 376 | _file := filepath.Join(_dir, gitignore.File) 377 | _ignore := gitignore.NewWithErrors(_file, _error) 378 | if _ignore == nil { 379 | t.Fatalf("expected non-nil GitIgnore instance; nil found") 380 | } 381 | 382 | // ensure we encountered the right number of errors 383 | if len(_position) != _GITBADMATCHPATTERNS { 384 | t.Errorf( 385 | "match error mismatch; expected %d errors, got %d", 386 | _GITBADMATCHPATTERNS, len(_position), 387 | ) 388 | } 389 | 390 | // return the directory name and the GitIgnore instance 391 | return _dir, _ignore 392 | } // directory() 393 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Parser is the interface for parsing .gitignore files and extracting the set 8 | // of patterns specified in the .gitignore file. 9 | type Parser interface { 10 | // Parse returns all well-formed .gitignore Patterns contained within the 11 | // parser stream. Parsing will terminate at the end of the stream, or if 12 | // the parser error handler returns false. 13 | Parse() []Pattern 14 | 15 | // Next returns the next well-formed .gitignore Pattern from the parser 16 | // stream. If an error is encountered, and the error handler is either 17 | // not defined, or returns true, Next will skip to the end of the current 18 | // line and attempt to parse the next Pattern. If the error handler 19 | // returns false, or the parser reaches the end of the stream, Next 20 | // returns nil. 21 | Next() Pattern 22 | 23 | // Position returns the current position of the parser in the input stream. 24 | Position() Position 25 | } // Parser{} 26 | 27 | // parser is the implementation of the .gitignore parser 28 | type parser struct { 29 | _lexer Lexer 30 | _undo []*Token 31 | _error func(Error) bool 32 | } // parser{} 33 | 34 | // NewParser returns a new Parser instance for the given stream r. 35 | // If err is not nil, it will be called for every error encountered during 36 | // parsing. Parsing will terminate at the end of the stream, or if err 37 | // returns false. 38 | func NewParser(r io.Reader, err func(Error) bool) Parser { 39 | return &parser{_lexer: NewLexer(r), _error: err} 40 | } // NewParser() 41 | 42 | // Parse returns all well-formed .gitignore Patterns contained within the 43 | // parser stream. Parsing will terminate at the end of the stream, or if 44 | // the parser error handler returns false. 45 | func (p *parser) Parse() []Pattern { 46 | // keep parsing until there's no more patterns 47 | _patterns := make([]Pattern, 0) 48 | for { 49 | _pattern := p.Next() 50 | if _pattern == nil { 51 | return _patterns 52 | } 53 | _patterns = append(_patterns, _pattern) 54 | } 55 | } // Parse() 56 | 57 | // Next returns the next well-formed .gitignore Pattern from the parser stream. 58 | // If an error is encountered, and the error handler is either not defined, or 59 | // returns true, Next will skip to the end of the current line and attempt to 60 | // parse the next Pattern. If the error handler returns false, or the parser 61 | // reaches the end of the stream, Next returns nil. 62 | func (p *parser) Next() Pattern { 63 | // keep searching until we find the next pattern, or until we 64 | // reach the end of the file 65 | for { 66 | _token, _err := p.next() 67 | if _err != nil { 68 | if !p.errors(_err) { 69 | return nil 70 | } 71 | 72 | // we got an error from the lexer, so skip the remainder 73 | // of this line and try again from the next line 74 | for _err != nil { 75 | _err = p.skip() 76 | if _err != nil { 77 | if !p.errors(_err) { 78 | return nil 79 | } 80 | } 81 | } 82 | continue 83 | } 84 | 85 | switch _token.Type { 86 | // we're at the end of the file 87 | case EOF: 88 | return nil 89 | 90 | // we have a blank line or comment 91 | case EOL: 92 | continue 93 | case COMMENT: 94 | continue 95 | 96 | // otherwise, attempt to build the next pattern 97 | default: 98 | _pattern, _err := p.build(_token) 99 | if _err != nil { 100 | if !p.errors(_err) { 101 | return nil 102 | } 103 | 104 | // we encountered an error parsing the retrieved tokens 105 | // - skip to the end of the line 106 | for _err != nil { 107 | _err = p.skip() 108 | if _err != nil { 109 | if !p.errors(_err) { 110 | return nil 111 | } 112 | } 113 | } 114 | 115 | // skip to the next token 116 | continue 117 | } else if _pattern != nil { 118 | return _pattern 119 | } 120 | } 121 | } 122 | } // Next() 123 | 124 | // Position returns the current position of the parser in the input stream. 125 | func (p *parser) Position() Position { 126 | // if we have any previously read tokens, then the token at 127 | // the end of the "undo" list (most recently "undone") gives the 128 | // position of the parser 129 | _length := len(p._undo) 130 | if _length != 0 { 131 | return p._undo[_length-1].Position 132 | } 133 | 134 | // otherwise, return the position of the lexer 135 | return p._lexer.Position() 136 | } // Position() 137 | 138 | // 139 | // private methods 140 | // 141 | 142 | // build attempts to build a well-formed .gitignore Pattern starting from the 143 | // given Token t. An Error will be returned if the sequence of tokens returned 144 | // by the Lexer does not represent a valid Pattern. 145 | func (p *parser) build(t *Token) (Pattern, Error) { 146 | // attempt to create a valid pattern 147 | switch t.Type { 148 | // we have a negated pattern 149 | case NEGATION: 150 | return p.negation(t) 151 | 152 | // attempt to build a path specification 153 | default: 154 | return p.path(t) 155 | } 156 | } // build() 157 | 158 | // negation attempts to build a well-formed negated .gitignore Pattern starting 159 | // from the negation Token t. As with build, negation returns an Error if the 160 | // sequence of tokens returned by the Lexer does not represent a valid Pattern. 161 | func (p *parser) negation(t *Token) (Pattern, Error) { 162 | // a negation appears before a path specification, so 163 | // skip the negation token 164 | _next, _err := p.next() 165 | if _err != nil { 166 | return nil, _err 167 | } 168 | 169 | // extract the sequence of tokens for this path 170 | _tokens, _err := p.sequence(_next) 171 | if _err != nil { 172 | return nil, _err 173 | } 174 | 175 | // include the "negation" token at the front of the sequence 176 | _tokens = append([]*Token{t}, _tokens...) 177 | 178 | // return the Pattern instance 179 | return NewPattern(_tokens), nil 180 | } // negation() 181 | 182 | // path attempts to build a well-formed .gitignore Pattern representing a path 183 | // specification, starting with the Token t. If the sequence of tokens returned 184 | // by the Lexer does not represent a valid Pattern, path returns an Error. 185 | // Trailing whitespace is dropped from the sequence of pattern tokens. 186 | func (p *parser) path(t *Token) (Pattern, Error) { 187 | // extract the sequence of tokens for this path 188 | _tokens, _err := p.sequence(t) 189 | if _err != nil { 190 | return nil, _err 191 | } 192 | 193 | // remove trailing whitespace tokens 194 | _length := len(_tokens) 195 | for _length > 0 { 196 | // if we have a non-whitespace token, we can stop 197 | _length-- 198 | if _tokens[_length].Type != WHITESPACE { 199 | break 200 | } 201 | 202 | // otherwise, truncate the token list 203 | _tokens = _tokens[:_length] 204 | } 205 | 206 | // return the Pattern instance 207 | return NewPattern(_tokens), nil 208 | } // path() 209 | 210 | // sequence attempts to extract a well-formed Token sequence from the Lexer 211 | // representing a .gitignore Pattern. sequence returns an Error if the 212 | // retrieved sequence of tokens does not represent a valid Pattern. 213 | func (p *parser) sequence(t *Token) ([]*Token, Error) { 214 | // extract the sequence of tokens for a valid path 215 | // - this excludes the negation token, which is handled as 216 | // a special case before sequence() is called 217 | switch t.Type { 218 | // the path starts with a separator 219 | case SEPARATOR: 220 | return p.separator(t) 221 | 222 | // the path starts with the "any" pattern ("**") 223 | case ANY: 224 | return p.any(t) 225 | 226 | // the path starts with whitespace, wildcard or a pattern 227 | case WHITESPACE: 228 | fallthrough 229 | case PATTERN: 230 | return p.pattern(t) 231 | } 232 | 233 | // otherwise, we have an invalid specification 234 | p.undo(t) 235 | return nil, p.err(InvalidPatternError) 236 | } // sequence() 237 | 238 | // separator attempts to retrieve a valid sequence of tokens that may appear 239 | // after the path separator '/' Token t. An Error is returned if the sequence if 240 | // tokens is not valid, or if there is an error extracting tokens from the 241 | // input stream. 242 | func (p *parser) separator(t *Token) ([]*Token, Error) { 243 | // build a list of tokens that may appear after a separator 244 | _tokens := []*Token{t} 245 | _token, _err := p.next() 246 | if _err != nil { 247 | return _tokens, _err 248 | } 249 | 250 | // what tokens are we allowed to have follow a separator? 251 | switch _token.Type { 252 | // a separator can be followed by a pattern or 253 | // an "any" pattern (i.e. "**") 254 | case ANY: 255 | _next, _err := p.any(_token) 256 | return append(_tokens, _next...), _err 257 | 258 | case WHITESPACE: 259 | fallthrough 260 | case PATTERN: 261 | _next, _err := p.pattern(_token) 262 | return append(_tokens, _next...), _err 263 | 264 | // if we encounter end of line or file we are done 265 | case EOL: 266 | fallthrough 267 | case EOF: 268 | return _tokens, nil 269 | 270 | // a separator can be followed by another separator 271 | // - it's not ideal, and not very useful, but it's interpreted 272 | // as a single separator 273 | // - we could clean it up here, but instead we pass 274 | // everything down to the matching later on 275 | case SEPARATOR: 276 | _next, _err := p.separator(_token) 277 | return append(_tokens, _next...), _err 278 | } 279 | 280 | // any other token is invalid 281 | p.undo(_token) 282 | return _tokens, p.err(InvalidPatternError) 283 | } // separator() 284 | 285 | // any attempts to retrieve a valid sequence of tokens that may appear 286 | // after the any '**' Token t. An Error is returned if the sequence if 287 | // tokens is not valid, or if there is an error extracting tokens from the 288 | // input stream. 289 | func (p *parser) any(t *Token) ([]*Token, Error) { 290 | // build the list of tokens that may appear after "any" (i.e. "**") 291 | _tokens := []*Token{t} 292 | _token, _err := p.next() 293 | if _err != nil { 294 | return _tokens, _err 295 | } 296 | 297 | // what tokens are we allowed to have follow an "any" symbol? 298 | switch _token.Type { 299 | // an "any" token may only be followed by a separator 300 | case SEPARATOR: 301 | _next, _err := p.separator(_token) 302 | return append(_tokens, _next...), _err 303 | 304 | // whitespace is acceptable if it takes us to the end of the line 305 | case WHITESPACE: 306 | return _tokens, p.eol() 307 | 308 | // if we encounter end of line or file we are done 309 | case EOL: 310 | fallthrough 311 | case EOF: 312 | return _tokens, nil 313 | } 314 | 315 | // any other token is invalid 316 | p.undo(_token) 317 | return _tokens, p.err(InvalidPatternError) 318 | } // any() 319 | 320 | // pattern attempts to retrieve a valid sequence of tokens that may appear 321 | // after the path pattern Token t. An Error is returned if the sequence if 322 | // tokens is not valid, or if there is an error extracting tokens from the 323 | // input stream. 324 | func (p *parser) pattern(t *Token) ([]*Token, Error) { 325 | // build the list of tokens that may appear after a pattern 326 | _tokens := []*Token{t} 327 | _token, _err := p.next() 328 | if _err != nil { 329 | return _tokens, _err 330 | } 331 | 332 | // what tokens are we allowed to have follow a pattern? 333 | var _next []*Token 334 | switch _token.Type { 335 | case SEPARATOR: 336 | _next, _err = p.separator(_token) 337 | return append(_tokens, _next...), _err 338 | 339 | case WHITESPACE: 340 | fallthrough 341 | case PATTERN: 342 | _next, _err = p.pattern(_token) 343 | return append(_tokens, _next...), _err 344 | 345 | // if we encounter end of line or file we are done 346 | case EOL: 347 | fallthrough 348 | case EOF: 349 | return _tokens, nil 350 | } 351 | 352 | // any other token is invalid 353 | p.undo(_token) 354 | return _tokens, p.err(InvalidPatternError) 355 | } // pattern() 356 | 357 | // eol attempts to consume the next Lexer token to read the end of line or end 358 | // of file. If a EOL or EOF is not reached , eol will return an error. 359 | func (p *parser) eol() Error { 360 | // are we at the end of the line? 361 | _token, _err := p.next() 362 | if _err != nil { 363 | return _err 364 | } 365 | 366 | // have we encountered whitespace only? 367 | switch _token.Type { 368 | // if we're at the end of the line or file, we're done 369 | case EOL: 370 | fallthrough 371 | case EOF: 372 | p.undo(_token) 373 | return nil 374 | } 375 | 376 | // otherwise, we have an invalid pattern 377 | p.undo(_token) 378 | return p.err(InvalidPatternError) 379 | } // eol() 380 | 381 | // next returns the next token from the Lexer, or an error if there is a 382 | // problem reading from the input stream. 383 | func (p *parser) next() (*Token, Error) { 384 | // do we have any previously read tokens? 385 | _length := len(p._undo) 386 | if _length > 0 { 387 | _token := p._undo[_length-1] 388 | p._undo = p._undo[:_length-1] 389 | return _token, nil 390 | } 391 | 392 | // otherwise, attempt to retrieve the next token from the lexer 393 | return p._lexer.Next() 394 | } // next() 395 | 396 | // skip reads Tokens from the input until the end of line or end of file is 397 | // reached. If there is a problem reading tokens, an Error is returned. 398 | func (p *parser) skip() Error { 399 | // skip to the next end of line or end of file token 400 | for { 401 | _token, _err := p.next() 402 | if _err != nil { 403 | return _err 404 | } 405 | 406 | // if we have an end of line or file token, then we can stop 407 | switch _token.Type { 408 | case EOL: 409 | fallthrough 410 | case EOF: 411 | return nil 412 | } 413 | } 414 | } // skip() 415 | 416 | // undo returns the given Token t to the parser input stream to be retrieved 417 | // again on a subsequent call to next. 418 | func (p *parser) undo(t *Token) { 419 | // add this token to the list of previously read tokens 420 | // - initialise the undo list if required 421 | if p._undo == nil { 422 | p._undo = make([]*Token, 0, 1) 423 | } 424 | p._undo = append(p._undo, t) 425 | } // undo() 426 | 427 | // err returns an Error for the error e, capturing the current parser Position. 428 | func (p *parser) err(e error) Error { 429 | // convert the error to include the parser position 430 | return NewError(e, p.Position()) 431 | } // err() 432 | 433 | // errors returns the response from the parser error handler to the Error e. If 434 | // no error handler has been configured for this parser, errors returns true. 435 | func (p *parser) errors(e Error) bool { 436 | // do we have an error handler? 437 | if p._error == nil { 438 | return true 439 | } 440 | 441 | // pass the error through to the error handler 442 | // - if this returns false, parsing will stop 443 | return p._error(e) 444 | } // errors() 445 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/denormal/go-gitignore" 8 | ) 9 | 10 | type parsetest struct { 11 | good int 12 | bad int 13 | position []gitignore.Position 14 | failures []gitignore.Error 15 | errors func(gitignore.Error) bool 16 | } // parsetest{} 17 | 18 | // TestParser tests the behaviour of gitignore.Parser 19 | func TestParser(t *testing.T) { 20 | _test := &parsetest{good: _GITPATTERNS, bad: _GITBADPATTERNS} 21 | _test.position = make([]gitignore.Position, 0) 22 | 23 | // record the position of encountered errors 24 | _test.errors = func(e gitignore.Error) bool { 25 | _test.position = append(_test.position, e.Position()) 26 | return true 27 | } 28 | 29 | // run this parser test 30 | parse(t, _test) 31 | } // TestParser() 32 | 33 | // TestParserError tests the behaviour of the gitignore.Parser with an error 34 | // handler that returns false on receiving an error 35 | func TestParserError(t *testing.T) { 36 | _test := &parsetest{good: _GITPATTERNSFALSE, bad: _GITBADPATTERNSFALSE} 37 | _test.position = make([]gitignore.Position, 0) 38 | 39 | // record the position of encountered errors 40 | // - return false to stop parsing 41 | _test.errors = func(e gitignore.Error) bool { 42 | _test.position = append(_test.position, e.Position()) 43 | return false 44 | } 45 | 46 | // run this parser test 47 | parse(t, _test) 48 | } // TestParserError() 49 | 50 | func TestParserInvalid(t *testing.T) { 51 | _test := &parsetest{good: _GITINVALIDPATTERNS, bad: _GITINVALIDERRORS} 52 | _test.position = make([]gitignore.Position, 0) 53 | 54 | // record the position of encountered errors 55 | _test.errors = func(e gitignore.Error) bool { 56 | _test.position = append(_test.position, e.Position()) 57 | _test.failures = append(_test.failures, e) 58 | return true 59 | } 60 | 61 | // run this parser test 62 | invalidparse(t, _test) 63 | } // TestParserInvalid() 64 | 65 | func TestParserInvalidFalse(t *testing.T) { 66 | _test := &parsetest{ 67 | good: _GITINVALIDPATTERNSFALSE, 68 | bad: _GITINVALIDERRORSFALSE, 69 | } 70 | _test.position = make([]gitignore.Position, 0) 71 | 72 | // record the position of encountered errors 73 | _test.errors = func(e gitignore.Error) bool { 74 | _test.position = append(_test.position, e.Position()) 75 | _test.failures = append(_test.failures, e) 76 | return false 77 | } 78 | 79 | // run this parser test 80 | invalidparse(t, _test) 81 | } // TestParserInvalidFalse() 82 | 83 | func parse(t *testing.T, test *parsetest) { 84 | // create a temporary .gitignore 85 | _buffer, _err := buffer(_GITIGNORE) 86 | if _err != nil { 87 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 88 | } 89 | 90 | // ensure we have a non-nil Parser instance 91 | _parser := gitignore.NewParser(_buffer, test.errors) 92 | if _parser == nil { 93 | t.Error("expected non-nil Parser instance; nil found") 94 | } 95 | 96 | // before we parse, what position do we have? 97 | _position := _parser.Position() 98 | if !coincident(_position, _BEGINNING) { 99 | t.Errorf( 100 | "beginning position mismatch; expected %s, got %s", 101 | pos(_BEGINNING), pos(_position), 102 | ) 103 | } 104 | 105 | // attempt to parse the .gitignore 106 | _patterns := _parser.Parse() 107 | 108 | // ensure we encountered the expected bad patterns 109 | if len(test.position) != test.bad { 110 | t.Errorf( 111 | "parse error mismatch; expected %d errors, got %d", 112 | test.bad, len(test.position), 113 | ) 114 | } else { 115 | // ensure the bad pattern positions are correct 116 | for _i := 0; _i < test.bad; _i++ { 117 | _got := test.position[_i] 118 | _expected := _GITBADPOSITION[_i] 119 | 120 | if !coincident(_got, _expected) { 121 | t.Errorf( 122 | "bad pattern position mismatch; expected %q, got %q", 123 | pos(_expected), pos(_got), 124 | ) 125 | } 126 | } 127 | } 128 | 129 | // ensure we encountered the right number of good patterns 130 | if len(_patterns) != test.good { 131 | t.Errorf( 132 | "parse pattern mismatch; expected %d patterns, got %d", 133 | test.good, len(_patterns), 134 | ) 135 | } else { 136 | // ensure the good pattern positions are correct 137 | for _i := 0; _i < len(_patterns); _i++ { 138 | _got := _patterns[_i].Position() 139 | _expected := _GITPOSITION[_i] 140 | 141 | if !coincident(_got, _expected) { 142 | t.Errorf( 143 | "pattern position mismatch; expected %q, got %q", 144 | pos(_expected), pos(_got), 145 | ) 146 | } 147 | } 148 | 149 | // ensure the retrieved patterns are correct 150 | // - we check the string form of the pattern against the respective 151 | // lines from the .gitignore 152 | // - we must special-case patterns that end in whitespace that 153 | // can be ignored (i.e. it's not escaped) 154 | _lines := strings.Split(_GITIGNORE, "\n") 155 | for _i := 0; _i < len(_patterns); _i++ { 156 | _pattern := _patterns[_i] 157 | _got := _pattern.String() 158 | _line := _pattern.Position().Line 159 | _expected := _lines[_line-1] 160 | 161 | if _got != _expected { 162 | // if the two strings aren't the same, then check to see if 163 | // the difference is trailing whitespace 164 | // - the expected string may have whitespace, while the 165 | // pattern string does not 166 | // - patterns have their trailing whitespace removed, so 167 | // - we perform this check here, since it's possible for 168 | // a pattern to end in a whitespace character (e.g. '\ ') 169 | // and we don't want to be too heavy handed with our 170 | // removal of whitespace 171 | // - only do this check for non-comments 172 | if !strings.HasPrefix(_expected, "#") { 173 | _new := strings.TrimRight(_expected, " \t") 174 | if _new == _got { 175 | continue 176 | } 177 | } 178 | t.Errorf( 179 | "pattern mismatch; expected %q, got %q at %s", 180 | _expected, _got, pos(_pattern.Position()), 181 | ) 182 | } 183 | } 184 | } 185 | } // parse() 186 | 187 | func invalidparse(t *testing.T, test *parsetest) { 188 | _buffer, _err := buffer(_GITINVALID) 189 | if _err != nil { 190 | t.Fatalf("unable to create temporary .gitignore: %s", _err.Error()) 191 | } 192 | 193 | // create the parser instance 194 | _parser := gitignore.NewParser(_buffer, test.errors) 195 | if _parser == nil { 196 | t.Error("expected non-nil Parser instance; nil found") 197 | } 198 | 199 | // attempt to parse the .gitignore 200 | _patterns := _parser.Parse() 201 | 202 | // ensure we have the correct number of errors encountered 203 | if len(test.failures) != test.bad { 204 | t.Fatalf( 205 | "unexpected invalid parse errors; expected %d, got %d", 206 | test.bad, len(test.failures), 207 | ) 208 | } else { 209 | for _i := 0; _i < test.bad; _i++ { 210 | _expected := _GITINVALIDERROR[_i] 211 | _got := test.failures[_i] 212 | 213 | // is this error the same as expected? 214 | // if !_got.Is(_expected) { 215 | if _got.Underlying() != _expected { 216 | t.Fatalf( 217 | "unexpected invalid parse error; expected %q, got %q", 218 | _expected.Error(), _got.Error(), 219 | ) 220 | } 221 | } 222 | } 223 | 224 | // ensure we have the correct number of patterns 225 | if len(_patterns) != test.good { 226 | t.Fatalf( 227 | "unexpected invalid parse patterns; expected %d, got %d", 228 | test.good, len(_patterns), 229 | ) 230 | } else { 231 | for _i := 0; _i < test.good; _i++ { 232 | _expected := _GITINVALIDPATTERN[_i] 233 | _got := _patterns[_i] 234 | 235 | // is this pattern the same as expected? 236 | if _got.String() != _expected { 237 | t.Fatalf( 238 | "unexpected invalid parse pattern; expected %q, got %q", 239 | _expected, _got, 240 | ) 241 | } 242 | } 243 | } 244 | } // invalidparse() 245 | -------------------------------------------------------------------------------- /pattern.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/danwakefield/fnmatch" 8 | ) 9 | 10 | // Pattern represents per-line patterns within a .gitignore file 11 | type Pattern interface { 12 | Match 13 | 14 | // Match returns true if the given path matches the name pattern. If the 15 | // pattern is meant for directories only, and the path is not a directory, 16 | // Match will return false. The matching is performed by fnmatch(). It 17 | // is assumed path is relative to the base path of the owning GitIgnore. 18 | Match(string, bool) bool 19 | } 20 | 21 | // pattern is the base implementation of a .gitignore pattern 22 | type pattern struct { 23 | _negated bool 24 | _anchored bool 25 | _directory bool 26 | _string string 27 | _fnmatch string 28 | _position Position 29 | } // pattern() 30 | 31 | // name represents patterns matching a file or path name (i.e. the last 32 | // component of a path) 33 | type name struct { 34 | pattern 35 | } // name{} 36 | 37 | // path represents a pattern that contains at least one path separator within 38 | // the pattern (i.e. not at the start or end of the pattern) 39 | type path struct { 40 | pattern 41 | _depth int 42 | } // path{} 43 | 44 | // any represents a pattern that contains at least one "any" token "**" 45 | // allowing for recursive matching. 46 | type any struct { 47 | pattern 48 | _tokens []*Token 49 | } // any{} 50 | 51 | // NewPattern returns a Pattern from the ordered slice of Tokens. The tokens are 52 | // assumed to represent a well-formed .gitignore pattern. A Pattern may be 53 | // negated, anchored to the start of the path (relative to the base directory 54 | // of tie containing .gitignore), or match directories only. 55 | func NewPattern(tokens []*Token) Pattern { 56 | // if we have no tokens there is no pattern 57 | if len(tokens) == 0 { 58 | return nil 59 | } 60 | 61 | // extract the pattern position from first token 62 | _position := tokens[0].Position 63 | _string := tokenset(tokens).String() 64 | 65 | // is this a negated pattern? 66 | _negated := false 67 | if tokens[0].Type == NEGATION { 68 | _negated = true 69 | tokens = tokens[1:] 70 | } 71 | 72 | // is this pattern anchored to the start of the path? 73 | _anchored := false 74 | if tokens[0].Type == SEPARATOR { 75 | _anchored = true 76 | tokens = tokens[1:] 77 | } 78 | 79 | // is this pattern for directories only? 80 | _directory := false 81 | _last := len(tokens) - 1 82 | if tokens[_last].Type == SEPARATOR { 83 | _directory = true 84 | tokens = tokens[:_last] 85 | } 86 | 87 | // build the pattern expression 88 | _fnmatch := tokenset(tokens).String() 89 | _pattern := &pattern{ 90 | _negated: _negated, 91 | _anchored: _anchored, 92 | _position: _position, 93 | _directory: _directory, 94 | _string: _string, 95 | _fnmatch: _fnmatch, 96 | } 97 | return _pattern.compile(tokens) 98 | } // NewPattern() 99 | 100 | // compile generates a specific Pattern (i.e. name, path or any) 101 | // represented by the list of tokens. 102 | func (p *pattern) compile(tokens []*Token) Pattern { 103 | // what tokens do we have in this pattern? 104 | // - ANY token means we can match to any depth 105 | // - SEPARATOR means we have path rather than file matching 106 | _separator := false 107 | for _, _token := range tokens { 108 | switch _token.Type { 109 | case ANY: 110 | return p.any(tokens) 111 | case SEPARATOR: 112 | _separator = true 113 | } 114 | } 115 | 116 | // should we perform path or name/file matching? 117 | if _separator { 118 | return p.path(tokens) 119 | } else { 120 | return p.name(tokens) 121 | } 122 | } // compile() 123 | 124 | // Ignore returns true if the pattern describes files or paths that should be 125 | // ignored. 126 | func (p *pattern) Ignore() bool { return !p._negated } 127 | 128 | // Include returns true if the pattern describes files or paths that should be 129 | // included (i.e. not ignored) 130 | func (p *pattern) Include() bool { return p._negated } 131 | 132 | // Position returns the position of the first token of this pattern. 133 | func (p *pattern) Position() Position { return p._position } 134 | 135 | // String returns the string representation of the pattern. 136 | func (p *pattern) String() string { return p._string } 137 | 138 | // 139 | // name patterns 140 | // - designed to match trailing file/directory names only 141 | // 142 | 143 | // name returns a Pattern designed to match file or directory names, with no 144 | // path elements. 145 | func (p *pattern) name(tokens []*Token) Pattern { 146 | return &name{*p} 147 | } // name() 148 | 149 | // Match returns true if the given path matches the name pattern. If the 150 | // pattern is meant for directories only, and the path is not a directory, 151 | // Match will return false. The matching is performed by fnmatch(). It 152 | // is assumed path is relative to the base path of the owning GitIgnore. 153 | func (n *name) Match(path string, isdir bool) bool { 154 | // are we expecting a directory? 155 | if n._directory && !isdir { 156 | return false 157 | } 158 | 159 | // should we match the whole path, or just the last component? 160 | if n._anchored { 161 | return fnmatch.Match(n._fnmatch, path, 0) 162 | } else { 163 | _, _base := filepath.Split(path) 164 | return fnmatch.Match(n._fnmatch, _base, 0) 165 | } 166 | } // Match() 167 | 168 | // 169 | // path patterns 170 | // - designed to match complete or partial paths (not just filenames) 171 | // 172 | 173 | // path returns a Pattern designed to match paths that include at least one 174 | // path separator '/' neither at the end nor the start of the pattern. 175 | func (p *pattern) path(tokens []*Token) Pattern { 176 | // how many directory components are we expecting? 177 | _depth := 0 178 | for _, _token := range tokens { 179 | if _token.Type == SEPARATOR { 180 | _depth++ 181 | } 182 | } 183 | 184 | // return the pattern instance 185 | return &path{pattern: *p, _depth: _depth} 186 | } // path() 187 | 188 | // Match returns true if the given path matches the path pattern. If the 189 | // pattern is meant for directories only, and the path is not a directory, 190 | // Match will return false. The matching is performed by fnmatch() 191 | // with flags set to FNM_PATHNAME. It is assumed path is relative to the 192 | // base path of the owning GitIgnore. 193 | func (p *path) Match(path string, isdir bool) bool { 194 | // are we expecting a directory 195 | if p._directory && !isdir { 196 | return false 197 | } 198 | 199 | if fnmatch.Match(p._fnmatch, path, fnmatch.FNM_PATHNAME) { 200 | return true 201 | } else if p._anchored { 202 | return false 203 | } 204 | 205 | // match against the trailing path elements 206 | return fnmatch.Match(p._fnmatch, path, fnmatch.FNM_PATHNAME) 207 | } // Match() 208 | 209 | // 210 | // "any" patterns 211 | // 212 | 213 | // any returns a Pattern designed to match paths that include at least one 214 | // any pattern '**', specifying recursive matching. 215 | func (p *pattern) any(tokens []*Token) Pattern { 216 | // consider only the non-SEPARATOR tokens, as these will be matched 217 | // against the path components 218 | _tokens := make([]*Token, 0) 219 | for _, _token := range tokens { 220 | if _token.Type != SEPARATOR { 221 | _tokens = append(_tokens, _token) 222 | } 223 | } 224 | 225 | return &any{*p, _tokens} 226 | } // any() 227 | 228 | // Match returns true if the given path matches the any pattern. If the 229 | // pattern is meant for directories only, and the path is not a directory, 230 | // Match will return false. The matching is performed by recursively applying 231 | // fnmatch() with flags set to FNM_PATHNAME. It is assumed path is relative to 232 | // the base path of the owning GitIgnore. 233 | func (a *any) Match(path string, isdir bool) bool { 234 | // are we expecting a directory? 235 | if a._directory && !isdir { 236 | return false 237 | } 238 | 239 | // split the path into components 240 | _parts := strings.Split(path, string(_SEPARATOR)) 241 | 242 | // attempt to match the parts against the pattern tokens 243 | return a.match(_parts, a._tokens) 244 | } // Match() 245 | 246 | // match performs the recursive matching for 'any' patterns. An 'any' 247 | // token '**' may match any path component, or no path component. 248 | func (a *any) match(path []string, tokens []*Token) bool { 249 | // if we have no more tokens, then we have matched this path 250 | // if there are also no more path elements, otherwise there's no match 251 | if len(tokens) == 0 { 252 | return len(path) == 0 253 | } 254 | 255 | // what token are we trying to match? 256 | _token := tokens[0] 257 | switch _token.Type { 258 | case ANY: 259 | if len(path) == 0 { 260 | return a.match(path, tokens[1:]) 261 | } else { 262 | return a.match(path, tokens[1:]) || a.match(path[1:], tokens) 263 | } 264 | 265 | default: 266 | // if we have a non-ANY token, then we must have a non-empty path 267 | if len(path) != 0 { 268 | // if the current path element matches this token, 269 | // we match if the remainder of the path matches the 270 | // remaining tokens 271 | if fnmatch.Match(_token.Token(), path[0], fnmatch.FNM_PATHNAME) { 272 | return a.match(path[1:], tokens[1:]) 273 | } 274 | } 275 | } 276 | 277 | // if we are here, then we have no match 278 | return false 279 | } // match() 280 | 281 | // ensure the patterns confirm to the Pattern interface 282 | var _ Pattern = &name{} 283 | var _ Pattern = &path{} 284 | var _ Pattern = &any{} 285 | -------------------------------------------------------------------------------- /position.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Position represents the position of the .gitignore parser, and the position 8 | // of a .gitignore pattern within the parsed stream. 9 | type Position struct { 10 | File string 11 | Line int 12 | Column int 13 | Offset int 14 | } 15 | 16 | // String returns a string representation of the current position. 17 | func (p Position) String() string { 18 | _prefix := "" 19 | if p.File != "" { 20 | _prefix = p.File + ": " 21 | } 22 | 23 | if p.Line == 0 { 24 | return fmt.Sprintf("%s+%d", _prefix, p.Offset) 25 | } else if p.Column == 0 { 26 | return fmt.Sprintf("%s%d", _prefix, p.Line) 27 | } else { 28 | return fmt.Sprintf("%s%d:%d", _prefix, p.Line, p.Column) 29 | } 30 | } // String() 31 | 32 | // Zero returns true if the Position represents the zero Position 33 | func (p Position) Zero() bool { 34 | return p.Line+p.Column+p.Offset == 0 35 | } // Zero() 36 | -------------------------------------------------------------------------------- /position_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "github.com/denormal/go-gitignore" 5 | "testing" 6 | ) 7 | 8 | func TestPosition(t *testing.T) { 9 | // test the conversion of Positions to strings 10 | for _, _p := range _POSITIONS { 11 | _position := gitignore.Position{ 12 | File: _p.File, 13 | Line: _p.Line, 14 | Column: _p.Column, 15 | Offset: _p.Offset, 16 | } 17 | 18 | // ensure the string representation of the Position is as expected 19 | _rtn := _position.String() 20 | if _rtn != _p.String { 21 | t.Errorf( 22 | "position mismatch; expected %q, got %q", 23 | _p.String, _rtn, 24 | ) 25 | } 26 | } 27 | } // TestPosition() 28 | -------------------------------------------------------------------------------- /repository.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | const File = ".gitignore" 10 | 11 | // repository is the implementation of the set of .gitignore files within a 12 | // repository hierarchy 13 | type repository struct { 14 | ignore 15 | _errors func(e Error) bool 16 | _cache Cache 17 | _file string 18 | _exclude GitIgnore 19 | } // repository{} 20 | 21 | // NewRepository returns a GitIgnore instance representing a git repository 22 | // with root directory base. If base is not a directory, or base cannot be 23 | // read, NewRepository will return an error. 24 | // 25 | // Internally, NewRepository uses NewRepositoryWithFile. 26 | func NewRepository(base string) (GitIgnore, error) { 27 | return NewRepositoryWithFile(base, File) 28 | } // NewRepository() 29 | 30 | // NewRepositoryWithFile returns a GitIgnore instance representing a git 31 | // repository with root directory base. The repository will use file as 32 | // the name of the files within the repository from which to load the 33 | // .gitignore patterns. If file is the empty string, NewRepositoryWithFile 34 | // uses ".gitignore". If the ignore file name is ".gitignore", the returned 35 | // GitIgnore instance will also consider patterns listed in 36 | // $GIT_DIR/info/exclude when performing repository matching. 37 | // 38 | // Internally, NewRepositoryWithFile uses NewRepositoryWithErrors. 39 | func NewRepositoryWithFile(base, file string) (GitIgnore, error) { 40 | // define an error handler to catch any file access errors 41 | // - record the first encountered error 42 | var _error Error 43 | _errors := func(e Error) bool { 44 | if _error == nil { 45 | _error = e 46 | } 47 | return true 48 | } 49 | 50 | // attempt to retrieve the repository represented by this file 51 | _repository := NewRepositoryWithErrors(base, file, _errors) 52 | 53 | // did we encounter an error? 54 | // - if the error has a zero Position then it was encountered 55 | // before parsing was attempted, so we return that error 56 | if _error != nil { 57 | if _error.Position().Zero() { 58 | return nil, _error.Underlying() 59 | } 60 | } 61 | 62 | // otherwise, we ignore the parser errors 63 | return _repository, nil 64 | } // NewRepositoryWithFile() 65 | 66 | // NewRepositoryWithErrors returns a GitIgnore instance representing a git 67 | // repository with a root directory base. As with NewRepositoryWithFile, file 68 | // specifies the name of the files within the repository containing the 69 | // .gitignore patterns, and defaults to ".gitignore" if file is not specified. 70 | // If the ignore file name is ".gitignore", the returned GitIgnore instance 71 | // will also consider patterns listed in $GIT_DIR/info/exclude when performing 72 | // repository matching. 73 | // 74 | // If errors is given, it will be invoked for each error encountered while 75 | // matching a path against the repository GitIgnore (such as file permission 76 | // denied, or errors during .gitignore parsing). See Match below. 77 | // 78 | // Internally, NewRepositoryWithErrors uses NewRepositoryWithCache. 79 | func NewRepositoryWithErrors(base, file string, errors func(e Error) bool) GitIgnore { 80 | return NewRepositoryWithCache(base, file, NewCache(), errors) 81 | } // NewRepositoryWithErrors() 82 | 83 | // NewRepositoryWithCache returns a GitIgnore instance representing a git 84 | // repository with a root directory base. As with NewRepositoryWithErrors, 85 | // file specifies the name of the files within the repository containing the 86 | // .gitignore patterns, and defaults to ".gitignore" if file is not specified. 87 | // If the ignore file name is ".gitignore", the returned GitIgnore instance 88 | // will also consider patterns listed in $GIT_DIR/info/exclude when performing 89 | // repository matching. 90 | // 91 | // NewRepositoryWithCache will attempt to load each .gitignore within the 92 | // repository only once, using NewWithCache to store the corresponding 93 | // GitIgnore instance in cache. If cache is given as nil, 94 | // NewRepositoryWithCache will create a Cache instance for this repository. 95 | // 96 | // If errors is given, it will be invoked for each error encountered while 97 | // matching a path against the repository GitIgnore (such as file permission 98 | // denied, or errors during .gitignore parsing). See Match below. 99 | func NewRepositoryWithCache(base, file string, cache Cache, errors func(e Error) bool) GitIgnore { 100 | // do we have an error handler? 101 | _errors := errors 102 | if _errors == nil { 103 | _errors = func(e Error) bool { return true } 104 | } 105 | 106 | // extract the absolute path of the base directory 107 | _base, _err := filepath.Abs(base) 108 | if _err != nil { 109 | _errors(NewError(_err, Position{})) 110 | return nil 111 | } 112 | 113 | // ensure the given base is a directory 114 | _info, _err := os.Stat(_base) 115 | if _info != nil { 116 | if !_info.IsDir() { 117 | _err = InvalidDirectoryError 118 | } 119 | } 120 | if _err != nil { 121 | _errors(NewError(_err, Position{})) 122 | return nil 123 | } 124 | 125 | // if we haven't been given a base file name, use the default 126 | if file == "" { 127 | file = File 128 | } 129 | 130 | // are we matching .gitignore files? 131 | // - if we are, we also consider $GIT_DIR/info/exclude 132 | var _exclude GitIgnore 133 | if file == File { 134 | _exclude, _err = exclude(_base) 135 | if _err != nil { 136 | _errors(NewError(_err, Position{})) 137 | return nil 138 | } 139 | } 140 | 141 | // create the repository instance 142 | _ignore := ignore{_base: _base} 143 | _repository := &repository{ 144 | ignore: _ignore, 145 | _errors: _errors, 146 | _exclude: _exclude, 147 | _cache: cache, 148 | _file: file, 149 | } 150 | 151 | return _repository 152 | } // NewRepositoryWithCache() 153 | 154 | // Match attempts to match the path against this repository. Matching proceeds 155 | // according to normal gitignore rules, where .gtignore files in the same 156 | // directory as path, take precedence over .gitignore files higher up the 157 | // path hierarchy, and child files and directories are ignored if the parent 158 | // is ignored. If the path is matched by a gitignore pattern in the repository, 159 | // a Match is returned detailing the matched pattern. The returned Match 160 | // can be used to determine if the path should be ignored or included according 161 | // to the repository. 162 | // 163 | // If an error is encountered during matching, the repository error handler 164 | // (if configured via NewRepositoryWithErrors or NewRepositoryWithCache), will 165 | // be called. If the error handler returns false, matching will terminate and 166 | // Match will return nil. If handler returns true, Match will continue 167 | // processing in an attempt to match path. 168 | // 169 | // Match will raise an error and return nil if the absolute path cannot be 170 | // determined, or if its not possible to determine if path represents a file 171 | // or a directory. 172 | // 173 | // If path is not located under the root of this repository, Match returns nil. 174 | func (r *repository) Match(path string) Match { 175 | // ensure we have the absolute path for the given file 176 | _path, _err := filepath.Abs(path) 177 | if _err != nil { 178 | r._errors(NewError(_err, Position{})) 179 | return nil 180 | } 181 | 182 | // is the path a file or a directory? 183 | _info, _err := os.Stat(_path) 184 | if _err != nil { 185 | r._errors(NewError(_err, Position{})) 186 | return nil 187 | } 188 | _isdir := _info.IsDir() 189 | 190 | // attempt to match the absolute path 191 | return r.Absolute(_path, _isdir) 192 | } // Match() 193 | 194 | // Absolute attempts to match an absolute path against this repository. If the 195 | // path is not located under the base directory of this repository, or is not 196 | // matched by this repository, nil is returned. 197 | func (r *repository) Absolute(path string, isdir bool) Match { 198 | // does the file share the same directory as this ignore file? 199 | if !strings.HasPrefix(path, r.Base()) { 200 | return nil 201 | } 202 | 203 | // extract the relative path of this file 204 | _prefix := len(r.Base()) + 1 205 | _rel := string(path[_prefix:]) 206 | return r.Relative(_rel, isdir) 207 | } // Absolute() 208 | 209 | // Relative attempts to match a path relative to the repository base directory. 210 | // If the path is not matched by the repository, nil is returned. 211 | func (r *repository) Relative(path string, isdir bool) Match { 212 | // if there's no path, then there's nothing to match 213 | _path := filepath.Clean(path) 214 | if _path == "." { 215 | return nil 216 | } 217 | 218 | // repository matching: 219 | // - a child path cannot be considered if its parent is ignored 220 | // - a .gitignore in a lower directory overrides a .gitignore in a 221 | // higher directory 222 | 223 | // first, is the parent directory ignored? 224 | // - extract the parent directory from the current path 225 | _parent, _local := filepath.Split(_path) 226 | _match := r.Relative(_parent, true) 227 | if _match != nil { 228 | if _match.Ignore() { 229 | return _match 230 | } 231 | } 232 | _parent = filepath.Clean(_parent) 233 | 234 | // the parent directory isn't ignored, so we now look at the original path 235 | // - we consider .gitignore files in the current directory first, then 236 | // move up the path hierarchy 237 | var _last string 238 | for { 239 | _file := filepath.Join(r._base, _parent, r._file) 240 | _ignore := NewWithCache(_file, r._cache, r._errors) 241 | if _ignore != nil { 242 | _match := _ignore.Relative(_local, isdir) 243 | if _match != nil { 244 | return _match 245 | } 246 | } 247 | 248 | // if there's no parent, then we're done 249 | // - since we use filepath.Clean() we look for "." 250 | if _parent == "." { 251 | break 252 | } 253 | 254 | // we don't have a match for this file, so we progress up the 255 | // path hierarchy 256 | // - we are manually building _local using the .gitignore 257 | // separator "/", which is how we handle operating system 258 | // file system differences 259 | _parent, _last = filepath.Split(_parent) 260 | _parent = filepath.Clean(_parent) 261 | _local = _last + string(_SEPARATOR) + _local 262 | } 263 | 264 | // do we have a global exclude file? (i.e. GIT_DIR/info/exclude) 265 | if r._exclude != nil { 266 | return r._exclude.Relative(path, isdir) 267 | } 268 | 269 | // we have no match 270 | return nil 271 | } // Relative() 272 | 273 | // ensure repository satisfies the GitIgnore interface 274 | var _ GitIgnore = &repository{} 275 | -------------------------------------------------------------------------------- /repository_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/denormal/go-gitignore" 9 | ) 10 | 11 | type repositorytest struct { 12 | file string 13 | directory string 14 | cache gitignore.Cache 15 | cached bool 16 | error func(e gitignore.Error) bool 17 | errors []gitignore.Error 18 | bad int 19 | instance func(string) (gitignore.GitIgnore, error) 20 | exclude string 21 | gitdir string 22 | } // repostorytest{} 23 | 24 | func (r *repositorytest) create(path string, gitdir bool) (gitignore.GitIgnore, error) { 25 | // if we have an error handler, reset the list of errors 26 | if r.error != nil { 27 | r.errors = make([]gitignore.Error, 0) 28 | } 29 | 30 | if r.file == gitignore.File || r.file == "" { 31 | // should we create the global exclude file 32 | r.gitdir = os.Getenv("GIT_DIR") 33 | if gitdir { 34 | // create a temporary file for the global exclude file 35 | _exclude, _err := exclude(_GITEXCLUDE) 36 | if _err != nil { 37 | return nil, _err 38 | } 39 | 40 | // extract the current value of the GIT_DIR environment variable 41 | // and set the value to be that of the temporary file 42 | r.exclude = _exclude 43 | _err = os.Setenv("GIT_DIR", r.exclude) 44 | if _err != nil { 45 | return nil, _err 46 | } 47 | } else { 48 | _err := os.Unsetenv("GIT_DIR") 49 | if _err != nil { 50 | return nil, _err 51 | } 52 | } 53 | } 54 | 55 | // attempt to create the GitIgnore instance 56 | _repository, _err := r.instance(path) 57 | 58 | // if we encountered errors, and the first error has a zero position 59 | // then it represents a file access error 60 | // - extract the error and return it 61 | // - remove it from the list of errors 62 | if len(r.errors) > 0 { 63 | if r.errors[0].Position().Zero() { 64 | _err = r.errors[0].Underlying() 65 | r.errors = r.errors[1:] 66 | } 67 | } 68 | 69 | // return the GitIgnore instance 70 | return _repository, _err 71 | } // create() 72 | 73 | func (r *repositorytest) destroy() { 74 | // remove the temporary files and directories 75 | for _, _path := range []string{r.directory, r.exclude} { 76 | if _path != "" { 77 | defer os.RemoveAll(_path) 78 | } 79 | } 80 | 81 | if r.file == gitignore.File || r.file == "" { 82 | // reset the GIT_DIR environment variable 83 | if r.gitdir == "" { 84 | defer os.Unsetenv("GIT_DIR") 85 | } else { 86 | defer os.Setenv("GIT_DIR", r.gitdir) 87 | } 88 | } 89 | } // destroy() 90 | 91 | type invalidtest struct { 92 | *repositorytest 93 | tag string 94 | match func() gitignore.Match 95 | } // invalidtest{} 96 | 97 | func TestRepository(t *testing.T) { 98 | _test := &repositorytest{} 99 | _test.bad = _GITREPOSITORYERRORS 100 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 101 | return gitignore.NewRepository(path) 102 | } 103 | 104 | // perform the repository tests 105 | repository(t, _test, _REPOSITORYMATCHES) 106 | 107 | // remove the temporary directory used for this test 108 | defer _test.destroy() 109 | } // TestRepository() 110 | 111 | func TestRepositoryWithFile(t *testing.T) { 112 | _test := &repositorytest{} 113 | _test.bad = _GITREPOSITORYERRORS 114 | _test.file = gitignore.File + "-with-file" 115 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 116 | return gitignore.NewRepositoryWithFile(path, _test.file) 117 | } 118 | 119 | // perform the repository tests 120 | repository(t, _test, _REPOSITORYMATCHES) 121 | 122 | // remove the temporary directory used for this test 123 | defer _test.destroy() 124 | } // TestRepositoryWithFile() 125 | 126 | func TestRepositoryWithErrors(t *testing.T) { 127 | _test := &repositorytest{} 128 | _test.bad = _GITREPOSITORYERRORS 129 | _test.file = gitignore.File + "-with-errors" 130 | _test.error = func(e gitignore.Error) bool { 131 | _test.errors = append(_test.errors, e) 132 | return true 133 | } 134 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 135 | return gitignore.NewRepositoryWithErrors( 136 | path, _test.file, _test.error, 137 | ), nil 138 | } 139 | 140 | // perform the repository tests 141 | repository(t, _test, _REPOSITORYMATCHES) 142 | 143 | // remove the temporary directory used for this test 144 | defer _test.destroy() 145 | } // TestRepositoryWithErrors() 146 | 147 | func TestRepositoryWithErrorsFalse(t *testing.T) { 148 | _test := &repositorytest{} 149 | _test.bad = _GITREPOSITORYERRORSFALSE 150 | _test.file = gitignore.File + "-with-errors-false" 151 | _test.error = func(e gitignore.Error) bool { 152 | _test.errors = append(_test.errors, e) 153 | return false 154 | } 155 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 156 | return gitignore.NewRepositoryWithErrors( 157 | path, _test.file, _test.error, 158 | ), nil 159 | } 160 | 161 | // perform the repository tests 162 | repository(t, _test, _REPOSITORYMATCHESFALSE) 163 | 164 | // remove the temporary directory used for this test 165 | defer _test.destroy() 166 | } // TestRepositoryWithErrorsFalse() 167 | 168 | func TestRepositoryWithCache(t *testing.T) { 169 | _test := &repositorytest{} 170 | _test.bad = _GITREPOSITORYERRORS 171 | _test.cache = gitignore.NewCache() 172 | _test.cached = true 173 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 174 | return gitignore.NewRepositoryWithCache( 175 | path, _test.file, _test.cache, _test.error, 176 | ), nil 177 | } 178 | 179 | // perform the repository tests 180 | repository(t, _test, _REPOSITORYMATCHES) 181 | 182 | // clean up 183 | defer _test.destroy() 184 | 185 | // rerun the tests while accumulating errors 186 | _test.directory = "" 187 | _test.file = gitignore.File + "-with-cache" 188 | _test.error = func(e gitignore.Error) bool { 189 | _test.errors = append(_test.errors, e) 190 | return true 191 | } 192 | repository(t, _test, _REPOSITORYMATCHES) 193 | 194 | // remove the temporary directory used for this test 195 | _err := os.RemoveAll(_test.directory) 196 | if _err != nil { 197 | t.Fatalf( 198 | "unable to remove temporary directory %s: %s", 199 | _test.directory, _err.Error(), 200 | ) 201 | } 202 | 203 | // recreate the temporary directory 204 | // - this remove & recreate gives us an empty directory for the 205 | // repository test 206 | // - this lets us test the caching 207 | _err = os.MkdirAll(_test.directory, _GITMASK) 208 | if _err != nil { 209 | t.Fatalf( 210 | "unable to recreate temporary directory %s: %s", 211 | _test.directory, _err.Error(), 212 | ) 213 | } 214 | defer _test.destroy() 215 | 216 | // repeat the repository tests 217 | // - these should succeed using just the cache data 218 | repository(t, _test, _REPOSITORYMATCHES) 219 | } // TestRepositoryWithCache() 220 | 221 | func TestInvalidRepository(t *testing.T) { 222 | _test := &repositorytest{} 223 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 224 | return gitignore.NewRepository(path) 225 | } 226 | 227 | // perform the invalid repository tests 228 | invalid(t, _test) 229 | } // TestInvalidRepository() 230 | 231 | func TestInvalidRepositoryWithFile(t *testing.T) { 232 | _test := &repositorytest{} 233 | _test.file = gitignore.File + "-invalid-with-file" 234 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 235 | return gitignore.NewRepositoryWithFile(path, _test.file) 236 | } 237 | 238 | // perform the invalid repository tests 239 | invalid(t, _test) 240 | } // TestInvalidRepositoryWithFile() 241 | 242 | func TestInvalidRepositoryWithErrors(t *testing.T) { 243 | _test := &repositorytest{} 244 | _test.file = gitignore.File + "-invalid-with-errors" 245 | _test.error = func(e gitignore.Error) bool { 246 | _test.errors = append(_test.errors, e) 247 | return true 248 | } 249 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 250 | return gitignore.NewRepositoryWithErrors( 251 | path, _test.file, _test.error, 252 | ), nil 253 | } 254 | 255 | // perform the invalid repository tests 256 | invalid(t, _test) 257 | } // TestInvalidRepositoryWithErrors() 258 | 259 | func TestInvalidRepositoryWithErrorsFalse(t *testing.T) { 260 | _test := &repositorytest{} 261 | _test.file = gitignore.File + "-invalid-with-errors-false" 262 | _test.error = func(e gitignore.Error) bool { 263 | _test.errors = append(_test.errors, e) 264 | return false 265 | } 266 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 267 | return gitignore.NewRepositoryWithErrors( 268 | path, _test.file, _test.error, 269 | ), nil 270 | } 271 | 272 | // perform the invalid repository tests 273 | invalid(t, _test) 274 | } // TestInvalidRepositoryWithErrorsFalse() 275 | 276 | func TestInvalidRepositoryWithCache(t *testing.T) { 277 | _test := &repositorytest{} 278 | _test.file = gitignore.File + "-invalid-with-cache" 279 | _test.cache = gitignore.NewCache() 280 | _test.cached = true 281 | _test.error = func(e gitignore.Error) bool { 282 | _test.errors = append(_test.errors, e) 283 | return true 284 | } 285 | _test.instance = func(path string) (gitignore.GitIgnore, error) { 286 | return gitignore.NewRepositoryWithCache( 287 | path, _test.file, _test.cache, _test.error, 288 | ), nil 289 | } 290 | 291 | // perform the invalid repository tests 292 | invalid(t, _test) 293 | 294 | // repeat the tests using a default cache 295 | _test.cache = nil 296 | invalid(t, _test) 297 | } // TestInvalidRepositoryWithCache() 298 | 299 | // 300 | // helper functions 301 | // 302 | 303 | func repository(t *testing.T, test *repositorytest, m []match) { 304 | // if the test has no configured directory, then create a new 305 | // directory with the required .gitignore files 306 | if test.directory == "" { 307 | // what name should we use for the .gitignore file? 308 | // - if none is given, use the default 309 | _file := test.file 310 | if _file == "" { 311 | _file = gitignore.File 312 | } 313 | 314 | // create a temporary directory populated with sample .gitignore files 315 | // - first, augment the test data to include file names 316 | _map := make(map[string]string) 317 | for _k, _content := range _GITREPOSITORY { 318 | _map[_k+"/"+_file] = _content 319 | } 320 | _dir, _err := dir(_map) 321 | if _err != nil { 322 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 323 | } 324 | test.directory = _dir 325 | } 326 | 327 | // create the repository 328 | _repository, _err := test.create(test.directory, true) 329 | if _err != nil { 330 | t.Fatalf("unable to create repository: %s", _err.Error()) 331 | } 332 | 333 | // ensure we have a non-nill repository returned 334 | if _repository == nil { 335 | t.Error("expected non-nill GitIgnore repository instance; nil found") 336 | } 337 | 338 | // ensure the base of the repository is correct 339 | if _repository.Base() != test.directory { 340 | t.Errorf( 341 | "repository.Base() mismatch; expected %q, got %q", 342 | test.directory, _repository.Base(), 343 | ) 344 | } 345 | 346 | // we need to check each test to see if it's matching against a 347 | // GIT_DIR/info/exclude 348 | // - we only do this if the target does not use .gitignore 349 | // as the name of the ignore file 350 | _prepare := func(m match) match { 351 | if test.file == "" || test.file == gitignore.File { 352 | return m 353 | } else if m.Exclude { 354 | return match{m.Path, "", false, m.Exclude} 355 | } else { 356 | return m 357 | } 358 | } // _prepare() 359 | 360 | // perform the repository matching using absolute paths 361 | _cb := func(path string, isdir bool) gitignore.Match { 362 | _path := filepath.Join(_repository.Base(), path) 363 | return _repository.Absolute(_path, isdir) 364 | } 365 | for _, _test := range m { 366 | do(t, _cb, _prepare(_test)) 367 | } 368 | 369 | // repeat the tests using relative paths 370 | _repository, _err = test.create(test.directory, true) 371 | if _err != nil { 372 | t.Fatalf("unable to create repository: %s", _err.Error()) 373 | } 374 | _cb = func(path string, isdir bool) gitignore.Match { 375 | return _repository.Relative(path, isdir) 376 | } 377 | for _, _test := range m { 378 | do(t, _cb, _prepare(_test)) 379 | } 380 | 381 | // perform absolute path tests with paths not under the same repository 382 | _map := make(map[string]string) 383 | for _, _test := range m { 384 | _map[_test.Path] = " " 385 | } 386 | _new, _err := dir(_map) 387 | if _err != nil { 388 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 389 | } 390 | defer os.RemoveAll(_new) 391 | 392 | // first, perform Match() tests 393 | _repository, _err = test.create(test.directory, true) 394 | if _err != nil { 395 | t.Fatalf("unable to create repository: %s", _err.Error()) 396 | } 397 | for _, _test := range m { 398 | _path := filepath.Join(_new, _test.Local()) 399 | _match := _repository.Match(_path) 400 | if _match != nil { 401 | t.Fatalf("unexpected match; expected nil, got %v", _match) 402 | } 403 | } 404 | 405 | // next, perform Absolute() tests 406 | _repository, _err = test.create(test.directory, true) 407 | if _err != nil { 408 | t.Fatalf("unable to create repository: %s", _err.Error()) 409 | } 410 | for _, _test := range m { 411 | // build the absolute path 412 | _path := filepath.Join(_new, _test.Local()) 413 | 414 | // we don't expect to match paths not under this repository 415 | _match := _repository.Absolute(_path, _test.IsDir()) 416 | if _match != nil { 417 | t.Fatalf("unexpected match; expected nil, got %v", _match) 418 | } 419 | } 420 | 421 | // now, repeat the Match() test after having first removed the 422 | // temporary directory 423 | // - we are testing correct handling of missing files 424 | _err = os.RemoveAll(_new) 425 | if _err != nil { 426 | t.Fatalf( 427 | "unable to remove temporary directory %s: %s", 428 | _new, _err.Error(), 429 | ) 430 | } 431 | _repository, _err = test.create(test.directory, true) 432 | if _err != nil { 433 | t.Fatalf("unable to create repository: %s", _err.Error()) 434 | } 435 | for _, _test := range m { 436 | _path := filepath.Join(_new, _test.Local()) 437 | 438 | // if we have an error handler configured, we should be recording 439 | // and error in this call to Match() 440 | _before := len(test.errors) 441 | 442 | // perform the match 443 | _match := _repository.Match(_path) 444 | if _match != nil { 445 | t.Fatalf("unexpected match; expected nil, got %v", _match) 446 | } 447 | 448 | // were we recording errors? 449 | if test.error != nil { 450 | _after := len(test.errors) 451 | if !(_after > _before) { 452 | t.Fatalf( 453 | "expected Match() error; none found for %s", 454 | _path, 455 | ) 456 | } 457 | 458 | // ensure the most recent error is "not exists" 459 | _latest := test.errors[_after-1] 460 | _underlying := _latest.Underlying() 461 | if !os.IsNotExist(_underlying) { 462 | t.Fatalf( 463 | "unexpected Match() error for %s; expected %q, got %q", 464 | _path, os.ErrNotExist.Error(), _underlying.Error(), 465 | ) 466 | } 467 | } 468 | } 469 | 470 | // ensure Match() behaves as expected if the absolute path cannot 471 | // be determined 472 | // - we do this by choosing as our working directory a path 473 | // that this process does not have permission to 474 | _dir, _err := dir(nil) 475 | if _err != nil { 476 | t.Fatalf("unable to create temporary directory: %s", _err.Error()) 477 | } 478 | defer os.RemoveAll(_dir) 479 | 480 | _cwd, _err := os.Getwd() 481 | if _err != nil { 482 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 483 | } 484 | _err = os.Chdir(_dir) 485 | if _err != nil { 486 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 487 | } 488 | defer os.Chdir(_cwd) 489 | 490 | // remove permission from the temporary directory 491 | _err = os.Chmod(_dir, 0) 492 | if _err != nil { 493 | t.Fatalf( 494 | "unable to remove temporary directory %s: %s", 495 | _dir, _err.Error(), 496 | ) 497 | } 498 | 499 | // perform the repository tests 500 | _repository, _err = test.create(test.directory, true) 501 | if _err != nil { 502 | t.Fatalf("unable to create repository: %s", _err.Error()) 503 | } 504 | for _, _test := range m { 505 | _match := _repository.Match(_test.Local()) 506 | if _match != nil { 507 | t.Fatalf("unexpected match; expected nil, not %v", _match) 508 | } 509 | } 510 | 511 | if test.errors != nil { 512 | // ensure the number of errors is expected 513 | if len(test.errors) != test.bad { 514 | t.Fatalf( 515 | "unexpected repository errors; expected %d, got %d", 516 | test.bad, len(test.errors), 517 | ) 518 | } else { 519 | // if we're here, then we intended to record errors 520 | // - ensure we recorded the expected errors 521 | for _i := 0; _i < len(test.errors); _i++ { 522 | _got := test.errors[_i] 523 | _underlying := _got.Underlying() 524 | if os.IsNotExist(_underlying) || 525 | os.IsPermission(_underlying) { 526 | continue 527 | } else { 528 | t.Log(_i) 529 | t.Fatalf("unexpected repository error: %s", _got.Error()) 530 | } 531 | } 532 | } 533 | } 534 | } // repository() 535 | 536 | func invalid(t *testing.T, test *repositorytest) { 537 | // create a temporary file to use as the repository 538 | _file, _err := file("") 539 | if _err != nil { 540 | t.Fatalf("unable to create temporary file: %s", _err.Error()) 541 | } 542 | defer os.Remove(_file.Name()) 543 | 544 | // test repository instance creation against a file 545 | _repository, _err := test.create(_file.Name(), false) 546 | if _err == nil { 547 | t.Errorf( 548 | "invalid repository error; expected %q, got nil", 549 | gitignore.InvalidDirectoryError.Error(), 550 | ) 551 | } else if _err != gitignore.InvalidDirectoryError { 552 | t.Errorf( 553 | "invalid repository mismatch; expected %q, got %q", 554 | gitignore.InvalidDirectoryError.Error(), _err.Error(), 555 | ) 556 | } 557 | 558 | // ensure no repository is returned 559 | if _repository != nil { 560 | t.Errorf( 561 | "invalid repository; expected nil, got %v", 562 | _repository, 563 | ) 564 | } 565 | 566 | // now, remove the temporary file and repeat the tests 567 | _err = os.Remove(_file.Name()) 568 | if _err != nil { 569 | t.Fatalf( 570 | "unable to remove temporary file %s: %s", 571 | _file.Name(), _err.Error(), 572 | ) 573 | } 574 | 575 | // test repository instance creating against a missing file 576 | _repository, _err = test.create(_file.Name(), false) 577 | if _err == nil { 578 | t.Errorf( 579 | "invalid repository error; expected %q, got nil", 580 | gitignore.InvalidDirectoryError.Error(), 581 | ) 582 | } else if !os.IsNotExist(_err) { 583 | t.Errorf( 584 | "invalid repository mismatch; "+ 585 | "expected no such file or directory, got %q", 586 | _err.Error(), 587 | ) 588 | } 589 | 590 | // ensure no repository is returned 591 | if _repository != nil { 592 | t.Errorf( 593 | "invalid repository; expected nil, got %v", 594 | _repository, 595 | ) 596 | } 597 | 598 | // ensure we can't create a repository instance where the absolute path 599 | // of the repository cannot be determined 600 | // - we do this by choosing a working directory this process does 601 | // not have access to and using a relative path 602 | _map := map[string]string{gitignore.File: _GITIGNORE} 603 | _dir, _err := dir(_map) 604 | if _err != nil { 605 | t.Fatalf("unable to create a temporary directory: %s", _err.Error()) 606 | } 607 | defer os.RemoveAll(_dir) 608 | 609 | // now change the working directory 610 | _cwd, _err := os.Getwd() 611 | if _err != nil { 612 | t.Fatalf("unable to retrieve working directory: %s", _err.Error()) 613 | } 614 | _err = os.Chdir(_dir) 615 | if _err != nil { 616 | t.Fatalf("unable to chdir into temporary directory: %s", _err.Error()) 617 | } 618 | defer os.Chdir(_cwd) 619 | 620 | // remove permissions from the working directory 621 | _err = os.Chmod(_dir, 0) 622 | if _err != nil { 623 | t.Fatalf("unable remove temporary directory permissions: %s: %s", 624 | _dir, _err.Error(), 625 | ) 626 | } 627 | 628 | // test repository instance creating against a relative path 629 | // - the relative path exists 630 | _repository, _err = test.create(gitignore.File, false) 631 | if _err == nil { 632 | t.Errorf("expected repository error, got nil") 633 | } else if os.IsNotExist(_err) { 634 | t.Errorf( 635 | "unexpected repository error; file exists, but %q returned", 636 | _err.Error(), 637 | ) 638 | } 639 | 640 | // next, create a repository where we do not have read permission 641 | // to a .gitignore file within the repository 642 | // - this should trigger a panic() when attempting a file match 643 | for _, _test := range _REPOSITORYMATCHES { 644 | _map[_test.Path] = " " 645 | } 646 | _dir, _err = dir(_map) 647 | if _err != nil { 648 | t.Fatalf("unable to create a temporary directory: %s", _err.Error()) 649 | } 650 | defer os.RemoveAll(_dir) 651 | 652 | _git := filepath.Join(_dir, gitignore.File) 653 | _err = os.Chmod(_git, 0) 654 | if _err != nil { 655 | t.Fatalf("unable remove temporary .gitignore permissions: %s: %s", 656 | _git, _err.Error(), 657 | ) 658 | } 659 | 660 | // attempt to match a path in this repository 661 | // - it can be anything, so we just use the .gitignore itself 662 | // - between each test we recreate the repository instance to 663 | // remove the effect of any caching 664 | _instance := func() gitignore.GitIgnore { 665 | // reset the cache 666 | if test.cached { 667 | if test.cache != nil { 668 | test.cache = gitignore.NewCache() 669 | } 670 | } 671 | 672 | // create the new repository 673 | _repository, _err := test.create(_dir, false) 674 | if _err != nil { 675 | t.Fatalf("unable to create repository: %s", _err.Error()) 676 | } 677 | 678 | // return the repository 679 | return _repository 680 | } 681 | for _, _match := range _REPOSITORYMATCHES { 682 | _local := _match.Local() 683 | _isdir := _match.IsDir() 684 | _path := filepath.Join(_dir, _local) 685 | 686 | // try Match() with an absolute path 687 | _test := &invalidtest{repositorytest: test} 688 | _test.tag = "Match()" 689 | _test.match = func() gitignore.Match { 690 | return _instance().Match(_path) 691 | } 692 | run(t, _test) 693 | 694 | // try Absolute() with an absolute path 695 | _test = &invalidtest{repositorytest: test} 696 | _test.tag = "Absolute()" 697 | _test.match = func() gitignore.Match { 698 | return _instance().Absolute(_path, _isdir) 699 | } 700 | run(t, _test) 701 | 702 | // try Absolute() with an absolute path 703 | _test = &invalidtest{repositorytest: test} 704 | _test.tag = "Relative()" 705 | _test.match = func() gitignore.Match { 706 | return _instance().Relative(_local, _isdir) 707 | } 708 | run(t, _test) 709 | } 710 | } // invalid() 711 | 712 | func run(t *testing.T, test *invalidtest) { 713 | // perform the match, and ensure it returns nil, nil 714 | _match := test.match() 715 | if _match != nil { 716 | t.Fatalf("%s: unexpected match: %v", test.tag, _match) 717 | } else if test.errors == nil { 718 | return 719 | } 720 | 721 | // if we're here, then we intended to record errors 722 | // - ensure we recorded the expected errors 723 | for _i := 0; _i < len(test.errors); _i++ { 724 | _got := test.errors[_i] 725 | _underlying := _got.Underlying() 726 | if os.IsNotExist(_underlying) || 727 | os.IsPermission(_underlying) { 728 | continue 729 | } else { 730 | t.Fatalf( 731 | "%s: unexpected error: %q", 732 | test.tag, _got.Error(), 733 | ) 734 | } 735 | } 736 | } // run() 737 | -------------------------------------------------------------------------------- /rune.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | const ( 4 | // define the sentinel runes of the lexer 5 | _EOF = rune(0) 6 | _CR = rune('\r') 7 | _NEWLINE = rune('\n') 8 | _COMMENT = rune('#') 9 | _SEPARATOR = rune('/') 10 | _ESCAPE = rune('\\') 11 | _SPACE = rune(' ') 12 | _TAB = rune('\t') 13 | _NEGATION = rune('!') 14 | _WILDCARD = rune('*') 15 | ) 16 | -------------------------------------------------------------------------------- /token.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Token represents a parsed token from a .gitignore stream, encapsulating the 8 | // token type, the runes comprising the token, and the position within the 9 | // stream of the first rune of the token. 10 | type Token struct { 11 | Type TokenType 12 | Word []rune 13 | Position 14 | } 15 | 16 | // NewToken returns a Token instance of the given t, represented by the 17 | // word runes, at the stream position pos. If the token type is not know, the 18 | // returned instance will have type BAD. 19 | func NewToken(t TokenType, word []rune, pos Position) *Token { 20 | // ensure the type is valid 21 | if t < ILLEGAL || t > BAD { 22 | t = BAD 23 | } 24 | 25 | // return the token 26 | return &Token{Type: t, Word: word, Position: pos} 27 | } // NewToken() 28 | 29 | // Name returns a string representation of the Token type. 30 | func (t *Token) Name() string { 31 | return t.Type.String() 32 | } // Name() 33 | 34 | // Token returns the string representation of the Token word. 35 | func (t *Token) Token() string { 36 | return string(t.Word) 37 | } // Token() 38 | 39 | // String returns a string representation of the Token, encapsulating its 40 | // position in the input stream, its name (i.e. type), and its runes. 41 | func (t *Token) String() string { 42 | return fmt.Sprintf("%s: %s %q", t.Position.String(), t.Name(), t.Token()) 43 | } // String() 44 | -------------------------------------------------------------------------------- /token_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/denormal/go-gitignore" 8 | ) 9 | 10 | func TestToken(t *testing.T) { 11 | for _, _test := range _TOKENS { 12 | // create the token 13 | _position := gitignore.Position{ 14 | File: "file", 15 | Line: _test.Line, 16 | Column: _test.Column, 17 | Offset: _test.NewLine, 18 | } 19 | _token := gitignore.NewToken( 20 | _test.Type, []rune(_test.Token), _position, 21 | ) 22 | 23 | // ensure we have a non-nil token 24 | if _token == nil { 25 | t.Errorf( 26 | "unexpected nil Token for type %d %q", _test.Type, _test.Name, 27 | ) 28 | continue 29 | } 30 | 31 | // ensure the token type match 32 | if _token.Type != _test.Type { 33 | // if we have a bad token, then we accept token types that 34 | // are outside the range of permitted token values 35 | if _token.Type == gitignore.BAD { 36 | if _test.Type < gitignore.ILLEGAL || 37 | _test.Type > gitignore.BAD { 38 | goto NAME 39 | } 40 | } 41 | 42 | // otherwise, we have a type mismatch 43 | t.Errorf( 44 | "token type mismatch for %q; expected %d, got %d", 45 | _test.Name, _test.Type, _token.Type, 46 | ) 47 | continue 48 | } 49 | 50 | NAME: 51 | // ensure the token name match 52 | if _token.Name() != _test.Name { 53 | t.Errorf( 54 | "token name mismatch for type %d; expected %s, got %s", 55 | _test.Type, _test.Name, _token.Name(), 56 | ) 57 | continue 58 | } 59 | 60 | // ensure the positions are the same 61 | if !coincident(_position, _token.Position) { 62 | t.Errorf( 63 | "token position mismatch; expected %s, got %s", 64 | pos(_position), pos(_token.Position), 65 | ) 66 | continue 67 | } 68 | 69 | // ensure the string form of the token is as expected 70 | _string := fmt.Sprintf( 71 | "%s: %s %q", _position, _test.Name, _test.Token, 72 | ) 73 | if _string != _token.String() { 74 | t.Errorf( 75 | "token string mismatch; expected %q, got %q", 76 | _string, _token.String(), 77 | ) 78 | } 79 | } 80 | } // TestToken() 81 | -------------------------------------------------------------------------------- /tokenset.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | // tokenset represents an ordered list of Tokens 4 | type tokenset []*Token 5 | 6 | // String() returns a concatenated string of all runes represented by the 7 | // list of tokens. 8 | func (t tokenset) String() string { 9 | // concatenate the tokens into a single string 10 | _rtn := "" 11 | for _, _t := range []*Token(t) { 12 | _rtn = _rtn + _t.Token() 13 | } 14 | return _rtn 15 | } // String() 16 | -------------------------------------------------------------------------------- /tokentype.go: -------------------------------------------------------------------------------- 1 | package gitignore 2 | 3 | type TokenType int 4 | 5 | const ( 6 | ILLEGAL TokenType = iota 7 | EOF 8 | EOL 9 | WHITESPACE 10 | COMMENT 11 | SEPARATOR 12 | NEGATION 13 | PATTERN 14 | ANY 15 | BAD 16 | ) 17 | 18 | // String returns a string representation of the Token type. 19 | func (t TokenType) String() string { 20 | switch t { 21 | case ILLEGAL: 22 | return "ILLEGAL" 23 | case EOF: 24 | return "EOF" 25 | case EOL: 26 | return "EOL" 27 | case WHITESPACE: 28 | return "WHITESPACE" 29 | case COMMENT: 30 | return "COMMENT" 31 | case SEPARATOR: 32 | return "SEPARATOR" 33 | case NEGATION: 34 | return "NEGATION" 35 | case PATTERN: 36 | return "PATTERN" 37 | case ANY: 38 | return "ANY" 39 | default: 40 | return "BAD TOKEN" 41 | } 42 | } // String() 43 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package gitignore_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/denormal/go-gitignore" 13 | ) 14 | 15 | func file(content string) (*os.File, error) { 16 | // create a temporary file 17 | _file, _err := ioutil.TempFile("", "gitignore") 18 | if _err != nil { 19 | return nil, _err 20 | } 21 | 22 | // populate this file with the example .gitignore 23 | _, _err = _file.WriteString(content) 24 | if _err != nil { 25 | defer os.Remove(_file.Name()) 26 | return nil, _err 27 | } 28 | _, _err = _file.Seek(0, io.SeekStart) 29 | if _err != nil { 30 | defer os.Remove(_file.Name()) 31 | return nil, _err 32 | } 33 | 34 | // we have a temporary file containing the .gitignore 35 | return _file, nil 36 | } // file() 37 | 38 | func dir(content map[string]string) (string, error) { 39 | // create a temporary directory 40 | _dir, _err := ioutil.TempDir("", "") 41 | if _err != nil { 42 | return "", _err 43 | } 44 | 45 | // resolve the path of this directory 46 | // - we do this to handle systems with a temporary directory 47 | // that is a symbolic link 48 | _dir, _err = filepath.EvalSymlinks(_dir) 49 | if _err != nil { 50 | defer os.RemoveAll(_dir) 51 | return "", _err 52 | } 53 | 54 | // populate the temporary directory with the content map 55 | // - each key of the map is a file name 56 | // - each value of the map is the file content 57 | // - file names are relative to the temporary directory 58 | if content != nil { 59 | for _key, _content := range content { 60 | // ensure we have content to store 61 | if _content == "" { 62 | continue 63 | } 64 | 65 | // should we create a directory or a file? 66 | _isdir := false 67 | _path := _key 68 | if strings.HasSuffix(_path, "/") { 69 | _path = strings.TrimSuffix(_path, "/") 70 | _isdir = true 71 | } 72 | 73 | // construct the absolute path (according to the local file system) 74 | _abs := _dir 75 | _parts := strings.Split(_path, "/") 76 | _last := len(_parts) - 1 77 | if _isdir { 78 | _abs = filepath.Join(_abs, filepath.Join(_parts...)) 79 | } else if _last > 0 { 80 | _abs = filepath.Join(_abs, filepath.Join(_parts[:_last]...)) 81 | } 82 | 83 | // ensure this directory exists 84 | _err = os.MkdirAll(_abs, _GITMASK) 85 | if _err != nil { 86 | defer os.RemoveAll(_dir) 87 | return "", _err 88 | } else if _isdir { 89 | continue 90 | } 91 | 92 | // create the absolute path for the target file 93 | _abs = filepath.Join(_abs, _parts[_last]) 94 | 95 | // write the contents to this file 96 | _file, _err := os.Create(_abs) 97 | if _err != nil { 98 | defer os.RemoveAll(_dir) 99 | return "", _err 100 | } 101 | _, _err = _file.WriteString(_content) 102 | if _err != nil { 103 | defer os.RemoveAll(_dir) 104 | return "", _err 105 | } 106 | _err = _file.Close() 107 | if _err != nil { 108 | defer os.RemoveAll(_dir) 109 | return "", _err 110 | } 111 | } 112 | } 113 | 114 | // return the temporary directory name 115 | return _dir, nil 116 | } // dir() 117 | 118 | func exclude(content string) (string, error) { 119 | // create a temporary folder with the info/ subfolder 120 | _dir, _err := dir(nil) 121 | if _err != nil { 122 | return "", _err 123 | } 124 | _info := filepath.Join(_dir, "info") 125 | _err = os.MkdirAll(_info, _GITMASK) 126 | if _err != nil { 127 | defer os.RemoveAll(_dir) 128 | return "", _err 129 | } 130 | 131 | // create the exclude file 132 | _exclude := filepath.Join(_info, "exclude") 133 | _err = ioutil.WriteFile(_exclude, []byte(content), _GITMASK) 134 | if _err != nil { 135 | defer os.RemoveAll(_dir) 136 | return "", _err 137 | } 138 | 139 | // return the temporary directory name 140 | return _dir, nil 141 | } // exclude() 142 | 143 | func coincident(a, b gitignore.Position) bool { 144 | return a.File == b.File && 145 | a.Line == b.Line && 146 | a.Column == b.Column && 147 | a.Offset == b.Offset 148 | } // coincident() 149 | 150 | func pos(p gitignore.Position) string { 151 | _prefix := p.File 152 | if _prefix != "" { 153 | _prefix = _prefix + ": " 154 | } 155 | 156 | return fmt.Sprintf("%s%d:%d [%d]", _prefix, p.Line, p.Column, p.Offset) 157 | } // pos() 158 | 159 | func buffer(content string) (*bytes.Buffer, error) { 160 | // return a buffered .gitignore 161 | return bytes.NewBufferString(content), nil 162 | } // buffer() 163 | 164 | func null() gitignore.GitIgnore { 165 | // return an empty GitIgnore instance 166 | return gitignore.New(bytes.NewBuffer(nil), "", nil) 167 | } // null() 168 | --------------------------------------------------------------------------------