diff --git a/.github/actions/go-setup/action.yml b/.github/actions/go-setup/action.yml index 648179e..d8db4b8 100644 --- a/.github/actions/go-setup/action.yml +++ b/.github/actions/go-setup/action.yml @@ -12,4 +12,5 @@ runs: shell: bash run: | go install github.com/magefile/mage + go install github.com/hexira/go-ignore-cov@latest mage -v bootstrap diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 7635c8b..5dcf515 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -6,4 +6,6 @@ runs: steps: - name: Run Tests shell: bash - run: mage -v test + run: | + mage -v test + go-ignore-cov --file cover.out diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7152ac..4a82f15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 @@ -21,7 +21,7 @@ jobs: release: needs: test runs-on: ubuntu-latest - name: Build release binaries + name: Build and Release steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml index 288b094..774e116 100644 --- a/.github/workflows/test-dev.yml +++ b/.github/workflows/test-dev.yml @@ -10,7 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test-master.yml b/.github/workflows/test-master.yml index b9c15c8..d96eef0 100644 --- a/.github/workflows/test-master.yml +++ b/.github/workflows/test-master.yml @@ -10,7 +10,7 @@ on: jobs: test: runs-on: ubuntu-latest - name: Run Tests + name: Tests steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 4b0c54d..44ae040 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # reactENV -[![](https://img.shields.io/npm/v/%40reactenv%2Fcli)](https://www.npmjs.com/package/@reactenv/cli) +[![](https://img.shields.io/npm/v/%40reactenv%2Fcli)](https://www.npmjs.com/package/@reactenv/cli) [![Coverage Status](https://coveralls.io/repos/github/hmerritt/reactenv/badge.svg?branch=master)](https://coveralls.io/github/hmerritt/reactenv?branch=master) Inject environment variables into a **bundled** react app (after `build`). @@ -10,23 +10,23 @@ Useful for creating generic Docker images. Build your app once and add build fil ### Features ⚡ -- No runtime overhead -- No app code changes required -- Injection is strict by default, and will error if any values are missing -- Blazing fast environment variable injection (~1ms for a basic react app) -- (Optional) Bundler plugins to automate processing `process.env` values during build - - [Webpack plugin `@reactenv/webpack`](https://github.com/hmerritt/reactenv/tree/master/npm/plugin-webpack) +- No runtime overhead +- No app code changes required +- Injection is strict by default, and will error if any values are missing +- Blazing fast environment variable injection (~1ms for a basic react app) +- (Optional) Bundler plugins to automate processing `process.env` values during build + - [Webpack plugin `@reactenv/webpack`](https://github.com/hmerritt/reactenv/tree/master/npm/plugin-webpack) https://github.com/user-attachments/assets/c51465c9-d828-45e5-b469-a95e743d7d02 ### Jump to: -- [Install](#install) -- [Usage](#usage) -- [Example](#example) -- [Reasoning](#reasoning) -- [Aims](#aims) -- [Licence](#licence) +- [Install](#install) +- [Usage](#usage) +- [Example](#example) +- [Reasoning](#reasoning) +- [Aims](#aims) +- [Licence](#licence) ## Install @@ -69,8 +69,12 @@ It uses the current host enviroment variables and will replace all matches in th All you need to do is run `reactenv run ` and it will do it's thing: ```sh -# Inject environment variables into all `.js` files in `dist` directory +# Inject environment variables into all `.js` files in `dist` directory (recursively) $ reactenv run dist + +# Override the file matcher (regex or glob, matching relative paths) +$ reactenv run --match "glob:**/*.mjs" dist +$ reactenv run --match "regex:^assets/.*\\.js$" dist ``` After running `reactenv`, your app is ready to be deployed and served! @@ -177,10 +181,10 @@ I'm aware that this solution has it's drawbacks and I don't recommend it for eve Since this is being ran **after** a build, this program needs to be 100% reliable. If somthing does go wrong, it catches and reports it so a failed build does not end up in production. -- Fast -- Reliable -- Easy to **debug** -- Simple to use +- Fast +- Reliable +- Easy to **debug** +- Simple to use ## Developing diff --git a/command/base.go b/command/base.go deleted file mode 100644 index 0af025e..0000000 --- a/command/base.go +++ /dev/null @@ -1,116 +0,0 @@ -package command - -import ( - "bytes" - "fmt" - "os" - "strings" - - "github.com/hmerritt/reactenv/ui" - - "github.com/jessevdk/go-flags" - "github.com/posener/complete" -) - -// Slice of all flag names -var FlagNames = []string{flagStrict.Name, flagForce.Name} - -// Slice of global flag names -var FlagNamesGlobal = []string{flagStrict.Name, flagForce.Name} - -// Master command type which is present in all commands -// -// Used to standardize UI output -type BaseCommand struct { - UI *ui.Ui -} - -func GetBaseCommand() *BaseCommand { - return &BaseCommand{ - UI: ui.GetUi(), - } -} - -type Flag struct { - Name string - Usage string - Default interface{} - Value interface{} - Completion complete.Predictor -} - -type FlagMap map[string]*Flag - -func (fm *FlagMap) Get(flagName string) *Flag { - fl, ok := (*fm)[flagName] - if ok { - return fl - } - return nil -} - -// Help builds usage string for all flags in a FlagMap -func (fm *FlagMap) Help() string { - var out bytes.Buffer - - for _, flag := range *fm { - fmt.Fprintf(&out, " --%s \n %s\n\n", flag.Name, flag.Usage) - } - - return strings.TrimRight(out.String(), "\n") -} - -// Parse CLI args to FlagMap -func (fm *FlagMap) Parse(UI *ui.Ui, args []string) []string { - // Struct used to parse flags - var opts struct { - Strict bool `short:"s" long:"strict"` - Force bool `short:"f" long:"force"` - } - - // Parse flags from `args'. - args, err := flags.ParseArgs(&opts, flagSingleToDoubleDash(args)) - - if err != nil { - UI.Error("Unable to parse flag from the arguments entered '" + fmt.Sprint(args[0]) + "'") - UI.Warn("Flags are entered with double dashes '--', for example '--strict'") - os.Exit(1) - } - - updateFmWithOps := func(flagName string, value interface{}) { - // Check if flag name exists in fm - _, ok := (*fm)[flagName] - - // Update 'fm' if flag exists in map. - if ok { - (*fm)[flagName].Value = value - } - } - - updateFmWithOps("strict", opts.Strict) - updateFmWithOps("force", opts.Force) - - return args -} - -// flag definitions - -// flag --strict -// -// Stop after any errors when deploying -var flagStrict = Flag{ - Name: "strict", - Usage: "Stop after any errors or warnings.", - Default: false, - Value: false, -} - -// flag --force -// -// Prevents CLI prompts asking confirmation -var flagForce = Flag{ - Name: "force", - Usage: "Bypasses CLI prompts without asking for confirmation.", - Default: false, - Value: false, -} diff --git a/command/base_helpers.go b/command/base_helpers.go deleted file mode 100644 index 6b5005a..0000000 --- a/command/base_helpers.go +++ /dev/null @@ -1,53 +0,0 @@ -package command - -import ( - "fmt" -) - -// Populate map of select flags (defaults to ALL flags) -// -// Commands can choose which flags they need -func GetFlagMap(which []string) *FlagMap { - // Convert which slice to a map - // - // This improves performace as map lookups are O(1) - whichMap := make(map[string]struct{}, len(which)) - for _, i := range which { - whichMap[i] = struct{}{} - } - - // Create flag map - fm := make(FlagMap, len(which)) - - addToMap := func(fl *Flag) { - // Check if flag name exists in whichMap - _, ok := whichMap[fl.Name] - - // Add to 'fm' if. - // 'whichMap' map is empty, - // or flag exists in map. - if len(whichMap) == 0 || ok { - fm[fl.Name] = fl - } - } - - addToMap(&flagStrict) - addToMap(&flagForce) - - return &fm -} - -// Detect long flags entered with one dash '-' -// and add a dash to prevent a panic when parsing -// -// -strict -> --strict -func flagSingleToDoubleDash(args []string) []string { - for i, arg := range args { - for _, fl := range FlagNames { - if arg == fmt.Sprintf("-%s", fl) { - args[i] = fmt.Sprintf("--%s", fl) - } - } - } - return args -} diff --git a/command/command.go b/command/command.go index 71e7a48..bc895bd 100644 --- a/command/command.go +++ b/command/command.go @@ -1,38 +1,44 @@ package command import ( - "fmt" "os" + "github.com/hmerritt/reactenv/ui" "github.com/hmerritt/reactenv/version" - - "github.com/mitchellh/cli" + "github.com/spf13/cobra" ) +var Ui = ui.GetUi() + func Run() { - // Initiate new CLI app - app := cli.NewCLI("reactenv", version.GetVersion().VersionNumber()) - app.Args = os.Args[1:] - - // Feed active commands to CLI app - app.Commands = map[string]cli.CommandFactory{ - "run": func() (cli.Command, error) { - return &RunCommand{ - BaseCommand: GetBaseCommand(), - }, nil - }, + rootCmd := NewRootCommand() + if err := rootCmd.Execute(); err != nil { + os.Exit(1) } +} - // Run app - exitStatus, err := app.Run() - if err != nil { - os.Stderr.WriteString(fmt.Sprint(err)) +func NewRootCommand() *cobra.Command { + showVersion := false + + // Setup root CLI + rootCmd := &cobra.Command{ + Use: "reactenv", + Short: "Inject environment variables into a built react app", + Run: func(cmd *cobra.Command, args []string) { + if showVersion { + Ui.Output(version.GetVersion().VersionNumber()) + return + } + _ = cmd.Help() + }, + SilenceUsage: true, } - // Exit without an error if no arguments were passed - if len(app.Args) == 0 { - os.Exit(0) - } + // Flags + rootCmd.Flags().BoolVar(&showVersion, "version", false, "Show version") + + // Commands + rootCmd.AddCommand(NewCommandRun()) - os.Exit(exitStatus) + return rootCmd } diff --git a/command/run.go b/command/run.go index 87f7714..910d710 100644 --- a/command/run.go +++ b/command/run.go @@ -1,151 +1,185 @@ -package command - -import ( - "fmt" - "os" - "regexp" - "strings" - - "github.com/hmerritt/reactenv/reactenv" - "github.com/hmerritt/reactenv/ui" -) - -type RunCommand struct { - *BaseCommand -} - -func (c *RunCommand) Synopsis() string { - return "Inject environment variables into a built react app" -} - -func (c *RunCommand) Help() string { - jsInfo := c.UI.Colorize(".js", c.UI.InfoColor) - helpText := fmt.Sprintf(` -Usage: reactenv run [options] PATH - -Inject environment variables into a built react app. - -Example: - $ reactenv run ./dist/assets - - dist/assets - ├── index.css - ├── index-csxw0qbp%s - ├── login.lazy-b839zm%s - └── user.lazy-c7942lh%s <- Runs on all %s files in PATH -`, jsInfo, jsInfo, jsInfo, jsInfo) - - return strings.TrimSpace(helpText) -} - -func (c *RunCommand) Flags() *FlagMap { - return GetFlagMap(FlagNamesGlobal) -} - -func (c *RunCommand) Run(args []string) int { - duration := ui.InitDuration(c.UI) - - args = c.Flags().Parse(c.UI, args) - - if len(args) == 0 { - c.UI.Error("No asset PATH entered.") - c.exitWithHelp() - } - - pathToAssets := args[0] - - if _, err := os.Stat(pathToAssets); os.IsNotExist(err) { - c.UI.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) - c.exitWithHelp() - } - - // @TODO: Add flag to specify matcher - fileMatchExpression := `.*\.js` - _, err := regexp.Compile(fileMatchExpression) - - if err != nil { - c.UI.Error(fmt.Sprintf("File match expression '%s' is not valid.\n", fileMatchExpression)) - c.UI.Error(fmt.Sprintf("%v", err)) - c.exitWithHelp() - } - - renv := reactenv.NewReactenv(c.UI) - - err = renv.FindFiles(pathToAssets, fileMatchExpression) - - if err != nil { - c.UI.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) - c.UI.Error(fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(renv.Files) == 0 { - c.UI.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchExpression)) - os.Exit(1) - } - - err = renv.FindOccurrences() - - if err != nil { - c.UI.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets)) - c.UI.Error(fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if renv.OccurrencesTotal == 0 { - c.UI.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchExpression, pathToAssets), 0)) - c.UI.Warn(ui.WrapAtLength("Possible causes:", 4)) - c.UI.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) - c.UI.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) - c.UI.Warn("") - duration.In(c.UI.WarnColor, "") - return 1 - } - - c.UI.Output( - fmt.Sprintf( - "Found %d reactenv environment %s in %d/%d matching files:", - renv.OccurrencesTotal, - ui.Pluralize("variable", renv.OccurrencesTotal), - len(renv.Files), - renv.FilesMatchTotal, - ), - ) - for fileIndex, fileOccurrencesTotal := range renv.OccurrencesByFile { - c.UI.Output( - fmt.Sprintf( - " - %4dx in %s", - len(fileOccurrencesTotal.Occurrences), - (*renv.Files[fileIndex]).Name(), - ), - ) - } - c.UI.Output("") - - c.UI.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) - envValuesMissing := 0 - for occurrenceKey := range renv.OccurrenceKeys { - check := "✅" - if _, ok := renv.OccurrenceKeysReplacement[occurrenceKey]; !ok { - check = "❌" - envValuesMissing++ - } - c.UI.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) - } - c.UI.Output("") - - if envValuesMissing > 0 { - c.UI.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) - os.Exit(1) - } - - renv.ReplaceOccurrences() - - duration.In(c.UI.SuccessColor, fmt.Sprintf("Injected all environment variables")) - return 0 -} - -func (c *RunCommand) exitWithHelp() { - c.UI.Output("\nSee 'reactenv run --help'.") - os.Exit(1) -} +package command + +import ( + "fmt" + "os" + "strings" + + "github.com/hmerritt/reactenv/reactenv" + "github.com/hmerritt/reactenv/ui" + "github.com/spf13/cobra" +) + +type RunCommand struct { + FileMatchPattern string +} + +const defaultFileMatchPattern = `.*\.js$` + +func (c *RunCommand) Synopsis() string { + return "Inject environment variables into a bundled react app" +} + +func (c *RunCommand) Help() string { + jsInfo := Ui.Colorize(".js", Ui.InfoColor) + helpText := fmt.Sprintf(` +Usage: reactenv run [options] PATH + +Inject environment variables into a built react app + +Usage: + reactenv run PATH [flags] + +Flags: + -h, --help help for run + --match string File match pattern (regex or glob) (default ".*\\.js$") + +Examples: + $ reactenv run --match "glob:**/*.mjs" ./dist + $ reactenv run --match "regex:^assets/.*\\.js$" ./dist + $ reactenv run ./dist + + dist/ + ├── login/ + │ ├── login.css + │ └── login.lazy-b839zm%s + ├── user/ + │ ├── user.css + │ └── user.lazy-c7942lh%s <- Runs on all %s files in PATH (recursively) + ├── index.html + ├── index-csxw0qbp%s + ├── robots.txt + └── sitemap.xml +`, jsInfo, jsInfo, jsInfo, jsInfo) + + return strings.TrimSpace(helpText) +} + +func NewCommandRun() *cobra.Command { + run := &RunCommand{} + + cmd := &cobra.Command{ + Use: "run PATH", + Short: run.Synopsis(), + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + run.Run(args) + }, + } + + cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + Ui.Output(run.Help()) + }) + + cmd.Flags().StringVar(&run.FileMatchPattern, "match", defaultFileMatchPattern, "File match pattern (regex or glob)") + + return cmd +} + +func (c *RunCommand) Run(args []string) int { + duration := ui.InitDuration(Ui) + + if len(args) == 0 { + Ui.Error("No asset PATH entered.") + c.exitWithHelp() + } + + pathToAssets := args[0] + + fileMatchPattern := c.FileMatchPattern + + if _, err := os.Stat(pathToAssets); os.IsNotExist(err) { + Ui.Error(fmt.Sprintf("File PATH '%s' does not exist.", pathToAssets)) + c.exitWithHelp() + } + + renv := reactenv.NewReactenv(Ui) + + err := renv.FindFiles(pathToAssets, fileMatchPattern) + + if err != nil { + if matchErr, ok := err.(*reactenv.FileMatchError); ok { + Ui.Error(fmt.Sprintf("File match pattern '%s' is not valid.", matchErr.Pattern)) + if matchErr.AutoRegexErr != nil { + Ui.Error(fmt.Sprintf("Regex error: %v", matchErr.AutoRegexErr)) + Ui.Error(fmt.Sprintf("Glob error: %v", matchErr.Err)) + } else { + Ui.Error(fmt.Sprintf("%v", matchErr.Err)) + } + c.exitWithHelp() + } + Ui.Error(fmt.Sprintf("Error reading files in PATH '%s'.\n", pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(renv.Files) == 0 { + Ui.Error(fmt.Sprintf("No files found in path '%s' using matcher '%s'", pathToAssets, fileMatchPattern)) + os.Exit(1) + } + + err = renv.FindOccurrences() + + if err != nil { + Ui.Error(fmt.Sprintf("There was an error while searching for __reactenv variables in the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchPattern, pathToAssets)) + Ui.Error(fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if renv.OccurrencesTotal == 0 { + Ui.Warn(ui.WrapAtLength(fmt.Sprintf("No reactenv environment variables were found in any of the %d '%s' files within '%s', therefore nothing was injected.\n", renv.FilesMatchTotal, fileMatchPattern, pathToAssets), 0)) + Ui.Warn(ui.WrapAtLength("Possible causes:", 4)) + Ui.Warn(ui.WrapAtLength(" - reactenv has already ran on these files", 4)) + Ui.Warn(ui.WrapAtLength(" - Environment variables were not replaced with `__reactenv.` during build", 4)) + Ui.Warn("") + duration.In(Ui.WarnColor, "") + return 1 + } + + Ui.Output( + fmt.Sprintf( + "Found %d reactenv environment %s in %d/%d matching files:", + renv.OccurrencesTotal, + ui.Pluralize("variable", renv.OccurrencesTotal), + len(renv.Files), + renv.FilesMatchTotal, + ), + ) + for fileIndex, fileOccurrencesTotal := range renv.OccurrencesByFile { + Ui.Output( + fmt.Sprintf( + " - %4dx in %s", + len(fileOccurrencesTotal.Occurrences), + renv.FileRelPaths[fileIndex], + ), + ) + } + Ui.Output("") + + Ui.Output(fmt.Sprintf("Environment %s checklist (ticked if value has been set):", ui.Pluralize("variable", renv.OccurrencesTotal))) + envValuesMissing := 0 + for occurrenceKey := range renv.OccurrenceKeys { + check := "✅" + if _, ok := renv.OccurrenceKeysReplacement[occurrenceKey]; !ok { + check = "❌" + envValuesMissing++ + } + Ui.Output(fmt.Sprintf(" - %4s %s", check, occurrenceKey)) + } + Ui.Output("") + + if envValuesMissing > 0 { + Ui.Error(fmt.Sprintf("Environment %s not set. See above checklist for missing values.", ui.Pluralize("variable", envValuesMissing))) + os.Exit(1) + } + + renv.ReplaceOccurrences() + + duration.In(Ui.SuccessColor, fmt.Sprintf("Injected all environment variables")) + return 0 +} + +func (c *RunCommand) exitWithHelp() { + Ui.Output("\nSee 'reactenv run --help'.") + os.Exit(1) +} diff --git a/command/run_integration_test.go b/command/run_integration_test.go new file mode 100644 index 0000000..bc7729c --- /dev/null +++ b/command/run_integration_test.go @@ -0,0 +1,290 @@ +package command + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + runCommandHelperEnv = "REACTENV_RUN_HELPER" + runCommandHelperMode = "REACTENV_RUN_MODE" + runCommandHelperPath = "REACTENV_RUN_PATH" +) + +const ( + runHelperModeMissingArgs = "missing-args" + runHelperModeInvalidMatch = "invalid-match" + runHelperModeMissingEnv = "missing-env" + runHelperModeNoFiles = "no-files" + runHelperModePartialWrite = "partial-write" +) + +func TestRunCommandRunSuccessDefaultMatcher(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + ignoredTxt := filepath.Join(nestedDir, "ignored.txt") + ignoredCSS := filepath.Join(tempDir, "style.css") + ignoredJS := filepath.Join(tempDir, "misc.js") + + writeFile(t, rootJS, `const api="__reactenv.API_URL";`) + writeFile(t, nestedJS, `const name="__reactenv.NAME";`) + writeFile(t, ignoredTxt, `__reactenv.IGNORED`) + writeFile(t, ignoredCSS, "body { color: red; }") + writeFile(t, ignoredJS, "no matches") + + t.Setenv("API_URL", "https://example.com") + t.Setenv("NAME", "app-name") + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 0, exitCode) + + require.Equal(t, `const api="https://example.com";`, readFile(t, rootJS)) + require.Equal(t, `const name="app-name";`, readFile(t, nestedJS)) + require.Equal(t, "__reactenv.IGNORED", readFile(t, ignoredTxt)) + require.Equal(t, "body { color: red; }", readFile(t, ignoredCSS)) + require.Equal(t, "no matches", readFile(t, ignoredJS)) +} + +func TestRunCommandRunSuccessCustomMatcher(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + targetJS := filepath.Join(nestedDir, "only.js") + skipJS := filepath.Join(nestedDir, "skip.js") + + writeFile(t, rootJS, `const root="__reactenv.ROOT";`) + writeFile(t, targetJS, `const nested="__reactenv.NESTED";`) + writeFile(t, skipJS, `const skip="__reactenv.SKIP";`) + + t.Setenv("ROOT", "root") + t.Setenv("NESTED", "nested") + t.Setenv("SKIP", "skip") + + cmd := &RunCommand{FileMatchPattern: "glob:nested/only.js"} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 0, exitCode) + + require.Equal(t, `const nested="nested";`, readFile(t, targetJS)) + require.Equal(t, `const root="__reactenv.ROOT";`, readFile(t, rootJS)) + require.Equal(t, `const skip="__reactenv.SKIP";`, readFile(t, skipJS)) +} + +func TestRunCommandRunMissingEnvExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + + originalRoot := `const missing="__reactenv.MISSING";` + originalNested := `const present="__reactenv.PRESENT";` + + writeFile(t, rootJS, originalRoot) + writeFile(t, nestedJS, originalNested) + + err := runCommandHelperProcess("TestRunCommandRunMissingEnvExits", runHelperModeMissingEnv, tempDir, "PRESENT=present", "MISSING=") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) + + require.Equal(t, originalRoot, readFile(t, rootJS)) + require.Equal(t, originalNested, readFile(t, nestedJS)) +} + +func TestRunCommandRunMissingArgsExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + err := runCommandHelperProcess("TestRunCommandRunMissingArgsExits", runHelperModeMissingArgs, "", "") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunInvalidMatchExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + writeFile(t, filepath.Join(tempDir, "root.js"), `const api="__reactenv.API_URL";`) + + err := runCommandHelperProcess("TestRunCommandRunInvalidMatchExits", runHelperModeInvalidMatch, tempDir, "API_URL=https://example.com") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunNoFilesFoundExits(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + writeFile(t, filepath.Join(tempDir, "style.css"), "body { color: red; }") + + err := runCommandHelperProcess("TestRunCommandRunNoFilesFoundExits", runHelperModeNoFiles, tempDir) + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) +} + +func TestRunCommandRunNoOccurrencesReturnsOne(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + + writeFile(t, rootJS, "const api='no matches';") + writeFile(t, nestedJS, "const name='still none';") + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + exitCode := cmd.Run([]string{tempDir}) + require.Equal(t, 1, exitCode) + + require.Equal(t, "const api='no matches';", readFile(t, rootJS)) + require.Equal(t, "const name='still none';", readFile(t, nestedJS)) +} + +func TestRunCommandRunMissingEnvNoPartialWrites(t *testing.T) { + if handleRunCommandHelper(t) { + return + } + + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.MkdirAll(nestedDir, 0755), "create nested dir") + + rootJS := filepath.Join(tempDir, "root.js") + nestedJS := filepath.Join(nestedDir, "app.js") + otherJS := filepath.Join(nestedDir, "other.js") + + originalRoot := `const missing="__reactenv.MISSING";` + originalNested := `const present="__reactenv.PRESENT";` + originalOther := `const another="__reactenv.ANOTHER";` + + writeFile(t, rootJS, originalRoot) + writeFile(t, nestedJS, originalNested) + writeFile(t, otherJS, originalOther) + + err := runCommandHelperProcess("TestRunCommandRunMissingEnvNoPartialWrites", runHelperModePartialWrite, tempDir, "PRESENT=present", "ANOTHER=another", "MISSING=") + + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + require.Equal(t, 1, exitErr.ExitCode()) + + require.Equal(t, originalRoot, readFile(t, rootJS)) + require.Equal(t, originalNested, readFile(t, nestedJS)) + require.Equal(t, originalOther, readFile(t, otherJS)) +} + +func handleRunCommandHelper(t *testing.T) bool { + t.Helper() + + if os.Getenv(runCommandHelperEnv) != "1" { + return false + } + + mode := os.Getenv(runCommandHelperMode) + helperPath := os.Getenv(runCommandHelperPath) + + cmd := &RunCommand{FileMatchPattern: defaultFileMatchPattern} + + switch mode { + case runHelperModeMissingArgs: + cmd.Run([]string{}) + case runHelperModeInvalidMatch: + cmd.FileMatchPattern = "[" + cmd.Run([]string{helperPath}) + case runHelperModeMissingEnv: + cmd.Run([]string{helperPath}) + case runHelperModeNoFiles: + cmd.Run([]string{helperPath}) + case runHelperModePartialWrite: + cmd.Run([]string{helperPath}) + default: + t.Fatalf("unknown helper mode: %s", mode) + } + + return true +} + +func runCommandHelperProcess(testName string, mode string, dir string, envOverrides ...string) error { + cmd := exec.Command(os.Args[0], "-test.run="+testName) + env := append([]string{}, os.Environ()...) + env = append(env, runCommandHelperEnv+"=1", runCommandHelperMode+"="+mode) + if dir != "" { + env = append(env, runCommandHelperPath+"="+dir) + } + + if len(envOverrides) > 0 { + for _, override := range envOverrides { + if strings.HasSuffix(override, "=") { + env = filterOutEnv(env, override) + continue + } + env = append(env, override) + } + } + + cmd.Env = env + return cmd.Run() +} + +func writeFile(t *testing.T, path string, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0644), "write file %s", path) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err, "read file %s", path) + return string(content) +} + +func filterOutEnv(env []string, prefixes ...string) []string { + filtered := make([]string, 0, len(env)) + for _, entry := range env { + skip := false + for _, prefix := range prefixes { + if strings.HasPrefix(entry, prefix) { + skip = true + break + } + } + if !skip { + filtered = append(filtered, entry) + } + } + return filtered +} diff --git a/go.mod b/go.mod index e364bc3..08636b6 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,16 @@ go 1.25.7 require ( github.com/briandowns/spinner v1.23.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/fatih/color v1.18.0 - github.com/jessevdk/go-flags v1.6.1 github.com/magefile/mage v1.15.0 github.com/mitchellh/cli v1.1.5 github.com/mitchellh/gox v1.0.1 - github.com/posener/complete v1.2.3 + github.com/pkg/sftp v1.13.10 github.com/schollz/progressbar/v3 v3.19.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.48.0 gotest.tools/gotestsum v1.13.0 ) @@ -32,6 +34,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -39,12 +42,12 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/iochan v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pkg/sftp v1.13.10 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/posener/complete v1.2.3 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index ecd2aa4..4967821 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,13 @@ github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE5 github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -54,8 +57,8 @@ github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= -github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -97,6 +100,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -105,6 +109,10 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -112,6 +120,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/magefile.go b/magefile.go index 1246633..48e086e 100644 --- a/magefile.go +++ b/magefile.go @@ -64,6 +64,16 @@ func TestWatch() error { }) } +// Prints coverage report +func Coverage() error { + log := NewLogger() + defer log.End() + return RunSync([][]string{ + {"go-ignore-cov", "--file", "cover.out"}, + {"go", "tool", "cover", "-func", "cover.out"}, + }) +} + func Bench() error { log := NewLogger() defer log.End() diff --git a/npm/reactenv-darwin-arm64/package.json b/npm/reactenv-darwin-arm64/package.json index ee25301..535dfd5 100644 --- a/npm/reactenv-darwin-arm64/package.json +++ b/npm/reactenv-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-arm64", - "version": "0.1.96", + "version": "0.1.116", "description": "The macOS ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-darwin-x64/package.json b/npm/reactenv-darwin-x64/package.json index c9f7b03..b7adbbb 100644 --- a/npm/reactenv-darwin-x64/package.json +++ b/npm/reactenv-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-darwin-x64", - "version": "0.1.96", + "version": "0.1.116", "description": "The macOS 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-arm64/package.json b/npm/reactenv-linux-arm64/package.json index b395215..e9e528b 100644 --- a/npm/reactenv-linux-arm64/package.json +++ b/npm/reactenv-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-arm64", - "version": "0.1.96", + "version": "0.1.116", "description": "The Linux ARM 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-linux-x64/package.json b/npm/reactenv-linux-x64/package.json index 27bc591..a093e67 100644 --- a/npm/reactenv-linux-x64/package.json +++ b/npm/reactenv-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-linux-x64", - "version": "0.1.96", + "version": "0.1.116", "description": "The Linux 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv-win32-x64/package.json b/npm/reactenv-win32-x64/package.json index 680394d..dc47f5d 100644 --- a/npm/reactenv-win32-x64/package.json +++ b/npm/reactenv-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli-win32-x64", - "version": "0.1.96", + "version": "0.1.116", "description": "The Windows 64-bit binary for reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "os": [ diff --git a/npm/reactenv/package.json b/npm/reactenv/package.json index 1414e73..b7e0874 100644 --- a/npm/reactenv/package.json +++ b/npm/reactenv/package.json @@ -1,6 +1,6 @@ { "name": "@reactenv/cli", - "version": "0.1.96", + "version": "0.1.116", "description": "reactenv, an experimental solution to inject env variables after a build.", "license": "Apache-2.0", "bin": { diff --git a/reactenv/reactenv.go b/reactenv/reactenv.go index b48ec6a..55b9bf2 100644 --- a/reactenv/reactenv.go +++ b/reactenv/reactenv.go @@ -6,9 +6,12 @@ import ( "io/fs" "os" "path" + "path/filepath" "regexp" + "sort" "strings" + "github.com/bmatcuk/doublestar/v4" "github.com/hmerritt/reactenv/ui" ) @@ -16,6 +19,29 @@ const ( REACTENV_PREFIX = "__reactenv" ) +const ( + fileMatchModeAuto = "auto" + fileMatchModeRegex = "regex" + fileMatchModeGlob = "glob" +) + +type FileMatchError struct { + Pattern string + Mode string + Err error + AutoRegexErr error +} + +func (e *FileMatchError) Error() string { + if e == nil { + return "" + } + if e.Mode == fileMatchModeAuto && e.AutoRegexErr != nil { + 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) + } + return fmt.Sprintf("file match pattern '%s' is not valid for %s: %v", e.Pattern, e.Mode, e.Err) +} + type Reactenv struct { UI *ui.Ui @@ -26,6 +52,8 @@ type Reactenv struct { FilesMatchTotal int // Files with occurrences (not every matched file will have an occurrence, so this may be less than `FilesMatchTotal`) Files []*fs.DirEntry + // Relative paths (from Dir) for each file in Files. + FileRelPaths []string // Total individual occurrences count OccurrencesTotal int @@ -52,6 +80,7 @@ func NewReactenv(ui *ui.Ui) *Reactenv { UI: ui, Dir: "", Files: make([]*fs.DirEntry, 0), + FileRelPaths: make([]string, 0), OccurrencesTotal: 0, OccurrencesByFile: make([]*FileOccurrences, 0), OccurrenceKeys: make(OccurrenceKeys), @@ -59,38 +88,155 @@ func NewReactenv(ui *ui.Ui) *Reactenv { } } -// Populates `Reactenv.Files` with all files that match `fileMatchExpression` +// Populates `Reactenv.Files` with all files that match `fileMatchExpression`. +// Patterns support regex or glob (auto-detected, with optional "regex:" / "glob:" prefixes). func (r *Reactenv) FindFiles(dir string, fileMatchExpression string) error { r.Dir = dir r.Files = make([]*fs.DirEntry, 0) - files, err := os.ReadDir(r.Dir) + r.FileRelPaths = make([]string, 0) + fileMatcher, _, err := buildFileMatcher(fileMatchExpression) if err != nil { return err } - fileMatcher, err := regexp.Compile(fileMatchExpression) + type fileMatch struct { + entry fs.DirEntry + relPath string + } + + matches := make([]fileMatch, 0) + + err = filepath.WalkDir(r.Dir, func(walkPath string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if entry.IsDir() { + // Prevent scanning of node_modules directory. If necessary, you can bypass this by + // pointing run reactenv to the node_modules directory directly (e.g. `reactenv run node_modules`). + if entry.Name() == "node_modules" && walkPath != r.Dir { + return filepath.SkipDir + } + return nil + } + + relPath, err := filepath.Rel(r.Dir, walkPath) + if err != nil { + relPath = entry.Name() + } + relPath = filepath.ToSlash(relPath) + + if fileMatcher(relPath) { + matches = append(matches, fileMatch{ + entry: entry, + relPath: relPath, + }) + } + + return nil + }) if err != nil { return err } - for _, file := range files { - if fileMatcher.MatchString(file.Name()) && !file.IsDir() { - fileEntry := file - r.Files = append(r.Files, &fileEntry) - } + // Enforce deterministic sorting of matches + sort.Slice(matches, func(i, j int) bool { + return matches[i].relPath < matches[j].relPath + }) + + // Populate `Reactenv.Files` and `Reactenv.FileRelPaths` with sorted matches + for _, match := range matches { + fileEntry := match.entry + r.Files = append(r.Files, &fileEntry) + r.FileRelPaths = append(r.FileRelPaths, match.relPath) } - r.FilesMatchTotal = len(r.Files) + r.FilesMatchTotal = len(matches) return nil } +func buildFileMatcher(pattern string) (func(string) bool, string, error) { + rawPattern := pattern + mode := fileMatchModeAuto + + if strings.HasPrefix(pattern, "regex:") { + mode = fileMatchModeRegex + pattern = strings.TrimPrefix(pattern, "regex:") + } else if strings.HasPrefix(pattern, "glob:") { + mode = fileMatchModeGlob + pattern = strings.TrimPrefix(pattern, "glob:") + } + + switch mode { + case fileMatchModeRegex: + matcher, err := buildRegexMatcher(rawPattern, pattern) + if err != nil { + return nil, mode, err + } + return matcher, mode, nil + case fileMatchModeGlob: + matcher, err := buildGlobMatcher(rawPattern, pattern) + if err != nil { + return nil, mode, err + } + return matcher, mode, nil + default: + matcher, err := buildRegexMatcher(rawPattern, pattern) + if err == nil { + return matcher, fileMatchModeRegex, nil + } + + globMatcher, globErr := buildGlobMatcher(rawPattern, pattern) + if globErr == nil { + return globMatcher, fileMatchModeGlob, nil + } + + return nil, fileMatchModeAuto, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeAuto, + Err: globErr, + AutoRegexErr: err, + } + } +} + +func buildRegexMatcher(rawPattern string, pattern string) (func(string) bool, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeRegex, + Err: err, + } + } + + return func(relPath string) bool { + return re.MatchString(relPath) + }, nil +} + +func buildGlobMatcher(rawPattern string, pattern string) (func(string) bool, error) { + if _, err := doublestar.Match(pattern, ""); err != nil { + return nil, &FileMatchError{ + Pattern: rawPattern, + Mode: fileMatchModeGlob, + Err: err, + } + } + + return func(relPath string) bool { + match, err := doublestar.Match(pattern, relPath) + return err == nil && match + }, nil +} + // Run a callback for each File func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePath string) error) error { for fileIndex, file := range r.Files { - filePath := path.Join(r.Dir, (*file).Name()) + filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex]) err := fileCb(fileIndex, *file, filePath) if err != nil { return err @@ -103,7 +249,7 @@ func (r *Reactenv) FilesWalk(fileCb func(fileIndex int, file fs.DirEntry, filePa // Run a callback for each File, passing in the file contents func (r *Reactenv) FilesWalkContents(fileCb func(fileIndex int, file fs.DirEntry, filePath string, fileContents []byte) error) error { for fileIndex, file := range r.Files { - filePath := path.Join(r.Dir, (*file).Name()) + filePath := path.Join(r.Dir, r.FileRelPaths[fileIndex]) fileContents, err := os.ReadFile(filePath) if err != nil { @@ -162,7 +308,7 @@ func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int { firstByte := data[current] isValidStart := (firstByte >= 'a' && firstByte <= 'z') || (firstByte >= 'A' && firstByte <= 'Z') || - firstByte == '_' || firstByte == '$' + firstByte == '_' if !isValidStart { // Abort: The character following the dot is invalid @@ -180,7 +326,7 @@ func FindAllOccurrenceBytePositions(data []byte, prefix []byte) [][]int { isValid := (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || - b == '_' || b == '$' + b == '_' if !isValid { break @@ -204,6 +350,7 @@ func (r *Reactenv) FindOccurrences() error { // Prep for removing files with no occurrences newFiles := make([]*fs.DirEntry, 0, len(r.Files)) + newFileRelPaths := make([]string, 0, len(r.Files)) newOccurrencesByFile := make([]*FileOccurrences, 0) fileIndexesToRemove := make(map[int]int, 0) @@ -219,7 +366,7 @@ func (r *Reactenv) FindOccurrences() error { for _, occurrence := range fileOccurrences { occurrenceText := string(fileContents[occurrence[0]:occurrence[1]]) - envName := strings.Replace(occurrenceText, "__reactenv.", "", 1) + envName := strings.Replace(occurrenceText, string(prefix), "", 1) envValue, envExists := os.LookupEnv(envName) r.OccurrencesByFile[fileIndex].Occurrences = append(r.OccurrencesByFile[fileIndex].Occurrences, Occurrence{ @@ -250,11 +397,13 @@ func (r *Reactenv) FindOccurrences() error { for fileIndex, file := range r.Files { if _, ok := fileIndexesToRemove[fileIndex]; !ok { newFiles = append(newFiles, file) + newFileRelPaths = append(newFileRelPaths, r.FileRelPaths[fileIndex]) newOccurrencesByFile = append(newOccurrencesByFile, r.OccurrencesByFile[fileIndex]) } } r.Files = newFiles + r.FileRelPaths = newFileRelPaths r.OccurrencesByFile = newOccurrencesByFile } diff --git a/reactenv/reactenv_matcher_test.go b/reactenv/reactenv_matcher_test.go new file mode 100644 index 0000000..94439db --- /dev/null +++ b/reactenv/reactenv_matcher_test.go @@ -0,0 +1,120 @@ +package reactenv + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildFileMatcherModes(t *testing.T) { + t.Run("AutoRegex", func(t *testing.T) { + matcher, mode, err := buildFileMatcher(`^nested/.*\.js$`) + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("nested/app.js")) + require.False(t, matcher("root.js")) + }) + + t.Run("AutoGlob", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("**/*.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeGlob, mode) + require.True(t, matcher("nested/app.js")) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.css")) + }) + + t.Run("RegexPrefix", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("regex:^assets/.*\\.js$") + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("assets/app.js")) + require.False(t, matcher("app.js")) + }) + + t.Run("GlobPrefix", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("glob:*.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeGlob, mode) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.js")) + }) + + t.Run("AutoPrefersRegexWhenBothValid", func(t *testing.T) { + matcher, mode, err := buildFileMatcher("app.js") + require.NoError(t, err) + require.Equal(t, fileMatchModeRegex, mode) + require.True(t, matcher("nested/app.js")) + }) +} + +func TestBuildFileMatcherAutoInvalid(t *testing.T) { + _, mode, err := buildFileMatcher("[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeAuto, mode) + require.Equal(t, fileMatchModeAuto, matchErr.Mode) + require.NotNil(t, matchErr.AutoRegexErr) + require.NotNil(t, matchErr.Err) +} + +func TestBuildFileMatcherPrefixInvalid(t *testing.T) { + t.Run("RegexPrefix", func(t *testing.T) { + _, mode, err := buildFileMatcher("regex:[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeRegex, mode) + require.Equal(t, fileMatchModeRegex, matchErr.Mode) + require.NotNil(t, matchErr.Err) + }) + + t.Run("GlobPrefix", func(t *testing.T) { + _, mode, err := buildFileMatcher("glob:[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeGlob, mode) + require.Equal(t, fileMatchModeGlob, matchErr.Mode) + require.NotNil(t, matchErr.Err) + }) +} + +func TestBuildRegexMatcherMatches(t *testing.T) { + matcher, err := buildRegexMatcher("regex:^assets/.*\\.js$", `^assets/.*\.js$`) + require.NoError(t, err) + require.True(t, matcher("assets/app.js")) + require.False(t, matcher("app.js")) +} + +func TestBuildRegexMatcherInvalid(t *testing.T) { + _, err := buildRegexMatcher("[", "[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeRegex, matchErr.Mode) + require.NotNil(t, matchErr.Err) +} + +func TestBuildGlobMatcherMatches(t *testing.T) { + matcher, err := buildGlobMatcher("glob:**/*.js", "**/*.js") + require.NoError(t, err) + require.True(t, matcher("nested/app.js")) + require.True(t, matcher("app.js")) + require.False(t, matcher("nested/app.css")) +} + +func TestBuildGlobMatcherInvalid(t *testing.T) { + _, err := buildGlobMatcher("[", "[") + require.Error(t, err) + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeGlob, matchErr.Mode) + require.NotNil(t, matchErr.Err) +} diff --git a/reactenv/reactenv_test.go b/reactenv/reactenv_test.go index 61c2f97..3b0c101 100644 --- a/reactenv/reactenv_test.go +++ b/reactenv/reactenv_test.go @@ -40,18 +40,150 @@ func TestReactenvFindFilesMatchesFilesAndIgnoresDirs(t *testing.T) { require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") require.Len(t, renv.Files, 2, "Files length") + require.Len(t, renv.FileRelPaths, 2, "FileRelPaths length") - found := map[string]bool{} - for _, file := range renv.Files { - found[(*file).Name()] = true + expected := []string{"alpha.js", "beta.js"} + + require.Equal(t, expected, []string{(*renv.Files[0]).Name(), (*renv.Files[1]).Name()}) + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesMatchesFilesRecursively(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + deepDir := filepath.Join(nestedDir, "deep") + require.NoError(t, os.Mkdir(deepDir, 0755), "create deep dir") + writeTestFile(t, deepDir, "deep.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 3, renv.FilesMatchTotal, "FilesMatchTotal") + require.Len(t, renv.Files, 3, "Files length") + require.Len(t, renv.FileRelPaths, 3, "FileRelPaths length") + + expected := []string{ + "nested/deep/deep.js", + "nested/nested.js", + "root.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesAutoFallsBackToGlob(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, "**/*.js"), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesRegexMatchesRelativePath(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `^nested/.*\.js$`), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesSupportsGlobPrefix(t *testing.T) { + tempDir := t.TempDir() + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, "glob:**/*.js"), "FindFiles returned error") + require.Equal(t, 1, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + } + + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesSkipsNodeModules(t *testing.T) { + tempDir := t.TempDir() + + writeTestFile(t, tempDir, "root.js") + + nodeModulesDir := filepath.Join(tempDir, "node_modules") + require.NoError(t, os.Mkdir(nodeModulesDir, 0755), "create node_modules dir") + writeTestFile(t, nodeModulesDir, "ignored.js") + + nestedDir := filepath.Join(tempDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(tempDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + "root.js", } - expected := map[string]bool{ - "alpha.js": true, - "beta.js": true, + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") +} + +func TestReactenvFindFilesAllowsRootNodeModules(t *testing.T) { + tempDir := t.TempDir() + + nodeModulesDir := filepath.Join(tempDir, "node_modules") + require.NoError(t, os.Mkdir(nodeModulesDir, 0755), "create node_modules dir") + writeTestFile(t, nodeModulesDir, "root.js") + + nestedDir := filepath.Join(nodeModulesDir, "nested") + require.NoError(t, os.Mkdir(nestedDir, 0755), "create nested dir") + writeTestFile(t, nestedDir, "nested.js") + + renv := NewReactenv(nil) + + require.NoError(t, renv.FindFiles(nodeModulesDir, `.*\.js$`), "FindFiles returned error") + require.Equal(t, 2, renv.FilesMatchTotal, "FilesMatchTotal") + + expected := []string{ + "nested/nested.js", + "root.js", } - require.Equal(t, expected, found, "matched files") + require.Equal(t, expected, renv.FileRelPaths, "matched relative paths") } func TestReactenvFindFilesReturnsErrorForMissingDir(t *testing.T) { @@ -65,7 +197,14 @@ func TestReactenvFindFilesReturnsErrorForBadRegex(t *testing.T) { renv := NewReactenv(nil) tempDir := t.TempDir() - require.Error(t, renv.FindFiles(tempDir, `[`), "expected error for invalid regex") + err := renv.FindFiles(tempDir, `[`) + require.Error(t, err, "expected error for invalid matcher") + + var matchErr *FileMatchError + require.ErrorAs(t, err, &matchErr) + require.Equal(t, fileMatchModeAuto, matchErr.Mode) + require.NotNil(t, matchErr.AutoRegexErr) + require.NotNil(t, matchErr.Err) } func TestReactenvFilesWalkCallsCallbackInOrder(t *testing.T) { @@ -233,9 +372,64 @@ func TestFindAllOccurrenceBytePositions(t *testing.T) { expected: nil, // The function should bypass this entirely. }, { - name: "Acceptance of leading dollar sign and underscore", - input: "__reactenv.$VALID __reactenv._VALID", - expected: [][]int{{0, 17}, {18, 35}}, + name: "Rejection of leading dollar sign", + input: "__reactenv.$INVALID", + expected: nil, + }, + { + name: "Rejection of leading percent sign", + input: "__reactenv.%INVALID", + expected: nil, + }, + { + name: "Rejection of leading exclamation mark", + input: "__reactenv.!INVALID", + expected: nil, + }, + { + name: "Rejection of leading ampersand", + input: "__reactenv.&INVALID", + expected: nil, + }, + { + name: "Rejection of leading asterisk", + input: "__reactenv.*INVALID", + expected: nil, + }, + { + name: "Rejection of leading open parenthesis", + input: "__reactenv.(INVALID", + expected: nil, + }, + { + name: "Rejection of leading close parenthesis", + input: "__reactenv.)INVALID", + expected: nil, + }, + { + name: "Rejection of leading open square bracket", + input: "__reactenv.[INVALID", + expected: nil, + }, + { + name: "Rejection of leading close square bracket", + input: "__reactenv.]INVALID", + expected: nil, + }, + { + name: "Rejection of leading open curly brace", + input: "__reactenv.{INVALID", + expected: nil, + }, + { + name: "Rejection of leading close curly brace", + input: "__reactenv.}INVALID", + expected: nil, + }, + { + name: "Acceptance of leading underscore", + input: "__reactenv._VALID", + expected: [][]int{{0, 17}}, }, { name: "Early termination upon encountering invalid characters", @@ -371,6 +565,7 @@ func TestReactenvFindOccurrencesFiltersFilesWithoutMatches(t *testing.T) { require.Equal(t, 3, renv.OccurrencesTotal) require.Len(t, renv.Files, 2) + require.Len(t, renv.FileRelPaths, 2) require.Len(t, renv.OccurrencesByFile, 2) counts := map[string]int{} @@ -383,6 +578,13 @@ func TestReactenvFindOccurrencesFiltersFilesWithoutMatches(t *testing.T) { "c.js": 2, }, counts) + paths := map[string]bool{} + for _, relPath := range renv.FileRelPaths { + paths[relPath] = true + } + + require.Equal(t, map[string]bool{"a.js": true, "c.js": true}, paths) + expectedKeys := map[string]bool{ "A": true, "B": true, @@ -408,6 +610,7 @@ func TestReactenvFindOccurrencesNoMatchesClearsFiles(t *testing.T) { require.Equal(t, 0, renv.OccurrencesTotal) require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) require.Empty(t, renv.OccurrenceKeysReplacement) @@ -429,6 +632,7 @@ func TestReactenvFindOccurrencesResetsStateOnRepeat(t *testing.T) { require.Equal(t, map[string]bool{"ONE": true}, renv.OccurrenceKeys) require.Equal(t, map[string]string{"ONE": "1"}, renv.OccurrenceKeysReplacement) require.Len(t, renv.Files, 1) + require.Len(t, renv.FileRelPaths, 1) require.Len(t, renv.OccurrencesByFile, 1) require.NoError(t, os.WriteFile(filePath, []byte("no occurrences"), 0644)) @@ -437,6 +641,7 @@ func TestReactenvFindOccurrencesResetsStateOnRepeat(t *testing.T) { require.Equal(t, 0, renv.OccurrencesTotal) require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) require.Empty(t, renv.OccurrenceKeysReplacement) @@ -464,10 +669,12 @@ func FuzzReactenvFindOccurrences(f *testing.F) { expectedTotal := len(matches) require.Equal(t, expectedTotal, renv.OccurrencesTotal) + require.Equal(t, len(renv.Files), len(renv.FileRelPaths)) require.Equal(t, len(renv.Files), len(renv.OccurrencesByFile)) if expectedTotal == 0 { require.Empty(t, renv.Files) + require.Empty(t, renv.FileRelPaths) require.Empty(t, renv.OccurrencesByFile) require.Empty(t, renv.OccurrenceKeys) return diff --git a/ui/progress_bar.go b/ui/progress_bar.go index e4e81bc..5707564 100644 --- a/ui/progress_bar.go +++ b/ui/progress_bar.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( diff --git a/ui/spinner.go b/ui/spinner.go index b84aec7..461e3a1 100644 --- a/ui/spinner.go +++ b/ui/spinner.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( diff --git a/ui/ui.go b/ui/ui.go index d5876bf..6d09c6c 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,3 +1,4 @@ +//coverage:ignore file package ui import ( diff --git a/version/version.go b/version/version.go index 7ae172d..c1072e1 100644 --- a/version/version.go +++ b/version/version.go @@ -1,8 +1,10 @@ +//coverage:ignore file package version import ( "bytes" "fmt" + "os" ) // VersionInfo @@ -90,6 +92,14 @@ func (c *VersionInfo) FullVersionNumber(rev bool) string { } func PrintTitle() { + // Skip printing title and version, usually because the output needs to be pipe-able, when: + // - `completion` command + // - `--version` flag + args := os.Args[1:] + if len(args) > 0 && (args[0] == "completion" || args[0] == "--version") { + return + } + // Get version info versionStruct := GetVersion() diff --git a/version/version_base.go b/version/version_base.go index c98b00f..6b3ea28 100644 --- a/version/version_base.go +++ b/version/version_base.go @@ -1,3 +1,4 @@ +//coverage:ignore file package version const ( @@ -14,7 +15,7 @@ var ( // The compilation date. This will be filled in by the compiler. BuildDate string - Version = "0.1.96" + Version = "0.1.116" VersionPrerelease = "" VersionMetadata = "" )