Skip to content

Commit

Permalink
feat: support custom split functions in createCommandReaders
Browse files Browse the repository at this point in the history
Default is (still) split by line, but in some cases interesting command output spans multiple lines (e.g. commit messages)

This also provides a split function definition that splits by a string.
  • Loading branch information
gerrnot committed Apr 17, 2024
1 parent 31bb808 commit 59e6c53
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 3 deletions.
24 changes: 21 additions & 3 deletions io/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func RunCmdWithOutputParser(config CmdConfig, prompt bool, regExpStruct ...*CmdO
}

cmd := config.GetCmd()
stdoutReader, stderrReader, err := createCommandReaders(cmd)
stdoutReader, stderrReader, err := createCommandReaders(cmd, config)
if err != nil {
return
}
Expand All @@ -95,6 +95,8 @@ func RunCmdWithOutputParser(config CmdConfig, prompt bool, regExpStruct ...*CmdO
go func() {
defer wg.Done()
for stdoutReader.Scan() {
// Notice that a line might actually be a multiline string if CmdConfig specifies a Split() function that
// produces such!
line, _ := processLine(regExpStruct, stdoutReader.Text(), errChan)
if prompt {
fmt.Fprintf(os.Stderr, line+"\n")
Expand Down Expand Up @@ -175,7 +177,7 @@ func processLine(regExpStruct []*CmdOutputPattern, line string, errChan chan err
// Create command stdout and stderr readers.
// The returned readers are automatically closed after the running command exit and shouldn't be closed explicitly.
// cmd - The command to execute
func createCommandReaders(cmd *exec.Cmd) (*bufio.Scanner, *bufio.Scanner, error) {
func createCommandReaders(cmd *exec.Cmd, config CmdConfig) (*bufio.Scanner, *bufio.Scanner, error) {
stdoutReader, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
Expand All @@ -186,7 +188,13 @@ func createCommandReaders(cmd *exec.Cmd) (*bufio.Scanner, *bufio.Scanner, error)
return nil, nil, err
}

return bufio.NewScanner(stdoutReader), bufio.NewScanner(stderrReader), nil
stdoutScanner := bufio.NewScanner(stdoutReader)
stderrScanner := bufio.NewScanner(stderrReader)
if configSplit, ok := config.(CmdConfigSplit); ok {
stdoutScanner.Split(configSplit.Split())
stderrScanner.Split(configSplit.Split())
}
return stdoutScanner, stderrScanner, nil
}

type CmdConfig interface {
Expand All @@ -196,6 +204,16 @@ type CmdConfig interface {
GetErrWriter() io.WriteCloser
}

// CmdConfigSplit
// Optional Extension to CmdConfig
// If the caller implements a split function for its CmdConfig, it will be honored
// Otherwise the default applies (split and parse command output by line)
// Note: Will not add the Split function to CmdConfig, since this would require refactoring the entire code base.
// Also, this allows implementing optionality
type CmdConfigSplit interface {
Split() bufio.SplitFunc
}

// RegExp - The regexp that the line will be searched upon.
// MatchedResults - The slice result that was found by the regexp
// Line - The output line from the external process
Expand Down
34 changes: 34 additions & 0 deletions io/scan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io

import "bytes"

// This file is meant as extension point for go standard library file bufio/scan.go

// SplitAt returns a function that splits cmd output by a given string.
// The returned function implements the type SplitFunc as defined in bufio/scan.go!
// Tribute: https://stackoverflow.com/a/57232670/21511203
func SplitAt(substring string) func(data []byte, atEOF bool) (advance int, token []byte, err error) {
searchBytes := []byte(substring)
searchLen := len(searchBytes)
return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
dataLen := len(data)

// Return nothing if at end of file and no data passed
if atEOF && dataLen == 0 {
return 0, nil, nil
}

// Find next separator and return token
if i := bytes.Index(data, searchBytes); i >= 0 {
return i + searchLen, data[0:i], nil
}

// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return dataLen, data, nil
}

// Request more data.
return 0, nil, nil
}
}
56 changes: 56 additions & 0 deletions io/scan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package io

import (
"bufio"
"slices"
"strings"
"testing"
)

func TestSplitAt(t *testing.T) {
// Define test cases
testCases := []struct {
scenarioDescription string
inputData string
substring string
expectedSplits []string
}{
{
scenarioDescription: "Empty data",
inputData: "",
substring: "separator",
expectedSplits: []string{},
},
{
scenarioDescription: "Data does not contain the separator",
inputData: "someThing Without a matching SePaRaToR",
substring: "separator",
expectedSplits: []string{"someThing Without a matching SePaRaToR"},
},
{
scenarioDescription: "Data contains the separator once",
inputData: "AseparatorB",
substring: "separator",
expectedSplits: []string{"A", "B"},
},
{
scenarioDescription: "Data contains the separator more than once",
inputData: "AseparatorBseparatorC",
substring: "separator",
expectedSplits: []string{"A", "B", "C"},
},
}

// Run test cases
for _, tc := range testCases {
scanner := bufio.NewScanner(strings.NewReader(tc.inputData))
scanner.Split(SplitAt(tc.substring))
actualSplits := []string{}
for scanner.Scan() {
actualSplits = append(actualSplits, scanner.Text())
}
if !slices.Equal(tc.expectedSplits, actualSplits) {
t.Errorf("Test failed for scenario: %s, input data: %s, substring: %s\nExpected: %s\nActual: %s", tc.scenarioDescription, tc.inputData, tc.substring, tc.expectedSplits, actualSplits)
}
}
}

0 comments on commit 59e6c53

Please sign in to comment.