diff --git a/internal/colorize/cropped.go b/internal/colorize/wrap.go similarity index 79% rename from internal/colorize/cropped.go rename to internal/colorize/wrap.go index 793a139513..74ca14e77b 100644 --- a/internal/colorize/cropped.go +++ b/internal/colorize/wrap.go @@ -5,14 +5,14 @@ import ( "strings" ) -type CroppedLines []CroppedLine +type WrappedLines []WrappedLine -type CroppedLine struct { +type WrappedLine struct { Line string Length int } -func (c CroppedLines) String() string { +func (c WrappedLines) String() string { var result string for _, crop := range c { result = result + crop.Line @@ -22,8 +22,9 @@ func (c CroppedLines) String() string { } var indentRegexp = regexp.MustCompile(`^([ ]+)`) +var isLinkRegexp = regexp.MustCompile(`\s*(\[[^\]]+\])?https?://`) -func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines { +func Wrap(text string, maxLen int, includeLineEnds bool, continuation string) WrappedLines { indent := "" if indentMatch := indentRegexp.FindStringSubmatch(text); indentMatch != nil { indent = indentMatch[0] @@ -32,11 +33,13 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines } } - entries := make([]CroppedLine, 0) + maxLen -= len(continuation) + + entries := make([]WrappedLine, 0) colorCodes := colorRx.FindAllStringSubmatchIndex(text, -1) isLineEnd := false - entry := CroppedLine{} + entry := WrappedLine{} for pos, amend := range text { inColorTag := inRange(pos, colorCodes) @@ -69,8 +72,8 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines } } // 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:] + if i > 0 && i < len(entry.Line)-1 && !isLinkRegexp.MatchString(entry.Line[i:]) { + wrapped = indent + continuation + entry.Line[i:] entry.Line = entry.Line[:i] entry.Length -= wrappedLength isLineEnd = true // emulate for wrapping purposes @@ -79,11 +82,11 @@ func GetCroppedText(text string, maxLen int, includeLineEnds bool) CroppedLines } } entries = append(entries, entry) - entry = CroppedLine{Line: wrapped, Length: wrappedLength} + entry = WrappedLine{Line: wrapped, Length: wrappedLength} } if isLineEnd && includeLineEnds { - entries = append(entries, CroppedLine{"\n", 1}) + entries = append(entries, WrappedLine{"\n", 1}) } } diff --git a/internal/colorize/cropped_test.go b/internal/colorize/wrap_test.go similarity index 62% rename from internal/colorize/cropped_test.go rename to internal/colorize/wrap_test.go index c16dac4544..f4d793c8a0 100644 --- a/internal/colorize/cropped_test.go +++ b/internal/colorize/wrap_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func Test_GetCroppedText(t *testing.T) { +func Test_Wrap(t *testing.T) { type args struct { text string maxLen int @@ -15,74 +15,74 @@ func Test_GetCroppedText(t *testing.T) { tests := []struct { name string args args - want CroppedLines + want WrappedLines }{ { "No split", args{"[HEADING]Hello[/RESET]", 5}, - []CroppedLine{{"[HEADING]Hello[/RESET]", 5}}, + []WrappedLine{{"[HEADING]Hello[/RESET]", 5}}, }, { "Split", args{"[HEADING]Hello[/RESET]", 3}, - []CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, + []WrappedLine{{"[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}}, + []WrappedLine{{"[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}}, + []WrappedLine{{"Hel", 3}, {"lo ", 3}, {"Wor", 3}, {"ld", 2}}, }, { "No split no match", args{"Hello", 5}, - []CroppedLine{{"Hello", 5}}, + []WrappedLine{{"Hello", 5}}, }, { "Split multi-byte characters", args{"✔ol1✔ol2✔ol3", 4}, - []CroppedLine{{"✔ol1", 4}, {"✔ol2", 4}, {"✔ol3", 4}}, + []WrappedLine{{"✔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}}, + []WrappedLine{{"[HEADING]✔ Some Text[/RESET]", 11}}, }, { "Split multi-byte character with tags", args{"[HEADING]✔ Some Text[/RESET]", 6}, - []CroppedLine{{"[HEADING]✔ Some", 6}, {" Text[/RESET]", 5}}, + []WrappedLine{{"[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}}, + []WrappedLine{{"[HEADING]✔ Some ", 7}, {"Text[/RESET]", 4}}, }, { "Split line break", args{"[HEADING]Hel\nlo[/RESET]", 5}, - []CroppedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, + []WrappedLine{{"[HEADING]Hel", 3}, {"lo[/RESET]", 2}}, }, { "Split nested", args{"[HEADING][NOTICE]Hello[/RESET][/RESET]", 3}, - []CroppedLine{{"[HEADING][NOTICE]Hel", 3}, {"lo[/RESET][/RESET]", 2}}, + []WrappedLine{{"[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) + 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_GetCroppedTextAsString(t *testing.T) { +func Test_WrapAsString(t *testing.T) { tests := []struct { name string text string @@ -119,11 +119,11 @@ func Test_GetCroppedTextAsString(t *testing.T) { } 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) { + 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("getCroppedText() = %v, want %v (crop data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got))) + t.Errorf("Wrap() = %v, want %v (crop data: %s)", escape(got.String()), escape(tt.text), escape(fmt.Sprintf("%#v", got))) } }) } 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..947013a5d0 --- /dev/null +++ b/internal/output/renderers/bulletlist.go @@ -0,0 +1,89 @@ +package renderers + +import ( + "fmt" + "strings" + + "github.com/ActiveState/cli/internal/colorize" + "github.com/ActiveState/cli/internal/multilog" + "github.com/ActiveState/cli/internal/output" + "github.com/ActiveState/cli/internal/termutils" +) + +type bulletList struct { + prefix string + items []string + bullets []string +} + +// BulletTree outputs a list like: +// +// ├─ one +// ├─ two +// │ wrapped +// └─ three +var BulletTree = []string{output.TreeMid, output.TreeMid, output.TreeLink, output.TreeEnd} + +// HeadedBulletTree outputs a list like: +// +// one +// ├─ two +// │ wrapped +// └─ three +var HeadedBulletTree = []string{"", output.TreeMid, output.TreeLink, output.TreeEnd} + +// NewBulletList returns a printable list of items prefixed with the given set of bullets. +// The set of bullets should contain four items: the bullet for the first item (e.g. ""); the +// bullet for each subsequent item (e.g. "├─"); the bullet for an item's wrapped lines, if any +// (e.g. "│"); and the bullet for the last item (e.g. "└─"). +// The returned list can be passed to a plain printer. It should not be passed to a structured +// printer. +func NewBulletList(prefix string, bullets, items []string) *bulletList { + if len(bullets) != 4 { + multilog.Error("Invalid bullet list: 4 bullets required") + bullets = BulletTree + } + return &bulletList{prefix, items, bullets} +} + +func (b *bulletList) MarshalOutput(format output.Format) interface{} { + 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[0] + if len(b.items) == 1 { + bullet = b.bullets[3] // special case list length of one; use last bullet + } + + if i == 0 { + if bullet != "" { + bullet += " " + } + item = b.prefix + bullet + item + } else { + bullet = b.bullets[1] + continuation := indent + b.bullets[2] + " " + if i == len(b.items)-1 { + bullet = b.bullets[3] // this is the last item + continuation = " " + } + wrapped := colorize.Wrap(item, termutils.GetWidth()-len(indent), true, continuation).String() + item = fmt.Sprintf("%s%s %s", indent, bullet, wrapped) + } + out[i] = item + } + + return strings.Join(out, "\n") +} diff --git a/internal/runbits/dependencies/changesummary.go b/internal/runbits/dependencies/changesummary.go index 38720f40d1..75e9a760f6 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.BulletTree, items)) out.Notice("") // blank line } diff --git a/internal/runbits/dependencies/summary.go b/internal/runbits/dependencies/summary.go index 33fc2f6a49..a9008ac3a5 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.BulletTree, items)) out.Notice("") // blank line } diff --git a/internal/runners/artifacts/artifacts.go b/internal/runners/artifacts/artifacts.go index 078d2668a1..6d28db8ab0 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), + })) 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), + })) 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..f1f886307b 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)) 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