diff --git a/Makefile b/Makefile index c2f8d23..315a4b4 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,9 @@ all: build .PHONY: build build: - @go build -o bin/regtest \ - -ldflags "-X '$(pkg)/pkg/version.Commit=$(git_commit)' -X '$(pkg)/pkg/version.Date=$(date)' -X '$(pkg)/pkg/version.Version=$(git_tag)'" ./cmd/ + @go build -o bin/reggi \ + -ldflags "-X '$(pkg)/pkg/version.Commit=$(git_commit)' -X '$(pkg)/pkg/version.Date=$(date)' -X '$(pkg)/pkg/version.Version=$(git_tag)'" ./cmd/reggi dev: build - @./bin/regtest pkg/ui/model.go test/fixtures/mac.log test/fixtures/test.txt + @./bin/reggi pkg/ui/model.go test/fixtures/mac.log test/fixtures/test.txt diff --git a/README.md b/README.md index 6a2b249..9892d93 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Regtest +# Reggi Interactively validate regex against test data in your console @@ -8,6 +8,33 @@ Interactively validate regex against test data in your console ![Example 3](./assets/example3.png) +# Install + +``` +$ go get github.com/byxorna/reggi/cmd/reggi +``` + +Then, `reggi some.txt files.txt` to interactively test your regexp against your files. + +# Use + +## Input Mode + +Enter a regex to match against the currently focused buffer. Matches are highlighted. + +- `ctrl-y` enable match all expressions +- `ctrl-l` enable multiline match: ^ and $ match begin/end line +- `ctrl-s` enable span line: let . match \n +- `ctrl-i` enable insensitive matching +- Press `esc` to enter pager + +## Pager Mode + +- `i`,`a` to go back to the regex editor +- `H`,`L` to change buffers (if multiple files are open) +- Normal pagination (`hjkl`, `ctrl-f`, `ctrl-b`, `g`, `G`) +- `q`,`ctrl-c` to quit + # Dev ``` diff --git a/cmd/reggi/cmd.go b/cmd/reggi/cmd.go new file mode 100644 index 0000000..c28f964 --- /dev/null +++ b/cmd/reggi/cmd.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/byxorna/regtest/pkg/app" +) + +func main() { + app.Execute() +} diff --git a/cmd/regtest.go b/cmd/regtest.go deleted file mode 100644 index 6531a0e..0000000 --- a/cmd/regtest.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/byxorna/regtest/pkg/ui" - tea "github.com/charmbracelet/bubbletea" -) - -func main() { - model, err := ui.New(os.Args[1:]) - if err != nil { - fmt.Printf("Unable to initialize model: %v\n", err) - os.Exit(1) - } - p := tea.NewProgram(*model) - p.EnterAltScreen() - err = p.Start() - p.ExitAltScreen() - if err != nil { - fmt.Printf("Uh oh, there was an error: %v\n", err) - os.Exit(1) - } -} diff --git a/go.mod b/go.mod index ea95775..a81babf 100644 --- a/go.mod +++ b/go.mod @@ -12,5 +12,5 @@ require ( github.com/mattn/go-runewidth v0.0.10 github.com/muesli/termenv v0.7.4 github.com/rivo/tview v0.0.0-20210117162420-745e4ceeb711 - github.com/spf13/cobra v1.1.1 // indirect + github.com/spf13/cobra v1.1.3 // indirect ) diff --git a/go.sum b/go.sum index 952d537..f7db74a 100644 --- a/go.sum +++ b/go.sum @@ -218,6 +218,8 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -370,6 +372,7 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/app/reggi.go b/pkg/app/reggi.go new file mode 100644 index 0000000..79c9399 --- /dev/null +++ b/pkg/app/reggi.go @@ -0,0 +1,35 @@ +package app + +import ( + "fmt" + "os" + + "github.com/byxorna/regtest/pkg/ui" + "github.com/byxorna/regtest/pkg/version" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: version.Name, + Short: "Reggi is an interactive regular expression tester", + Version: version.Version, + RunE: func(cmd *cobra.Command, args []string) error { + model, err := ui.New(args) + if err != nil { + return fmt.Errorf("unable to initialize model: %v", err) + } + p := tea.NewProgram(*model) + p.EnterAltScreen() + err = p.Start() + p.ExitAltScreen() + return err + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go deleted file mode 100644 index 75f42c9..0000000 --- a/pkg/cli/cli.go +++ /dev/null @@ -1,147 +0,0 @@ -package cli - -import ( - "bufio" - "fmt" - "io" - "io/ioutil" - "os" - "regexp" - "time" - - "github.com/byxorna/regtest/pkg/input" - "github.com/byxorna/regtest/pkg/version" - tcell "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -const ( - InputCompileDelay = 300 * time.Millisecond -) - -var ( - defaultRegex = regexp.MustCompile(`Enter a regex`) -) - -type CLI interface { - Run() error -} - -type cli struct { - *tview.Application - layout *tview.Flex - infoView *tview.TextView - inputView *tview.InputField - pages *tview.Pages - - inputChan chan string - focus focus // what component is focused (see SetFocus()) - - fileViews map[string]*fileView -} - -func New(files []string) CLI { - c := cli{ - inputChan: make(chan string, 10), // buffer input for better response and avoiding deadlocks - fileViews: map[string]*fileView{}, - focus: FocusInput, - } - c.Application = tview.NewApplication() - c.layout = tview.NewFlex() - c.infoView = tview.NewTextView(). - SetScrollable(false) - c.infoView.SetBorderPadding(0, 0, 1, 1). - SetBorder(true) - - c.inputView = inputView() - c.inputView.SetBorder(true) - - c.pages = tview.NewPages() - - for _, f := range files { - fh, err := os.Open(f) - if err != nil { - c.ShowError(err) - continue - } - err = c.OpenFile(f, fh) - if err != nil { - c.ShowError(err) - continue - } - } - if len(files) == 0 { - fmt.Fprintf(os.Stderr, "Reading from stdin...\n") - reader := bufio.NewReader(os.Stdin) - err := c.OpenFile("stdin", reader) - if err != nil { - c.ShowError(err) - } - c.pages.ShowPage("stdin") - } else { - // TODO handle no file here - c.pages.ShowPage(files[0]) - } - - c.layout.AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(c.inputView, 0, 1, true). - AddItem(c.infoView, 0, 1, false), 3, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(c.pages, 0, 1, false), 0, 5, false), 0, 1, false).SetFullScreen(true) - c.Application.SetRoot(c.layout, false).SetFocus(c.inputView) - - c.layout.SetTitle(c.windowTitle()).SetBorder(true) - - go input.Debounce(InputCompileDelay, c.inputChan, func(txt string) { - c.UpdateView(txt) - //TODO: this should move somewhere more central - c.Application.Draw() - }) - - // debounce keystrokes and aggregate evnts to compile regex after delay - c.inputView.SetChangedFunc(func(txt string) { - c.inputChan <- txt - }) - - return &c -} - -func (c *cli) OpenFile(f string, fh io.Reader) error { - data, err := ioutil.ReadAll(fh) - if err != nil { - return err - } - - fv := NewFileView(f, string(data)) - c.fileViews[f] = fv - c.pages.AddPage(f, fv, true, false) - return nil -} - -func (c *cli) Run() error { - c.HandleInputCapture() - c.UpdateView("") - return c.Application.Run() -} - -func inputView() *tview.InputField { - f := tview.NewInputField() - f.SetLabel("r/") - f.SetFieldBackgroundColor(tcell.ColorBlack). - SetFieldTextColor(tcell.ColorWhite). - SetTitle("Regex") - f.SetBorder(true).SetBorderPadding(0, 0, 1, 1) - return f -} - -func (c *cli) FocusedFileView() *fileView { - focusedFile, _ := c.pages.GetFrontPage() - return c.fileViews[focusedFile] -} - -func (c *cli) windowTitle() string { - fv := c.FocusedFileView() - numFiles := c.pages.GetPageCount() - return fmt.Sprintf("[%d] Regtest: %s (%s)", numFiles, fv.FileName(), version.Version) -} diff --git a/pkg/cli/fileview.go b/pkg/cli/fileview.go deleted file mode 100644 index bb4b72a..0000000 --- a/pkg/cli/fileview.go +++ /dev/null @@ -1,55 +0,0 @@ -package cli - -import ( - //tcell "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -// houses the view for a single file's contents and matches -type fileView struct { - *tview.Flex - textView *tview.TextView - fieldView *tview.TextView - rawText string - fileName string -} - -func NewFileView(fName string, content string) *fileView { - textView := tview.NewTextView(). - SetScrollable(true). - SetDynamicColors(true). - SetRegions(true) - - /* - textView.SetInputCapture( - func(event *tcell.EventKey) *tcell.EventKey { - r, c := textView.GetScrollOffset() - fmt.Printf("%d|%d", r, c) - return event - }) - */ - - fieldView := tview.NewTextView(). - SetScrollable(true). - SetDynamicColors(false). - SetWrap(false). - SetRegions(false) - fieldView.SetBorder(true).SetTitle("Captures") - - container := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(textView, 0, 4, false). - AddItem(fieldView, 30, 1, false) - - fv := fileView{ - Flex: container, - textView: textView, - fieldView: fieldView, - rawText: content, - fileName: fName, - } - return &fv -} - -func (fv *fileView) FileName() string { - return fv.fileName -} diff --git a/pkg/cli/highlight.go b/pkg/cli/highlight.go deleted file mode 100644 index ddf3c86..0000000 --- a/pkg/cli/highlight.go +++ /dev/null @@ -1,69 +0,0 @@ -package cli - -import ( - "fmt" - - tcell "github.com/gdamore/tcell/v2" -) - -var ( - matchColor = tcell.ColorSilver - // TODO: use https://medialab.github.io/iwanthue/ to roll better colors - captureColors = []tcell.Color{ - tcell.ColorBlue, - tcell.ColorMaroon, - tcell.ColorGreen, - tcell.ColorYellow, - tcell.ColorNavy, - tcell.ColorPurple, - tcell.ColorTeal, - tcell.ColorRed, - tcell.ColorLime, - tcell.ColorFuchsia, - tcell.ColorAqua, - } -) - -const ( - NoCapture = -1 -) - -func CaptureColor(idx int) tcell.Color { - if idx == NoCapture { - return matchColor - } - return captureColors[idx%len(captureColors)] -} - -// HighlightID is a region identifier in tview that helps -// identify the line, match, and submatch -type HighlightID struct { - LineNum int - MatchNum int - Capture int -} - -func (h *HighlightID) String() string { - if h.Capture != NoCapture { - return fmt.Sprintf("%d:%d:%d", h.LineNum, h.MatchNum, h.Capture) - } else { - return fmt.Sprintf("%d:%d:-", h.LineNum, h.MatchNum) - } -} - -func NewHighlightID(linenum int, matchnum int, submatch int) *HighlightID { - h := HighlightID{ - LineNum: linenum, - MatchNum: matchnum, - } - if submatch >= 0 { - h.Capture = submatch - } else { - h.Capture = NoCapture - } - return &h -} - -func (h *HighlightID) IsCapture() bool { - return h.Capture != NoCapture -} diff --git a/pkg/cli/navi.go b/pkg/cli/navi.go deleted file mode 100644 index 124c26d..0000000 --- a/pkg/cli/navi.go +++ /dev/null @@ -1,88 +0,0 @@ -package cli - -import ( - tcell "github.com/gdamore/tcell/v2" -) - -var () - -type focus int -type focusdir int - -const ( - FocusInput focus = iota - FocusText - FocusCaptures - - FocusDirectionLeft focusdir = iota - FocusDirectionRight - FocusDirectionUp - FocusDirectionDown -) - -func (c *cli) HandleInputCapture() { - windowNaviLatch := false - c.Application.SetInputCapture( - func(event *tcell.EventKey) *tcell.EventKey { - // handle navigation between window elements with ctrl-w + directionals - switch { - case event.Key() == tcell.KeyCtrlW: - windowNaviLatch = true - return nil - case windowNaviLatch: - windowNaviLatch = false - switch event.Rune() { - case 'h', tcell.RuneLArrow: - c.SetFocus(FocusDirectionLeft) - return nil - case 'j', tcell.RuneDArrow: - c.SetFocus(FocusDirectionDown) - return nil - case 'k', tcell.RuneUArrow: - c.SetFocus(FocusDirectionUp) - return nil - case 'l', tcell.RuneRArrow: - c.SetFocus(FocusDirectionLeft) - return nil - } - } - - return event - }) -} - -func (c *cli) SetFocus(direction focusdir) { - fv := c.focusedFileView() - switch c.focus { - case FocusInput: - switch direction { - case FocusDirectionDown, FocusDirectionUp: - c.Application.SetFocus(fv.textView) - c.focus = FocusText - } - case FocusCaptures: - switch direction { - case FocusDirectionDown, FocusDirectionUp: - c.Application.SetFocus(c.inputView) - c.focus = FocusInput - case FocusDirectionLeft, FocusDirectionRight: - c.Application.SetFocus(fv.textView) - c.focus = FocusText - } - case FocusText: - switch direction { - case FocusDirectionDown, FocusDirectionUp: - c.Application.SetFocus(c.inputView) - c.focus = FocusInput - case FocusDirectionLeft, FocusDirectionRight: - c.Application.SetFocus(fv.fieldView) - c.focus = FocusCaptures - } - } -} - -func (c *cli) focusedFileView() *fileView { - focusedFile, _ := c.pages.GetFrontPage() - fv := c.fileViews[focusedFile] - return fv -} diff --git a/pkg/cli/regex.go b/pkg/cli/regex.go deleted file mode 100644 index e8e8088..0000000 --- a/pkg/cli/regex.go +++ /dev/null @@ -1,129 +0,0 @@ -package cli - -import ( - "fmt" - "regexp" - "sort" - "strings" - - tcell "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func (c *cli) UpdateView(rawRe string) { - compiledRe, err := regexp.Compile(rawRe) - c.UpdateInfoView(rawRe, compiledRe, err) - c.HandleFilter(compiledRe) -} - -func (c *cli) UpdateInfoView(rawRe string, compiledRe *regexp.Regexp, err error) { - if rawRe == "" { - c.infoView.SetText("Enter a regex").SetTextColor(tcell.ColorViolet) - } else if err != nil { - c.ShowError(err) - } else { - c.infoView. - SetText(fmt.Sprintf("Compiled: %+v (%d captures)", compiledRe, compiledRe.NumSubexp())). - SetTextColor(tcell.ColorTeal) - } -} - -func (c *cli) ShowError(err error) { - c.infoView.SetText(fmt.Sprintf("%v", err)).SetTextColor(tcell.ColorRed) -} - -func (c *cli) HandleFilter(re *regexp.Regexp) { - fv := c.focusedFileView() - - // populate the text view with fields highlighted - processedText := "" - highlightids := []string{} - lines := strings.Split(tview.Escape(fv.rawText), "\n") - matchingCaptures := map[int][]string{} - for lineNo, rawline := range lines { - if re == nil { - processedText += rawline + "\n" - continue - } - capMatches := re.FindAllStringSubmatchIndex(rawline, -1) - // TODO: bytesbuffer would be snappier - line := "" - - var prevHighlight *HighlightID - var currentHighlight *HighlightID - for n := 0; n < len(rawline); n++ { - prevHighlight = currentHighlight - currentHighlight = nil - for matchID, match := range capMatches { - for i := 0; i < len(match)/2; i++ { - if n >= match[i] && n < match[i+1] { - currentHighlight = NewHighlightID(lineNo, matchID, i) - break - } - } - if currentHighlight != nil { - break - } - } - - if prevHighlight != currentHighlight { - if currentHighlight != nil { - color := `blue` // normal highlights - if currentHighlight.IsCapture() { - color = `red` - } - line += fmt.Sprintf(`["%s"][%s]`, currentHighlight, color) - highlightids = append(highlightids, currentHighlight.String()) - } - if currentHighlight == nil { - line += `[""][white]` - } - } - line += string(rawline[n]) - } - - processedText += line + "\n" - - // now store the captures for viewing on the right panel - captures := []string{} - for _, match := range capMatches { - if len(match) <= 2 { - continue - } - for i := 0; i < len(match)/2; i++ { - if i == 0 { - continue - } - captures = append(captures, rawline[match[2*i]:match[2*i+1]]) - } - } - matchingCaptures[lineNo] = captures - } - fv.textView.Highlight(highlightids...) - fv.textView.SetText(processedText) - - // for the fields view, for the currently selected lines, show the matches in a list - linesWithCaptures := make([]int, len(matchingCaptures)) - i := 0 - for k := range matchingCaptures { - linesWithCaptures[i] = k - i++ - } - sort.Ints(linesWithCaptures) - txt := "" - for lineNo := range linesWithCaptures { - captures := matchingCaptures[lineNo] - if len(captures) == 0 { - continue - } - x := make([]string, len(captures)) - for i, f := range captures { - x[i] = fmt.Sprintf(" match %d: %s", i, f) - } - txt += fmt.Sprintf("Line %d:\n%s\n", lineNo, strings.Join(x, "\n")) - } - if txt == "" { - txt = "No captures" - } - fv.fieldView.SetText(txt).ScrollToBeginning() -}