From b5cbc60090775d024f9b10e5c84624f7e8b85880 Mon Sep 17 00:00:00 2001 From: Alex Rakowski <20504869+agrski@users.noreply.github.com> Date: Thu, 9 Jun 2022 23:46:47 +0100 Subject: [PATCH] Add console writer for match results (#27) * Add constants for ANSI colour & escape codes * Add end column & full line fields for found matches * Add basic console writer for matches * Fix incorrect match fields used in console writer * Handle colour output toggle in console writer * Update exact matcher tests to remove unit-valued offsets for line & column numbers It is simpler for other parts of the application to treat these values as offsets, rather than correcting for human/end-user expectations. Instead, it will be the role of any presentation layer implementation to make such adjustments as it sees fit. * Add expected end columns & matching lines to exact matcher test cases * Fix line & column values to remove unit offsets * Add end column for matches to results * Add matching lines to exact matcher results * Add flags & parsing logic for colour for match results * Make ANSI escape/colour codes private to package * Make ANSI escape/colour code type private to package * Move third-party imports to separate group from in-app imports * Update go.mod * Use line number not path as match prefix in console writer * Add func to create a logger for console-writing purposes * Add matching & console logging for first fetched result instead of simply printing * Replace zerolog with simple STDOUT interactions for console writer Zerolog's console logging is expensive, due to the following: * The full zerolog JSON log creation is run * The marshalled JSON is unmarshalled back into Go objects * These objects are treated as being of type 'any', thus use reflection to be written out In all, this is significantly more expensive than using STDOUT directly. Furthermore, the zerolog logger creation adds more complexity than interacting with an io.Writer (or io.StringWriter), especially considering the existing ANSI colour handling. * Handle colour toggle for file paths in console logger * Propagate colour toggle to zerolog logger --- cmd/cli/args.go | 25 +++++++++++- cmd/cli/main.go | 21 +++++++--- go.mod | 1 + pkg/match/exact.go | 17 ++++---- pkg/match/exact_test.go | 36 +++++++++++------ pkg/match/match.go | 6 ++- pkg/present/console/colour.go | 61 ++++++++++++++++++++++++++++ pkg/present/console/console.go | 74 ++++++++++++++++++++++++++++++++++ 8 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 pkg/present/console/colour.go create mode 100644 pkg/present/console/console.go diff --git a/cmd/cli/args.go b/cmd/cli/args.go index df04d78..b30e2c2 100644 --- a/cmd/cli/args.go +++ b/cmd/cli/args.go @@ -4,12 +4,14 @@ import ( "errors" "flag" "fmt" + "os" "strings" "github.com/agrski/greg/pkg/auth" fetchTypes "github.com/agrski/greg/pkg/fetch/types" "github.com/agrski/greg/pkg/match" "github.com/agrski/greg/pkg/types" + "github.com/mattn/go-isatty" "golang.org/x/oauth2" ) @@ -39,8 +41,10 @@ type rawArgs struct { accessToken string accessTokenFile string // Presentation/display behaviour - quiet bool - verbose bool + quiet bool + verbose bool + colour bool + noColour bool } type Args struct { @@ -49,6 +53,7 @@ type Args struct { filetypes []types.FileExtension tokenSource oauth2.TokenSource verbosity VerbosityLevel + enableColour bool } func GetArgs() (*Args, error) { @@ -81,12 +86,15 @@ func GetArgs() (*Args, error) { verbosity := getVerbosity(raw.quiet, raw.verbose) + enableColour := getColourEnabled(raw.colour, raw.noColour) + return &Args{ location: location, searchPattern: pattern, filetypes: filetypes, tokenSource: tokenSource, verbosity: verbosity, + enableColour: enableColour, }, nil } @@ -112,6 +120,8 @@ func parseArguments() (*rawArgs, error) { ) flag.BoolVar(&args.quiet, "quiet", false, "disable logging; overrides verbose mode") flag.BoolVar(&args.verbose, "verbose", false, "increase logging; overridden by quiet mode") + flag.BoolVar(&args.colour, "colour", false, "force coloured outputs; overridden by no-colour") + flag.BoolVar(&args.noColour, "no-colour", false, "force uncoloured outputs; overrides colour") flag.Parse() if 1 != flag.NArg() { @@ -247,3 +257,14 @@ func getVerbosity(quiet bool, verbose bool) VerbosityLevel { return VerbosityNormal } } + +func getColourEnabled(forceColour bool, forceNoColour bool) bool { + if forceNoColour { + return false + } else if forceColour { + return true + } else { + fd := os.Stdout.Fd() + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) + } +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 13e57f4..220edfd 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -7,19 +7,23 @@ import ( "strings" "time" + "github.com/rs/zerolog" + "github.com/agrski/greg/pkg/fetch" fetchTypes "github.com/agrski/greg/pkg/fetch/types" - "github.com/rs/zerolog" + "github.com/agrski/greg/pkg/match" + "github.com/agrski/greg/pkg/present/console" ) func main() { - logger := makeLogger(zerolog.InfoLevel) - args, err := GetArgs() if err != nil { + logger := makeLogger(zerolog.InfoLevel, false) logger.Fatal().Err(err).Send() } + logger := makeLogger(zerolog.InfoLevel, args.enableColour) + switch args.verbosity { case VerbosityQuiet: logger = logger.Level(zerolog.Disabled) @@ -29,6 +33,10 @@ func main() { // Already at normal verbosity } + console := console.New(os.Stdout, args.enableColour) + + matcher := match.New(logger, args.filetypes) + fetcher := fetch.New(logger, args.location, args.tokenSource) uri := makeURI(args.location) @@ -41,12 +49,14 @@ func main() { fetcher.Start() next, ok := fetcher.Next() if ok { - fmt.Println(next) + if m, ok := matcher.Match(args.searchPattern, next); ok { + console.Write(next, m) + } } fetcher.Stop() } -func makeLogger(level zerolog.Level) zerolog.Logger { +func makeLogger(level zerolog.Level, enableColour bool) zerolog.Logger { fieldKeyFormatter := func(v interface{}) string { return strings.ToUpper( fmt.Sprintf("%s=", v), @@ -54,6 +64,7 @@ func makeLogger(level zerolog.Level) zerolog.Logger { } logWriter := zerolog.ConsoleWriter{ Out: os.Stderr, + NoColor: !enableColour, TimeFormat: time.RFC3339, FormatLevel: func(v interface{}) string { l, ok := v.(string) diff --git a/go.mod b/go.mod index 6810758..ade8cd4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/hasura/go-graphql-client v0.6.3 + github.com/mattn/go-isatty v0.0.12 github.com/rs/zerolog v1.26.1 github.com/stretchr/testify v1.4.0 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b diff --git a/pkg/match/exact.go b/pkg/match/exact.go index 568a1b5..84c4d7b 100644 --- a/pkg/match/exact.go +++ b/pkg/match/exact.go @@ -33,16 +33,19 @@ func (em *exactMatcher) Match(pattern string, next *types.FileInfo) (*Match, boo lineReader := bufio.NewScanner( strings.NewReader(next.Text), ) - row := uint(0) - for lineReader.Scan() { - row++ - - matchColumns := em.matchLine(pattern, lineReader.Text()) + for row := 0; lineReader.Scan(); row++ { + line := lineReader.Text() + matchColumns := em.matchLine(pattern, line) for _, column := range matchColumns { match.Positions = append( match.Positions, - &FilePosition{Line: row, Column: column}, + &FilePosition{ + Line: uint(row), + ColumnStart: column, + ColumnEnd: column + uint(len(pattern)), + Text: line, + }, ) } } @@ -68,7 +71,7 @@ func (em *exactMatcher) matchLine(pattern string, line string) []uint { break } else { column += offset - matchColumns = append(matchColumns, uint(1+column)) + matchColumns = append(matchColumns, uint(column)) column += len(pattern) line = line[offset+len(pattern):] diff --git a/pkg/match/exact_test.go b/pkg/match/exact_test.go index 73355d1..2744491 100644 --- a/pkg/match/exact_test.go +++ b/pkg/match/exact_test.go @@ -59,8 +59,10 @@ func TestMatch(t *testing.T) { expected: &Match{ Positions: []*FilePosition{ { - Line: 1, - Column: 5, + Line: 0, + ColumnStart: 4, + ColumnEnd: 7, + Text: "foo bar baz", }, }, }, @@ -79,8 +81,10 @@ foo expected: &Match{ Positions: []*FilePosition{ { - Line: 5, - Column: 1, + Line: 4, + ColumnStart: 0, + ColumnEnd: 3, + Text: "foo", }, }, }, @@ -99,12 +103,16 @@ foo fifth expected: &Match{ Positions: []*FilePosition{ { - Line: 2, - Column: 8, + Line: 1, + ColumnStart: 7, + ColumnEnd: 10, + Text: "second foo", }, { - Line: 5, - Column: 1, + Line: 4, + ColumnStart: 0, + ColumnEnd: 3, + Text: "foo fifth", }, }, }, @@ -118,12 +126,16 @@ foo fifth expected: &Match{ Positions: []*FilePosition{ { - Line: 1, - Column: 1, + Line: 0, + ColumnStart: 0, + ColumnEnd: 3, + Text: "foo bar foo", }, { - Line: 1, - Column: 9, + Line: 0, + ColumnStart: 8, + ColumnEnd: 11, + Text: "foo bar foo", }, }, }, diff --git a/pkg/match/match.go b/pkg/match/match.go index 79de0c4..34dc1bc 100644 --- a/pkg/match/match.go +++ b/pkg/match/match.go @@ -14,8 +14,10 @@ type Match struct { } type FilePosition struct { - Line uint - Column uint + Line uint + ColumnStart uint + ColumnEnd uint + Text string } type filteringMatcher struct { diff --git a/pkg/present/console/colour.go b/pkg/present/console/colour.go new file mode 100644 index 0000000..ebf77c8 --- /dev/null +++ b/pkg/present/console/colour.go @@ -0,0 +1,61 @@ +package console + +// ANSI colour codes + +type ansiCode string + +const ( + escape ansiCode = "\u001b" + csi ansiCode = "[" + codePrefix = escape + csi + codeSuffix = "m" +) + +const ( + reset ansiCode = codePrefix + "0" + codeSuffix + + bold ansiCode = codePrefix + "1" + codeSuffix + faint ansiCode = codePrefix + "2" + codeSuffix + italic ansiCode = codePrefix + "3" + codeSuffix + underline ansiCode = codePrefix + "4" + codeSuffix + invert ansiCode = codePrefix + "7" + codeSuffix + conceal ansiCode = codePrefix + "8" + codeSuffix + strikethrough ansiCode = codePrefix + "9" + codeSuffix + + fgBlack ansiCode = codePrefix + "30" + codeSuffix + fgRed ansiCode = codePrefix + "31" + codeSuffix + fgGreen ansiCode = codePrefix + "32" + codeSuffix + fgYellow ansiCode = codePrefix + "33" + codeSuffix + fgBlue ansiCode = codePrefix + "34" + codeSuffix + fgMagenta ansiCode = codePrefix + "35" + codeSuffix + fgCyan ansiCode = codePrefix + "36" + codeSuffix + fgWhite ansiCode = codePrefix + "37" + codeSuffix + fgDefault ansiCode = codePrefix + "39" + codeSuffix + + bgBlack ansiCode = codePrefix + "40" + codeSuffix + bgRed ansiCode = codePrefix + "41" + codeSuffix + bgGreen ansiCode = codePrefix + "42" + codeSuffix + bgYellow ansiCode = codePrefix + "43" + codeSuffix + bgBlue ansiCode = codePrefix + "44" + codeSuffix + bgMagenta ansiCode = codePrefix + "45" + codeSuffix + bgCyan ansiCode = codePrefix + "46" + codeSuffix + bgWhite ansiCode = codePrefix + "47" + codeSuffix + + fgIntenseBlack ansiCode = codePrefix + "90" + codeSuffix + fgIntenseRed ansiCode = codePrefix + "91" + codeSuffix + fgIntenseGreen ansiCode = codePrefix + "92" + codeSuffix + fgIntenseYellow ansiCode = codePrefix + "93" + codeSuffix + fgIntenseBlue ansiCode = codePrefix + "94" + codeSuffix + fgIntenseMagenta ansiCode = codePrefix + "95" + codeSuffix + fgIntenseCyan ansiCode = codePrefix + "96" + codeSuffix + fgIntenseWhite ansiCode = codePrefix + "97" + codeSuffix + + bgIntenseBlack ansiCode = codePrefix + "100" + codeSuffix + bgIntenseRed ansiCode = codePrefix + "101" + codeSuffix + bgIntenseGreen ansiCode = codePrefix + "102" + codeSuffix + bgIntenseYellow ansiCode = codePrefix + "103" + codeSuffix + bgIntenseBlue ansiCode = codePrefix + "104" + codeSuffix + bgIntenseMagenta ansiCode = codePrefix + "105" + codeSuffix + bgIntenseCyan ansiCode = codePrefix + "106" + codeSuffix + bgIntenseWhite ansiCode = codePrefix + "107" + codeSuffix +) diff --git a/pkg/present/console/console.go b/pkg/present/console/console.go new file mode 100644 index 0000000..ee44dce --- /dev/null +++ b/pkg/present/console/console.go @@ -0,0 +1,74 @@ +package console + +import ( + "io" + "strconv" + "strings" + + "github.com/agrski/greg/pkg/match" + "github.com/agrski/greg/pkg/types" +) + +type Console struct { + enableColour bool + out io.StringWriter +} + +func New(out io.StringWriter, enableColour bool) *Console { + return &Console{ + enableColour: enableColour, + out: out, + } +} + +func (c *Console) Write(fileInfo *types.FileInfo, match *match.Match) { + sb := strings.Builder{} + + if c.enableColour { + sb.WriteString(string(fgBlue)) + sb.WriteString(fileInfo.Path) + sb.WriteString(string(reset)) + } else { + sb.WriteString(fileInfo.Path) + } + sb.WriteString("\n") + _, err := c.out.WriteString(sb.String()) + if err != nil { + return + } + + for _, p := range match.Positions { + sb := strings.Builder{} + + line := strconv.Itoa(int(p.Line + 1)) + + if c.enableColour { + // Line number + sb.WriteString(string(fgMagenta)) + sb.WriteString(line) + sb.WriteString(string(reset)) + sb.WriteByte(':') + // Text + sb.WriteString(p.Text[:p.ColumnStart]) + sb.WriteString(string(fgRed)) + sb.WriteString(p.Text[p.ColumnStart:p.ColumnEnd]) + sb.WriteString(string(reset)) + sb.WriteString(p.Text[p.ColumnEnd:]) + } else { + // Line number + sb.WriteString(line) + sb.WriteByte(':') + // Text + sb.WriteString(p.Text) + } + + sb.WriteString("\n") + + _, err := c.out.WriteString(sb.String()) + if err != nil { + return + } + } + + _, _ = c.out.WriteString("\n") +}