aboutsummaryrefslogtreecommitdiff
path: root/internal/ignore/ignore.go
blob: fff6dcc31dea8bd392e702d49a64ddd8e5525d12 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
// SPDX-License-Identifier: GPL-3.0-or-later

// Package ignore implements a gitignore(5)-compatible path matcher.
package ignore

import (
	"errors"
	"fmt"
	"regexp"
	"strings"
)

// pattern is one compiled gitignore(5) pattern.
type pattern struct {
	re      *regexp.Regexp
	negate  bool
	dirOnly bool
}

// Matcher decides which paths a set of gitignore(5) patterns ignores.
type Matcher struct {
	patterns []pattern
}

// New compiles patterns in gitignore(5) syntax, in order. Blank patterns and
// patterns starting with "#" are skipped. A nil or empty list gives a
// matcher that matches nothing.
func New(patterns []string) (*Matcher, error) {
	m := &Matcher{}
	for _, orig := range patterns {
		p := orig
		if p == "" || strings.HasPrefix(p, "#") {
			continue
		}
		negate := false
		switch {
		case strings.HasPrefix(p, "!"):
			negate = true
			p = p[1:]
		case strings.HasPrefix(p, `\!`), strings.HasPrefix(p, `\#`):
			p = p[1:]
		}
		p = trimTrailingSpaces(p)
		dirOnly := false
		if strings.HasSuffix(p, "/") {
			dirOnly = true
			p = strings.TrimSuffix(p, "/")
		}
		reStr, err := toRegexp(p)
		if err != nil {
			return nil, fmt.Errorf("bad ignore pattern %q: %v", orig, err)
		}
		re, err := regexp.Compile(reStr)
		if err != nil {
			return nil, fmt.Errorf("bad ignore pattern %q: %v", orig, err)
		}
		m.patterns = append(m.patterns, pattern{re: re, negate: negate, dirOnly: dirOnly})
	}
	return m, nil
}

// trimTrailingSpaces removes trailing spaces that are not escaped by a
// preceding backslash, per gitignore(5).
func trimTrailingSpaces(p string) string {
	for strings.HasSuffix(p, " ") {
		n := 0
		for i := len(p) - 2; i >= 0 && p[i] == '\\'; i-- {
			n++
		}
		if n%2 == 1 {
			break // the trailing space is escaped: keep it
		}
		p = p[:len(p)-1]
	}
	return p
}

// Match reports whether rel is ignored. rel is slash-separated and relative
// to the root, with no leading slash and no trailing slash, and each of its
// path components must be "." and ".."-free (as produced by a directory
// walk, not a raw user-typed path).
func (m *Matcher) Match(rel string, isDir bool) bool {
	parts := strings.Split(rel, "/")
	for i := 1; i < len(parts); i++ {
		ancestor := strings.Join(parts[:i], "/")
		if m.matchOne(ancestor, true) {
			return true
		}
	}
	return m.matchOne(rel, isDir)
}

// matchOne applies every pattern in order to one path and returns the final
// ignored state, without considering ancestor directories.
func (m *Matcher) matchOne(path string, isDir bool) bool {
	ignored := false
	for _, pat := range m.patterns {
		if pat.dirOnly && !isDir {
			continue
		}
		if pat.re.MatchString(path) {
			ignored = !pat.negate
		}
	}
	return ignored
}

// toRegexp translates the body of one gitignore pattern (no leading "!",
// no trailing "/") into a regular expression over slash-separated paths.
func toRegexp(p string) (string, error) {
	anchored := strings.Contains(p, "/")
	p = strings.TrimPrefix(p, "/")
	var b strings.Builder
	b.WriteString("^")
	if !anchored {
		b.WriteString("(?:.*/)?")
	}
	for i := 0; i < len(p); i++ {
		c := p[i]
		switch {
		case strings.HasPrefix(p[i:], "**/") && (i == 0 || p[i-1] == '/'):
			b.WriteString("(?:.*/)?")
			i += 2
		case strings.HasPrefix(p[i:], "/**") && i+3 == len(p):
			b.WriteString("/.*")
			i += 2
		case strings.HasPrefix(p[i:], "**"):
			b.WriteString("[^/]*")
			i++
		case c == '*':
			b.WriteString("[^/]*")
		case c == '?':
			b.WriteString("[^/]")
		case c == '[':
			negated := false
			start := i + 1
			if start < len(p) && (p[start] == '!' || p[start] == '^') {
				negated = true
				start++
			}
			items, end, err := parseClassBody(p, start)
			if err != nil {
				return "", err
			}
			b.WriteString(renderClass(items, negated))
			i = end
		case c == '\\' && i+1 < len(p):
			i++
			b.WriteString(regexp.QuoteMeta(string(p[i])))
		default:
			b.WriteString(regexp.QuoteMeta(string(c)))
		}
	}
	b.WriteString("$")
	return b.String(), nil
}

// classItem is one member of a gitignore bracket expression: either a
// verbatim POSIX bracket subexpression ("[:digit:]", "[.ch.]", "[=e=]"), or
// a literal byte (lo == hi) or a byte range (lo-hi).
type classItem struct {
	posix  string
	lo, hi byte
}

// parseClassBody scans p starting at start (just past "[", "[!" or "[^")
// for the members of a bracket expression, up to and including the
// matching unescaped "]". A "]" appearing immediately at start is a
// literal member, not the terminator, per glob syntax (e.g. "[]a]" matches
// "]" or "a"). Inside the class, "\x" escapes x to a literal member,
// neutralizing any meaning x would otherwise have (closing the class,
// starting a POSIX subexpression, forming a range) — matching git's
// wildmatch. It returns the members found and the index of the closing
// "]", or an error if none is found (including a class left unterminated
// because its only "]" was escaped).
func parseClassBody(p string, start int) ([]classItem, int, error) {
	var items []classItem
	k := start
	if k < len(p) && p[k] == ']' {
		items = append(items, classItem{lo: ']', hi: ']'})
		k++
	}
	for k < len(p) && p[k] != ']' {
		// "\x" escapes x to a literal member; it can never start a
		// POSIX subexpression or a range. A backslash with nothing
		// after it can never close the class either.
		if p[k] == '\\' {
			if k+1 >= len(p) {
				return nil, 0, errors.New("unterminated [")
			}
			items = append(items, classItem{lo: p[k+1], hi: p[k+1]})
			k += 2
			continue
		}
		if p[k] == '[' && k+1 < len(p) && (p[k+1] == ':' || p[k+1] == '.' || p[k+1] == '=') {
			delim := p[k+1]
			rest := strings.Index(p[k+2:], string(delim)+"]")
			if rest < 0 {
				return nil, 0, errors.New("unterminated [")
			}
			stop := k + 2 + rest + 2
			items = append(items, classItem{posix: p[k:stop]})
			k = stop
			continue
		}
		// "x-y" is a range unless the "-" is the last character before
		// the closing "]", in which case it is a literal "-".
		if k+2 < len(p) && p[k+1] == '-' && p[k+2] != ']' {
			items = append(items, classItem{lo: p[k], hi: p[k+2]})
			k += 3
			continue
		}
		items = append(items, classItem{lo: p[k], hi: p[k]})
		k++
	}
	if k >= len(p) {
		return nil, 0, errors.New("unterminated [")
	}
	return items, k, nil
}

// renderClass turns the members of a bracket expression into a Go regexp
// character class. Git's bracket expressions never match "/", regardless
// of negation, so "/" is dropped from a positive class (splitting any range
// that spans it) and added to a negated one. A positive class left
// matching nothing (e.g. "[/]") is rendered as a class that can never
// match any character, since RE2 has no empty class or lookahead.
func renderClass(items []classItem, negated bool) string {
	if negated {
		items = append(items, classItem{lo: '/', hi: '/'})
		return "[^" + renderClassItems(items) + "]"
	}
	items = excludeSlash(items)
	if len(items) == 0 {
		return `[^\x00-\x{10FFFF}]`
	}
	return "[" + renderClassItems(items) + "]"
}

// posixSlashFree gives the ASCII definition, minus "/", of every standard
// POSIX bracket class whose definition otherwise includes it: graph and
// print (visible characters, "/" among them) and punct (documented in
// gitignore's own toRegexp derivation as "!-. :-@ [-` {-~", i.e. punct's
// usual "!-/" range with "/" trimmed to "!-."). The other nine standard
// classes (alnum, alpha, blank, cntrl, digit, lower, space, upper,
// xdigit) never contain "/" and so need no rewriting.
var posixSlashFree = map[string][]classItem{
	"graph": {{lo: '!', hi: '.'}, {lo: '0', hi: '~'}},
	"print": {{lo: ' ', hi: '.'}, {lo: '0', hi: '~'}},
	"punct": {{lo: '!', hi: '.'}, {lo: ':', hi: '@'}, {lo: '[', hi: '`'}, {lo: '{', hi: '~'}},
}

// posixClassName returns the name inside a "[:name:]" POSIX bracket class,
// or "", false for anything else (a collating symbol "[.x.]", an
// equivalence class "[=x=]", or a malformed value).
func posixClassName(posix string) (string, bool) {
	if strings.HasPrefix(posix, "[:") && strings.HasSuffix(posix, ":]") {
		return posix[2 : len(posix)-2], true
	}
	return "", false
}

// excludeSlash removes "/" from a positive class's members, splitting any
// range that spans it into the parts on either side, and substituting the
// "/"-free ASCII definition for any POSIX class that would otherwise
// include it.
func excludeSlash(items []classItem) []classItem {
	var out []classItem
	for _, it := range items {
		switch {
		case it.posix != "":
			if name, ok := posixClassName(it.posix); ok {
				if repl, hasSlash := posixSlashFree[name]; hasSlash {
					out = append(out, repl...)
					continue
				}
			}
			out = append(out, it)
		case it.lo == '/' && it.hi == '/':
			// drop the lone literal "/"
		case it.lo <= '/' && '/' <= it.hi:
			if it.lo <= '/'-1 {
				out = append(out, classItem{lo: it.lo, hi: '/' - 1})
			}
			if '/'+1 <= it.hi {
				out = append(out, classItem{lo: '/' + 1, hi: it.hi})
			}
		default:
			out = append(out, it)
		}
	}
	return out
}

func renderClassItems(items []classItem) string {
	var b strings.Builder
	for _, it := range items {
		switch {
		case it.posix != "":
			b.WriteString(it.posix)
		case it.lo == it.hi:
			b.WriteString(classChar(it.lo))
		default:
			b.WriteString(classChar(it.lo))
			b.WriteByte('-')
			b.WriteString(classChar(it.hi))
		}
	}
	return b.String()
}

// classChar escapes a byte that would otherwise be misread by the Go
// regexp parser when emitted as a member (or range endpoint) inside a
// character class: "]" would close the class, "^" would negate it if
// first, "-" would start a range, "[" could open a POSIX subexpression,
// and "\" always needs escaping to be literal.
func classChar(c byte) string {
	switch c {
	case '\\', ']', '^', '-', '[':
		return "\\" + string(c)
	default:
		return string(c)
	}
}