• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hmerritt / reactenv / 21967512001

12 Feb 2026 10:57PM UTC coverage: 78.109% (+37.2%) from 40.958%
21967512001

push

github

web-flow
Merge pull request #6 from hmerritt/dev

Major refactor

194 of 290 new or added lines in 3 files covered. (66.9%)

1 existing line in 1 file now uncovered.

446 of 571 relevant lines covered (78.11%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.43
/reactenv/reactenv.go
1
package reactenv
2

3
import (
4
        "bytes"
5
        "fmt"
6
        "io/fs"
7
        "os"
8
        "path"
9
        "path/filepath"
10
        "regexp"
11
        "sort"
12
        "strings"
13

14
        "github.com/bmatcuk/doublestar/v4"
15
        "github.com/hmerritt/reactenv/ui"
16
)
17

18
const (
19
        REACTENV_PREFIX = "__reactenv"
20
)
21

22
const (
23
        fileMatchModeAuto  = "auto"
24
        fileMatchModeRegex = "regex"
25
        fileMatchModeGlob  = "glob"
26
)
27

28
type FileMatchError struct {
29
        Pattern      string
30
        Mode         string
31
        Err          error
32
        AutoRegexErr error
33
}
34

NEW
35
func (e *FileMatchError) Error() string {
×
NEW
36
        if e == nil {
×
NEW
37
                return ""
×
NEW
38
        }
×
NEW
39
        if e.Mode == fileMatchModeAuto && e.AutoRegexErr != nil {
×
NEW
40
                return fmt.Sprintf("file match pattern '%s' is not valid as regex or glob: regex error: %v; glob error: %v", e.Pattern, e.AutoRegexErr, e.Err)
×
NEW
41
        }
×
NEW
42
        return fmt.Sprintf("file match pattern '%s' is not valid for %s: %v", e.Pattern, e.Mode, e.Err)
×
43
}
44

45
type Reactenv struct {
46
        UI *ui.Ui
47

48
        // Path of directory to scan
49
        Dir string
50

51
        // Total file count - that have matches - within specified `Dir`
52
        FilesMatchTotal int
53
        // Files with occurrences (not every matched file will have an occurrence, so this may be less than `FilesMatchTotal`)
54
        Files []*fs.DirEntry
55
        // Relative paths (from Dir) for each file in Files.
56
        FileRelPaths []string
57

58
        // Total individual occurrences count
59
        OccurrencesTotal int
60
        // Each file occurrence count + keys
61
        OccurrencesByFile []*FileOccurrences
62
        // Map of all unique environment variable keys
63
        OccurrenceKeys OccurrenceKeys
64
        // Map of all environment variable key values (keys will be replaced with these values)
65
        OccurrenceKeysReplacement OccurrenceKeysReplacement
66
}
67

68
type Occurrence = struct {
69
        Key      string
70
        StartEnd []int
71
}
72
type OccurrenceKeys = map[string]bool
73
type OccurrenceKeysReplacement = map[string]string
74
type FileOccurrences = struct {
75
        Occurrences []Occurrence
76
}
77

78
func NewReactenv(ui *ui.Ui) *Reactenv {
1✔
79
        return &Reactenv{
1✔
80
                UI:                        ui,
1✔
81
                Dir:                       "",
1✔
82
                Files:                     make([]*fs.DirEntry, 0),
1✔
83
                FileRelPaths:              make([]string, 0),
1✔
84
                OccurrencesTotal:          0,
1✔
85
                OccurrencesByFile:         make([]*FileOccurrences, 0),
1✔
86
                OccurrenceKeys:            make(OccurrenceKeys),
1✔
87
                OccurrenceKeysReplacement: make(OccurrenceKeysReplacement),
1✔
88
        }
1✔
89
}
1✔
90

91
// Populates `Reactenv.Files` with all files that match `fileMatchExpression`.
92
// Patterns support regex or glob (auto-detected, with optional "regex:" / "glob:" prefixes).
93
func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error {
1✔
94
        r.Dir = dir
1✔
95
        r.Files = make([]*fs.DirEntry, 0)
1✔
96
        r.FileRelPaths = make([]string, 0)
1✔
97

1✔
98
        fileMatcher, _, err := buildFileMatcher(fileMatchExpression)
1✔
99
        if err != nil {
2✔
100
                return err
1✔
101
        }
1✔
102

103
        type fileMatch struct {
1✔
104
                entry   fs.DirEntry
1✔
105
                relPath string
1✔
106
        }
1✔
107

1✔
108
        matches := make([]fileMatch, 0)
1✔
109

1✔
110
        err = filepath.WalkDir(r.Dir, func(walkPath string, entry fs.DirEntry, walkErr error) error {
2✔
111
                if walkErr != nil {
2✔
112
                        return walkErr
1✔
113
                }
1✔
114

115
                if entry.IsDir() {
2✔
116
                        // Prevent scanning of node_modules directory. If necessary, you can bypass this by
1✔
117
                        // pointing run reactenv to the node_modules directory directly (e.g. `reactenv run node_modules`).
1✔
118
                        if entry.Name() == "node_modules" && walkPath != r.Dir {
2✔
119
                                return filepath.SkipDir
1✔
120
                        }
1✔
121
                        return nil
1✔
122
                }
123

124
                relPath, err := filepath.Rel(r.Dir, walkPath)
1✔
125
                if err != nil {
1✔
NEW
126
                        relPath = entry.Name()
×
NEW
127
                }
×
128
                relPath = filepath.ToSlash(relPath)
1✔
129

1✔
130
                if fileMatcher(relPath) {
2✔
131
                        matches = append(matches, fileMatch{
1✔
132
                                entry:   entry,
1✔
133
                                relPath: relPath,
1✔
134
                        })
1✔
135
                }
1✔
136

137
                return nil
1✔
138
        })
139

140
        if err != nil {
2✔
141
                return err
1✔
142
        }
1✔
143

144
        // Enforce deterministic sorting of matches
145
        sort.Slice(matches, func(i, j int) bool {
2✔
146
                return matches[i].relPath < matches[j].relPath
1✔
147
        })
1✔
148

149
        // Populate `Reactenv.Files` and `Reactenv.FileRelPaths` with sorted matches
150
        for _, match := range matches {
2✔
151
                fileEntry := match.entry
1✔
152
                r.Files = append(r.Files, &fileEntry)
1✔
153
                r.FileRelPaths = append(r.FileRelPaths, match.relPath)
1✔
154
        }
1✔
155

156
        r.FilesMatchTotal = len(matches)
1✔
157

1✔
158
        return nil
1✔
159
}
160

161
func buildFileMatcher(pattern string) (func(string) bool, string, error) {
1✔
162
        rawPattern := pattern
1✔
163
        mode := fileMatchModeAuto
1✔
164

1✔
165
        if strings.HasPrefix(pattern, "regex:") {
2✔
166
                mode = fileMatchModeRegex
1✔
167
                pattern = strings.TrimPrefix(pattern, "regex:")
1✔
168
        } else if strings.HasPrefix(pattern, "glob:") {
3✔
169
                mode = fileMatchModeGlob
1✔
170
                pattern = strings.TrimPrefix(pattern, "glob:")
1✔
171
        }
1✔
172

173
        switch mode {
1✔
174
        case fileMatchModeRegex:
1✔
175
                matcher, err := buildRegexMatcher(rawPattern, pattern)
1✔
176
                if err != nil {
2✔
177
                        return nil, mode, err
1✔
178
                }
1✔
179
                return matcher, mode, nil
1✔
180
        case fileMatchModeGlob:
1✔
181
                matcher, err := buildGlobMatcher(rawPattern, pattern)
1✔
182
                if err != nil {
2✔
183
                        return nil, mode, err
1✔
184
                }
1✔
185
                return matcher, mode, nil
1✔
186
        default:
1✔
187
                matcher, err := buildRegexMatcher(rawPattern, pattern)
1✔
188
                if err == nil {
2✔
189
                        return matcher, fileMatchModeRegex, nil
1✔
190
                }
1✔
191

192
                globMatcher, globErr := buildGlobMatcher(rawPattern, pattern)
1✔
193
                if globErr == nil {
2✔
194
                        return globMatcher, fileMatchModeGlob, nil
1✔
195
                }
1✔
196

197
                return nil, fileMatchModeAuto, &FileMatchError{
1✔
198
                        Pattern:      rawPattern,
1✔
199
                        Mode:         fileMatchModeAuto,
1✔
200
                        Err:          globErr,
1✔
201
                        AutoRegexErr: err,
1✔
202
                }
1✔
203
        }
204
}
205

206
func buildRegexMatcher(rawPattern string, pattern string) (func(string) bool, error) {
1✔
207
        re, err := regexp.Compile(pattern)
1✔
208
        if err != nil {
2✔
209
                return nil, &FileMatchError{
1✔
210
                        Pattern: rawPattern,
1✔
211
                        Mode:    fileMatchModeRegex,
1✔
212
                        Err:     err,
1✔
213
                }
1✔
214
        }
1✔
215

216
        return func(relPath string) bool {
2✔
217
                return re.MatchString(relPath)
1✔
218
        }, nil
1✔
219
}
220

221
func buildGlobMatcher(rawPattern string, pattern string) (func(string) bool, error) {
1✔
222
        if _, err := doublestar.Match(pattern, ""); err != nil {
2✔
223
                return nil, &FileMatchError{
1✔
224
                        Pattern: rawPattern,
1✔
225
                        Mode:    fileMatchModeGlob,
1✔
226
                        Err:     err,
1✔
227
                }
1✔
228
        }
1✔
229

230
        return func(relPath string) bool {
2✔
231
                match, err := doublestar.Match(pattern, relPath)
1✔
232
                return err == nil && match
1✔
233
        }, nil
1✔
234
}
235

236
// Run a callback for each File
237
func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePath string) error) error {
1✔
238
        for fileIndex, file := range r.Files {
2✔
239
                filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex])
1✔
240
                err := fileCb(fileIndex, *file, filePath)
1✔
241
                if err != nil {
2✔
242
                        return err
1✔
243
                }
1✔
244
        }
245

246
        return nil
1✔
247
}
248

249
// Run a callback for each File, passing in the file contents
250
func (r *Reactenv) FilesWalkContents(fileCb func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error) error {
1✔
251
        for fileIndex, file := range r.Files {
2✔
252
                filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex])
1✔
253
                fileContents, err := os.ReadFile(filePath)
1✔
254

1✔
255
                if err != nil {
2✔
256
                        r.UI.Error(fmt.Sprintf("Error when reading file '%s'.\n", (*file).Name()))
1✔
257
                        r.UI.Error(fmt.Sprintf("%v", err))
1✔
258
                        os.Exit(1)
1✔
259
                }
1✔
260

261
                err = fileCb(fileIndex, *file, filePath, fileContents)
1✔
262

1✔
263
                if err != nil {
2✔
264
                        return err
1✔
265
                }
1✔
266
        }
267

268
        return nil
1✔
269
}
270

271
// Strictly locates all instances of valid reactenv variables and returns their byte
272
// start and end indices.
273
//
274
// This implementation allows for side-by-side occurrences, which a regex pattern
275
// match would not (without an unsupported lookahead assertion).
276
func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int {
1✔
277
        // Guard against infinite loop allocation caused by empty prefixes.
1✔
278
        if len(prefix) == 0 {
2✔
279
                return nil
1✔
280
        }
1✔
281

282
        var indices [][]int
1✔
283

1✔
284
        // Establish absolute boundaries by finding all prefix locations
1✔
285
        var prefixStarts []int
1✔
286
        offset := 0
1✔
287
        for {
2✔
288
                idx := bytes.Index(data[offset:], prefix)
1✔
289
                if idx == -1 {
2✔
290
                        break
1✔
291
                }
292
                absoluteIdx := offset + idx
1✔
293
                prefixStarts = append(prefixStarts, absoluteIdx)
1✔
294
                offset = absoluteIdx + len(prefix)
1✔
295
        }
296

297
        // Iterate through boundaries and enforce positional grammar
298
        for i, start := range prefixStarts {
2✔
299
                current := start + len(prefix)
1✔
300

1✔
301
                limit := len(data)
1✔
302
                if i+1 < len(prefixStarts) {
2✔
303
                        limit = prefixStarts[i+1]
1✔
304
                }
1✔
305

306
                // Validate the initial character (cannot be a numeral).
307
                if current < limit {
2✔
308
                        firstByte := data[current]
1✔
309
                        isValidStart := (firstByte >= 'a' && firstByte <= 'z') ||
1✔
310
                                (firstByte >= 'A' && firstByte <= 'Z') ||
1✔
311
                                firstByte == '_'
1✔
312

1✔
313
                        if !isValidStart {
2✔
314
                                // Abort: The character following the dot is invalid
1✔
315
                                continue
1✔
316
                        }
317
                        current++
1✔
318
                } else {
1✔
319
                        // Abort: The string terminates immediately after the dot
1✔
320
                        continue
1✔
321
                }
322

323
                // Scan forward for all subsequent valid identifier bytes
324
                for current < limit {
2✔
325
                        b := data[current]
1✔
326
                        isValid := (b >= 'a' && b <= 'z') ||
1✔
327
                                (b >= 'A' && b <= 'Z') ||
1✔
328
                                (b >= '0' && b <= '9') ||
1✔
329
                                b == '_'
1✔
330

1✔
331
                        if !isValid {
2✔
332
                                break
1✔
333
                        }
334
                        current++
1✔
335
                }
336

337
                indices = append(indices, []int{start, current})
1✔
338
        }
339

340
        return indices
1✔
341
}
342

343
// Walks every file and populates `Reactenv.Occurrences*` fields.
344
func (r *Reactenv) FindOccurrences() error {
1✔
345
        // Reset occurrence fields
1✔
346
        r.OccurrencesTotal = 0
1✔
347
        r.OccurrencesByFile = make([]*FileOccurrences, 0)
1✔
348
        r.OccurrenceKeys = make(OccurrenceKeys)
1✔
349
        r.OccurrenceKeysReplacement = make(OccurrenceKeysReplacement)
1✔
350

1✔
351
        // Prep for removing files with no occurrences
1✔
352
        newFiles := make([]*fs.DirEntry, 0, len(r.Files))
1✔
353
        newFileRelPaths := make([]string, 0, len(r.Files))
1✔
354
        newOccurrencesByFile := make([]*FileOccurrences, 0)
1✔
355
        fileIndexesToRemove := make(map[int]int, 0)
1✔
356

1✔
357
        err := r.FilesWalkContents(func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error {
2✔
358
                prefix := fmt.Appendf([]byte(""), "%s.", REACTENV_PREFIX)
1✔
359
                fileOccurrences := FindAllOccurrenceBytePositions(fileContents, prefix)
1✔
360

1✔
361
                fileOccurrencesToStore := make([]Occurrence, 0, len(fileOccurrences))
1✔
362
                r.OccurrencesTotal += len(fileOccurrences)
1✔
363
                r.OccurrencesByFile = append(r.OccurrencesByFile, &FileOccurrences{
1✔
364
                        Occurrences: fileOccurrencesToStore,
1✔
365
                })
1✔
366

1✔
367
                for _, occurrence := range fileOccurrences {
2✔
368
                        occurrenceText := string(fileContents[occurrence[0]:occurrence[1]])
1✔
369
                        envName := strings.Replace(occurrenceText, string(prefix), "", 1)
1✔
370
                        envValue, envExists := os.LookupEnv(envName)
1✔
371

1✔
372
                        r.OccurrencesByFile[fileIndex].Occurrences = append(r.OccurrencesByFile[fileIndex].Occurrences, Occurrence{
1✔
373
                                Key:      envName,
1✔
374
                                StartEnd: occurrence,
1✔
375
                        })
1✔
376

1✔
377
                        r.OccurrenceKeys[envName] = true
1✔
378

1✔
379
                        if envExists {
2✔
380
                                r.OccurrenceKeysReplacement[envName] = envValue
1✔
381
                        }
1✔
382
                }
383

384
                if len(fileOccurrences) == 0 {
2✔
385
                        fileIndexesToRemove[fileIndex] = fileIndex
1✔
386
                }
1✔
387

388
                return nil
1✔
389
        })
390

391
        if err != nil {
1✔
392
                return err
×
393
        }
×
394

395
        // Remove files with no occurrences
396
        if len(fileIndexesToRemove) > 0 {
2✔
397
                for fileIndex, file := range r.Files {
2✔
398
                        if _, ok := fileIndexesToRemove[fileIndex]; !ok {
2✔
399
                                newFiles = append(newFiles, file)
1✔
400
                                newFileRelPaths = append(newFileRelPaths, r.FileRelPaths[fileIndex])
1✔
401
                                newOccurrencesByFile = append(newOccurrencesByFile, r.OccurrencesByFile[fileIndex])
1✔
402
                        }
1✔
403
                }
404

405
                r.Files = newFiles
1✔
406
                r.FileRelPaths = newFileRelPaths
1✔
407
                r.OccurrencesByFile = newOccurrencesByFile
1✔
408
        }
409

410
        return nil
1✔
411
}
412

413
func (r *Reactenv) ReplaceOccurrences() {
1✔
414
        r.FilesWalkContents(func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error {
2✔
415
                fileContentsNew := make([]byte, 0, len(fileContents))
1✔
416

1✔
417
                lastIndex := 0
1✔
418
                for _, occurrence := range r.OccurrencesByFile[fileIndex].Occurrences {
2✔
419
                        start, end := occurrence.StartEnd[0], occurrence.StartEnd[1]
1✔
420
                        envValue := r.OccurrenceKeysReplacement[occurrence.Key]
1✔
421

1✔
422
                        fileContentsNew = append(fileContentsNew, fileContents[lastIndex:start]...)
1✔
423
                        fileContentsNew = append(fileContentsNew, envValue...)
1✔
424
                        lastIndex = end
1✔
425
                }
1✔
426
                fileContentsNew = append(fileContentsNew, fileContents[lastIndex:]...)
1✔
427

1✔
428
                if err := os.WriteFile(filePath, fileContentsNew, 0644); err != nil {
1✔
429
                        r.UI.Error(fmt.Sprintf("Error when writing to file '%s'.\n", filePath))
×
430
                        r.UI.Error(fmt.Sprintf("%v", err))
×
431
                        os.Exit(1)
×
432
                }
×
433

434
                return nil
1✔
435
        })
436
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc