diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go index 5d0e47150..00a401be4 100644 --- a/pkg/cmd/gist/list/list.go +++ b/pkg/cmd/gist/list/list.go @@ -3,9 +3,11 @@ package list import ( "fmt" "net/http" + "regexp" "strings" "time" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" @@ -20,8 +22,10 @@ type ListOptions struct { Config func() (gh.Config, error) HttpClient func() (*http.Client, error) - Limit int - Visibility string // all, secret, public + Limit int + Filter *regexp.Regexp + IncludeContent bool + Visibility string // all, secret, public } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -33,10 +37,36 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman var flagPublic bool var flagSecret bool + var flagFilter string cmd := &cobra.Command{ - Use: "list", - Short: "List your gists", + Use: "list", + Short: "List your gists", + Long: heredoc.Docf(` + List gists from your user account. + + You can use a regular expression to filter the description, file names, + or even the content of files in the gist using %[1]s--filter%[1]s. + + For supported regular expression syntax, see . + + Use %[1]s--include-content%[1]s to include content of files, noting that + this will be slower and increase the rate limit used. Instead of printing a table, + code will be printed with highlights similar to %[1]sgh search code%[1]s: + + {{gist ID}} {{file name}} + {{description}} + {{matching lines from content}} + + No highlights or other color is printed when output is redirected. + `, "`"), + Example: heredoc.Doc(` + # list all secret gists from your user account + $ gh gist list --secret + + # find all gists from your user account mentioning "octo" anywhere + $ gh gist list --filter octo --include-content + `), Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -44,6 +74,18 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } + if flagFilter == "" { + if opts.IncludeContent { + return cmdutil.FlagErrorf("cannot use --include-content without --filter") + } + } else { + if filter, err := regexp.CompilePOSIX(flagFilter); err != nil { + return err + } else { + opts.Filter = filter + } + } + opts.Visibility = "all" if flagSecret { opts.Visibility = "secret" @@ -61,6 +103,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch") cmd.Flags().BoolVar(&flagPublic, "public", false, "Show only public gists") cmd.Flags().BoolVar(&flagSecret, "secret", false, "Show only secret gists") + cmd.Flags().StringVar(&flagFilter, "filter", "", "Filter gists using a regular `expression`") + cmd.Flags().BoolVar(&opts.IncludeContent, "include-content", false, "Include gists' file content when filtering") return cmd } @@ -78,7 +122,13 @@ func listRun(opts *ListOptions) error { host, _ := cfg.Authentication().DefaultHost() - gists, err := shared.ListGists(client, host, opts.Limit, opts.Visibility) + // Filtering can take a while so start the progress indicator. StopProgressIndicator will no-op if not running. + if opts.Filter != nil { + opts.IO.StartProgressIndicator() + } + gists, err := shared.ListGists(client, host, opts.Limit, opts.Filter, opts.IncludeContent, opts.Visibility) + opts.IO.StopProgressIndicator() + if err != nil { return err } @@ -93,8 +143,38 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } - cs := opts.IO.ColorScheme() - tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED")) + if opts.Filter != nil && opts.IncludeContent { + return printContent(opts.IO, gists, opts.Filter) + } + + return printTable(opts.IO, gists, opts.Filter) +} + +func printTable(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error { + cs := io.ColorScheme() + tp := tableprinter.New(io, tableprinter.WithHeader("ID", "DESCRIPTION", "FILES", "VISIBILITY", "UPDATED")) + + // Highlight filter matches in the description when printing the table. + highlightDescription := func(s string) string { + if filter != nil { + if str, err := highlightMatch(s, filter, nil, cs.Bold, cs.Highlight); err == nil { + return str + } + } + return cs.Bold(s) + } + + // Highlight the files column when any file name matches the filter. + highlightFilesFunc := func(gist *shared.Gist) func(string) string { + if filter != nil { + for _, file := range gist.Files { + if filter.MatchString(file.Filename) { + return cs.Highlight + } + } + } + return normal + } for _, gist := range gists { fileCount := len(gist.Files) @@ -119,9 +199,12 @@ func listRun(opts *ListOptions) error { tp.AddField(gist.ID) tp.AddField( text.RemoveExcessiveWhitespace(description), - tableprinter.WithColor(cs.Bold), + tableprinter.WithColor(highlightDescription), + ) + tp.AddField( + text.Pluralize(fileCount, "file"), + tableprinter.WithColor(highlightFilesFunc(&gist)), ) - tp.AddField(text.Pluralize(fileCount, "file")) tp.AddField(visibility, tableprinter.WithColor(visColor)) tp.AddTimeField(time.Now(), gist.UpdatedAt, cs.Gray) tp.EndRow() @@ -129,3 +212,98 @@ func listRun(opts *ListOptions) error { return tp.Render() } + +// printContent prints a gist with optional description and content similar to `gh search code` +// including highlighted matches in the form: +// +// {{gist ID}} {{file name}} +// {{description, if any}} +// {{content lines with matches, if any}} +// +// If printing to a non-TTY stream the format will be the same but without highlights. +func printContent(io *iostreams.IOStreams, gists []shared.Gist, filter *regexp.Regexp) error { + const tab string = " " + cs := io.ColorScheme() + + out := &strings.Builder{} + var filename, description string + var err error + split := func(r rune) bool { + return r == '\n' || r == '\r' + } + + for _, gist := range gists { + for _, file := range gist.Files { + matched := false + out.Reset() + + if filename, err = highlightMatch(file.Filename, filter, &matched, cs.Green, cs.Highlight); err != nil { + return err + } + fmt.Fprintln(out, cs.Blue(gist.ID), filename) + + if gist.Description != "" { + if description, err = highlightMatch(gist.Description, filter, &matched, cs.Bold, cs.Highlight); err != nil { + return err + } + fmt.Fprintf(out, "%s%s\n", tab, description) + } + + if file.Content != "" { + for _, line := range strings.FieldsFunc(file.Content, split) { + if filter.MatchString(line) { + if line, err = highlightMatch(line, filter, &matched, normal, cs.Highlight); err != nil { + return err + } + fmt.Fprintf(out, "%[1]s%[1]s%[2]s\n", tab, line) + } + } + } + + if matched { + fmt.Fprintln(io.Out, out.String()) + } + } + } + + return nil +} + +func highlightMatch(s string, filter *regexp.Regexp, matched *bool, color, highlight func(string) string) (string, error) { + matches := filter.FindAllStringIndex(s, -1) + if matches == nil { + return color(s), nil + } + + out := strings.Builder{} + + // Color up to the first match. If an empty string, no ANSI color sequence is added. + if _, err := out.WriteString(color(s[:matches[0][0]])); err != nil { + return "", err + } + + // Highlight each match, then color the remaining text which, if an empty string, no ANSI color sequence is added. + for i, match := range matches { + if _, err := out.WriteString(highlight(s[match[0]:match[1]])); err != nil { + return "", err + } + + text := s[match[1]:] + if i+1 < len(matches) { + text = s[match[1]:matches[i+1][0]] + } + if _, err := out.WriteString(color(text)); err != nil { + return "", nil + } + } + + if matched != nil { + *matched = *matched || true + } + + return out.String(), nil +} + +func normal(s string) string { + return s +} diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index a15bae6aa..e6cb35d53 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "net/http" + "regexp" "testing" "time" @@ -19,9 +20,10 @@ import ( func TestNewCmdList(t *testing.T) { tests := []struct { - name string - cli string - wants ListOptions + name string + cli string + wants ListOptions + wantsErr bool }{ { name: "no arguments", @@ -70,6 +72,31 @@ func TestNewCmdList(t *testing.T) { Visibility: "all", }, }, + { + name: "invalid limit", + cli: "--limit 0", + wantsErr: true, + }, + { + name: "filter and include-content", + cli: "--filter octo --include-content", + wants: ListOptions{ + Limit: 10, + Filter: regexp.MustCompilePOSIX("octo"), + IncludeContent: true, + Visibility: "all", + }, + }, + { + name: "invalid filter", + cli: "--filter octo(", + wantsErr: true, + }, + { + name: "include content without filter", + cli: "--include-content", + wantsErr: true, + }, } for _, tt := range tests { @@ -90,6 +117,10 @@ func TestNewCmdList(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } assert.NoError(t, err) assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) @@ -110,6 +141,7 @@ func Test_listRun(t *testing.T) { wantErr bool wantOut string stubs func(*httpmock.Registry) + color bool nontty bool }{ { @@ -358,6 +390,225 @@ func Test_listRun(t *testing.T) { `), nontty: true, }, + { + name: "filtered", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + Visibility: "all", + }, + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + absTime.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Docf(` + 1234%[1]socto match in the description%[1]s1 file%[1]spublic%[1]s2020-07-30T15:24:28Z + 2345%[1]smatch in the file name%[1]s2 files%[1]ssecret%[1]s2020-07-30T15:24:28Z + `, "\t"), + }, + { + name: "filtered (tty)", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + Visibility: "all", + }, + color: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + sixHoursAgo.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Docf(` + %[1]s[0;2;4;37mID %[1]s[0m %[1]s[0;2;4;37mDESCRIPTION %[1]s[0m %[1]s[0;2;4;37mFILES %[1]s[0m %[1]s[0;2;4;37mVISIBILITY%[1]s[0m %[1]s[0;2;4;37mUPDATED %[1]s[0m + 1234 %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m 1 file %[1]s[0;32mpublic %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m + 2345 %[1]s[0;1;39mmatch in the file name %[1]s[0m %[1]s[0;30;43m2 files%[1]s[0m %[1]s[0;31msecret %[1]s[0m %[1]s[38;5;242mabout 6 hours ago%[1]s[m + `, "\x1b"), + }, + { + name: "filtered with content", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + IncludeContent: true, + Visibility: "all", + }, + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + absTime.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Doc(` + 1234 main.txt + octo match in the description + + 2345 octo.txt + match in the file name + + 3456 main.txt + match in the file text + octo in the text + + `), + }, + { + name: "filtered with content (tty)", + opts: &ListOptions{ + Filter: regexp.MustCompile("octo"), + IncludeContent: true, + Visibility: "all", + }, + color: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(query), + httpmock.StringResponse(fmt.Sprintf( + `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [ + { "name": "main.txt", "text": "foo" } + ], + "description": "octo match in the description", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "2345", + "files": [ + { "name": "main.txt", "text": "foo" }, + { "name": "octo.txt", "text": "bar" } + ], + "description": "match in the file name", + "updatedAt": "%[1]v", + "isPublic": false + }, + { + "name": "3456", + "files": [ + { "name": "main.txt", "text": "octo in the text" } + ], + "description": "match in the file text", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + sixHoursAgo.Format(time.RFC3339), + )), + ) + }, + wantOut: heredoc.Docf(` + %[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m + %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m + + %[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m + %[1]s[0;1;39mmatch in the file name%[1]s[0m + + %[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m + %[1]s[0;1;39mmatch in the file text%[1]s[0m + %[1]s[0;30;43mocto%[1]s[0m in the text + + `, "\x1b"), + }, } for _, tt := range tests { @@ -373,6 +624,7 @@ func Test_listRun(t *testing.T) { } ios, _, stdout, _ := iostreams.Test() + ios.SetColorEnabled(tt.color) ios.SetStdoutTTY(!tt.nontty) tt.opts.IO = ios @@ -396,3 +648,59 @@ func Test_listRun(t *testing.T) { }) } } + +func Test_highlightMatch(t *testing.T) { + regex := regexp.MustCompilePOSIX(`[Oo]cto`) + tests := []struct { + name string + input string + color bool + want string + }{ + { + name: "single match", + input: "Octo", + want: "Octo", + }, + { + name: "single match (color)", + input: "Octo", + color: true, + want: "\x1b[0;30;43mOcto\x1b[0m", + }, + { + name: "single match with extra", + input: "Hello, Octocat!", + want: "Hello, Octocat!", + }, + { + name: "single match with extra (color)", + input: "Hello, Octocat!", + color: true, + want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m", + }, + { + name: "multiple matches", + input: "Octocat/octo", + want: "Octocat/octo", + }, + { + name: "multiple matches (color)", + input: "Octocat/octo", + color: true, + want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := iostreams.NewColorScheme(tt.color, false, false) + + matched := false + got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight) + assert.NoError(t, err) + assert.True(t, matched) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 66ba6fe0a..ee6dcf1e3 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "sort" "strings" "time" @@ -74,7 +75,9 @@ func GistIDFromURL(gistURL string) (string, error) { return "", fmt.Errorf("Invalid gist URL %s", u) } -func ListGists(client *http.Client, hostname string, limit int, visibility string) ([]Gist, error) { +const maxPerPage = 100 + +func ListGists(client *http.Client, hostname string, limit int, filter *regexp.Regexp, includeContent bool, visibility string) ([]Gist, error) { type response struct { Viewer struct { Gists struct { @@ -82,6 +85,7 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin Description string Files []struct { Name string + Text string `graphql:"text @include(if: $includeContent)"` } IsPublic bool Name string @@ -96,14 +100,33 @@ func ListGists(client *http.Client, hostname string, limit int, visibility strin } perPage := limit - if perPage > 100 { - perPage = 100 + if perPage > maxPerPage { + perPage = maxPerPage } variables := map[string]interface{}{ - "per_page": githubv4.Int(perPage), - "endCursor": (*githubv4.String)(nil), - "visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)), + "per_page": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + "visibility": githubv4.GistPrivacy(strings.ToUpper(visibility)), + "includeContent": githubv4.Boolean(includeContent), + } + + filterFunc := func(gist *Gist) bool { + if filter.MatchString(gist.Description) { + return true + } + + for _, file := range gist.Files { + if filter.MatchString(file.Filename) { + return true + } + + if includeContent && filter.MatchString(file.Content) { + return true + } + } + + return false } gql := api.NewClientFromHTTP(client) @@ -122,19 +145,22 @@ pagination: for _, file := range gist.Files { files[file.Name] = &GistFile{ Filename: file.Name, + Content: file.Text, } } - gists = append( - gists, - Gist{ - ID: gist.Name, - Description: gist.Description, - Files: files, - UpdatedAt: gist.UpdatedAt, - Public: gist.IsPublic, - }, - ) + gist := Gist{ + ID: gist.Name, + Description: gist.Description, + Files: files, + UpdatedAt: gist.UpdatedAt, + Public: gist.IsPublic, + } + + if filter == nil || filterFunc(&gist) { + gists = append(gists, gist) + } + if len(gists) == limit { break pagination } @@ -177,7 +203,7 @@ func IsBinaryContents(contents []byte) bool { } func PromptGists(prompter prompter.Prompter, client *http.Client, host string, cs *iostreams.ColorScheme) (gistID string, err error) { - gists, err := ListGists(client, host, 10, "all") + gists, err := ListGists(client, host, 10, nil, false, "all") if err != nil { return "", err } diff --git a/pkg/cmd/search/code/code.go b/pkg/cmd/search/code/code.go index 0c318be74..761d2602c 100644 --- a/pkg/cmd/search/code/code.go +++ b/pkg/cmd/search/code/code.go @@ -143,7 +143,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error { } fmt.Fprintf(io.Out, "%s %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path)) for _, match := range code.TextMatches { - lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled()) + lines := formatMatch(match.Fragment, match.Matches, io) for _, line := range lines { fmt.Fprintf(io.Out, "\t%s\n", strings.TrimSpace(line)) } @@ -153,7 +153,7 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error { } for _, code := range results.Items { for _, match := range code.TextMatches { - lines := formatMatch(match.Fragment, match.Matches, io.ColorEnabled()) + lines := formatMatch(match.Fragment, match.Matches, io) for _, line := range lines { fmt.Fprintf(io.Out, "%s:%s: %s\n", cs.Blue(code.Repository.FullName), cs.GreenBold(code.Path), strings.TrimSpace(line)) } @@ -162,7 +162,9 @@ func displayResults(io *iostreams.IOStreams, results search.CodeResult) error { return nil } -func formatMatch(t string, matches []search.Match, colorize bool) []string { +func formatMatch(t string, matches []search.Match, io *iostreams.IOStreams) []string { + cs := io.ColorScheme() + startIndices := map[int]struct{}{} endIndices := map[int]struct{}{} for _, m := range matches { @@ -186,14 +188,10 @@ func formatMatch(t string, matches []search.Match, colorize bool) []string { continue } if _, ok := startIndices[i]; ok { - if colorize { - b.WriteString("\x1b[30;43m") // black text on yellow background - } + b.WriteString(cs.HighlightStart()) found = true } else if _, ok := endIndices[i]; ok { - if colorize { - b.WriteString("\x1b[m") // color reset - } + b.WriteString(cs.Reset()) } b.WriteRune(c) } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 804b9a275..c8d48168f 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -8,6 +8,10 @@ import ( "github.com/mgutz/ansi" ) +const ( + highlightStyle = "black:yellow" +) + var ( magenta = ansi.ColorFunc("magenta") cyan = ansi.ColorFunc("cyan") @@ -20,6 +24,8 @@ var ( bold = ansi.ColorFunc("default+b") cyanBold = ansi.ColorFunc("cyan+b") greenBold = ansi.ColorFunc("green+b") + highlightStart = ansi.ColorCode(highlightStyle) + highlight = ansi.ColorFunc(highlightStyle) gray256 = func(t string) string { return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) @@ -176,6 +182,30 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string { return colo("X") } +func (c *ColorScheme) HighlightStart() string { + if !c.enabled { + return "" + } + + return highlightStart +} + +func (c *ColorScheme) Highlight(t string) string { + if !c.enabled { + return t + } + + return highlight(t) +} + +func (c *ColorScheme) Reset() string { + if !c.enabled { + return "" + } + + return ansi.Reset +} + func (c *ColorScheme) ColorFromString(s string) func(string) string { s = strings.ToLower(s) var fn func(string) string