larc r12

287 lines ยท 6.1 KB Raw
1 package ignore
2
3 import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 )
10
11 /* Ignore file parser for .larcignore and .gitignore.
12 * Supports basic glob patterns like:
13 * - *.log
14 * - /build/
15 * - node_modules/
16 * - !important.log (negation)
17 *
18 * Also supports nested ignore files in subdirectories,
19 * matching git behavior.
20 */
21
22 // Matcher checks if paths should be ignored
23 type Matcher struct {
24 root string
25 patterns []pattern // root-level patterns
26 cache map[string][]pattern // dir -> patterns cache
27 mu sync.RWMutex
28 }
29
30 type pattern struct {
31 pattern string
32 negation bool
33 dirOnly bool
34 baseDir string // directory this pattern is relative to
35 }
36
37 // New creates a new ignore matcher for the given repository root
38 func New(root string) *Matcher {
39 m := &Matcher{
40 root: root,
41 cache: make(map[string][]pattern),
42 }
43
44 /* load root .gitignore first */
45 m.patterns = m.loadPatterns(root, "")
46
47 return m
48 }
49
50 func (m *Matcher) loadPatterns(dir, baseDir string) []pattern {
51 var patterns []pattern
52
53 /* load .gitignore first */
54 patterns = append(patterns, m.loadFile(filepath.Join(dir, ".gitignore"), baseDir)...)
55
56 /* load .larcignore (overrides .gitignore) */
57 patterns = append(patterns, m.loadFile(filepath.Join(dir, ".larcignore"), baseDir)...)
58
59 return patterns
60 }
61
62 func (m *Matcher) loadFile(path, baseDir string) []pattern {
63 var patterns []pattern
64
65 f, err := os.Open(path)
66 if err != nil {
67 return patterns
68 }
69 defer f.Close()
70
71 scanner := bufio.NewScanner(f)
72 for scanner.Scan() {
73 line := strings.TrimSpace(scanner.Text())
74
75 /* skip empty lines and comments */
76 if line == "" || strings.HasPrefix(line, "#") {
77 continue
78 }
79
80 p := pattern{pattern: line, baseDir: baseDir}
81
82 /* check for negation */
83 if strings.HasPrefix(line, "!") {
84 p.negation = true
85 p.pattern = line[1:]
86 }
87
88 /* check for directory-only pattern */
89 if strings.HasSuffix(p.pattern, "/") {
90 p.dirOnly = true
91 p.pattern = strings.TrimSuffix(p.pattern, "/")
92 }
93
94 /* remove leading slash (makes it relative to this dir) */
95 p.pattern = strings.TrimPrefix(p.pattern, "/")
96
97 patterns = append(patterns, p)
98 }
99
100 return patterns
101 }
102
103 // Match checks if a path should be ignored
104 func (m *Matcher) Match(path string, isDir bool) bool {
105 /* always ignore .larc directory */
106 if path == ".larc" || strings.HasPrefix(path, ".larc/") {
107 return true
108 }
109
110 /* collect all applicable patterns from root to parent dir */
111 allPatterns := m.collectPatterns(path)
112
113 ignored := false
114
115 for _, p := range allPatterns {
116 /* skip dir-only patterns for files */
117 if p.dirOnly && !isDir {
118 continue
119 }
120
121 /* compute relative path from pattern's base directory */
122 relPath := path
123 if p.baseDir != "" {
124 if !strings.HasPrefix(path, p.baseDir+"/") && path != p.baseDir {
125 continue
126 }
127 relPath = strings.TrimPrefix(path, p.baseDir+"/")
128 }
129
130 if m.matchPattern(p.pattern, relPath) {
131 ignored = !p.negation
132 }
133 }
134
135 return ignored
136 }
137
138 // collectPatterns gathers patterns from root and all parent directories of path
139 func (m *Matcher) collectPatterns(path string) []pattern {
140 var allPatterns []pattern
141
142 /* start with root patterns */
143 allPatterns = append(allPatterns, m.patterns...)
144
145 /* walk through parent directories and collect their patterns */
146 parts := strings.Split(filepath.Dir(path), "/")
147 currentDir := ""
148
149 for _, part := range parts {
150 if part == "." || part == "" {
151 continue
152 }
153
154 if currentDir == "" {
155 currentDir = part
156 } else {
157 currentDir = currentDir + "/" + part
158 }
159
160 /* skip .larc directory */
161 if currentDir == ".larc" || strings.HasPrefix(currentDir, ".larc/") {
162 continue
163 }
164
165 patterns := m.getPatternsForDir(currentDir)
166 allPatterns = append(allPatterns, patterns...)
167 }
168
169 return allPatterns
170 }
171
172 // getPatternsForDir returns cached patterns for a directory
173 func (m *Matcher) getPatternsForDir(dir string) []pattern {
174 m.mu.RLock()
175 patterns, ok := m.cache[dir]
176 m.mu.RUnlock()
177
178 if ok {
179 return patterns
180 }
181
182 /* load patterns from this directory */
183 m.mu.Lock()
184 defer m.mu.Unlock()
185
186 /* double-check after acquiring write lock */
187 if patterns, ok := m.cache[dir]; ok {
188 return patterns
189 }
190
191 absDir := filepath.Join(m.root, dir)
192 patterns = m.loadPatterns(absDir, dir)
193 m.cache[dir] = patterns
194
195 return patterns
196 }
197
198 func (m *Matcher) matchPattern(pattern, path string) bool {
199 /* exact match */
200 if pattern == path {
201 return true
202 }
203
204 /* check if pattern matches any component */
205 if !strings.Contains(pattern, "/") {
206 /* pattern like "*.log" or "node_modules" */
207 name := filepath.Base(path)
208
209 /* try glob match on filename */
210 if matched, _ := filepath.Match(pattern, name); matched {
211 return true
212 }
213
214 /* also check if any path component matches */
215 parts := strings.Split(path, "/")
216 for _, part := range parts {
217 if matched, _ := filepath.Match(pattern, part); matched {
218 return true
219 }
220 }
221
222 return false
223 }
224
225 /* pattern with path separator */
226 /* try glob match on full path */
227 if matched, _ := filepath.Match(pattern, path); matched {
228 return true
229 }
230
231 /* try matching as prefix */
232 if strings.HasPrefix(path, pattern+"/") {
233 return true
234 }
235
236 /* try double-star pattern */
237 if strings.Contains(pattern, "**") {
238 return m.matchDoubleStar(pattern, path)
239 }
240
241 return false
242 }
243
244 func (m *Matcher) matchDoubleStar(pattern, path string) bool {
245 /* split pattern by ** */
246 parts := strings.Split(pattern, "**")
247 if len(parts) != 2 {
248 return false
249 }
250
251 prefix := parts[0]
252 suffix := strings.TrimPrefix(parts[1], "/")
253
254 /* check prefix */
255 if prefix != "" && !strings.HasPrefix(path, prefix) {
256 return false
257 }
258
259 /* check suffix */
260 if suffix == "" {
261 return true
262 }
263
264 /* find suffix in remaining path */
265 remaining := strings.TrimPrefix(path, prefix)
266 pathParts := strings.Split(remaining, "/")
267
268 for i := range pathParts {
269 subpath := strings.Join(pathParts[i:], "/")
270 if matched, _ := filepath.Match(suffix, subpath); matched {
271 return true
272 }
273 /* also try matching just the filename part */
274 if matched, _ := filepath.Match(suffix, pathParts[len(pathParts)-1]); matched {
275 return true
276 }
277 }
278
279 return false
280 }
281
282 // ShouldIgnore is a convenience function
283 func ShouldIgnore(root, path string, isDir bool) bool {
284 m := New(root)
285 return m.Match(path, isDir)
286 }
287