diff --git a/internal/render/command.go b/internal/render/command.go new file mode 100644 index 0000000..1c1c223 --- /dev/null +++ b/internal/render/command.go @@ -0,0 +1,161 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/text" +) + +// mappedWord pairs a single word from a command +// with the index of its originating Segment, +// so that the segment's style can be applied +// during line-by-line rendering. +type mappedWord struct { + text string + segmentIndex int +} + +// renderCommand writes a styled, wrapped command to w. +// +// 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 { + mappedWords := mapWords(segments, r.output.OptionStyle) + if len(mappedWords) == 0 { + return nil + } + + displayText := commandText(mappedWords) + exampleIndent := strings.Repeat(" ", r.indent.Example) + lines := wrapLines( + r.output.LineLength, + exampleIndent, + displayText, + ) + + wordOffset := 0 + + for _, line := range lines { + words := strings.Fields(line) + if len(words) == 0 { + continue + } + + if err := r.renderCommandLine( + w, + words, + mappedWords, + segments, + exampleIndent, + &wordOffset, + ); err != nil { + return err + } + } + + return nil +} + +// renderCommandLine writes one indented line of a command, +// applying the style of each word's originating Segment. +// wordOffset tracks the current position in mappedWords +// across multi-line rendering. +func (r *Renderer) renderCommandLine( + w io.Writer, + words []string, + mappedWords []mappedWord, + segments []Segment, + indent string, + wordOffset *int, +) error { + _, err := io.WriteString(w, indent) + if err != nil { + return err + } + + for j, word := range words { + if *wordOffset >= len(mappedWords) { + break + } + + mapped := mappedWords[*wordOffset] + segment := segments[mapped.segmentIndex] + + if _, err := io.WriteString( + w, + r.applyStyle( + r.styleForSegment(&segment), + word, + ), + ); err != nil { + return err + } + + if j < len(words)-1 { + _, err := io.WriteString(w, " ") + if err != nil { + return err + } + } + + *wordOffset++ + } + + _, err = io.WriteString(w, "\n") + return err +} + +// mapWords flattens each Segment's DisplayText into individual words. +func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { + var mappedWords []mappedWord + for i, segment := range segments { + words := strings.FieldsSeq(segment.DisplayText(optionStyle)) + for word := range words { + mappedWords = append( + mappedWords, + mappedWord{ + text: word, + segmentIndex: i, + }, + ) + } + } + + return mappedWords +} + +// commandText joins the text fields of mapped words back +// into a single space-separated string, suitable for text wrapping. +func commandText(words []mappedWord) string { + var b strings.Builder + + for i, word := range words { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(word.text) + } + + return b.String() +} + +// wrapLines wraps displayText to fit within width columns. +// Continuation lines are prefixed with indent. +// If width ≤ 0 the text is returned as a single-element slice (no wrapping). +func wrapLines( + width int, + indent, + displayText string, +) []string { + var wrapped string + if width <= 0 { + return []string{displayText} + } + + wrapped = text.Wrap(displayText, width, indent) + return strings.Split(wrapped, "\n") +} diff --git a/internal/render/command_test.go b/internal/render/command_test.go new file mode 100644 index 0000000..e1e6e99 --- /dev/null +++ b/internal/render/command_test.go @@ -0,0 +1,470 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestRenderCommand(t *testing.T) { + t.Parallel() + + noColorRenderer := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + + tests := []struct { + name string + renderer *Renderer + raw string + want string + }{ + { + name: "empty segments writes nothing", + renderer: noColorRenderer, + raw: "", + want: "", + }, + { + name: "simple text command", + renderer: noColorRenderer, + raw: "tar cf archive.tar", + want: " tar cf archive.tar\n", + }, + { + name: "command with placeholders", + renderer: noColorRenderer, + raw: "tar cf {{archive.tar}} {{dest}}", + want: " tar cf archive.tar dest\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + segments := ParseCommand(tt.raw) + var buf strings.Builder + err := tt.renderer.renderCommand(&buf, segments) + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } + + t.Run("wrapping produces multiple lines", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 15, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("some very long command")) + assert.NoError(t, err) + assert.Equal(t, " some very long\n command\n", buf.String()) + }) + + t.Run("option rendered with short style", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleShort, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd -s\n", buf.String()) + }) + + t.Run("option rendered with combined style", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleCombined, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd [-s|--long]\n", buf.String()) + }) + + t.Run("colorized output contains ANSI sequences", func(t *testing.T) { + r := &Renderer{ + useColor: true, + style: config.DefaultStyleConfig(), + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("echo hello")) + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "\x1b[36m") + assert.Contains(t, output, "\x1b[0m") + assert.Contains(t, output, "echo") + assert.Contains(t, output, "hello") + }) + + t.Run("error from renderCommandLine propagates", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + err := r.renderCommand(&errorWriter{err: errors.New("write error")}, ParseCommand("echo hi")) + assert.ErrorContains(t, err, "write error") + }) +} + +func TestMapWords(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segments []Segment + optionStyle config.OptionStyle + want []mappedWord + }{ + { + name: "empty segments", + segments: nil, + optionStyle: config.OptionStyleLong, + want: nil, + }, + { + name: "single text segment", + segments: []Segment{{Kind: Text, Text: "hello"}}, + optionStyle: config.OptionStyleLong, + want: []mappedWord{{text: "hello", segmentIndex: 0}}, + }, + { + name: "text segment with multiple words", + segments: []Segment{{Kind: Text, Text: "tar cf archive.tar"}}, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "tar", segmentIndex: 0}, + {text: "cf", segmentIndex: 0}, + {text: "archive.tar", segmentIndex: 0}, + }, + }, + { + name: "multiple text segments", + segments: []Segment{ + {Kind: Text, Text: "mv "}, + {Kind: Text, Text: "src "}, + {Kind: Text, Text: "dst"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "mv", segmentIndex: 0}, + {text: "src", segmentIndex: 1}, + {text: "dst", segmentIndex: 2}, + }, + }, + { + name: "option with short style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleShort, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "-s", segmentIndex: 1}, + }, + }, + { + name: "option with long style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "--long", segmentIndex: 1}, + }, + }, + { + name: "option with combined style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleCombined, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "[-s|--long]", segmentIndex: 1}, + }, + }, + { + name: "placeholder segment", + segments: []Segment{ + {Kind: Text, Text: "echo "}, + {Kind: Placeholder, Text: "hello"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "echo", segmentIndex: 0}, + {text: "hello", segmentIndex: 1}, + }, + }, + { + name: "mixed text option and placeholder", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-o", Long: "--output"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "file"}, + }, + optionStyle: config.OptionStyleShort, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "-o", segmentIndex: 1}, + {text: "file", segmentIndex: 3}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapWords(tt.segments, tt.optionStyle) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommandText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + words []mappedWord + want string + }{ + { + name: "empty", + words: nil, + want: "", + }, + { + name: "single word", + words: []mappedWord{ + {text: "hello"}, + }, + want: "hello", + }, + { + name: "multiple words", + words: []mappedWord{ + {text: "tar"}, + {text: "cf"}, + {text: "archive.tar"}, + }, + want: "tar cf archive.tar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := commandText(tt.words) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWrapLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + width int + indent string + text string + want []string + }{ + { + name: "width zero returns single line", + width: 0, + indent: " ", + text: "some very long command that exceeds any reasonable width", + want: []string{"some very long command that exceeds any reasonable width"}, + }, + { + name: "width negative returns single line", + width: -1, + indent: " ", + text: "short", + want: []string{"short"}, + }, + { + name: "width exceeds text returns single line", + width: 100, + indent: " ", + text: "short text", + want: []string{"short text"}, + }, + { + name: "width less than text wraps with indent", + width: 5, + indent: " ", + text: "a b c d e", + want: []string{ + "a b c", + " d e", + }, + }, + { + name: "empty text returns single empty string", + width: 80, + indent: " ", + text: "", + want: []string{""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapLines(tt.width, tt.indent, tt.text) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRenderCommandLine(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + } + + tests := []struct { + name string + words []string + mappedWords []mappedWord + segments []Segment + indent string + want string + wantOffset int + writer io.Writer + wantErr string + }{ + { + name: "single word", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + want: " hello\n", + wantOffset: 1, + }, + { + name: "multiple words", + words: []string{"tar", "cf", "archive.tar"}, + mappedWords: []mappedWord{ + {text: "tar", segmentIndex: 0}, + {text: "cf", segmentIndex: 0}, + {text: "archive.tar", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "tar cf archive.tar"}, + }, + indent: " ", + want: " tar cf archive.tar\n", + wantOffset: 3, + }, + { + name: "break when mapped words exhausted", + words: []string{"a", "b", "c"}, + mappedWords: []mappedWord{ + {text: "a", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "a"}, + }, + indent: " ", + want: " a \n", + wantOffset: 1, + }, + { + name: "write error", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset := 0 + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderCommandLine( + w, + tt.words, + tt.mappedWords, + tt.segments, + tt.indent, + &offset, + ) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantOffset, offset) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/internal/render/doc.go b/internal/render/doc.go new file mode 100644 index 0000000..9a0e8b3 --- /dev/null +++ b/internal/render/doc.go @@ -0,0 +1,8 @@ +// Package render generates terminal-formatted output for tldr pages. +// +// It parses markdown-formatted tldr pages and renders them as +// colored, wrapped terminal text with syntax highlighting for command examples. +// +// Text is automatically wrapped to fit within a configurable maximum line width. +// Color output can be disabled with the WithColor(false) option. +package render diff --git a/internal/render/main_test.go b/internal/render/main_test.go new file mode 100644 index 0000000..286d5bb --- /dev/null +++ b/internal/render/main_test.go @@ -0,0 +1,11 @@ +package render + +// errorWriter is a test stub that always returns the configured write error. +type errorWriter struct { + err error +} + +// Write always returns the configured error. +func (w *errorWriter) Write(p []byte) (int, error) { + return 0, w.err +} diff --git a/internal/render/page.go b/internal/render/page.go new file mode 100644 index 0000000..f09e13f --- /dev/null +++ b/internal/render/page.go @@ -0,0 +1,256 @@ +package render + +import ( + "regexp" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" +) + +var ( + // placeholderPattern matches tldr placeholder tokens like {{archive.tar}} + // and captures the inner content (the text between the braces). + placeholderPattern = regexp.MustCompile(`\{\{(.*?)\}\}`) + + // optionPattern matches the option syntax embedded inside a placeholder, + // e.g. [-s|--long]. It captures the short form and the long form. + optionPattern = regexp.MustCompile(`^\[(.+)\|(.+)\]$`) +) + +// Kind represents the type of a parsed command segment. +type Kind int + +const ( + // Text is a plain literal segment that should be rendered as-is. + Text Kind = iota + + // Placeholder is a user-supplied value wrapped in {{...}}. + Placeholder + + // Option is a command-line flag embedded in {{[...|...]}} syntax. + Option +) + +// Segment holds a parsed piece of a command string. +// A segment is either literal text, +// a user-supplied placeholder value, +// or a command-line option with short and long forms. +type Segment struct { + Kind Kind + Text string + Short string + Long string +} + +// Example is a single tldr example +// consisting of a description +// and the associated command text. +type Example struct { + Description string + Command string +} + +// Page is a parsed tldr page containing a title, +// zero or more description lines, +// an optional "More information" URL, +// the filesystem path to the source file, +// and a list of examples. +type Page struct { + Title string + URL string + Path string + Description []string + Examples []Example +} + +// Parse parses a raw markdown tldr page string into a Page. +// +// It recognises the following markdown structure: +// - # Title +// - > Description lines (with optional "More information: " extraction) +// - - Example descriptions (optionally ending with a colon) +// - `command` lines (associated with the preceding example) +// +// Nil and empty content produce a Page with zero-valued fields. +func Parse(content string) *Page { + p := &Page{} + lines := strings.SplitSeq(content, "\n") + + for line := range lines { + switch { + case strings.HasPrefix(line, "# ") && p.Title == "": + p.Title = line[2:] + case strings.HasPrefix(line, "> "): + body := line[2:] + if url := parseURL(body); url != "" { + p.URL = url + } else { + p.Description = append(p.Description, body) + } + case strings.HasPrefix(line, "- "): + desc := strings.TrimSuffix(line[2:], ":") + p.Examples = append( + p.Examples, + Example{ + Description: desc, + }, + ) + case strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`"): + cmd := strings.TrimPrefix( + strings.TrimSuffix(line, "`"), + "`", + ) + + if len(p.Examples) == 0 { + p.Examples = append( + p.Examples, + Example{ + Command: cmd, + }, + ) + } else { + last := &p.Examples[len(p.Examples)-1] + if last.Command == "" { + last.Command = cmd + } + } + } + } + + return p +} + +// ParseCommand splits a raw command string into a slice of Segments. +// +// Placeholder tokens wrapped in {{...}} are extracted +// as either Placeholder or Option segments depending on their inner content. +// Text outside of placeholders becomes Text segments. +// An empty string returns nil. +func ParseCommand(raw string) []Segment { + if raw == "" { + return nil + } + + matches := placeholderPattern.FindAllStringSubmatchIndex(raw, -1) + if len(matches) == 0 { + return []Segment{ + { + Kind: Text, + Text: raw, + }, + } + } + + lastEnd := 0 + var segments []Segment + + for _, match := range matches { + matchStart := match[0] + matchEnd := match[1] + groupStart := match[2] + groupEnd := match[3] + + if lastEnd < matchStart { + segments = append( + segments, + Segment{ + Kind: Text, + Text: raw[lastEnd:matchStart], + }, + ) + } + + inner := raw[groupStart:groupEnd] + segments = append( + segments, + parseInnerPlaceholders(inner), + ) + + lastEnd = matchEnd + } + + if lastEnd < len(raw) { + segments = append( + segments, + Segment{ + Kind: Text, + Text: raw[lastEnd:], + }, + ) + } + + return segments +} + +// DisplayText returns the text +// that should be displayed for the segment, +// taking the option display style into account. +// +// For Option segments the return value depends on style: +// - OptionStyleShort returns the short form (e.g. "-s") +// - OptionStyleLong returns the long form (e.g. "--long") +// - OptionStyleCombined returns both (e.g. "[-s|--long]") +// +// All other kinds return the Text field unchanged. +func (s Segment) DisplayText(style config.OptionStyle) string { + switch s.Kind { + case Option: + switch style { + case config.OptionStyleShort: + return s.Short + case config.OptionStyleLong: + return s.Long + case config.OptionStyleCombined: + return "[" + s.Short + "|" + s.Long + "]" + default: + return s.Long + } + default: + return s.Text + } +} + +// parseURL extracts the URL from a "More information" description line. +// It expects the URL to be wrapped in angle brackets like . +// Returns the empty string if no valid URL is found. +func parseURL(body string) string { + if !strings.Contains(body, "More information: <") { + return "" + } + + start := strings.Index(body, "<") + end := strings.Index(body, ">") + if start != -1 && end != -1 && start < end { + return body[start+1 : end] + } + + return "" +} + +// parseInnerPlaceholders parses the inner content of a {{...}} placeholder. +// +// If the inner content matches the option pattern [short|long], +// an Option segment is returned with the short and long forms correctly +// identified regardless of their order. +// Otherwise a plain Placeholder segment is returned. +func parseInnerPlaceholders(inner string) Segment { + if options := optionPattern.FindStringSubmatch(inner); len(options) == 3 { + left, right := options[1], options[2] + short, long := left, right + + if strings.HasPrefix(left, "--") && !strings.HasPrefix(right, "--") { + short, long = right, left + } + + return Segment{ + Kind: Option, + Long: long, + Short: short, + } + } + + return Segment{ + Kind: Placeholder, + Text: inner, + } +} diff --git a/internal/render/page_test.go b/internal/render/page_test.go new file mode 100644 index 0000000..7583674 --- /dev/null +++ b/internal/render/page_test.go @@ -0,0 +1,335 @@ +package render + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want *Page + }{ + { + name: "full page with title description URL and examples", + content: "# tar\n\n> archive utility.\n> More information: .\n\n- create an archive:\n\n`tar cf {{archive.tar}} {{files}}`\n\n- extract an archive:\n\n`tar xf {{archive.tar}}`\n", + want: &Page{ + Title: "tar", + Description: []string{"archive utility."}, + URL: "https://example.org/tar", + Examples: []Example{ + {Description: "create an archive", Command: "tar cf {{archive.tar}} {{files}}"}, + {Description: "extract an archive", Command: "tar xf {{archive.tar}}"}, + }, + }, + }, + { + name: "title only", + content: "# title-only\n", + want: &Page{ + Title: "title-only", + }, + }, + { + name: "description without URL", + content: "# desc-test\n\n> just a description.\n", + want: &Page{ + Title: "desc-test", + Description: []string{"just a description."}, + }, + }, + { + name: "multiple description lines merged with space", + content: "# multi-desc\n\n> line one.\n> line two.\n> line three.\n", + want: &Page{ + Title: "multi-desc", + Description: []string{"line one.", "line two.", "line three."}, + }, + }, + { + name: "example description without trailing colon", + content: "# no-colon\n\n- description without colon\n\n`echo hello`\n", + want: &Page{ + Title: "no-colon", + Examples: []Example{ + {Description: "description without colon", Command: "echo hello"}, + }, + }, + }, + { + name: "command with placeholders", + content: "# placeholders\n\n- example:\n\n`tar cf {{archive.tar}} {{files}}`\n", + want: &Page{ + Title: "placeholders", + Examples: []Example{ + {Description: "example", Command: "tar cf {{archive.tar}} {{files}}"}, + }, + }, + }, + { + name: "command without placeholder", + content: "# plain\n\n- example:\n\n`ls -la`\n", + want: &Page{ + Title: "plain", + Examples: []Example{ + {Description: "example", Command: "ls -la"}, + }, + }, + }, + { + name: "URL extracted from More information line", + content: "# with-url\n\n> A description.\n> More information: .\n", + want: &Page{ + Title: "with-url", + Description: []string{"A description."}, + URL: "https://example.org/custom", + }, + }, + { + name: "empty content", + content: "", + want: &Page{}, + }, + { + name: "example without command", + content: "# no-command\n\n- description only:\n", + want: &Page{ + Title: "no-command", + Examples: []Example{ + {Description: "description only"}, + }, + }, + }, + { + name: "command without preceding description", + content: "# bare-command\n\n`echo hello`\n", + want: &Page{ + Title: "bare-command", + Examples: []Example{ + {Command: "echo hello"}, + }, + }, + }, + { + name: "real-world content from fixture", + content: "# test page\n\n> This is a test page.\n> More information: .\n\n- This is a description of a `command` example:\n\n`command --opt1 --opt2 {{placeholder}}`\n\n- Another one:\n\n`command --opt1 {{placeholder1 placeholder2 ...}}`\n", + want: &Page{ + Title: "test page", + Description: []string{"This is a test page."}, + URL: "https://example.org", + Examples: []Example{ + {Description: "This is a description of a `command` example", Command: "command --opt1 --opt2 {{placeholder}}"}, + {Description: "Another one", Command: "command --opt1 {{placeholder1 placeholder2 ...}}"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Parse(tt.content) + require.NotNil(t, got) + assert.Equal(t, tt.want.Title, got.Title, "Title mismatch") + assert.Equal(t, tt.want.Description, got.Description, "Description mismatch") + assert.Equal(t, tt.want.URL, got.URL, "URL mismatch") + assert.Equal(t, tt.want.Examples, got.Examples, "Examples mismatch") + }) + } +} + +func TestParseCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []Segment + }{ + { + name: "no placeholders", + raw: "tar cf archive.tar", + want: []Segment{ + {Kind: Text, Text: "tar cf archive.tar"}, + }, + }, + { + name: "single placeholder", + raw: "tar cf {{archive.tar}}", + want: []Segment{ + {Kind: Text, Text: "tar cf "}, + {Kind: Placeholder, Text: "archive.tar"}, + }, + }, + { + name: "multiple placeholders", + raw: "mv {{source}} {{destination}}", + want: []Segment{ + {Kind: Text, Text: "mv "}, + {Kind: Placeholder, Text: "source"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "destination"}, + }, + }, + { + name: "option placeholder short long", + raw: "cmd {{[-s|--long]}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + }, + { + name: "option placeholder long short reversed", + raw: "cmd {{[--long|-s]}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + }, + { + name: "mixed text placeholder and option", + raw: "cmd {{[-s|--long]}} {{file}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "file"}, + }, + }, + { + name: "empty string", + raw: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseCommand(tt.raw) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDisplayText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segment Segment + style config.OptionStyle + want string + }{ + { + name: "SegmentText with short style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleShort, + want: "hello", + }, + { + name: "SegmentText with long style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleLong, + want: "hello", + }, + { + name: "SegmentText with both style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleCombined, + want: "hello", + }, + { + name: "SegmentPlaceholder with short style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleShort, + want: "file", + }, + { + name: "SegmentPlaceholder with long style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleLong, + want: "file", + }, + { + name: "SegmentPlaceholder with both style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleCombined, + want: "file", + }, + { + name: "SegmentOption with short style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleShort, + want: "-s", + }, + { + name: "SegmentOption with long style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleLong, + want: "--long", + }, + { + name: "SegmentOption with both style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleCombined, + want: "[-s|--long]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.segment.DisplayText(tt.style) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + want string + }{ + {name: "valid URL with trailing text", body: "More information: .", want: "https://example.org"}, + {name: "valid URL without trailing text", body: "More information: ", want: "https://example.org"}, + {name: "no URL marker", body: "Some description text.", want: ""}, + {name: "missing opening bracket", body: "More information: https://example.org>.", want: ""}, + {name: "missing closing bracket", body: "More information: .", want: ""}, + {name: "empty body", body: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseURL(tt.body) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseInnerPlaceholders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inner string + want Segment + }{ + {name: "option short long", inner: "[-s|--long]", want: Segment{Kind: Option, Short: "-s", Long: "--long"}}, + {name: "option long short reversed", inner: "[--long|-s]", want: Segment{Kind: Option, Short: "-s", Long: "--long"}}, + {name: "plain placeholder", inner: "file", want: Segment{Kind: Placeholder, Text: "file"}}, + {name: "path placeholder", inner: "path/to/file", want: Segment{Kind: Placeholder, Text: "path/to/file"}}, + {name: "empty inner", inner: "", want: Segment{Kind: Placeholder, Text: ""}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInnerPlaceholders(tt.inner) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..6b5a0b6 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,313 @@ +package render + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" + "github.com/TheRootDaemon/tlgc/pathutil" + "github.com/TheRootDaemon/tlgc/termcolor" + "github.com/TheRootDaemon/tlgc/text" +) + +// Renderer renders parsed tldr pages to a writer +// with optional ANSI color and configurable styles, indentation, and output settings. +type Renderer struct { + useColor bool // whether ANSI color output is enabled + w io.Writer // destination for rendered output + style config.StyleConfig // style configuration for each page element + output config.OutputConfig // output visibility and formatting options + indent config.IndentConfig // indentation per section +} + +// RenderOption configures a Renderer. +type RenderOption func(*Renderer) + +// WithColor enables or disables ANSI color output. +func WithColor(enabled bool) RenderOption { + return func(r *Renderer) { + r.useColor = enabled + } +} + +// WithWriter sets the output writer for the Renderer. +func WithWriter(w io.Writer) RenderOption { + return func(r *Renderer) { + r.w = w + } +} + +// WithStyle replaces the default style configuration for all page elements. +func WithStyle(style config.StyleConfig) RenderOption { + return func(r *Renderer) { + r.style = style + } +} + +// WithOutput replaces the default output configuration +// (title visibility, hyphens, edit link, line length, etc.). +func WithOutput(output config.OutputConfig) RenderOption { + return func(r *Renderer) { + r.output = output + } +} + +// WithIndent replaces the default indentation configuration +// for each section (title, description, bullet, example). +func WithIndent(indent config.IndentConfig) RenderOption { + return func(r *Renderer) { + r.indent = indent + } +} + +// New creates a Renderer that writes to w. +// +// Defaults from the active config are used for style, output, and indentation; +// options may override any of these. +func New(w io.Writer, options ...RenderOption) *Renderer { + r := &Renderer{ + useColor: termcolor.SupportsColor(), + w: w, + style: config.Style(), + output: config.Output(), + indent: config.Indent(), + } + + for _, option := range options { + option(r) + } + + return r +} + +// Render writes a formatted tldr page to the Renderer's writer. +// platform is used only when PlatformTitle is enabled. +// Nil pages are silently ignored. +func (r *Renderer) Render(platform string, p *Page) error { + if p == nil { + return nil + } + + if r.output.EditLink { + if url := buildEditURL(p.Path, p.URL); url != "" { + if err := r.renderEditLink(r.w, url); err != nil { + return err + } + } + } + + if r.output.RawMarkdown { + return r.renderRaw(p) + } + + if r.output.ShowTitle && p.Title != "" { + title := p.Title + if r.output.PlatformTitle && platform != "" { + title = platform + " (" + p.Title + ")" + } + + if err := r.renderTitle(r.w, title); err != nil { + return err + } + + _, err := io.WriteString(r.w, "\n\n") + if err != nil { + return nil + } + } + + if err := r.renderDescriptions(r.w, p.Description, p.URL); err != nil { + return err + } + + for i, ex := range p.Examples { + if i > 0 && !r.output.Compact { + _, err := io.WriteString(r.w, "\n") + if err != nil { + return err + } + } + + if err := r.renderExample(r.w, ex); err != nil { + return err + } + } + + return nil +} + +// renderEditLink writes the edit URL to w, with a trailing newline +// unless output is in compact mode. +func (r *Renderer) renderEditLink(w io.Writer, url string) error { + logger.Info("edit this page on GitHub") + _, err := io.WriteString(w, url) + if err != nil { + return err + } + + if !r.output.Compact { + _, err := io.WriteString(w, "\n") + return err + } + + return nil +} + +// renderRaw reads the raw markdown file at p.Path and +// writes it to the Renderer's writer. +func (r *Renderer) renderRaw(p *Page) error { + data, err := os.ReadFile(p.Path) + if err != nil { + return err + } + + _, err = r.w.Write(data) + return err +} + +// renderTitle writes the page title, +// styled with r.style.Title and +// indented by r.indent.Title spaces. +func (r *Renderer) renderTitle(w io.Writer, title string) error { + indent := strings.Repeat(" ", r.indent.Title) + + _, err := io.WriteString( + w, + r.applyStyle( + r.style.Title, + r.wrapText(title, indent), + ), + ) + return err +} + +// 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 { + _, err := io.WriteString( + w, + r.applyStyle( + r.style.Description, + r.wrapText(text, indent), + ), + ) + if err != nil { + return err + } + + _, err = io.WriteString(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 { + 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 { + return err + } + } + + if url != "" { + if err := r.renderDescriptionLine(w, "More information: "+url+".", indent); err != nil { + return err + } + } + + _, err := io.WriteString(w, "\n") + return err +} + +// 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 { + _, err := io.WriteString( + w, + r.applyStyle( + r.style.Bullet, + r.wrapText(text, indent), + ), + ) + + return err +} + +// renderExample writes one example, +// a bullet line for the description (prefixed with ExamplePrefix when ShowHyphens is set) +// followed by the styled command text on the next line. +// In compact mode the blank line between bullet and command is omitted. +func (r *Renderer) renderExample(w io.Writer, ex Example) error { + indent := strings.Repeat(" ", r.indent.Bullet) + desc := ex.Description + + if r.output.ShowHyphens { + desc = r.output.ExamplePrefix + desc + } + + if err := r.renderBulletLine(w, desc, indent); err != nil { + return err + } + + if !r.output.Compact { + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } + } + + if ex.Command != "" { + segments := ParseCommand(ex.Command) + if err := r.renderCommand(w, segments); err != nil { + return err + } + } + + return nil +} + +// buildEditURL returns the GitHub edit URL for a tldr page. +// If url is non-empty it is returned as-is (the page has a custom source). +// Otherwise the URL is constructed from the page's file path. +func buildEditURL(path, url string) string { + if url != "" { + return url + } + + if path != "" { + page := pathutil.PageName(path) + platform := pathutil.PagePlatform(path) + return fmt.Sprintf( + "https://github.com/tldr-pages/tldr/edit/main/pages/%s/%s.md", + platform, + page, + ) + } + + return "" +} + +// wrapText applies text wrapping to s and prepends indent to every line. +// Wrapping is controlled by r.output.LineLength; +// when LineLength ≤ 0 or s is empty, +// only the indent is prepended without wrapping. +func (r *Renderer) wrapText(s, indent string) string { + if r.output.LineLength <= 0 || s == "" { + return indent + s + } + + wrapped := text.Wrap(s, r.output.LineLength, indent) + return indent + wrapped +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..8f9caf4 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,919 @@ +package render + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []RenderOption + want bool + }{ + {name: "overrides to false", opts: []RenderOption{WithColor(false)}, want: false}, + {name: "overrides to true", opts: []RenderOption{WithColor(true)}, want: true}, + {name: "last option wins", opts: []RenderOption{WithColor(false), WithColor(true)}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, tt.opts...) + assert.Equal(t, tt.want, r.useColor) + }) + } +} + +func TestWithWriter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + writer io.Writer + }{ + {name: "os.Stdout", writer: os.Stdout}, + {name: "nil writer", writer: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New(&strings.Builder{}, WithWriter(tt.writer)) + assert.Equal(t, tt.writer, r.w) + }) + } +} + +func TestWithStyle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style config.StyleConfig + }{ + { + name: "custom style", + style: config.StyleConfig{ + Title: config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + }, + }, + }, + { + name: "zero value style", + style: config.StyleConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithStyle(tt.style)) + assert.Equal(t, tt.style, r.style) + }) + } +} + +func TestWithOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output config.OutputConfig + }{ + { + name: "custom output", + output: config.OutputConfig{ + ShowTitle: false, + LineLength: 50, + Compact: true, + }, + }, + { + name: "zero value output", + output: config.OutputConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithOutput(tt.output)) + assert.Equal(t, tt.output, r.output) + }) + } +} + +func TestWithIndent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + indent config.IndentConfig + }{ + { + name: "custom indent", + indent: config.IndentConfig{ + Title: 0, + Description: 1, + Bullet: 2, + Example: 3, + }, + }, + { + name: "zero value indent", + indent: config.IndentConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithIndent(tt.indent)) + assert.Equal(t, tt.indent, r.indent) + }) + } +} + +func TestNew(t *testing.T) { + t.Run("defaults with no config loaded", func(t *testing.T) { + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + t.Setenv("NO_COLOR", "1") + + var buf strings.Builder + r := New(&buf) + + assert.False(t, r.useColor) + assert.Equal(t, &buf, r.w) + assert.Equal(t, config.DefaultStyleConfig(), r.style) + assert.Equal(t, config.DefaultOutputConfig(), r.output) + assert.Equal(t, config.DefaultIndentConfig(), r.indent) + }) + + t.Run("picks up custom config", func(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + content := ` +[output] +show_title = false +line_length = 60 + +[indent] +title = 4 +example = 6 +` + require.NoError(t, os.WriteFile(cfgPath, []byte(content), 0o644)) + t.Setenv("TLGC_CONFIG", cfgPath) + t.Setenv("NO_COLOR", "1") + config.ResetForTesting() + require.NoError(t, config.Initialize()) + t.Cleanup(config.ResetForTesting) + + var buf strings.Builder + r := New(&buf) + + assert.Equal(t, 4, r.indent.Title) + assert.Equal(t, 6, r.indent.Example) + assert.Equal(t, 2, r.indent.Description) + assert.Equal(t, 2, r.indent.Bullet) + assert.False(t, r.output.ShowTitle) + assert.Equal(t, 60, r.output.LineLength) + assert.False(t, r.output.ShowHyphens) + }) + + t.Run("color disabled when TERM is dumb", func(t *testing.T) { + t.Setenv("TERM", "dumb") + + var buf strings.Builder + r := New(&buf) + + assert.False(t, r.useColor) + }) +} + +func TestRender(t *testing.T) { + fullPageWant := " tar\n\n" + + " archive utility.\n" + + " More information: https://example.org.\n" + + "\n" + + " create archive\n" + + " tar cf archive.tar\n" + + "\n" + + " extract\n" + + " tar xf archive.tar\n" + + tests := []struct { + name string + platform string + renderer *Renderer + page *Page + want string + contains []string + notContains []string + wantErr string + }{ + { + name: "nil page returns nil", + renderer: &Renderer{}, + want: "", + }, + { + name: "empty page with only title", + renderer: &Renderer{output: config.OutputConfig{ShowTitle: true}, indent: config.IndentConfig{Title: 2}}, + page: &Page{Title: "tar"}, + want: " tar\n\n", + }, + { + name: "full page renders all sections in order", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: true}, + indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, + }, + page: &Page{ + Title: "tar", + Description: []string{"archive utility."}, + URL: "https://example.org", + Examples: []Example{ + {Description: "create archive", Command: "tar cf archive.tar"}, + {Description: "extract", Command: "tar xf archive.tar"}, + }, + }, + want: fullPageWant, + }, + { + name: "platform title includes platform prefix", + platform: "linux", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: true, PlatformTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{Title: "tar"}, + want: " linux (tar)\n\n", + }, + { + name: "edit link rendered before title", + renderer: &Renderer{ + output: config.OutputConfig{EditLink: true, ShowTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{ + Path: "/pages/common/tar.md", + Title: "tar", + }, + contains: []string{ + "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md\n", + " tar\n\n", + }, + }, + { + name: "raw markdown mode writes file content", + renderer: &Renderer{output: config.OutputConfig{RawMarkdown: true}}, + page: &Page{}, + want: "# test page\n\n> description.\n", + }, + { + name: "compact mode omits blank lines between examples", + renderer: &Renderer{ + output: config.OutputConfig{Compact: true}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + }, + page: &Page{ + Examples: []Example{ + {Description: "first", Command: "echo a"}, + {Description: "second", Command: "echo b"}, + }, + }, + notContains: []string{"\n\n"}, + }, + { + name: "title hidden when ShowTitle is false", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: false}, + indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, + }, + page: &Page{ + Title: "tar", + Description: []string{"desc."}, + Examples: []Example{{Description: "ex", Command: "cmd"}}, + }, + notContains: []string{"tar"}, + }, + { + name: "write error propagates", + renderer: &Renderer{ + w: &errorWriter{err: errors.New("write error")}, + output: config.OutputConfig{ShowTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{Title: "tar"}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + if tt.renderer.w == nil { + tt.renderer.w = &buf + } + + if tt.renderer.output.RawMarkdown && tt.want != "" { + path := filepath.Join(t.TempDir(), "page.md") + require.NoError(t, os.WriteFile(path, []byte(tt.want), 0o644)) + tt.page.Path = path + } + + err := tt.renderer.Render(tt.platform, tt.page) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + got := buf.String() + + if tt.want != "" { + assert.Equal(t, tt.want, got) + } + for _, s := range tt.contains { + assert.Contains(t, got, s) + } + for _, s := range tt.notContains { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestRenderEditLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + compact bool + url string + writer io.Writer + want string + wantErr string + }{ + { + name: "non-compact adds newline", + compact: false, + url: "https://example.com", + want: "https://example.com\n", + }, + { + name: "compact omits newline", + compact: true, + url: "https://example.com", + want: "https://example.com", + }, + { + name: "write error", + compact: false, + url: "url", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{Compact: tt.compact}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderEditLink(w, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderRaw(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + writer io.Writer + want string + wantErr string + wantAnyErr bool + }{ + { + name: "writes file content", + content: "# hello", + want: "# hello", + }, + { + name: "file not found", + wantAnyErr: true, + }, + { + name: "write error", + content: "data", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := "/nonexistent/file.md" + if tt.content != "" { + path = filepath.Join(t.TempDir(), "page.md") + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + r := &Renderer{w: w} + err := r.renderRaw(&Page{Path: path}) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + if tt.wantAnyErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + indent int + useColor bool + style config.OutputStyle + writer io.Writer + want string + wantErr string + }{ + { + name: "title with indent", + title: "tar", + indent: 2, + want: " tar", + }, + { + name: "zero indent", + title: "tar", + indent: 0, + want: "tar", + }, + { + name: "colorized title", + title: "tar", + indent: 2, + useColor: true, + style: config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + }, + }, + { + name: "write error", + title: "tar", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + style: config.StyleConfig{Title: tt.style}, + indent: config.IndentConfig{Title: tt.indent}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderTitle(w, tt.title) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + got := buf.String() + + if tt.useColor { + assert.Contains(t, got, " tar") + assert.Contains(t, got, "\x1b[") + assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestRenderDescriptionLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text with trailing newline", + text: "hello", + indent: " ", + want: " hello\n", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptionLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderDescriptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + descs []string + url string + writer io.Writer + want string + wantErr string + }{ + { + name: "no descriptions and no url returns nil", + descs: nil, + url: "", + want: "", + }, + { + name: "single description", + descs: []string{"hello"}, + want: " hello\n\n", + }, + { + name: "multiple descriptions", + descs: []string{"first", "second"}, + want: " first\n second\n\n", + }, + { + name: "description with URL", + descs: []string{"hello"}, + url: "https://example.org", + want: " hello\n More information: https://example.org.\n\n", + }, + { + name: "URL only no descriptions", + url: "https://example.org", + want: " More information: https://example.org.\n\n", + }, + { + name: "write error", + descs: []string{"hello"}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + indent: config.IndentConfig{Description: 2}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptions(w, tt.descs, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderBulletLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text without trailing newline", + text: "hello", + indent: " ", + want: " hello", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderBulletLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderExample(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ex Example + indent config.IndentConfig + output config.OutputConfig + writer io.Writer + want string + wantErr string + }{ + { + name: "description and command non-compact", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create archive\n tar cf archive.tar\n", + }, + { + name: "description only no command", + ex: Example{Description: "just a description"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " just a description\n", + }, + { + name: "hyphens enabled", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{ShowHyphens: true, ExamplePrefix: "- "}, + want: " - create archive\n tar cf archive.tar\n", + }, + { + name: "compact mode no blank line", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{Compact: true}, + want: " create archive tar cf archive.tar\n", + }, + { + name: "write error", + ex: Example{Description: "error"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: tt.output, + indent: tt.indent, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderExample(w, tt.ex) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestBuildEditURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + url string + want string + }{ + { + name: "non-empty url returned as-is", + path: "/pages/common/tar.md", + url: "https://example.com", + want: "https://example.com", + }, + { + name: "empty url constructs from path", + path: "/pages/common/tar.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md", + }, + { + name: "linux platform extracted correctly", + path: "/pages/linux/apt.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/linux/apt.md", + }, + { + name: "windows platform extracted correctly", + path: "/pages/windows/dir.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/windows/dir.md", + }, + { + name: "empty path and empty url returns empty", + path: "", + url: "", + want: "", + }, + { + name: "path without .md extension adds .md", + path: "/pages/common/some-page", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/some-page.md", + }, + { + name: "url takes precedence over path", + path: "/pages/common/tar.md", + url: "https://custom.com/edit", + want: "https://custom.com/edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildEditURL(tt.path, tt.url) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWrapText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lineLength int + s string + indent string + want string + }{ + { + name: "line length zero returns indent plus text", + lineLength: 0, + s: "hello world", + indent: " ", + want: " hello world", + }, + { + name: "line length negative returns indent plus text", + lineLength: -1, + s: "short", + indent: " ", + want: " short", + }, + { + name: "empty text returns indent", + lineLength: 80, + s: "", + indent: ">>", + want: ">>", + }, + { + name: "text fits within line length", + lineLength: 80, + s: "hi", + indent: " ", + want: " hi", + }, + { + name: "text wraps with indent on continuation lines", + lineLength: 12, + s: "hello world foo", + indent: "> ", + want: "> hello world\n> foo", + }, + { + name: "long word exceeds line length without splitting", + lineLength: 5, + s: "superlongword", + indent: "- ", + want: "- superlongword", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{LineLength: tt.lineLength}, + } + got := r.wrapText(tt.s, tt.indent) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/style.go b/internal/render/style.go new file mode 100644 index 0000000..6865906 --- /dev/null +++ b/internal/render/style.go @@ -0,0 +1,66 @@ +package render + +import ( + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/termcolor" +) + +// applyStyle applies the ANSI styling defined by s to the text t. +// When the renderer's useColor is false, t is returned unchanged. +func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { + if !r.useColor { + return t + } + + return termcolor.Sprint(styleString(s), t) +} + +// styleForSegment returns the OutputStyle +// that should be used for a given Segment kind. +// Text segments use the Example style; +// Placeholder and Option segments use the Placeholder style. +func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { + switch s.Kind { + case Text: + return r.style.Example + case Placeholder: + return r.style.Placeholder + case Option: + return r.style.Placeholder + default: + return r.style.Example + } +} + +// styleString converts an OutputStyle into a space-separated string +// of style directives (e.g. "bold red on_blue") suitable for termcolor.Sprint. +// Named foreground and background colors are included +// only when they differ from the default and are non-empty. +func styleString(s config.OutputStyle) string { + var parts []string + if s.Bold { + parts = append(parts, "bold") + } + if s.Dim { + parts = append(parts, "dim") + } + if s.Italic { + parts = append(parts, "italic") + } + if s.Underline { + parts = append(parts, "underline") + } + if s.Strikethrough { + parts = append(parts, "strikethrough") + } + if s.Color.Kind == config.ColorKindNamed && s.Color.Named != config.ColorDefault && s.Color.Named != "" { + parts = append(parts, string(s.Color.Named)) + } + if s.Background.Kind == config.ColorKindNamed && s.Background.Named != config.ColorDefault && s.Background.Named != "" { + parts = append(parts, "on_"+string(s.Background.Named)) + } + + return strings.Join(parts, " ") +} diff --git a/internal/render/style_test.go b/internal/render/style_test.go new file mode 100644 index 0000000..9936115 --- /dev/null +++ b/internal/render/style_test.go @@ -0,0 +1,290 @@ +package render + +import ( + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestApplyStyle(t *testing.T) { + t.Parallel() + + someStyle := config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + } + + tests := []struct { + name string + useColor bool + style config.OutputStyle + input string + }{ + { + name: "useColor false returns input unchanged with empty style", + useColor: false, + style: config.OutputStyle{}, + input: "hello world", + }, + { + name: "useColor false returns input unchanged with styled style", + useColor: false, + style: someStyle, + input: "tar cf archive.tar", + }, + { + name: "useColor true returns styled output not equal to input", + useColor: true, + style: someStyle, + input: "placeholder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + } + got := r.applyStyle(tt.style, tt.input) + + if !tt.useColor { + assert.Equal(t, tt.input, got) + } else { + assert.NotEqual(t, tt.input, got) + assert.Contains(t, got, tt.input) + assert.True(t, strings.Contains(got, "\x1b[")) + assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + } + }) + } + + t.Run("useColor true with empty style returns input unchanged", func(t *testing.T) { + r := &Renderer{useColor: true} + input := "some text" + got := r.applyStyle(config.OutputStyle{}, input) + assert.Equal(t, input, got) + }) +} + +func TestStyleForSegment(t *testing.T) { + t.Parallel() + + exampleStyle := config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorCyan}, + } + + placeholderStyle := config.OutputStyle{ + Italic: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + } + + r := &Renderer{ + style: config.StyleConfig{ + Example: exampleStyle, + Placeholder: placeholderStyle, + }, + } + + tests := []struct { + name string + segment *Segment + want config.OutputStyle + }{ + { + name: "Text segment returns Example style", + segment: &Segment{Kind: Text}, + want: exampleStyle, + }, + { + name: "Placeholder segment returns Placeholder style", + segment: &Segment{Kind: Placeholder}, + want: placeholderStyle, + }, + { + name: "Option segment returns Placeholder style", + segment: &Segment{Kind: Option, Short: "-a", Long: "--all"}, + want: placeholderStyle, + }, + { + name: "Unknown kind defaults to Example style", + segment: &Segment{Kind: Kind(99)}, + want: exampleStyle, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := r.styleForSegment(tt.segment) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStyleString(t *testing.T) { + t.Parallel() + + red := config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed} + blue := config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorBlue} + def := config.DefaultColor() + + tests := []struct { + name string + style config.OutputStyle + want string + }{ + { + name: "empty style", + style: config.OutputStyle{}, + want: "", + }, + { + name: "bold only", + style: config.OutputStyle{Bold: true}, + want: "bold", + }, + { + name: "dim only", + style: config.OutputStyle{Dim: true}, + want: "dim", + }, + { + name: "italic only", + style: config.OutputStyle{Italic: true}, + want: "italic", + }, + { + name: "underline only", + style: config.OutputStyle{Underline: true}, + want: "underline", + }, + { + name: "strikethrough only", + style: config.OutputStyle{Strikethrough: true}, + want: "strikethrough", + }, + { + name: "all effects", + style: config.OutputStyle{ + Bold: true, + Dim: true, + Italic: true, + Underline: true, + Strikethrough: true, + }, + want: "bold dim italic underline strikethrough", + }, + { + name: "foreground color only", + style: config.OutputStyle{Color: red}, + want: "red", + }, + { + name: "background color only", + style: config.OutputStyle{Background: blue}, + want: "on_blue", + }, + { + name: "foreground and background combined", + style: config.OutputStyle{Color: red, Background: blue}, + want: "red on_blue", + }, + { + name: "effect with foreground and background", + style: config.OutputStyle{Bold: true, Color: red, Background: blue}, + want: "bold red on_blue", + }, + { + name: "default color excluded", + style: config.OutputStyle{Color: def}, + want: "", + }, + { + name: "default background excluded", + style: config.OutputStyle{Background: def}, + want: "", + }, + { + name: "both default color and background excluded", + style: config.OutputStyle{Color: def, Background: def}, + want: "", + }, + { + name: "default foreground but real background still included", + style: config.OutputStyle{Color: def, Background: blue}, + want: "on_blue", + }, + { + name: "default background but real foreground still included", + style: config.OutputStyle{Color: red, Background: def}, + want: "red", + }, + { + name: "default color and effects — effects still included", + style: config.OutputStyle{ + Color: def, + Bold: true, + }, + want: "bold", + }, + { + name: "empty named color excluded", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindNamed, Named: ""}}, + want: "", + }, + { + name: "zero-value color kind excluded (Kind=0 but Named empty)", + style: config.OutputStyle{Color: config.OutputColor{Kind: 0, Named: ""}}, + want: "", + }, + { + name: "256-color foreground excluded from styleString", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindColor256, Color256: 208}}, + want: "", + }, + { + name: "256-color background excluded from styleString", + style: config.OutputStyle{Background: config.OutputColor{Kind: config.ColorKindColor256, Color256: 42}}, + want: "", + }, + { + name: "RGB foreground excluded from styleString", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindRGB, RGB: [3]uint8{255, 0, 0}}}, + want: "", + }, + { + name: "RGB background excluded from styleString", + style: config.OutputStyle{Background: config.OutputColor{Kind: config.ColorKindRGB, RGB: [3]uint8{0, 255, 0}}}, + want: "", + }, + { + name: "multiple effects with colors", + style: config.OutputStyle{ + Bold: true, + Italic: true, + Underline: true, + Color: red, + Background: blue, + }, + want: "bold italic underline red on_blue", + }, + { + name: "effects with only background", + style: config.OutputStyle{ + Dim: true, + Strikethrough: true, + Background: blue, + }, + want: "dim strikethrough on_blue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := styleString(tt.style) + assert.Equal(t, tt.want, got) + }) + } +}