larc r26

294 lines ยท 6.3 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
193 /* quick check: skip if directory doesn't exist */
194 if _, err := os.Stat(absDir); err != nil {
195 m.cache[dir] = nil
196 return nil
197 }
198
199 patterns = m.loadPatterns(absDir, dir)
200 m.cache[dir] = patterns
201
202 return patterns
203 }
204
205 func (m *Matcher) matchPattern(pattern, path string) bool {
206 /* exact match */
207 if pattern == path {
208 return true
209 }
210
211 /* check if pattern matches any component */
212 if !strings.Contains(pattern, "/") {
213 /* pattern like "*.log" or "node_modules" */
214 name := filepath.Base(path)
215
216 /* try glob match on filename */
217 if matched, _ := filepath.Match(pattern, name); matched {
218 return true
219 }
220
221 /* also check if any path component matches */
222 parts := strings.Split(path, "/")
223 for _, part := range parts {
224 if matched, _ := filepath.Match(pattern, part); matched {
225 return true
226 }
227 }
228
229 return false
230 }
231
232 /* pattern with path separator */
233 /* try glob match on full path */
234 if matched, _ := filepath.Match(pattern, path); matched {
235 return true
236 }
237
238 /* try matching as prefix */
239 if strings.HasPrefix(path, pattern+"/") {
240 return true
241 }
242
243 /* try double-star pattern */
244 if strings.Contains(pattern, "**") {
245 return m.matchDoubleStar(pattern, path)
246 }
247
248 return false
249 }
250
251 func (m *Matcher) matchDoubleStar(pattern, path string) bool {
252 /* split pattern by ** */
253 parts := strings.Split(pattern, "**")
254 if len(parts) != 2 {
255 return false
256 }
257
258 prefix := parts[0]
259 suffix := strings.TrimPrefix(parts[1], "/")
260
261 /* check prefix */
262 if prefix != "" && !strings.HasPrefix(path, prefix) {
263 return false
264 }
265
266 /* check suffix */
267 if suffix == "" {
268 return true
269 }
270
271 /* find suffix in remaining path */
272 remaining := strings.TrimPrefix(path, prefix)
273 pathParts := strings.Split(remaining, "/")
274
275 for i := range pathParts {
276 subpath := strings.Join(pathParts[i:], "/")
277 if matched, _ := filepath.Match(suffix, subpath); matched {
278 return true
279 }
280 /* also try matching just the filename part */
281 if matched, _ := filepath.Match(suffix, pathParts[len(pathParts)-1]); matched {
282 return true
283 }
284 }
285
286 return false
287 }
288
289 // ShouldIgnore is a convenience function
290 func ShouldIgnore(root, path string, isDir bool) bool {
291 m := New(root)
292 return m.Match(path, isDir)
293 }
294