diff --git a/activestate.yaml b/activestate.yaml index 0f18c9cfdb..8d021d9ce1 100644 --- a/activestate.yaml +++ b/activestate.yaml @@ -396,6 +396,11 @@ scripts: TARGET_BRANCH="master" fi + if [ "$TARGET_BRANCH" == "master" ]; then + echo "Target branch is master, not checking for newlines" + exit 0 + fi + git fetch --quiet origin $TARGET_BRANCH:refs/remotes/origin/$TARGET_BRANCH CHANGED=$(git diff --name-only origin/$TARGET_BRANCH | grep -v testdata | grep -v vendor) diff --git a/cmd/state-svc/internal/messages/messages.go b/cmd/state-svc/internal/messages/messages.go index 88cdf78f9e..1495a8a1b2 100644 --- a/cmd/state-svc/internal/messages/messages.go +++ b/cmd/state-svc/internal/messages/messages.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "runtime/debug" "sync" "time" @@ -15,6 +16,7 @@ import ( "github.com/ActiveState/cli/internal/httputil" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/poller" + "github.com/ActiveState/cli/internal/runbits/panics" "github.com/ActiveState/cli/internal/strutils" auth "github.com/ActiveState/cli/pkg/platform/authentication" "github.com/ActiveState/cli/pkg/sysinfo" @@ -43,6 +45,9 @@ func New(cfg *config.Instance, auth *auth.Auth) (*Messages, error) { } poll := poller.New(1*time.Hour, func() (interface{}, error) { + defer func() { + panics.LogAndPanic(recover(), debug.Stack()) + }() resp, err := fetch() return resp, err }) @@ -74,7 +79,11 @@ func (m *Messages) Check(command string, flags []string) ([]*graph.MessageInfo, if cacheValue == nil { return []*graph.MessageInfo{}, nil } - allMessages := cacheValue.([]*graph.MessageInfo) + + allMessages, ok := cacheValue.([]*graph.MessageInfo) + if !ok { + return nil, errs.New("cacheValue has unexpected type: %T", cacheValue) + } conditionParams := *m.baseParams // copy conditionParams.UserEmail = m.auth.Email() @@ -110,7 +119,11 @@ func check(params *ConditionParams, messages []*graph.MessageInfo, lastReportMap logging.Debug("Checking message %s", message.ID) // Ensure we don't show the same message too often if lastReport, ok := lastReportMap[message.ID]; ok { - lastReportTime, err := time.Parse(time.RFC3339, lastReport.(string)) + lr, ok := lastReport.(string) + if !ok { + return nil, errs.New("Could not get last reported time for message %s as it's not a string: %T", message.ID, lastReport) + } + lastReportTime, err := time.Parse(time.RFC3339, lr) if err != nil { return nil, errs.New("Could not parse last reported time for message %s as it's not a valid RFC3339 value: %v", message.ID, lastReport) } diff --git a/cmd/state-svc/internal/resolver/resolver.go b/cmd/state-svc/internal/resolver/resolver.go index 7995751f88..ba91441e7f 100644 --- a/cmd/state-svc/internal/resolver/resolver.go +++ b/cmd/state-svc/internal/resolver/resolver.go @@ -24,9 +24,9 @@ import ( "github.com/ActiveState/cli/internal/graph" "github.com/ActiveState/cli/internal/logging" configMediator "github.com/ActiveState/cli/internal/mediators/config" - "github.com/ActiveState/cli/internal/multilog" "github.com/ActiveState/cli/internal/poller" "github.com/ActiveState/cli/internal/rtutils/ptr" + "github.com/ActiveState/cli/internal/runbits/panics" "github.com/ActiveState/cli/internal/updater" "github.com/ActiveState/cli/pkg/platform/authentication" "github.com/ActiveState/cli/pkg/projectfile" @@ -57,6 +57,9 @@ func New(cfg *config.Instance, an *sync.Client, auth *authentication.Auth) (*Res upchecker := updater.NewDefaultChecker(cfg, an) pollUpdate := poller.New(1*time.Hour, func() (interface{}, error) { + defer func() { + panics.LogAndPanic(recover(), debug.Stack()) + }() logging.Debug("Poller checking for update info") return upchecker.CheckFor(constants.ChannelName, "") }) @@ -71,6 +74,9 @@ func New(cfg *config.Instance, an *sync.Client, auth *authentication.Auth) (*Res } pollAuth := poller.New(time.Duration(int64(time.Millisecond)*pollRate), func() (interface{}, error) { + defer func() { + panics.LogAndPanic(recover(), debug.Stack()) + }() if auth.SyncRequired() { return nil, auth.Sync() } @@ -110,7 +116,7 @@ func (r *Resolver) Query() genserver.QueryResolver { return r } func (r *Resolver) Mutation() genserver.MutationResolver { return r } func (r *Resolver) Version(ctx context.Context) (*graph.Version, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() r.an.EventWithLabel(anaConsts.CatStateSvc, "endpoint", "Version") logging.Debug("Version resolver") @@ -126,7 +132,7 @@ func (r *Resolver) Version(ctx context.Context) (*graph.Version, error) { } func (r *Resolver) AvailableUpdate(ctx context.Context, desiredChannel, desiredVersion string) (*graph.AvailableUpdate, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() if desiredChannel == "" { desiredChannel = constants.ChannelName @@ -174,7 +180,7 @@ func (r *Resolver) AvailableUpdate(ctx context.Context, desiredChannel, desiredV } func (r *Resolver) Projects(ctx context.Context) ([]*graph.Project, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() r.an.EventWithLabel(anaConsts.CatStateSvc, "endpoint", "Projects") logging.Debug("Projects resolver") @@ -194,7 +200,7 @@ func (r *Resolver) Projects(ctx context.Context) ([]*graph.Project, error) { } func (r *Resolver) AnalyticsEvent(_ context.Context, category, action, source string, _label *string, dimensionsJson string) (*graph.AnalyticsEventResponse, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() logging.Debug("Analytics event resolver: %s - %s: %s (%s)", category, action, ptr.From(_label, "NIL"), source) @@ -228,7 +234,7 @@ func (r *Resolver) AnalyticsEvent(_ context.Context, category, action, source st } func (r *Resolver) ReportRuntimeUsage(_ context.Context, pid int, exec, source string, dimensionsJSON string) (*graph.ReportRuntimeUsageResponse, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() logging.Debug("Runtime usage resolver: %d - %s", pid, exec) var dims *dimensions.Values @@ -242,26 +248,26 @@ func (r *Resolver) ReportRuntimeUsage(_ context.Context, pid int, exec, source s } func (r *Resolver) CheckMessages(ctx context.Context, command string, flags []string) ([]*graph.MessageInfo, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() logging.Debug("Check messages resolver") return r.messages.Check(command, flags) } func (r *Resolver) ConfigChanged(ctx context.Context, key string) (*graph.ConfigChangedResponse, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() go configMediator.NotifyListeners(key) return &graph.ConfigChangedResponse{Received: true}, nil } func (r *Resolver) FetchLogTail(ctx context.Context) (string, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() return logging.ReadTail(), nil } func (r *Resolver) GetProcessesInUse(ctx context.Context, execDir string) ([]*graph.ProcessInfo, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() inUse := r.rtwatch.GetProcessesInUse(execDir) processes := make([]*graph.ProcessInfo, 0, len(inUse)) @@ -272,7 +278,7 @@ func (r *Resolver) GetProcessesInUse(ctx context.Context, execDir string) ([]*gr } func (r *Resolver) GetJwt(ctx context.Context) (*graph.Jwt, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() if err := r.auth.MaybeRenew(); err != nil { return nil, errs.Wrap(err, "Could not renew auth token") @@ -308,7 +314,7 @@ func (r *Resolver) GetJwt(ctx context.Context) (*graph.Jwt, error) { } func (r *Resolver) HashGlobs(ctx context.Context, wd string, globs []string) (*graph.GlobResult, error) { - defer func() { handlePanics(recover(), debug.Stack()) }() + defer func() { panics.LogAndPanic(recover(), debug.Stack()) }() hash, files, err := r.fileHasher.HashFiles(wd, globs) if err != nil { @@ -341,11 +347,3 @@ func (r *Resolver) SetCache(ctx context.Context, key string, value string, expir r.globalCache.Set(key, value, time.Duration(expiry)*time.Second) return &graphqltypes.Void{}, nil } - -func handlePanics(recovered interface{}, stack []byte) { - if recovered != nil { - multilog.Error("Panic: %v", recovered) - logging.Debug("Stack: %s", string(stack)) - panic(recovered) // We're only logging the panic, not interrupting it - } -} diff --git a/internal/colorize/cropped.go b/internal/colorize/cropped.go deleted file mode 100644 index 793a139513..0000000000 --- a/internal/colorize/cropped.go +++ /dev/null @@ -1,107 +0,0 @@ -package colorize - -import ( - "regexp" - "strings" -) - -type CroppedLines []CroppedLine - -type CroppedLine struct { - Line string - Length int -} - -func (c CroppedLines) String() string { - var result string - for _, crop := range c { - result = result + crop.Line - } - - return result -} - -var indentRegexp = regexp.MustCompile(`^([ ]+)`) - -func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines { - indent := "" - if indentMatch := indentRegexp.FindStringSubmatch(text); indentMatch != nil { - indent = indentMatch[0] - if len(text) > len(indent) && strings.HasPrefix(text[len(indent):], "• ") { - indent += " " - } - } - - entries := make([]CroppedLine, 0) - colorCodes := colorRx.FindAllStringSubmatchIndex(text, -1) - - isLineEnd := false - entry := CroppedLine{} - for pos, amend := range text { - inColorTag := inRange(pos, colorCodes) - - isLineEnd = amend == '\n' - - if !isLineEnd { - entry.Line += string(amend) - if !inColorTag { - entry.Length++ - } - } - - // Ensure the next position is not within a color tag and check conditions that would end this entry - if isLineEnd || (!inRange(pos+1, colorCodes) && (entry.Length == maxLen || pos == len(text)-1)) { - wrapped := "" - wrappedLength := len(indent) - nextCharIsSpace := pos+1 < len(text) && isSpace(text[pos+1]) - if !isLineEnd && entry.Length == maxLen && !nextCharIsSpace && pos < len(text)-1 { - // Put the current word on the next line, if possible. - // Find the start of the current word and its printed length, taking color ranges and - // multi-byte characters into account. - i := len(entry.Line) - 1 - for ; i > 0; i-- { - if isSpace(entry.Line[i]) { - i++ // preserve trailing space - break - } - if !inRange(pos-(len(entry.Line)-i), colorCodes) && !isUTF8TrailingByte(entry.Line[i]) { - wrappedLength++ - } - } - // Extract the word from the current line if it doesn't start the line. - if i > 0 && i < len(entry.Line)-1 { - wrapped = indent + entry.Line[i:] - entry.Line = entry.Line[:i] - entry.Length -= wrappedLength - isLineEnd = true // emulate for wrapping purposes - } else { - wrappedLength = len(indent) // reset - } - } - entries = append(entries, entry) - entry = CroppedLine{Line: wrapped, Length: wrappedLength} - } - - if isLineEnd && includeLineEnds { - entries = append(entries, CroppedLine{"\n", 1}) - } - } - - return entries -} - -func inRange(pos int, ranges [][]int) bool { - for _, intRange := range ranges { - start, stop := intRange[0], intRange[1] - if pos >= start && pos <= stop-1 { - return true - } - } - return false -} - -func isSpace(b byte) bool { return b == ' ' || b == '\t' } - -func isUTF8TrailingByte(b byte) bool { - return b >= 0x80 && b < 0xC0 -} diff --git a/internal/colorize/cropped_test.go b/internal/colorize/cropped_test.go deleted file mode 100644 index c16dac4544..0000000000 --- a/internal/colorize/cropped_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package colorize - -import ( - "fmt" - "reflect" - "strings" - "testing" -) - -func Test_GetCroppedText(t *testing.T) { - type args struct { - text string - maxLen int - } - tests := []struct { - name string - args args - want CroppedLines - }{ - { - "No split", - args{"[HEADING]Hello[/RESET]", 5}, - []CroppedLine{{"[HEADING]Hello[/RESET]", 5}}, - }, - { - "Split", - args{"[HEADING]Hello[/RESET]", 3}, - []CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, - }, - { - "Split multiple", - args{"[HEADING]Hello World[/RESET]", 3}, - []CroppedLine{{"[HEADING]Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld[/RESET]", 2}}, - }, - { - "Split multiple no match", - args{"Hello World", 3}, - []CroppedLine{{"Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld", 2}}, - }, - { - "No split no match", - args{"Hello", 5}, - []CroppedLine{{"Hello", 5}}, - }, - { - "Split multi-byte characters", - args{"✔ol1✔ol2✔ol3", 4}, - []CroppedLine{{"✔ol1", 4}, {"✔ol2", 4}, {"✔ol3", 4}}, - }, - { - "No split multi-byte character with tags", - args{"[HEADING]✔ Some Text[/RESET]", 20}, - []CroppedLine{{"[HEADING]✔ Some Text[/RESET]", 11}}, - }, - { - "Split multi-byte character with tags", - args{"[HEADING]✔ Some Text[/RESET]", 6}, - []CroppedLine{{"[HEADING]✔ Some", 6}, {" Text[/RESET]", 5}}, - }, - { - "Split multi-byte character with tags by words", - args{"[HEADING]✔ Some Text[/RESET]", 10}, - []CroppedLine{{"[HEADING]✔ Some ", 7}, {"Text[/RESET]", 4}}, - }, - { - "Split line break", - args{"[HEADING]Hel\nlo[/RESET]", 5}, - []CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, - }, - { - "Split nested", - args{"[HEADING][NOTICE]Hello[/RESET][/RESET]", 3}, - []CroppedLine{{"[HEADING][NOTICE]Hel", 3}, {"lo[/RESET][/RESET]", 2}}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := GetCroppedText(tt.args.text, tt.args.maxLen, false); !reflect.DeepEqual(got, tt.want) { - t.Errorf("getCroppedText() = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_GetCroppedTextAsString(t *testing.T) { - tests := []struct { - name string - text string - }{ - { - // This should only give two empty lines, because the first line break is effectively part of the first line - "Line Endings", - "test\n\n\ntest", - }, - { - "Ends with Multiple Line Endings", - "test\n\n\n", - }, - { - "Starts with Multiple Line Endings", - "\n\n\ntest", - }, - { - "Double Line Ending", - "X\n\n", - }, - { - "Double Line Endings", - "X\n\nX\n\nX\n\n", - }, - { - "Just Line Endings", - "\n\n\n", - }, - { - "Single Line Ending", - "\n", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := GetCroppedText(tt.text, 999, true); !reflect.DeepEqual(got.String(), tt.text) { - escape := func(v string) string { - return strings.Replace(v, "\n", "\\n", -1) - } - t.Errorf("getCroppedText() = %v, want %v (crop data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got))) - } - }) - } -} diff --git a/internal/colorize/wrap.go b/internal/colorize/wrap.go new file mode 100644 index 0000000000..d40234b0be --- /dev/null +++ b/internal/colorize/wrap.go @@ -0,0 +1,161 @@ +package colorize + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +type WrappedLines []WrappedLine + +type WrappedLine struct { + Line string + Length int +} + +func (c WrappedLines) String() string { + var result string + for _, wrapped := range c { + result = result + wrapped.Line + } + + return result +} + +var indentRegexp = regexp.MustCompile(`^([ ]+)`) +var isLinkRegexp = regexp.MustCompile(`\s*(\[[^\]]+\])?https?://`) + +func Wrap(text string, maxLen int, includeLineEnds bool, continuation string) WrappedLines { + // Determine indentation of wrapped lines based on any leading indentation. + indent := "" + if indentMatch := indentRegexp.FindStringSubmatch(text); indentMatch != nil { + indent = indentMatch[0] + if len(text) > len(indent) && strings.HasPrefix(text[len(indent):], "• ") { + // If text to wrap is a bullet item, indent extra to be flush after bullet. + indent += " " + } + } + + // If wrapping includes continuation text, reduce maximum wrapping length accordingly. + maxLen -= utf8.RuneCountInString(StripColorCodes(continuation)) + + entries := make([]WrappedLine, 0) + colorCodes := colorRx.FindAllStringSubmatchIndex(text, -1) + colorNames := colorRx.FindAllStringSubmatch(text, -1) + + entry := WrappedLine{} + // Iterate over the text, one character at a time, and construct wrapped lines while doing so. + for pos, amend := range text { + isLineEnd := amend == '\n' + isTextEnd := pos == len(text)-1 + + // Add the current character to the wrapped line. + // Update the wrapped line's length as long as the added character is not part of a tag like + // [ERROR] or [/RESET]. + if !isLineEnd { + entry.Line += string(amend) + if !inRange(pos, colorCodes) { + entry.Length++ + } + } + atWrapPosition := entry.Length == maxLen + + // When we've reached the end of the line, either naturally (line or text end), or when we've + // reached the wrap position (maximum length), we need to wrap the current word (if any) and + // set up the next (wrapped) line. + // Note that if we've reached the wrap position but there's a tag immediately after it, we want + // to include the tag, so do not wrap in that case. + if isLineEnd || isTextEnd || (atWrapPosition && !inRange(pos+1, colorCodes)) { + wrapped := "" // the start of the next (wrapped) line + wrappedLength := len(indent) // the current length of the next (wrapped) line + + // We need to prepare the next (wrapped) line unless we're at line end, text end, not at wrap + // position, or the next character is a space (i.e. no wrapping needed). + nextCharIsSpace := !isTextEnd && isSpace(text[pos+1]) + if !isLineEnd && !isTextEnd && atWrapPosition && !nextCharIsSpace { + // Determine the start of the current word along with its printed length (taking color + // ranges and multi-byte characters into account). + // We need to know these things in order to put it on the next line (if possible). + i := len(entry.Line) - 1 + for ; i > 0; i-- { + if isSpace(entry.Line[i]) { + i++ // preserve trailing space + break + } + if !inRange(pos-(len(entry.Line)-i), colorCodes) && !isUTF8TrailingByte(entry.Line[i]) { + wrappedLength++ + } + } + + // We can wrap this word on the next line as long as it's not at the beginning of the + // current line and it's not part of a hyperlink. + canWrap := i > 0 && !isLinkRegexp.MatchString(entry.Line[i:]) + if canWrap { + wrapped = entry.Line[i:] + entry.Line = entry.Line[:i] + entry.Length -= wrappedLength + isLineEnd = true // emulate for wrapping purposes + + // Prepend the continuation string to the wrapped line, and indent it as necessary. + // The continuation itself should not be tagged with anything like [ERROR], but any text + // after the continuation should be tagged. + if continuation != "" { + if tags := colorTags(pos, colorCodes, colorNames); len(tags) > 0 { + wrapped = fmt.Sprintf("%s[/RESET]%s%s%s", indent, continuation, strings.Join(tags, ""), wrapped) + } else { + wrapped = indent + continuation + wrapped + } + } else { + wrapped = indent + wrapped + } + } else { + wrappedLength = len(indent) // reset + } + } + + entries = append(entries, entry) + entry = WrappedLine{Line: wrapped, Length: wrappedLength} + } + + if isLineEnd && includeLineEnds { + entries = append(entries, WrappedLine{"\n", 1}) + } + } + + return entries +} + +func inRange(pos int, ranges [][]int) bool { + for _, intRange := range ranges { + start, stop := intRange[0], intRange[1] + if pos >= start && pos <= stop-1 { + return true + } + } + return false +} + +// colorTags returns the currently active color tags (if any) at the given position. +func colorTags(pos int, ranges [][]int, names [][]string) []string { + tags := make([]string, 0) + for i, intRange := range ranges { + if pos < intRange[0] { + break // before [COLOR] + } + if pos > intRange[0] { + if names[i][1] == "/RESET" { + tags = make([]string, 0) // clear + } else { + tags = append(tags, names[i][0]) + } + } + } + return tags +} + +func isSpace(b byte) bool { return b == ' ' || b == '\t' } + +func isUTF8TrailingByte(b byte) bool { + return b >= 0x80 && b < 0xC0 +} diff --git a/internal/colorize/wrap_test.go b/internal/colorize/wrap_test.go new file mode 100644 index 0000000000..2c5d61dfda --- /dev/null +++ b/internal/colorize/wrap_test.go @@ -0,0 +1,159 @@ +package colorize + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_Wrap(t *testing.T) { + type args struct { + text string + maxLen int + } + tests := []struct { + name string + args args + want WrappedLines + }{ + { + "No split", + args{"[HEADING]Hello[/RESET]", 5}, + []WrappedLine{{"[HEADING]Hello[/RESET]", 5}}, + }, + { + "Split", + args{"[HEADING]Hello[/RESET]", 3}, + []WrappedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, + }, + { + "Split multiple", + args{"[HEADING]Hello World[/RESET]", 3}, + []WrappedLine{{"[HEADING]Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld[/RESET]", 2}}, + }, + { + "Split multiple no match", + args{"Hello World", 3}, + []WrappedLine{{"Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld", 2}}, + }, + { + "No split no match", + args{"Hello", 5}, + []WrappedLine{{"Hello", 5}}, + }, + { + "Split multi-byte characters", + args{"✔ol1✔ol2✔ol3", 4}, + []WrappedLine{{"✔ol1", 4}, {"✔ol2", 4}, {"✔ol3", 4}}, + }, + { + "No split multi-byte character with tags", + args{"[HEADING]✔ Some Text[/RESET]", 20}, + []WrappedLine{{"[HEADING]✔ Some Text[/RESET]", 11}}, + }, + { + "Split multi-byte character with tags", + args{"[HEADING]✔ Some Text[/RESET]", 6}, + []WrappedLine{{"[HEADING]✔ Some", 6}, {" Text[/RESET]", 5}}, + }, + { + "Split multi-byte character with tags by words", + args{"[HEADING]✔ Some Text[/RESET]", 10}, + []WrappedLine{{"[HEADING]✔ Some ", 7}, {"Text[/RESET]", 4}}, + }, + { + "Split line break", + args{"[HEADING]Hel\nlo[/RESET]", 5}, + []WrappedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, + }, + { + "Split nested", + args{"[HEADING][NOTICE]Hello[/RESET][/RESET]", 3}, + []WrappedLine{{"[HEADING][NOTICE]Hel", 3}, {"lo[/RESET][/RESET]", 2}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Wrap(tt.args.text, tt.args.maxLen, false, ""); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Wrap() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_WrapAsString(t *testing.T) { + tests := []struct { + name string + text string + }{ + { + // This should only give two empty lines, because the first line break is effectively part of the first line + "Line Endings", + "test\n\n\ntest", + }, + { + "Ends with Multiple Line Endings", + "test\n\n\n", + }, + { + "Starts with Multiple Line Endings", + "\n\n\ntest", + }, + { + "Double Line Ending", + "X\n\n", + }, + { + "Double Line Endings", + "X\n\nX\n\nX\n\n", + }, + { + "Just Line Endings", + "\n\n\n", + }, + { + "Single Line Ending", + "\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Wrap(tt.text, 999, true, ""); !reflect.DeepEqual(got.String(), tt.text) { + escape := func(v string) string { + return strings.Replace(v, "\n", "\\n", -1) + } + t.Errorf("Wrap() = %v, want %v (wrap data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got))) + } + }) + } +} + +func TestWrapBullet(t *testing.T) { + lines := Wrap(" • This is a bullet", 15, true, "") + assert.Equal(t, " • This is a \n bullet", lines.String()) +} + +func TestWrapContinuation(t *testing.T) { + // Test normal wrapping with no continuation. + lines := Wrap("This is an error", 9, true, "") + assert.Equal(t, "This is \nan error", lines.String()) + + // Verify continuations are not tagged. + lines = Wrap("[ERROR]This is an error[/RESET]", 10, true, "|") + assert.Equal(t, "[ERROR]This is \n[/RESET]|[ERROR]an error[/RESET]", lines.String()) + + // Verify only active tags come after continuations. + lines = Wrap("[BOLD]This is not[/RESET] an error", 10, true, "|") + assert.Equal(t, "[BOLD]This is \n[/RESET]|[BOLD]not[/RESET] an \n|error", lines.String()) + + // Verify continuations are not tagged, even if [/RESET] is omitted. + lines = Wrap("[ERROR]This is an error", 10, true, "|") + assert.Equal(t, "[ERROR]This is \n[/RESET]|[ERROR]an error", lines.String()) + + // Verify multiple tags are restored after continuations. + lines = Wrap("[BOLD][RED]This is a bold, red message[/RESET]", 11, true, "|") + assert.Equal(t, "[BOLD][RED]This is a \n[/RESET]|[BOLD][RED]bold, red \n[/RESET]|[BOLD][RED]message[/RESET]", lines.String()) +} diff --git a/internal/output/plain.go b/internal/output/plain.go index 3859303cf9..a927ced6f1 100644 --- a/internal/output/plain.go +++ b/internal/output/plain.go @@ -103,7 +103,7 @@ func (f *Plain) write(writer io.Writer, value interface{}) { // writeNow is a little helper that just writes the given value to the requested writer (no marshalling) func (f *Plain) writeNow(writer io.Writer, value string) { if f.Config().Interactive { - value = wordWrap(value) + value = colorize.Wrap(value, termutils.GetWidth(), true, "").String() } _, err := colorize.Colorize(value, writer, !f.cfg.Colored) if err != nil { @@ -111,14 +111,6 @@ func (f *Plain) writeNow(writer io.Writer, value string) { } } -func wordWrap(text string) string { - return wordWrapWithWidth(text, termutils.GetWidth()) -} - -func wordWrapWithWidth(text string, width int) string { - return colorize.GetCroppedText(text, width, true).String() -} - const nilText = "" // sprint will marshal and return the given value as a string diff --git a/internal/output/renderers/bulletlist.go b/internal/output/renderers/bulletlist.go new file mode 100644 index 0000000000..ca760beed4 --- /dev/null +++ b/internal/output/renderers/bulletlist.go @@ -0,0 +1,106 @@ +package renderers + +import ( + "strings" + "unicode/utf8" + + "github.com/ActiveState/cli/internal/colorize" + "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/termutils" +) + +type Bullets struct { + Start string + Mid string + Link string + End string +} + +type bulletList struct { + prefix string + items []string + bullets Bullets +} + +// BulletTree outputs a list like: +// +// ├─ one +// ├─ two +// │ wrapped +// └─ three +var BulletTree = Bullets{output.TreeMid, output.TreeMid, output.TreeLink + " ", output.TreeEnd} + +// BulletTreeDisabled is like BulletTree, but tags the tree glyphs with [DISABLED]. +var BulletTreeDisabled = Bullets{ + "[DISABLED]" + output.TreeMid + "[/RESET]", + "[DISABLED]" + output.TreeMid + "[/RESET]", + "[DISABLED]" + output.TreeLink + " [/RESET]", + "[DISABLED]" + output.TreeEnd + "[/RESET]", +} + +// HeadedBulletTree outputs a list like: +// +// one +// ├─ two +// │ wrapped +// └─ three +var HeadedBulletTree = Bullets{"", output.TreeMid, output.TreeLink + " ", output.TreeEnd} + +func NewBulletList(prefix string, bullets Bullets, items []string) *bulletList { + return &bulletList{prefix, items, bullets} +} + +// str is the business logic for returning a bullet list's string representation for a given +// maximum width. Clients should call String() instead. Only tests should directly call this +// function. +func (b *bulletList) str(maxWidth int) string { + out := make([]string, len(b.items)) + + // Determine the indentation of each item. + // If the prefix is pure indentation, then the indent is that prefix. + // If the prefix is not pure indentation, then the indent is the number of characters between + // the first non-space character and the end of the prefix. + // For example, both "* " and " * " have and indent of 2 because items should be indented to + // match the bullet item's left margin (note that wrapping will auto-indent to match the leading + // space in the second example). + indent := b.prefix + if nonIndent := strings.TrimLeft(b.prefix, " "); nonIndent != "" { + indent = strings.Repeat(" ", len(nonIndent)) + } + + for i, item := range b.items { + bullet := b.bullets.Start + if len(b.items) == 1 { + bullet = b.bullets.End // special case list length of one; use last bullet + } + + prefix := "" + continuation := "" + if i == 0 { + if bullet != "" { + bullet += " " + } + prefix = b.prefix + bullet + } else { + bullet = b.bullets.Mid + " " + continuation = indent + b.bullets.Link + " " + if i == len(b.items)-1 { + bullet = b.bullets.End + " " // this is the last item + continuation = " " + } + prefix = indent + bullet + } + wrapped := colorize.Wrap(item, maxWidth-len(indent)-bulletLength(bullet), true, continuation).String() + out[i] = prefix + wrapped + } + + return strings.Join(out, "\n") +} + +func (b *bulletList) String() string { + return b.str(termutils.GetWidth()) +} + +func bulletLength(bullet string) int { + return utf8.RuneCountInString(colorize.StripColorCodes(bullet)) +} diff --git a/internal/output/renderers/bulletlist_test.go b/internal/output/renderers/bulletlist_test.go new file mode 100644 index 0000000000..ad8f85102b --- /dev/null +++ b/internal/output/renderers/bulletlist_test.go @@ -0,0 +1,33 @@ +package renderers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBulletTree(t *testing.T) { + assert.Equal(t, "├─ one\n├─ two \n│ wrapped\n└─ three", + NewBulletList("", BulletTree, []string{"one", "two wrapped", "three"}).str(13)) +} + +func TestBulletTreeDisabled(t *testing.T) { + assert.Equal(t, "[DISABLED]├─[/RESET] one\n[DISABLED]├─[/RESET] two \n[DISABLED]│ [/RESET] wrapped\n[DISABLED]└─[/RESET] three", + NewBulletList("", BulletTreeDisabled, []string{"one", "two wrapped", "three"}).str(13)) +} + +func TestHeadedBulletTree(t *testing.T) { + assert.Equal(t, "one\n├─ two \n│ wrapped\n└─ three", + NewBulletList("", HeadedBulletTree, []string{"one", "two wrapped", "three"}).str(13)) +} + +func TestUnwrappedLink(t *testing.T) { + assert.Equal(t, "├─ one\n└─ https://host:port/path#anchor", + NewBulletList("", BulletTree, []string{"one", "https://host:port/path#anchor"}).str(10)) +} + +func TestIndented(t *testing.T) { + // Note: use max width of 17 instead of 15 to account for extra continuation indent (2+2). + assert.Equal(t, " ├─ one\n ├─ two \n │ wrapped\n └─ three", + NewBulletList(" ", BulletTree, []string{"one", "two wrapped", "three"}).str(17)) +} diff --git a/internal/runbits/dependencies/changesummary.go b/internal/runbits/dependencies/changesummary.go index 38720f40d1..a1c94ea6b4 100644 --- a/internal/runbits/dependencies/changesummary.go +++ b/internal/runbits/dependencies/changesummary.go @@ -9,6 +9,7 @@ import ( "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/output/renderers" "github.com/ActiveState/cli/internal/sliceutils" "github.com/ActiveState/cli/pkg/buildplan" ) @@ -107,12 +108,8 @@ func OutputChangeSummary(out output.Outputer, newBuildPlan *buildplan.BuildPlan, // └─ name@oldVersion → name@newVersion (Updated) // depending on whether or not it has subdependencies, and whether or not showUpdatedPackages is // `true`. + items := make([]string, len(directDependencies)) for i, ingredient := range directDependencies { - prefix := output.TreeMid - if i == len(directDependencies)-1 { - prefix = output.TreeEnd - } - // Retrieve runtime dependencies, and then filter out any dependencies that are common between all added ingredients. runtimeDeps := ingredient.RuntimeDependencies(true) runtimeDeps = runtimeDeps.Filter(func(i *buildplan.Ingredient) bool { _, ok := commonDependencies[i.IngredientID]; return !ok }) @@ -130,8 +127,9 @@ func OutputChangeSummary(out output.Outputer, newBuildPlan *buildplan.BuildPlan, item = fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] → %s (%s)", oldVersion.Name, oldVersion.Version, item, locale.Tl("updated", "updated")) } - out.Notice(fmt.Sprintf(" [DISABLED]%s[/RESET] %s", prefix, item)) + items[i] = item } + out.Notice(renderers.NewBulletList(" ", renderers.BulletTreeDisabled, items).String()) out.Notice("") // blank line } diff --git a/internal/runbits/dependencies/summary.go b/internal/runbits/dependencies/summary.go index 33fc2f6a49..ac14f260df 100644 --- a/internal/runbits/dependencies/summary.go +++ b/internal/runbits/dependencies/summary.go @@ -7,6 +7,7 @@ import ( "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/output/renderers" "github.com/ActiveState/cli/pkg/buildplan" ) @@ -27,12 +28,8 @@ func OutputSummary(out output.Outputer, directDependencies buildplan.Artifacts) out.Notice("") // blank line out.Notice(locale.Tl("setting_up_dependencies", " Setting up the following dependencies:")) + items := make([]string, len(ingredients)) for i, ingredient := range ingredients { - prefix := " " + output.TreeMid - if i == len(ingredients)-1 { - prefix = " " + output.TreeEnd - } - subDependencies := ingredient.RuntimeDependencies(true) if _, isCommon := commonDependencies[ingredient.IngredientID]; !isCommon { // If the ingredient is itself not a common sub-dependency; filter out any common sub dependencies so we don't @@ -44,10 +41,9 @@ func OutputSummary(out output.Outputer, directDependencies buildplan.Artifacts) subdepLocale = locale.Tl("summary_subdeps", "([ACTIONABLE]{{.V0}}[/RESET] sub-dependencies)", strconv.Itoa(numSubs)) } - item := fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] %s", ingredient.Name, ingredient.Version, subdepLocale) - - out.Notice(fmt.Sprintf("[DISABLED]%s[/RESET] %s", prefix, item)) + items[i] = fmt.Sprintf("[ACTIONABLE]%s@%s[/RESET] %s", ingredient.Name, ingredient.Version, subdepLocale) } + out.Notice(renderers.NewBulletList(" ", renderers.BulletTreeDisabled, items).String()) out.Notice("") // blank line } diff --git a/internal/runbits/panics/panics.go b/internal/runbits/panics/panics.go index 7dd3f3fc5f..d743f7a5e8 100644 --- a/internal/runbits/panics/panics.go +++ b/internal/runbits/panics/panics.go @@ -34,3 +34,12 @@ func LogPanics(recovered interface{}, stack []byte) bool { } return false } + +// LogAndPanic produces actionable output for panic events (that shouldn't happen) and panics +func LogAndPanic(recovered interface{}, stack []byte) { + if recovered != nil { + multilog.Error("Panic: %v", recovered) + logging.Debug("Stack: %s", string(stack)) + panic(recovered) // We're only logging the panic, not interrupting it + } +} diff --git a/internal/runners/artifacts/artifacts.go b/internal/runners/artifacts/artifacts.go index 078d2668a1..22734c783d 100644 --- a/internal/runners/artifacts/artifacts.go +++ b/internal/runners/artifacts/artifacts.go @@ -12,6 +12,7 @@ import ( "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/output/renderers" "github.com/ActiveState/cli/internal/primer" buildplanner_runbit "github.com/ActiveState/cli/internal/runbits/buildplanner" "github.com/ActiveState/cli/pkg/buildplan" @@ -204,9 +205,13 @@ func (b *Artifacts) outputPlain(out *StructuredOutput, fullID bool) error { for _, artifact := range platform.Artifacts { switch { case len(artifact.Errors) > 0: - b.out.Print(fmt.Sprintf(" • %s ([ERROR]%s[/RESET])", artifact.Name, locale.T("artifact_status_failed"))) - b.out.Print(fmt.Sprintf(" %s %s: [ERROR]%s[/RESET]", output.TreeMid, locale.T("artifact_status_failed_message"), strings.Join(artifact.Errors, ": "))) - b.out.Print(fmt.Sprintf(" %s %s: [ACTIONABLE]%s[/RESET]", output.TreeEnd, locale.T("artifact_status_failed_log"), artifact.LogURL)) + b.out.Print(renderers.NewBulletList(" • ", + renderers.HeadedBulletTree, + []string{ + fmt.Sprintf("%s ([ERROR]%s[/RESET])", artifact.Name, locale.T("artifact_status_failed")), + fmt.Sprintf("%s: [ERROR]%s[/RESET]", locale.T("artifact_status_failed_message"), strings.Join(artifact.Errors, ": ")), + fmt.Sprintf("%s: [ACTIONABLE]%s[/RESET]", locale.T("artifact_status_failed_log"), artifact.LogURL), + }).String()) continue case artifact.status == types.ArtifactSkipped: b.out.Print(fmt.Sprintf(" • %s ([NOTICE]%s[/RESET])", artifact.Name, locale.T("artifact_status_skipped"))) @@ -228,9 +233,13 @@ func (b *Artifacts) outputPlain(out *StructuredOutput, fullID bool) error { for _, artifact := range platform.Packages { switch { case len(artifact.Errors) > 0: - b.out.Print(fmt.Sprintf(" • %s ([ERROR]%s[/RESET])", artifact.Name, locale.T("artifact_status_failed"))) - b.out.Print(fmt.Sprintf(" %s %s: [ERROR]%s[/RESET]", output.TreeMid, locale.T("artifact_status_failed_message"), strings.Join(artifact.Errors, ": "))) - b.out.Print(fmt.Sprintf(" %s %s: [ACTIONABLE]%s[/RESET]", output.TreeEnd, locale.T("artifact_status_failed_log"), artifact.LogURL)) + b.out.Print(renderers.NewBulletList(" • ", + renderers.HeadedBulletTree, + []string{ + fmt.Sprintf("%s ([ERROR]%s[/RESET])", artifact.Name, locale.T("artifact_status_failed")), + fmt.Sprintf("%s: [ERROR]%s[/RESET]", locale.T("artifact_status_failed_message"), strings.Join(artifact.Errors, ": ")), + fmt.Sprintf("%s: [ACTIONABLE]%s[/RESET]", locale.T("artifact_status_failed_log"), artifact.LogURL), + }).String()) continue case artifact.status == types.ArtifactSkipped: b.out.Print(fmt.Sprintf(" • %s ([NOTICE]%s[/RESET])", artifact.Name, locale.T("artifact_status_skipped"))) diff --git a/internal/runners/cve/cve.go b/internal/runners/cve/cve.go index 20f6e51116..96c24a24f4 100644 --- a/internal/runners/cve/cve.go +++ b/internal/runners/cve/cve.go @@ -10,6 +10,7 @@ import ( "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/output/renderers" "github.com/ActiveState/cli/internal/primer" "github.com/ActiveState/cli/internal/runbits/rationalize" "github.com/ActiveState/cli/pkg/localcommit" @@ -193,17 +194,15 @@ func (rd *cveOutput) MarshalOutput(format output.Format) interface{} { return false }) + items := make([]string, len(ap.Details)) for i, d := range ap.Details { - bar := output.TreeMid - if i == len(ap.Details)-1 { - bar = output.TreeEnd - } severity := d.Severity if severity == "CRITICAL" { severity = fmt.Sprintf("[ERROR]%-10s[/RESET]", severity) } - rd.output.Print(fmt.Sprintf(" %s %-10s [ACTIONABLE]%s[/RESET]", bar, severity, d.CveID)) + items[i] = fmt.Sprintf("%-10s [ACTIONABLE]%s[/RESET]", severity, d.CveID) } + rd.output.Print(renderers.NewBulletList("", renderers.BulletTree, items).String()) rd.output.Print("") } diff --git a/internal/table/table.go b/internal/table/table.go index b609daa29f..56924766f1 100644 --- a/internal/table/table.go +++ b/internal/table/table.go @@ -180,9 +180,9 @@ func renderRow(providedColumns []string, colWidths []int) string { widths[len(widths)-1] = mathutils.Total(colWidths[len(widths)-1:]...) } - croppedColumns := []colorize.CroppedLines{} + croppedColumns := []colorize.WrappedLines{} for n, column := range providedColumns { - croppedColumns = append(croppedColumns, colorize.GetCroppedText(column, widths[n]-(padding*2), false)) + croppedColumns = append(croppedColumns, colorize.Wrap(column, widths[n]-(padding*2), false, "")) } var rendered = true