Skip to content
Merged
21 changes: 10 additions & 11 deletions internal/render/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
// so that the segment's style can be applied
// during line-by-line rendering.
//
// spaceAfter reports whether a space originally
// followedBySpace reports whether a space originally
// followed this word in the source,
// so commandText can reconstruct
// the display string faithfully
Expand All @@ -24,13 +24,14 @@ type mappedWord struct {
text string
}

// renderCommand writes a styled, wrapped command to w.
// renderCommand writes a styled,
// wrapped command to the configured [io.Writer].
//
// It decomposes segments into word-level mappings,
// wraps the combined text to fit r.output.LineLength,
// and renders each wrapped line with per-word
// segment styling via renderCommandLine.
func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error {
func (r *Renderer) renderCommand(segments []Segment) error {
mappedWords := mapWords(segments, r.output.OptionStyle)
if len(mappedWords) == 0 {
return nil
Expand All @@ -53,7 +54,6 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error {
}

if err := r.renderCommandLine(
w,
words,
mappedWords,
segments,
Expand All @@ -72,18 +72,17 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error {
// wordOffset tracks the current position in mappedWords
// across multi-line rendering.
//
// Consecutive mappedWords with spaceAfter=false form a single
// Consecutive mappedWords with followedBySpace = false form a single
// whitespace-delimited field in the wrapped display text;
// each sub-word is rendered with its own segment's style.
func (r *Renderer) renderCommandLine(
w io.Writer,
words []string,
mappedWords []mappedWord,
segments []Segment,
indent string,
wordOffset *int,
) error {
_, err := io.WriteString(w, indent)
_, err := io.WriteString(r.w, indent)
if err != nil {
return err
}
Expand All @@ -106,7 +105,7 @@ func (r *Renderer) renderCommandLine(
mw := mappedWords[k]
seg := segments[mw.segmentIndex]
if _, err := io.WriteString(
w,
r.w,
r.applyStyle(
r.styleForSegment(&seg),
mw.text,
Expand All @@ -117,14 +116,14 @@ func (r *Renderer) renderCommandLine(
}

if j < len(words)-1 {
_, err := io.WriteString(w, " ")
_, err := io.WriteString(r.w, " ")
if err != nil {
return err
}
}
}

_, err = io.WriteString(w, "\n")
_, err = io.WriteString(r.w, "\n")
return err
}

Expand Down Expand Up @@ -165,7 +164,7 @@ func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord {
// commandText joins the text fields of mapped words back
// into a single space-separated string, suitable for text wrapping.
// A space is inserted only where the original source had whitespace
// (recorded in spaceAfter), so punctuation spacing is preserved
// (recorded in followedBySpace), so punctuation spacing is preserved
// faithfully without a hard-coded punctuation list.
func commandText(words []mappedWord) string {
var b strings.Builder
Expand Down
38 changes: 26 additions & 12 deletions internal/render/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ func TestRenderCommand(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
segments := ParseCommand(tt.raw)
var buf strings.Builder
err := tt.renderer.renderCommand(&buf, segments)
w := io.Writer(&buf)
tt.renderer.w = w
err := tt.renderer.renderCommand(segments)
assert.NoError(t, err)
assert.Equal(t, tt.want, buf.String())
})
Expand All @@ -94,7 +96,10 @@ func TestRenderCommand(t *testing.T) {
func TestRenderCommand_Wrapping(t *testing.T) {
t.Parallel()

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
useColor: false,
output: config.OutputConfig{
OptionStyle: config.OptionStyleLong,
Expand All @@ -105,16 +110,18 @@ func TestRenderCommand_Wrapping(t *testing.T) {
},
}

var buf strings.Builder
err := r.renderCommand(&buf, ParseCommand("some very long command"))
err := r.renderCommand(ParseCommand("some very long command"))
assert.NoError(t, err)
assert.Equal(t, " some very long\n command\n", buf.String())
}

func TestRenderCommand_ShortOption(t *testing.T) {
t.Parallel()

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
useColor: false,
output: config.OutputConfig{
OptionStyle: config.OptionStyleShort,
Expand All @@ -125,16 +132,18 @@ func TestRenderCommand_ShortOption(t *testing.T) {
},
}

var buf strings.Builder
err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}"))
err := r.renderCommand(ParseCommand("cmd {{[-s|--long]}}"))
assert.NoError(t, err)
assert.Equal(t, " cmd -s\n", buf.String())
}

func TestRenderCommand_CombinedOption(t *testing.T) {
t.Parallel()

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
useColor: false,
output: config.OutputConfig{
OptionStyle: config.OptionStyleCombined,
Expand All @@ -144,16 +153,19 @@ func TestRenderCommand_CombinedOption(t *testing.T) {
Example: 4,
},
}
var buf strings.Builder
err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}"))

err := r.renderCommand(ParseCommand("cmd {{[-s|--long]}}"))
assert.NoError(t, err)
assert.Equal(t, " cmd [-s|--long]\n", buf.String())
}

func TestRenderCommand_Colorized(t *testing.T) {
t.Parallel()

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
useColor: true,
style: config.DefaultStyleConfig(),
output: config.OutputConfig{
Expand All @@ -165,8 +177,7 @@ func TestRenderCommand_Colorized(t *testing.T) {
},
}

var buf strings.Builder
err := r.renderCommand(&buf, ParseCommand("echo hello"))
err := r.renderCommand(ParseCommand("echo hello"))
assert.NoError(t, err)
output := buf.String()
assert.Contains(t, output, "\x1b[36m")
Expand All @@ -179,6 +190,9 @@ func TestRenderCommand_WriteError(t *testing.T) {
t.Parallel()

r := &Renderer{
w: &errorWriter{
err: errors.New("write error"),
},
useColor: false,
output: config.OutputConfig{
OptionStyle: config.OptionStyleLong,
Expand All @@ -189,7 +203,7 @@ func TestRenderCommand_WriteError(t *testing.T) {
},
}

err := r.renderCommand(&errorWriter{err: errors.New("write error")}, ParseCommand("echo hi"))
err := r.renderCommand(ParseCommand("echo hi"))
assert.ErrorContains(t, err, "write error")
}

Expand Down Expand Up @@ -273,12 +287,12 @@ func TestRenderCommandLine(t *testing.T) {

var buf strings.Builder
w := io.Writer(&buf)
r.w = w
if tt.writer != nil {
w = tt.writer
r.w = tt.writer
}

err := r.renderCommandLine(
w,
tt.words,
tt.mappedWords,
tt.segments,
Expand Down
20 changes: 9 additions & 11 deletions internal/render/description.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import (
// renderDescriptionLine writes one description line,
// styled with r.style.Description, indented,
// and followed by a newline.
func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error {
func (r *Renderer) renderDescriptionLine(text, indent string) error {
if err := r.renderStyledInline(
w,
text,
indent,
r.style.Description,
Expand All @@ -19,35 +18,35 @@ func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error
return err
}

_, err := io.WriteString(w, "\n")
_, err := io.WriteString(r.w, "\n")
return err
}

// renderDescriptions writes all description lines
// followed by the "More information" URL (if set),
// each indented by r.indent.Description.
// Writes a trailing blank line after descriptions.
func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) error {
func (r *Renderer) renderDescriptions(descs []string, url string) error {
if len(descs) == 0 && url == "" {
return nil
}

indent := strings.Repeat(" ", r.indent.Description)

for _, d := range descs {
if err := r.renderDescriptionLine(w, d, indent); err != nil {
if err := r.renderDescriptionLine(d, indent); err != nil {
return err
}
}

if url != "" {
if err := r.renderDescriptionURL(w, url, indent); err != nil {
if err := r.renderDescriptionURL(url, indent); err != nil {
return err
}
}

if !r.output.Compact {
_, err := io.WriteString(w, "\n")
_, err := io.WriteString(r.w, "\n")
return err
}

Expand All @@ -59,7 +58,7 @@ func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) e
// The "More information: " prefix and trailing "." use r.style.Description;
// the URL itself uses r.style.URL.
// The line is indented and wrapped via wrapText.
func (r *Renderer) renderDescriptionURL(w io.Writer, url, indent string) error {
func (r *Renderer) renderDescriptionURL(url, indent string) error {
var text strings.Builder
text.WriteString(
r.applyStyle(
Expand All @@ -83,7 +82,7 @@ func (r *Renderer) renderDescriptionURL(w io.Writer, url, indent string) error {
wrappedText := r.wrapText(text.String(), indent)

_, err := io.WriteString(
w,
r.w,
wrappedText+"\n",
)
return err
Expand All @@ -92,9 +91,8 @@ func (r *Renderer) renderDescriptionURL(w io.Writer, url, indent string) error {
// renderBulletLine writes one bullet item line,
// styled with r.style.Bullet and indented.
// No trailing newline is added.
func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error {
func (r *Renderer) renderBulletLine(text, indent string) error {
return r.renderStyledInline(
w,
text,
indent,
r.style.Bullet,
Expand Down
35 changes: 20 additions & 15 deletions internal/render/description_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,16 @@ func TestRenderDescriptionLine(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Renderer{}

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
}
if tt.writer != nil {
w = tt.writer
r.w = tt.writer
}

err := r.renderDescriptionLine(w, tt.text, tt.indent)
err := r.renderDescriptionLine(tt.text, tt.indent)

if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
Expand Down Expand Up @@ -121,18 +122,19 @@ func TestRenderDescriptions(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
indent: config.IndentConfig{Description: 2},
output: config.OutputConfig{Compact: tt.compact},
}

var buf strings.Builder
w := io.Writer(&buf)
if tt.writer != nil {
w = tt.writer
r.w = tt.writer
}

err := r.renderDescriptions(w, tt.descs, tt.url)
err := r.renderDescriptions(tt.descs, tt.url)

if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
Expand Down Expand Up @@ -188,7 +190,9 @@ func TestRenderDescriptionURL(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
useColor: tt.useColor,
style: config.StyleConfig{
Description: tt.descStyle,
Expand All @@ -197,12 +201,11 @@ func TestRenderDescriptionURL(t *testing.T) {
output: config.OutputConfig{LineLength: tt.lineLength},
}

w := io.Writer(&buf)
if tt.writer != nil {
w = tt.writer
r.w = tt.writer
}

err := r.renderDescriptionURL(w, tt.url, tt.indent)
err := r.renderDescriptionURL(tt.url, tt.indent)

if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
Expand Down Expand Up @@ -251,15 +254,17 @@ func TestRenderBulletLine(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Renderer{}

var buf strings.Builder
w := io.Writer(&buf)
r := &Renderer{
w: w,
}

if tt.writer != nil {
w = tt.writer
r.w = tt.writer
}

err := r.renderBulletLine(w, tt.text, tt.indent)
err := r.renderBulletLine(tt.text, tt.indent)

if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
Expand Down
Loading
Loading