diff --git a/ansi/baseelement.go b/ansi/baseelement.go index 93c27b30..ec70562d 100644 --- a/ansi/baseelement.go +++ b/ansi/baseelement.go @@ -40,6 +40,13 @@ func renderText(w io.Writer, p termenv.Profile, rules StylePrimitive, s string) return } + // If text contains ANSI sequences (like OSC 8 hyperlinks), write it directly + // without processing through termenv to avoid corruption + if containsANSISequences(s) { + _, _ = io.WriteString(w, s) + return + } + out := termenv.String(s) if rules.Upper != nil && *rules.Upper { out = termenv.String(cases.Upper(language.English).String(s)) @@ -124,7 +131,15 @@ func (e *BaseElement) doRender(w io.Writer, p termenv.Profile, st1, st2 StylePri return err } } - renderText(w, p, st2, escapeReplacer.Replace(s)) + + // Don't apply escapeReplacer to text that contains ANSI sequences + // This preserves custom formatter output (like OSC 8 hyperlinks) + processedText := s + if !containsANSISequences(s) { + processedText = escapeReplacer.Replace(s) + } + + renderText(w, p, st2, processedText) return nil } @@ -149,3 +164,15 @@ var escapeReplacer = strings.NewReplacer( "\\!", "!", "\\|", "|", ) + +// containsANSISequences checks if text contains ANSI escape sequences. +// This prevents processing of custom formatter output through escapeReplacer. +func containsANSISequences(text string) bool { + // Check for common ANSI sequence patterns: + // - CSI sequences: ESC[ + // - OSC sequences: ESC] + // - Simple escapes: ESC followed by letter + return len(text) > 1 && (strings.Contains(text, "\x1b[") || + strings.Contains(text, "\x1b]") || + strings.Contains(text, "\x1b\\")) +} diff --git a/ansi/elements.go b/ansi/elements.go index f8d0fef3..50f4fa66 100644 --- a/ansi/elements.go +++ b/ansi/elements.go @@ -246,10 +246,12 @@ func (tr *ANSIRenderer) NewElement(node ast.Node, source []byte) Element { return Element{ Renderer: &LinkElement{ - BaseURL: ctx.options.BaseURL, - URL: string(n.Destination), - Children: children, - SkipHref: isFooterLinks, + BaseURL: ctx.options.BaseURL, + URL: string(n.Destination), + Children: children, + SkipHref: isFooterLinks, + Formatter: ctx.options.LinkFormatter, + IsInTable: isFooterLinks, }, } case ast.KindAutoLink: @@ -286,15 +288,20 @@ func (tr *ANSIRenderer) NewElement(node ast.Node, source []byte) Element { text := linkWithSuffix(tl, ctx.table.tableLinks) renderer = &LinkElement{ - Children: []ElementRenderer{&BaseElement{Token: text}}, - URL: u, - SkipHref: true, + Children: []ElementRenderer{&BaseElement{Token: text}}, + URL: u, + SkipHref: true, + Formatter: ctx.options.LinkFormatter, // Only set when explicitly provided + IsAutoLink: true, + IsInTable: isFooterLinks, } } else { renderer = &LinkElement{ - Children: children, - URL: u, - SkipText: n.AutoLinkType != ast.AutoLinkEmail, + Children: children, + URL: u, + SkipText: n.AutoLinkType != ast.AutoLinkEmail, + Formatter: ctx.options.LinkFormatter, // Only set when explicitly provided + IsAutoLink: true, } } return Element{Renderer: renderer} diff --git a/ansi/hyperlink.go b/ansi/hyperlink.go new file mode 100644 index 00000000..8a926c86 --- /dev/null +++ b/ansi/hyperlink.go @@ -0,0 +1,321 @@ +package ansi + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strings" +) + +// OSC 8 hyperlink escape sequences +const ( + // hyperlinkStart is the OSC 8 sequence to begin a hyperlink + hyperlinkStart = "\x1b]8;;" + // hyperlinkMid separates the URL from the display text + hyperlinkMid = "\x1b\\" + // hyperlinkEnd terminates the hyperlink sequence + hyperlinkEnd = "\x1b]8;;\x1b\\" +) + +// formatHyperlink formats text as an OSC 8 hyperlink sequence. +// The OSC 8 format is: ESC]8;;URL ESC\ TEXT ESC]8;; ESC\ +// This creates a clickable hyperlink in supporting terminals where TEXT is displayed +// but clicking it navigates to URL. +// +// Parameters: +// - text: The visible text to display (may contain ANSI styling) +// - url: The target URL for the hyperlink +// +// Returns: +// - string: The formatted hyperlink with OSC 8 escape sequences +// +// Example: +// +// formatHyperlink("Click here", "https://example.com") +// // Returns: "\x1b]8;;https://example.com\x1b\\Click here\x1b]8;;\x1b\\" +func formatHyperlink(text, url string) string { + if url == "" { + return text + } + return fmt.Sprintf("%s%s%s%s%s", hyperlinkStart, url, hyperlinkMid, text, hyperlinkEnd) +} + +// supportsHyperlinks detects if the current terminal supports OSC 8 hyperlinks. +// This function examines environment variables to determine terminal capabilities. +// It checks TERM_PROGRAM first (more specific), then falls back to TERM for broader detection. +// +// Supported terminals: +// - iTerm2 (TERM_PROGRAM=iTerm.app) +// - VS Code (TERM_PROGRAM=vscode) +// - Windows Terminal (TERM_PROGRAM=Windows Terminal) +// - WezTerm (TERM_PROGRAM=WezTerm) +// - Terminals with xterm-256color or similar TERM values +// +// Parameters: +// - ctx: RenderContext containing rendering options and state +// +// Returns: +// - bool: true if the terminal supports OSC 8 hyperlinks, false otherwise +func supportsHyperlinks(ctx RenderContext) bool { + // Check TERM_PROGRAM first - this is more specific and reliable + termProgram := os.Getenv("TERM_PROGRAM") + if termProgram != "" { + supportingPrograms := map[string]bool{ + "iTerm.app": true, // iTerm2 + "vscode": true, // VS Code integrated terminal + "Windows Terminal": true, // Windows Terminal + "WezTerm": true, // WezTerm + "Hyper": true, // Hyper terminal + } + if supported, exists := supportingPrograms[termProgram]; exists { + return supported + } + } + + // Fall back to checking TERM environment variable + // Many modern terminals support OSC 8 even if TERM_PROGRAM isn't set + term := os.Getenv("TERM") + if term != "" { + // Common terminal types that support hyperlinks + supportingTerms := []string{ + "xterm-256color", + "screen-256color", + "tmux-256color", + "alacritty", + "xterm-kitty", + } + + for _, supportedTerm := range supportingTerms { + if strings.Contains(term, supportedTerm) { + return true + } + } + } + + // Check for terminal-specific environment variables + // Some terminals set their own identification variables + if os.Getenv("KITTY_WINDOW_ID") != "" { + return true // Kitty terminal + } + + if os.Getenv("ALACRITTY_LOG") != "" || os.Getenv("ALACRITTY_SOCKET") != "" { + return true // Alacritty terminal + } + + // Conservative default: assume no hyperlink support + return false +} + +// ansiEscapeRegex matches ANSI escape sequences for removal +// This regex pattern matches: +// - CSI sequences: ESC[ followed by parameters and final byte +// - OSC sequences: ESC] followed by content and terminator +// - Simple escape sequences: ESC followed by a single character +var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[a-zA-Z]`) + +// stripANSISequences removes ANSI escape sequences from text to extract plain text. +// This function is essential for extracting readable text content from styled terminal output. +// It handles various ANSI sequence types including: +// - CSI (Control Sequence Introducer) sequences for colors, cursor movement, etc. +// - OSC (Operating System Command) sequences for hyperlinks, titles, etc. +// - Simple escape sequences +// +// Parameters: +// - text: Input text that may contain ANSI escape sequences +// +// Returns: +// - string: Clean text with all ANSI sequences removed +// +// Example: +// +// stripANSISequences("\x1b[31mRed Text\x1b[0m") +// // Returns: "Red Text" +func stripANSISequences(text string) string { + if text == "" { + return text + } + return ansiEscapeRegex.ReplaceAllString(text, "") +} + +// extractTextFromChildren extracts plain text from a slice of ElementRenderer children. +// This function recursively renders child elements and strips ANSI sequences to produce +// clean text suitable for use in hyperlinks or other contexts where plain text is needed. +// +// The function handles nested elements by: +// 1. Rendering each child element with the provided context +// 2. Extracting the rendered output +// 3. Stripping ANSI escape sequences to get plain text +// 4. Concatenating all text results +// +// Parameters: +// - children: Slice of ElementRenderer objects to process +// - ctx: RenderContext for rendering the elements +// +// Returns: +// - string: Concatenated plain text from all children +// - error: Any error encountered during rendering +// +// Example usage in link processing: +// +// text, err := extractTextFromChildren(linkElement.children, renderCtx) +// if err != nil { +// return "", fmt.Errorf("failed to extract link text: %w", err) +// } +func extractTextFromChildren(children []ElementRenderer, ctx RenderContext) (string, error) { + if len(children) == 0 { + return "", nil + } + + var textBuffer bytes.Buffer + + for i, child := range children { + if child == nil { + continue // Skip nil children gracefully + } + + // Render the child element to capture its output + var childBuffer bytes.Buffer + if err := child.Render(&childBuffer, ctx); err != nil { + return "", fmt.Errorf("failed to render child element %d: %w", i, err) + } + + // Extract the rendered content and strip ANSI sequences + renderedText := childBuffer.String() + plainText := stripANSISequences(renderedText) + + // Add the plain text to our result buffer + textBuffer.WriteString(plainText) + } + + return textBuffer.String(), nil +} + +// applyStyleToText applies StylePrimitive formatting to text using Glamour's styling system. +// This function integrates with the existing Glamour styling infrastructure to ensure +// consistent text formatting across all components. +// +// The function leverages BaseElement rendering which handles: +// - Color application (foreground and background) +// - Text decorations (bold, underline, etc.) +// - Case transformations (upper, lower, title) +// - Prefix and suffix text +// +// Parameters: +// - text: The text content to style +// - style: StylePrimitive containing formatting rules +// - ctx: RenderContext for the current rendering session +// +// Returns: +// - string: The styled text with applied formatting +// - error: Any error encountered during styling +// +// Example: +// +// style := StylePrimitive{Color: stringPtr("red"), Bold: boolPtr(true)} +// styledText, err := applyStyleToText("Hello", style, ctx) +// // Returns: "\x1b[31;1mHello\x1b[0m" (red bold text) +func applyStyleToText(text string, style StylePrimitive, ctx RenderContext) (string, error) { + if text == "" { + return text, nil + } + + // If the text already contains ANSI escape sequences (like OSC 8 hyperlinks), + // don't process it through BaseElement as the escapeReplacer would corrupt them. + // This preserves custom formatter output that includes intentional escape sequences. + if containsANSISequences(text) { + return text, nil + } + + // Create a BaseElement with the text and style + element := &BaseElement{ + Token: text, + Style: style, + } + + // Use BaseElement's render method to apply styling consistently + var buf bytes.Buffer + if err := element.Render(&buf, ctx); err != nil { + return "", fmt.Errorf("failed to apply style to text %q: %w", text, err) + } + + return buf.String(), nil +} + +// Hyperlink represents a complete hyperlink with all necessary data for rendering. +// This struct encapsulates hyperlink information and provides methods for different +// rendering approaches (OSC 8, fallback, etc.). +type Hyperlink struct { + URL string // The target URL + Text string // Display text (plain, without ANSI sequences) + Title string // Optional title attribute +} + +// NewHyperlink creates a new Hyperlink from the given parameters. +// It automatically strips ANSI sequences from the text to ensure clean display. +// +// Parameters: +// - url: The target URL for the hyperlink +// - text: Display text (may contain ANSI sequences) +// - title: Optional title attribute +// +// Returns: +// - *Hyperlink: A new Hyperlink instance +func NewHyperlink(url, text, title string) *Hyperlink { + return &Hyperlink{ + URL: strings.TrimSpace(url), + Text: stripANSISequences(strings.TrimSpace(text)), + Title: strings.TrimSpace(title), + } +} + +// RenderOSC8 renders the hyperlink using OSC 8 escape sequences. +// This creates a clickable link in supporting terminals. +// +// Returns: +// - string: The hyperlink formatted with OSC 8 sequences +func (h *Hyperlink) RenderOSC8() string { + return formatHyperlink(h.Text, h.URL) +} + +// RenderPlain renders the hyperlink as plain text with URL. +// This provides a fallback for terminals that don't support OSC 8. +// +// Returns: +// - string: The hyperlink in "text (url)" format +func (h *Hyperlink) RenderPlain() string { + if h.Text == "" { + return h.URL + } + if h.URL == "" { + return h.Text + } + return fmt.Sprintf("%s (%s)", h.Text, h.URL) +} + +// RenderSmart renders the hyperlink using the best method for the current terminal. +// It uses OSC 8 for supporting terminals and falls back to plain text otherwise. +// +// Parameters: +// - ctx: RenderContext to determine terminal capabilities +// +// Returns: +// - string: The appropriately formatted hyperlink +func (h *Hyperlink) RenderSmart(ctx RenderContext) string { + if supportsHyperlinks(ctx) { + return h.RenderOSC8() + } + return h.RenderPlain() +} + +// Validate checks if the hyperlink has valid content. +// A valid hyperlink should have either a URL or display text. +// +// Returns: +// - error: An error if the hyperlink is invalid, nil otherwise +func (h *Hyperlink) Validate() error { + if h.URL == "" && h.Text == "" { + return fmt.Errorf("hyperlink must have either URL or text") + } + return nil +} diff --git a/ansi/hyperlink_test.go b/ansi/hyperlink_test.go new file mode 100644 index 00000000..8b1ffb98 --- /dev/null +++ b/ansi/hyperlink_test.go @@ -0,0 +1,597 @@ +package ansi + +import ( + "strings" + "testing" +) + +func TestFormatHyperlink(t *testing.T) { + tests := []struct { + name string + text string + url string + want string + }{ + { + name: "basic hyperlink", + text: "example", + url: "https://example.com", + want: "\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\", + }, + { + name: "empty text", + text: "", + url: "https://example.com", + want: "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\", + }, + { + name: "empty url", + text: "example", + url: "", + want: "example", + }, + { + name: "special characters in URL", + text: "example", + url: "https://example.com/path?param=value&other=test", + want: "\x1b]8;;https://example.com/path?param=value&other=test\x1b\\example\x1b]8;;\x1b\\", + }, + { + name: "unicode text", + text: "δΎ‹γˆ", + url: "https://example.com", + want: "\x1b]8;;https://example.com\x1b\\δΎ‹γˆ\x1b]8;;\x1b\\", + }, + { + name: "text with spaces", + text: "click here", + url: "https://example.com", + want: "\x1b]8;;https://example.com\x1b\\click here\x1b]8;;\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHyperlink(tt.text, tt.url) + if result != tt.want { + t.Errorf("formatHyperlink(%q, %q) = %q, want %q", tt.text, tt.url, result, tt.want) + } + }) + } +} + +func TestSupportsHyperlinks(t *testing.T) { + tests := []struct { + name string + termProgram string + term string + envVars map[string]string + want bool + }{ + { + name: "iTerm2", + termProgram: "iTerm.app", + want: true, + }, + { + name: "VS Code", + termProgram: "vscode", + want: true, + }, + { + name: "Windows Terminal", + termProgram: "Windows Terminal", + want: true, + }, + { + name: "WezTerm", + termProgram: "WezTerm", + want: true, + }, + { + name: "Hyper", + termProgram: "Hyper", + want: true, + }, + { + name: "xterm-256color", + term: "xterm-256color", + want: true, + }, + { + name: "screen-256color", + term: "screen-256color", + want: true, + }, + { + name: "tmux-256color", + term: "tmux-256color", + want: true, + }, + { + name: "alacritty", + term: "alacritty", + want: true, + }, + { + name: "xterm-kitty", + term: "xterm-kitty", + want: true, + }, + { + name: "basic xterm", + term: "xterm", + want: false, + }, + { + name: "unknown terminal", + want: false, + }, + { + name: "kitty terminal", + envVars: map[string]string{ + "KITTY_WINDOW_ID": "1", + }, + want: true, + }, + { + name: "alacritty by env var", + envVars: map[string]string{ + "ALACRITTY_LOG": "/tmp/log", + }, + want: true, + }, + { + name: "alacritty by socket env var", + envVars: map[string]string{ + "ALACRITTY_SOCKET": "/tmp/socket", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up environment + if tt.termProgram != "" { + t.Setenv("TERM_PROGRAM", tt.termProgram) + } else { + t.Setenv("TERM_PROGRAM", "") + } + + if tt.term != "" { + t.Setenv("TERM", tt.term) + } else { + t.Setenv("TERM", "") + } + + // Set additional environment variables + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + // Test + result := supportsHyperlinks(RenderContext{}) + if result != tt.want { + t.Errorf("supportsHyperlinks() = %v, want %v", result, tt.want) + } + }) + } +} + +func TestStripANSISequences(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "no ANSI sequences", + text: "Hello World", + want: "Hello World", + }, + { + name: "empty string", + text: "", + want: "", + }, + { + name: "red text", + text: "\x1b[31mRed Text\x1b[0m", + want: "Red Text", + }, + { + name: "bold text", + text: "\x1b[1mBold Text\x1b[0m", + want: "Bold Text", + }, + { + name: "hyperlink sequence", + text: "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\", + want: "Click Here", + }, + { + name: "multiple sequences", + text: "\x1b[31m\x1b[1mRed Bold\x1b[0m\x1b[0m", + want: "Red Bold", + }, + { + name: "cursor movement", + text: "\x1b[2JClear Screen\x1b[H", + want: "Clear Screen", + }, + { + name: "mixed content", + text: "Normal \x1b[31mRed\x1b[0m Normal \x1b]8;;url\x1b\\Link\x1b]8;;\x1b\\ Normal", + want: "Normal Red Normal Link Normal", + }, + { + name: "OSC with BEL terminator", + text: "\x1b]0;Title\x07Content", + want: "Content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripANSISequences(tt.text) + if result != tt.want { + t.Errorf("stripANSISequences(%q) = %q, want %q", tt.text, result, tt.want) + } + }) + } +} + +func TestExtractTextFromChildren(t *testing.T) { + tests := []struct { + name string + children []ElementRenderer + want string + wantErr bool + }{ + { + name: "no children", + children: []ElementRenderer{}, + want: "", + wantErr: false, + }, + { + name: "nil children", + children: nil, + want: "", + wantErr: false, + }, + { + name: "single text element", + children: []ElementRenderer{ + &BaseElement{Token: "Hello"}, + }, + want: "Hello", + wantErr: false, + }, + { + name: "multiple text elements", + children: []ElementRenderer{ + &BaseElement{Token: "Hello"}, + &BaseElement{Token: " "}, + &BaseElement{Token: "World"}, + }, + want: "Hello World", + wantErr: false, + }, + { + name: "element with ANSI sequences", + children: []ElementRenderer{ + &BaseElement{ + Token: "Styled", + Style: StylePrimitive{ + Color: stringPtr("#ff0000"), + }, + }, + }, + want: "Styled", + wantErr: false, + }, + { + name: "mixed elements with nil", + children: []ElementRenderer{ + &BaseElement{Token: "First"}, + nil, // Should be skipped gracefully + &BaseElement{Token: "Second"}, + }, + want: "FirstSecond", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + result, err := extractTextFromChildren(tt.children, ctx) + if (err != nil) != tt.wantErr { + t.Errorf("extractTextFromChildren() error = %v, wantErr %v", err, tt.wantErr) + return + } + if result != tt.want { + t.Errorf("extractTextFromChildren() = %q, want %q", result, tt.want) + } + }) + } +} + +func TestApplyStyleToText(t *testing.T) { + tests := []struct { + name string + text string + style StylePrimitive + want string + }{ + { + name: "empty text", + text: "", + style: StylePrimitive{}, + want: "", + }, + { + name: "no style", + text: "Hello", + style: StylePrimitive{}, + want: "Hello", // Should still contain the text + }, + { + name: "colored text", + text: "Hello", + style: StylePrimitive{ + Color: stringPtr("#ff0000"), + }, + want: "Hello", // Should contain the text (ANSI codes will be present too) + }, + { + name: "bold text", + text: "Hello", + style: StylePrimitive{ + Bold: boolPtr(true), + }, + want: "Hello", // Should contain the text + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + result, err := applyStyleToText(tt.text, tt.style, ctx) + if err != nil { + t.Errorf("applyStyleToText() error = %v", err) + return + } + + // Strip ANSI to check if text is present + plainResult := stripANSISequences(result) + if plainResult != tt.want { + t.Errorf("applyStyleToText() plain text = %q, want %q", plainResult, tt.want) + } + + // Check that result contains the original text + if tt.text != "" && !strings.Contains(result, tt.text) { + t.Errorf("applyStyleToText() result %q should contain original text %q", result, tt.text) + } + }) + } +} + +func TestHyperlinkStruct(t *testing.T) { + t.Run("NewHyperlink", func(t *testing.T) { + tests := []struct { + name string + url string + text string + title string + want *Hyperlink + }{ + { + name: "basic hyperlink", + url: "https://example.com", + text: "Example", + title: "Example Site", + want: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + Title: "Example Site", + }, + }, + { + name: "with whitespace", + url: " https://example.com ", + text: " Example ", + want: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + }, + }, + { + name: "with ANSI in text", + url: "https://example.com", + text: "\x1b[31mRed Text\x1b[0m", + want: &Hyperlink{ + URL: "https://example.com", + Text: "Red Text", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewHyperlink(tt.url, tt.text, tt.title) + if result.URL != tt.want.URL { + t.Errorf("NewHyperlink().URL = %q, want %q", result.URL, tt.want.URL) + } + if result.Text != tt.want.Text { + t.Errorf("NewHyperlink().Text = %q, want %q", result.Text, tt.want.Text) + } + if result.Title != tt.want.Title { + t.Errorf("NewHyperlink().Title = %q, want %q", result.Title, tt.want.Title) + } + }) + } + }) + + t.Run("RenderOSC8", func(t *testing.T) { + h := &Hyperlink{ + URL: "https://example.com", + Text: "Example", + } + + result := h.RenderOSC8() + want := "\x1b]8;;https://example.com\x1b\\Example\x1b]8;;\x1b\\" + if result != want { + t.Errorf("RenderOSC8() = %q, want %q", result, want) + } + }) + + t.Run("RenderPlain", func(t *testing.T) { + tests := []struct { + name string + h *Hyperlink + want string + }{ + { + name: "with text and URL", + h: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + }, + want: "Example (https://example.com)", + }, + { + name: "URL only", + h: &Hyperlink{ + URL: "https://example.com", + Text: "", + }, + want: "https://example.com", + }, + { + name: "text only", + h: &Hyperlink{ + URL: "", + Text: "Example", + }, + want: "Example", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.h.RenderPlain() + if result != tt.want { + t.Errorf("RenderPlain() = %q, want %q", result, tt.want) + } + }) + } + }) + + t.Run("RenderSmart", func(t *testing.T) { + h := &Hyperlink{ + URL: "https://example.com", + Text: "Example", + } + + // Test with hyperlink support + t.Setenv("TERM_PROGRAM", "iTerm.app") + ctx := RenderContext{} + result := h.RenderSmart(ctx) + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("RenderSmart() with hyperlink support should contain OSC 8 sequences, got %q", result) + } + + // Test without hyperlink support + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") + result = h.RenderSmart(ctx) + if strings.Contains(result, "\x1b]8;;") { + t.Errorf("RenderSmart() without hyperlink support should not contain OSC 8 sequences, got %q", result) + } + if !strings.Contains(result, "Example") || !strings.Contains(result, "https://example.com") { + t.Errorf("RenderSmart() should contain both text and URL in plain format, got %q", result) + } + }) + + t.Run("Validate", func(t *testing.T) { + tests := []struct { + name string + h *Hyperlink + wantErr bool + }{ + { + name: "valid with both URL and text", + h: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + }, + wantErr: false, + }, + { + name: "valid with URL only", + h: &Hyperlink{ + URL: "https://example.com", + Text: "", + }, + wantErr: false, + }, + { + name: "valid with text only", + h: &Hyperlink{ + URL: "", + Text: "Example", + }, + wantErr: false, + }, + { + name: "invalid - no URL or text", + h: &Hyperlink{ + URL: "", + Text: "", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.h.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + }) +} + +// Helper function for bool pointers +func boolPtr(b bool) *bool { + return &b +} diff --git a/ansi/link.go b/ansi/link.go index efc383d5..4965e2b2 100644 --- a/ansi/link.go +++ b/ansi/link.go @@ -9,15 +9,61 @@ import ( // A LinkElement is used to render hyperlinks. type LinkElement struct { - BaseURL string - URL string - Children []ElementRenderer - SkipText bool - SkipHref bool + BaseURL string + URL string + Children []ElementRenderer + SkipText bool + SkipHref bool + Title string // Optional title attribute from markdown + Formatter LinkFormatter // Custom formatter reference (nil = default behavior) + IsAutoLink bool // Track if this is an autolink + IsInTable bool // Track table context } // Render renders a LinkElement. func (e *LinkElement) Render(w io.Writer, ctx RenderContext) error { + // Check if custom formatter is set + if e.Formatter != nil { + return e.renderWithFormatter(w, ctx) + } + // If no formatter, use default behavior + return e.renderDefault(w, ctx) +} + +// renderWithFormatter creates LinkData struct and calls the custom formatter. +func (e *LinkElement) renderWithFormatter(w io.Writer, ctx RenderContext) error { + // Extract text from children + text, err := extractTextFromChildren(e.Children, ctx) + if err != nil { + return fmt.Errorf("failed to extract text from children: %w", err) + } + + // Create LinkData with all context + data := LinkData{ + URL: e.URL, + Text: text, + Title: e.Title, + BaseURL: e.BaseURL, + IsAutoLink: e.IsAutoLink, + IsInTable: e.IsInTable, + Children: e.Children, + LinkStyle: ctx.options.Styles.Link, + TextStyle: ctx.options.Styles.LinkText, + } + + // Call the custom formatter + result, err := e.Formatter.FormatLink(data, ctx) + if err != nil { + return fmt.Errorf("custom formatter error: %w", err) + } + + // Write the result + _, err = w.Write([]byte(result)) + return err +} + +// renderDefault moves existing rendering logic here for backward compatibility. +func (e *LinkElement) renderDefault(w io.Writer, ctx RenderContext) error { if !e.SkipText { if err := e.renderTextPart(w, ctx); err != nil { return err diff --git a/ansi/link_formatter.go b/ansi/link_formatter.go new file mode 100644 index 00000000..5853394f --- /dev/null +++ b/ansi/link_formatter.go @@ -0,0 +1,169 @@ +package ansi + +import ( + "fmt" + "net/url" + "strings" +) + +// LinkData contains all parsed link information available to formatters. +// It provides formatters with comprehensive context about the link being rendered, +// including styling, positioning context, and original markdown elements. +type LinkData struct { + // Basic link properties + URL string // The destination URL + Text string // The link text (extracted from children) + Title string // Optional title attribute from markdown + BaseURL string // Base URL for relative link resolution + + // Formatting context + IsAutoLink bool // Whether this is an autolink (e.g., ) + IsInTable bool // Whether link appears in a table context + Children []ElementRenderer // Original child elements for advanced rendering + + // Style context + LinkStyle StylePrimitive // Style configuration for the URL portion + TextStyle StylePrimitive // Style configuration for the text portion +} + +// LinkFormatter defines how links should be rendered. +// Custom formatters implement this interface to provide alternative link rendering. +// The FormatLink method receives complete link context and should return the +// formatted string representation of the link. +type LinkFormatter interface { + // FormatLink renders a link using the provided data and context. + // It returns the formatted link string and any formatting error. + FormatLink(data LinkData, ctx RenderContext) (string, error) +} + +// LinkFormatterFunc is an adapter type that allows functions to implement LinkFormatter. +// This enables convenient creation of link formatters using function literals. +type LinkFormatterFunc func(LinkData, RenderContext) (string, error) + +// FormatLink implements the LinkFormatter interface for LinkFormatterFunc. +func (f LinkFormatterFunc) FormatLink(data LinkData, ctx RenderContext) (string, error) { + return f(data, ctx) +} + +// Built-in Link Formatters +// +// These formatters provide common link rendering patterns and serve as examples +// for custom formatter implementations. + +// DefaultFormatter replicates the current Glamour link rendering behavior. +// It renders links in the format "text url" with appropriate styling applied. +// This formatter maintains backward compatibility with existing Glamour output. +var DefaultFormatter = LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + var result strings.Builder + + // Render text part if present + if data.Text != "" { + styledText, err := applyStyleToText(data.Text, data.TextStyle, ctx) + if err != nil { + return "", fmt.Errorf("failed to apply text style: %w", err) + } + result.WriteString(styledText) + } + + // Render URL part with space prefix if text exists + if data.URL != "" && !isFragmentOnlyURL(data.URL) { + if data.Text != "" { + result.WriteString(" ") + } + + resolvedURL := resolveRelativeURL(data.BaseURL, data.URL) + styledURL, err := applyStyleToText(resolvedURL, data.LinkStyle, ctx) + if err != nil { + return "", fmt.Errorf("failed to apply link style: %w", err) + } + result.WriteString(styledURL) + } + + return result.String(), nil +}) + +// TextOnlyFormatter shows only the link text, making it clickable in smart terminals. +// In terminals that support OSC 8 hyperlinks, the text becomes a clickable hyperlink. +// In other terminals, only the styled text is shown without the URL. +var TextOnlyFormatter = LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.Text == "" { + return "", nil + } + + styledText, err := applyStyleToText(data.Text, data.TextStyle, ctx) + if err != nil { + return "", fmt.Errorf("failed to apply text style: %w", err) + } + + // Make text clickable in supporting terminals + if supportsHyperlinks(ctx) { + return formatHyperlink(styledText, data.URL), nil + } + + return styledText, nil +}) + +// URLOnlyFormatter shows only URLs, hiding the link text. +// This formatter is useful for cases where space is limited or when +// the URL itself is more important than descriptive text. +var URLOnlyFormatter = LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.URL == "" || isFragmentOnlyURL(data.URL) { + return "", nil + } + + resolvedURL := resolveRelativeURL(data.BaseURL, data.URL) + styledURL, err := applyStyleToText(resolvedURL, data.LinkStyle, ctx) + if err != nil { + return "", fmt.Errorf("failed to apply link style: %w", err) + } + + return styledURL, nil +}) + +// HyperlinkFormatter renders links as OSC 8 hyperlinks in supporting terminals. +// The link text becomes clickable, while the URL remains hidden. +// In terminals without OSC 8 support, this formatter will not provide fallback +// and may result in escape sequences being displayed. +var HyperlinkFormatter = LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.Text == "" { + return "", nil + } + + styledText, err := applyStyleToText(data.Text, data.TextStyle, ctx) + if err != nil { + return "", fmt.Errorf("failed to apply text style: %w", err) + } + + return formatHyperlink(styledText, data.URL), nil +}) + +// SmartHyperlinkFormatter renders OSC 8 hyperlinks with intelligent fallback. +// In terminals that support hyperlinks, it shows clickable text. +// In other terminals, it falls back to the default "text url" format. +// This provides the best user experience across different terminal environments. +var SmartHyperlinkFormatter = LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if supportsHyperlinks(ctx) { + return HyperlinkFormatter.FormatLink(data, ctx) + } + return DefaultFormatter.FormatLink(data, ctx) +}) + +// Helper Functions +// +// These functions will be implemented in the hyperlink support file but are +// referenced here to define the formatter interfaces clearly. + +// Helper functions are implemented in ansi/hyperlink.go + +// isFragmentOnlyURL checks if a URL consists only of a fragment (anchor). +// This replicates the logic from the original LinkElement.renderHrefPart method: +// if err == nil && "#"+u.Fragment != e.URL { // if the URL only consists of an anchor, ignore it +func isFragmentOnlyURL(urlStr string) bool { + u, err := url.Parse(urlStr) + if err != nil { + return false // If can't parse, treat as normal URL + } + // Original logic: render if "#"+u.Fragment != e.URL + // So fragment-only if "#"+u.Fragment == e.URL + return "#"+u.Fragment == urlStr +} diff --git a/ansi/link_formatter_test.go b/ansi/link_formatter_test.go new file mode 100644 index 00000000..d7c5c87c --- /dev/null +++ b/ansi/link_formatter_test.go @@ -0,0 +1,583 @@ +package ansi + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func TestLinkFormatterInterface(t *testing.T) { + // Test that LinkFormatterFunc implements LinkFormatter + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return "test", nil + }) + + var _ LinkFormatter = formatter + + // Test basic functionality + result, err := formatter.FormatLink(LinkData{}, RenderContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "test" { + t.Errorf("expected 'test', got %q", result) + } +} + +func TestLinkFormatterError(t *testing.T) { + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return "", errors.New("formatter error") + }) + + _, err := formatter.FormatLink(LinkData{}, RenderContext{}) + if err == nil { + t.Error("expected error, got nil") + } + if err != nil && err.Error() != "formatter error" { + t.Errorf("expected 'formatter error', got %q", err.Error()) + } +} + +func TestLinkData(t *testing.T) { + tests := []struct { + name string + data LinkData + want LinkData + }{ + { + name: "complete link data", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + Title: "Example Site", + BaseURL: "https://base.com", + IsAutoLink: false, + IsInTable: false, + }, + want: LinkData{ + URL: "https://example.com", + Text: "Example", + Title: "Example Site", + BaseURL: "https://base.com", + IsAutoLink: false, + IsInTable: false, + }, + }, + { + name: "autolink data", + data: LinkData{ + URL: "https://example.com", + Text: "https://example.com", + IsAutoLink: true, + }, + want: LinkData{ + URL: "https://example.com", + Text: "https://example.com", + IsAutoLink: true, + }, + }, + { + name: "table link data", + data: LinkData{ + URL: "https://example.com", + Text: "Table Link", + IsInTable: true, + }, + want: LinkData{ + URL: "https://example.com", + Text: "Table Link", + IsInTable: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that all fields are properly set and accessible + if tt.data.URL != tt.want.URL { + t.Errorf("URL: expected %q, got %q", tt.want.URL, tt.data.URL) + } + if tt.data.Text != tt.want.Text { + t.Errorf("Text: expected %q, got %q", tt.want.Text, tt.data.Text) + } + if tt.data.Title != tt.want.Title { + t.Errorf("Title: expected %q, got %q", tt.want.Title, tt.data.Title) + } + if tt.data.BaseURL != tt.want.BaseURL { + t.Errorf("BaseURL: expected %q, got %q", tt.want.BaseURL, tt.data.BaseURL) + } + if tt.data.IsAutoLink != tt.want.IsAutoLink { + t.Errorf("IsAutoLink: expected %v, got %v", tt.want.IsAutoLink, tt.data.IsAutoLink) + } + if tt.data.IsInTable != tt.want.IsInTable { + t.Errorf("IsInTable: expected %v, got %v", tt.want.IsInTable, tt.data.IsInTable) + } + }) + } +} + +func TestDefaultFormatter(t *testing.T) { + tests := []struct { + name string + data LinkData + want string + }{ + { + name: "text and url", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + want: "Example https://example.com", // Current behavior + }, + { + name: "autolink", + data: LinkData{ + URL: "https://example.com", + Text: "https://example.com", + IsAutoLink: true, + }, + want: "https://example.com https://example.com", + }, + { + name: "empty text", + data: LinkData{ + URL: "https://example.com", + Text: "", + }, + want: "https://example.com", // Just URL when no text + }, + { + name: "fragment only URL", + data: LinkData{ + URL: "#fragment", + Text: "Fragment Link", + }, + want: "Fragment Link", // Fragment URLs are ignored + }, + { + name: "relative URL with base", + data: LinkData{ + URL: "/path/page", + Text: "Page", + BaseURL: "https://example.com", + }, + want: "Page https://example.com/path/page", + }, + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := DefaultFormatter.FormatLink(tt.data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + // Strip ANSI codes for easier comparison + plainResult := stripANSISequences(result) + if plainResult != tt.want { + t.Errorf("expected %q, got %q", tt.want, plainResult) + } + }) + } +} + +func TestTextOnlyFormatter(t *testing.T) { + tests := []struct { + name string + data LinkData + supportsHyperlinks bool + wantContains string + }{ + { + name: "hyperlink support", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + supportsHyperlinks: true, + wantContains: "\x1b]8;;https://example.com\x1b\\", + }, + { + name: "no hyperlink support", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + supportsHyperlinks: false, + wantContains: "Example", // Just text, no URL + }, + { + name: "empty text fallback", + data: LinkData{ + URL: "https://example.com", + Text: "", + }, + supportsHyperlinks: true, + wantContains: "", // Empty result for empty text + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock terminal support by setting environment variable + if tt.supportsHyperlinks { + t.Setenv("TERM_PROGRAM", "iTerm.app") + } else { + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + result, err := TextOnlyFormatter.FormatLink(tt.data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if tt.wantContains != "" && !strings.Contains(result, tt.wantContains) { + t.Errorf("expected result to contain %q, got %q", tt.wantContains, result) + } + }) + } +} + +func TestURLOnlyFormatter(t *testing.T) { + tests := []struct { + name string + data LinkData + want string + }{ + { + name: "normal link", + data: LinkData{ + URL: "https://example.com", + Text: "Example Text", + }, + want: "https://example.com", + }, + { + name: "fragment only URL", + data: LinkData{ + URL: "#fragment", + Text: "Fragment", + }, + want: "", + }, + { + name: "empty URL", + data: LinkData{ + URL: "", + Text: "Text Only", + }, + want: "", + }, + { + name: "relative URL with base", + data: LinkData{ + URL: "/path", + Text: "Path", + BaseURL: "https://example.com", + }, + want: "https://example.com/path", + }, + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := URLOnlyFormatter.FormatLink(tt.data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + plainResult := stripANSISequences(result) + if plainResult != tt.want { + t.Errorf("expected %q, got %q", tt.want, plainResult) + } + }) + } +} + +func TestHyperlinkFormatter(t *testing.T) { + tests := []struct { + name string + data LinkData + want string + }{ + { + name: "normal link", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + want: "\x1b]8;;https://example.com\x1b\\Example\x1b]8;;\x1b\\", + }, + { + name: "link with title", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + Title: "Example Site", + }, + want: "\x1b]8;;https://example.com\x1b\\Example\x1b]8;;\x1b\\", + }, + { + name: "empty text", + data: LinkData{ + URL: "https://example.com", + Text: "", + }, + want: "", + }, + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := HyperlinkFormatter.FormatLink(tt.data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if tt.want != "" { + if !strings.Contains(result, tt.want) { + t.Errorf("expected result to contain %q, got %q", tt.want, result) + } + } else { + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + } + }) + } +} + +func TestSmartHyperlinkFormatter(t *testing.T) { + tests := []struct { + name string + data LinkData + supportsHyperlinks bool + wantHyperlink bool + }{ + { + name: "hyperlink support", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + supportsHyperlinks: true, + wantHyperlink: true, + }, + { + name: "no hyperlink support", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + supportsHyperlinks: false, + wantHyperlink: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock terminal support + if tt.supportsHyperlinks { + t.Setenv("TERM_PROGRAM", "iTerm.app") + } else { + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + result, err := SmartHyperlinkFormatter.FormatLink(tt.data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if tt.wantHyperlink { + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("expected hyperlink sequence, got %q", result) + } + } else { + // Should fall back to default format + plainResult := stripANSISequences(result) + if !strings.Contains(plainResult, "Example") { + t.Errorf("expected 'Example' in result, got %q", plainResult) + } + if !strings.Contains(plainResult, "https://example.com") { + t.Errorf("expected 'https://example.com' in result, got %q", plainResult) + } + } + }) + } +} + +func TestFormatterErrorHandling(t *testing.T) { + errorFormatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return "", errors.New("formatter error") + }) + + _, err := errorFormatter.FormatLink(LinkData{}, RenderContext{}) + if err == nil { + t.Error("expected error, got nil") + } + if err != nil && !strings.Contains(err.Error(), "formatter error") { + t.Errorf("expected error to contain 'formatter error', got %q", err.Error()) + } +} + +func TestInvalidURLHandling(t *testing.T) { + tests := []struct { + name string + url string + }{ + {"empty URL", ""}, + {"malformed URL", "://invalid"}, + {"just fragment", "#fragment"}, + {"just query", "?param=value"}, + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := LinkData{ + URL: tt.url, + Text: "example", + } + + // Should not panic + result, err := DefaultFormatter.FormatLink(data, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result == "" { + t.Error("expected non-empty result") + } + }) + } +} + +func TestLinkInTableContext(t *testing.T) { + data := LinkData{ + URL: "https://example.com", + Text: "example", + IsInTable: true, + } + + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.IsInTable { + return data.Text, nil // Table links: text only + } + return fmt.Sprintf("%s (%s)", data.Text, data.URL), nil + }) + + result, err := formatter.FormatLink(data, RenderContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "example" { + t.Errorf("expected 'example', got %q", result) + } + if strings.Contains(result, "https://example.com") { + t.Errorf("result should not contain URL, got %q", result) + } +} + +func TestAutoLinkContext(t *testing.T) { + data := LinkData{ + URL: "https://example.com", + Text: "https://example.com", + IsAutoLink: true, + } + + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.IsAutoLink { + return fmt.Sprintf("<%s>", data.URL), nil + } + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil + }) + + result, err := formatter.FormatLink(data, RenderContext{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected '', got %q", result) + } +} + +// Helper function to create string pointer +func stringPtr(s string) *string { + return &s +} diff --git a/ansi/link_test.go b/ansi/link_test.go new file mode 100644 index 00000000..cf68514b --- /dev/null +++ b/ansi/link_test.go @@ -0,0 +1,584 @@ +package ansi + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" + + "github.com/muesli/termenv" +) + +func TestLinkElementWithCustomFormatter(t *testing.T) { + tests := []struct { + name string + element *LinkElement + formatter LinkFormatter + want string + }{ + { + name: "custom formatter with text and URL", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + }, + formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil + }), + want: "[example](https://example.com)", + }, + { + name: "custom formatter text only", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "click here"}}, + }, + formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return data.Text, nil + }), + want: "click here", + }, + { + name: "custom formatter with title", + element: &LinkElement{ + URL: "https://example.com", + Title: "Example Site", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + }, + formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.Title != "" { + return fmt.Sprintf("%s (%s - %s)", data.Text, data.URL, data.Title), nil + } + return fmt.Sprintf("%s (%s)", data.Text, data.URL), nil + }), + want: "example (https://example.com - Example Site)", + }, + { + name: "autolink context", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "https://example.com"}}, + IsAutoLink: true, + }, + formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.IsAutoLink { + return fmt.Sprintf("<%s>", data.URL), nil + } + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil + }), + want: "", + }, + { + name: "table context", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "link"}}, + IsInTable: true, + }, + formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + if data.IsInTable { + return data.Text, nil // Tables: text only + } + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil + }), + want: "link", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.element.Formatter = tt.formatter + + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := tt.element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + result := buf.String() + // Strip ANSI codes for easier comparison + plainResult := stripANSISequences(result) + if plainResult != tt.want { + t.Errorf("expected %q, got %q", tt.want, plainResult) + } + }) + } +} + +func TestLinkElementWithoutFormatter(t *testing.T) { + tests := []struct { + name string + element *LinkElement + want []string // Multiple strings that should be present in output + }{ + { + name: "default behavior with text and URL", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + }, + want: []string{"example", "https://example.com"}, + }, + { + name: "with base URL", + element: &LinkElement{ + URL: "/path", + BaseURL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "path"}}, + }, + want: []string{"path", "https://example.com/path"}, + }, + { + name: "fragment only URL (should be ignored)", + element: &LinkElement{ + URL: "#fragment", + Children: []ElementRenderer{&BaseElement{Token: "fragment"}}, + }, + want: []string{"fragment"}, // URL should be ignored + }, + { + name: "skip text", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + SkipText: true, + }, + want: []string{"https://example.com"}, // Only URL + }, + { + name: "skip href", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + SkipHref: true, + }, + want: []string{"example"}, // Only text + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := tt.element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + result := buf.String() + plainResult := stripANSISequences(result) + + for _, expected := range tt.want { + if !strings.Contains(plainResult, expected) { + t.Errorf("expected result to contain %q, got %q", expected, plainResult) + } + } + }) + } +} + +func TestLinkElementFormatterError(t *testing.T) { + errorFormatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return "", errors.New("formatter error") + }) + + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + Formatter: errorFormatter, + } + + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + err := element.Render(&buf, ctx) + if err == nil { + t.Error("expected error, got nil") + } + if !strings.Contains(err.Error(), "formatter error") { + t.Errorf("expected error to contain 'formatter error', got %q", err.Error()) + } +} + +func TestLinkElementDataExtraction(t *testing.T) { + tests := []struct { + name string + element *LinkElement + check func(t *testing.T, data LinkData) + }{ + { + name: "basic data extraction", + element: &LinkElement{ + URL: "https://example.com", + Title: "Example Site", + BaseURL: "https://base.com", + IsAutoLink: true, + IsInTable: true, + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + }, + check: func(t *testing.T, data LinkData) { + if data.URL != "https://example.com" { + t.Errorf("URL: expected %q, got %q", "https://example.com", data.URL) + } + if data.Text != "example" { + t.Errorf("Text: expected %q, got %q", "example", data.Text) + } + if data.Title != "Example Site" { + t.Errorf("Title: expected %q, got %q", "Example Site", data.Title) + } + if data.BaseURL != "https://base.com" { + t.Errorf("BaseURL: expected %q, got %q", "https://base.com", data.BaseURL) + } + if !data.IsAutoLink { + t.Error("IsAutoLink: expected true") + } + if !data.IsInTable { + t.Error("IsInTable: expected true") + } + }, + }, + { + name: "multiple children", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{ + &BaseElement{Token: "Hello"}, + &BaseElement{Token: " "}, + &BaseElement{Token: "World"}, + }, + }, + check: func(t *testing.T, data LinkData) { + if data.Text != "Hello World" { + t.Errorf("Text: expected %q, got %q", "Hello World", data.Text) + } + }, + }, + { + name: "styled children", + element: &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{ + &BaseElement{ + Token: "Styled", + Style: StylePrimitive{ + Color: stringPtr("#ff0000"), + }, + }, + }, + }, + check: func(t *testing.T, data LinkData) { + if data.Text != "Styled" { + t.Errorf("Text: expected %q, got %q", "Styled", data.Text) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedData LinkData + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + capturedData = data + return "test", nil + }) + + tt.element.Formatter = formatter + + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := tt.element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + tt.check(t, capturedData) + }) + } +} + +func TestLinkElementStyleContext(t *testing.T) { + customStyles := StyleConfig{ + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + Bold: boolPtr(true), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#0000ff"), + Underline: boolPtr(true), + }, + } + + var capturedData LinkData + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + capturedData = data + return "test", nil + }) + + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + Formatter: formatter, + } + + var buf bytes.Buffer + options := Options{ + Styles: customStyles, + } + ctx := NewRenderContext(options) + + err := element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Check that styles were passed correctly + if capturedData.LinkStyle.Color == nil || *capturedData.LinkStyle.Color != "#00ff00" { + t.Errorf("LinkStyle.Color: expected %q, got %v", "#00ff00", capturedData.LinkStyle.Color) + } + if capturedData.TextStyle.Color == nil || *capturedData.TextStyle.Color != "#0000ff" { + t.Errorf("TextStyle.Color: expected %q, got %v", "#0000ff", capturedData.TextStyle.Color) + } +} + +func TestLinkElementComplexChildren(t *testing.T) { + // Test with nested elements that might have complex rendering + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{ + &BaseElement{ + Token: "Bold ", + Style: StylePrimitive{Bold: boolPtr(true)}, + }, + &BaseElement{ + Token: "and ", + }, + &BaseElement{ + Token: "Italic", + Style: StylePrimitive{Italic: boolPtr(true)}, + }, + }, + Formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return fmt.Sprintf("TEXT:%s URL:%s", data.Text, data.URL), nil + }), + } + + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + result := stripANSISequences(buf.String()) + expected := "TEXT:Bold and Italic URL:https://example.com" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestLinkElementNilChildren(t *testing.T) { + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{ + &BaseElement{Token: "First"}, + nil, // Should be handled gracefully + &BaseElement{Token: "Second"}, + }, + Formatter: LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return fmt.Sprintf("TEXT:%s", data.Text), nil + }), + } + + var buf bytes.Buffer + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + result := stripANSISequences(buf.String()) + expected := "TEXT:FirstSecond" + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestLinkElementRenderContext(t *testing.T) { + var capturedContext RenderContext + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + capturedContext = ctx + return "test", nil + }) + + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + Formatter: formatter, + } + + var buf bytes.Buffer + options := Options{ + ColorProfile: 256, // Example context data + WordWrap: 80, + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + err := element.Render(&buf, ctx) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Verify context was passed through + if capturedContext.options.ColorProfile != 256 { + t.Errorf("ColorProfile: expected 256, got %v", capturedContext.options.ColorProfile) + } + if capturedContext.options.WordWrap != 80 { + t.Errorf("WordWrap: expected 80, got %v", capturedContext.options.WordWrap) + } +} + +// Benchmark tests for performance +func BenchmarkLinkElementDefault(b *testing.B) { + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + } + + options := Options{ + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + } + ctx := NewRenderContext(options) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + element.Render(&buf, ctx) + } +} + +func BenchmarkLinkElementCustomFormatter(b *testing.B) { + formatter := LinkFormatterFunc(func(data LinkData, ctx RenderContext) (string, error) { + return fmt.Sprintf("%s (%s)", data.Text, data.URL), nil + }) + + element := &LinkElement{ + URL: "https://example.com", + Children: []ElementRenderer{&BaseElement{Token: "example"}}, + Formatter: formatter, + } + + bs := &BlockStack{} + bs.Push(BlockElement{Style: StyleBlock{}}) + + ctx := RenderContext{ + blockStack: bs, + options: Options{ + ColorProfile: termenv.TrueColor, + WordWrap: 80, + Styles: StyleConfig{ + Document: StyleBlock{}, + Link: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + LinkText: StylePrimitive{ + Color: stringPtr("#ffffff"), + }, + }, + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + element.Render(&buf, ctx) + } +} diff --git a/ansi/margin.go b/ansi/margin.go index 9de2ad06..9bf3f766 100644 --- a/ansi/margin.go +++ b/ansi/margin.go @@ -3,6 +3,7 @@ package ansi import ( "fmt" "io" + "strings" "github.com/muesli/reflow/indent" "github.com/muesli/reflow/padding" @@ -49,9 +50,26 @@ func NewMarginWriter(ctx RenderContext, w io.Writer, rules StyleBlock) *MarginWr } func (w *MarginWriter) Write(b []byte) (int, error) { + content := string(b) + if containsOSC8Sequences(content) { + // If content contains OSC 8 sequences, bypass reflow processing + // and write directly to avoid corruption of hyperlinks + n, err := w.w.Write(b) + if err != nil { + return 0, fmt.Errorf("glamour: error writing bytes: %w", err) + } + return n, nil + } + + // Safe to use reflow processing for content without OSC 8 sequences n, err := w.iw.Write(b) if err != nil { return 0, fmt.Errorf("glamour: error writing bytes: %w", err) } return n, nil } + +// containsOSC8Sequences checks specifically for OSC 8 hyperlink sequences +func containsOSC8Sequences(text string) bool { + return strings.Contains(text, "\x1b]8;;") +} diff --git a/ansi/osc8_validation_test.go b/ansi/osc8_validation_test.go new file mode 100644 index 00000000..cf3b1893 --- /dev/null +++ b/ansi/osc8_validation_test.go @@ -0,0 +1,450 @@ +package ansi + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// TestOSC8SequenceFormat validates that OSC 8 sequences are generated correctly +func TestOSC8SequenceFormat(t *testing.T) { + tests := []struct { + name string + text string + url string + expected string + }{ + { + name: "basic HTTP URL", + text: "Example", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Example\x1b]8;;\x1b\\", + }, + { + name: "HTTPS URL", + text: "Secure Site", + url: "https://secure.example.com", + expected: "\x1b]8;;https://secure.example.com\x1b\\Secure Site\x1b]8;;\x1b\\", + }, + { + name: "HTTP URL", + text: "Plain HTTP", + url: "http://plain.example.com", + expected: "\x1b]8;;http://plain.example.com\x1b\\Plain HTTP\x1b]8;;\x1b\\", + }, + { + name: "URL with path and query", + text: "Complex URL", + url: "https://example.com/path/to/page?param=value&other=test", + expected: "\x1b]8;;https://example.com/path/to/page?param=value&other=test\x1b\\Complex URL\x1b]8;;\x1b\\", + }, + { + name: "URL with fragment", + text: "Section Link", + url: "https://example.com/page#section", + expected: "\x1b]8;;https://example.com/page#section\x1b\\Section Link\x1b]8;;\x1b\\", + }, + { + name: "relative URL", + text: "Relative", + url: "/relative/path", + expected: "\x1b]8;;/relative/path\x1b\\Relative\x1b]8;;\x1b\\", + }, + { + name: "mailto URL", + text: "Email Link", + url: "mailto:user@example.com", + expected: "\x1b]8;;mailto:user@example.com\x1b\\Email Link\x1b]8;;\x1b\\", + }, + { + name: "file URL", + text: "Local File", + url: "file:///path/to/file.txt", + expected: "\x1b]8;;file:///path/to/file.txt\x1b\\Local File\x1b]8;;\x1b\\", + }, + { + name: "ftp URL", + text: "FTP Server", + url: "ftp://ftp.example.com/file.zip", + expected: "\x1b]8;;ftp://ftp.example.com/file.zip\x1b\\FTP Server\x1b]8;;\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHyperlink(tt.text, tt.url) + if result != tt.expected { + t.Errorf("formatHyperlink(%q, %q) = %q, expected %q", tt.text, tt.url, result, tt.expected) + } + }) + } +} + +// TestOSC8SequenceTextVariations tests various text content in hyperlinks +func TestOSC8SequenceTextVariations(t *testing.T) { + tests := []struct { + name string + text string + url string + expected string + }{ + { + name: "empty text", + text: "", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\", + }, + { + name: "single character", + text: "X", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\X\x1b]8;;\x1b\\", + }, + { + name: "text with spaces", + text: "Click here for more info", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Click here for more info\x1b]8;;\x1b\\", + }, + { + name: "text with punctuation", + text: "Hello, World!", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Hello, World!\x1b]8;;\x1b\\", + }, + { + name: "text with numbers", + text: "Version 1.2.3", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Version 1.2.3\x1b]8;;\x1b\\", + }, + { + name: "text with special characters", + text: "Cost: $19.99 (50% off!)", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Cost: $19.99 (50% off!)\x1b]8;;\x1b\\", + }, + { + name: "unicode text", + text: "δΎ‹γˆ", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\δΎ‹γˆ\x1b]8;;\x1b\\", + }, + { + name: "emoji text", + text: "Click here! πŸ‘‰", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Click here! πŸ‘‰\x1b]8;;\x1b\\", + }, + { + name: "mixed unicode and ascii", + text: "Hello δΈ–η•Œ World", + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\Hello δΈ–η•Œ World\x1b]8;;\x1b\\", + }, + { + name: "long text", + text: strings.Repeat("A", 100), + url: "https://example.com", + expected: "\x1b]8;;https://example.com\x1b\\" + strings.Repeat("A", 100) + "\x1b]8;;\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHyperlink(tt.text, tt.url) + if result != tt.expected { + t.Errorf("formatHyperlink(%q, %q) = %q, expected %q", tt.text, tt.url, result, tt.expected) + } + }) + } +} + +// TestOSC8SequenceURLVariations tests various URL formats +func TestOSC8SequenceURLVariations(t *testing.T) { + tests := []struct { + name string + text string + url string + expected string + }{ + { + name: "empty URL returns text only", + text: "Just Text", + url: "", + expected: "Just Text", + }, + { + name: "URL with international domain", + text: "International", + url: "https://δΎ‹γˆ.γƒ†γ‚Ήγƒˆ", + expected: "\x1b]8;;https://δΎ‹γˆ.γƒ†γ‚Ήγƒˆ\x1b\\International\x1b]8;;\x1b\\", + }, + { + name: "URL with port", + text: "Local Server", + url: "http://localhost:8080", + expected: "\x1b]8;;http://localhost:8080\x1b\\Local Server\x1b]8;;\x1b\\", + }, + { + name: "URL with credentials", + text: "Auth Required", + url: "https://user:pass@example.com", + expected: "\x1b]8;;https://user:pass@example.com\x1b\\Auth Required\x1b]8;;\x1b\\", + }, + { + name: "URL with encoded characters", + text: "Encoded URL", + url: "https://example.com/path%20with%20spaces", + expected: "\x1b]8;;https://example.com/path%20with%20spaces\x1b\\Encoded URL\x1b]8;;\x1b\\", + }, + { + name: "very long URL", + text: "Long URL", + url: "https://example.com/" + strings.Repeat("segment/", 50), + expected: "\x1b]8;;https://example.com/" + strings.Repeat("segment/", 50) + "\x1b\\Long URL\x1b]8;;\x1b\\", + }, + { + name: "data URL", + text: "Data URL", + url: "data:text/plain;base64,SGVsbG8gV29ybGQ=", + expected: "\x1b]8;;data:text/plain;base64,SGVsbG8gV29ybGQ=\x1b\\Data URL\x1b]8;;\x1b\\", + }, + { + name: "javascript URL", + text: "JS URL", + url: "javascript:alert('hello')", + expected: "\x1b]8;;javascript:alert('hello')\x1b\\JS URL\x1b]8;;\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHyperlink(tt.text, tt.url) + if result != tt.expected { + t.Errorf("formatHyperlink(%q, %q) = %q, expected %q", tt.text, tt.url, result, tt.expected) + } + }) + } +} + +// TestOSC8SequenceConstants validates the OSC 8 constants are correct +func TestOSC8SequenceConstants(t *testing.T) { + expectedStart := "\x1b]8;;" + expectedMid := "\x1b\\" + expectedEnd := "\x1b]8;;\x1b\\" + + if hyperlinkStart != expectedStart { + t.Errorf("hyperlinkStart = %q, expected %q", hyperlinkStart, expectedStart) + } + + if hyperlinkMid != expectedMid { + t.Errorf("hyperlinkMid = %q, expected %q", hyperlinkMid, expectedMid) + } + + if hyperlinkEnd != expectedEnd { + t.Errorf("hyperlinkEnd = %q, expected %q", hyperlinkEnd, expectedEnd) + } +} + +// TestOSC8SequenceStructure validates the complete sequence structure +func TestOSC8SequenceStructure(t *testing.T) { + text := "Example Text" + url := "https://example.com" + + result := formatHyperlink(text, url) + + // Validate sequence parts + if !strings.HasPrefix(result, hyperlinkStart+url+hyperlinkMid) { + t.Errorf("Result should start with hyperlink sequence: %q", result) + } + + if !strings.HasSuffix(result, hyperlinkEnd) { + t.Errorf("Result should end with hyperlink end sequence: %q", result) + } + + // Validate text placement + expectedTextStart := len(hyperlinkStart + url + hyperlinkMid) + expectedTextEnd := len(result) - len(hyperlinkEnd) + + if expectedTextEnd <= expectedTextStart { + t.Error("Invalid sequence structure") + } + + extractedText := result[expectedTextStart:expectedTextEnd] + if extractedText != text { + t.Errorf("Extracted text %q doesn't match original %q", extractedText, text) + } +} + +// TestOSC8SequenceBinaryCompatibility ensures sequences work with binary data +func TestOSC8SequenceBinaryCompatibility(t *testing.T) { + // Test that sequences don't break with binary data in URLs + binaryData := string([]byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}) + text := "Binary Test" + + result := formatHyperlink(text, binaryData) + + // Should still generate a valid sequence structure + if !strings.Contains(result, text) { + t.Error("Binary data in URL should not break text rendering") + } + + if !strings.Contains(result, hyperlinkStart) { + t.Error("Binary data should not break sequence start") + } + + if !strings.Contains(result, hyperlinkEnd) { + t.Error("Binary data should not break sequence end") + } +} + +// TestOSC8SequencePerformance ensures sequence generation is fast +func TestOSC8SequencePerformance(t *testing.T) { + text := "Performance Test" + url := "https://performance.example.com" + + // Run many iterations to check for performance issues + for i := 0; i < 10000; i++ { + result := formatHyperlink(text, url) + if result == "" { + t.Error("Empty result in performance test") + break + } + } +} + +// BenchmarkOSC8SequenceGeneration benchmarks sequence generation +func BenchmarkOSC8SequenceGeneration(b *testing.B) { + scenarios := map[string]struct { + text string + url string + }{ + "short": { + text: "Link", + url: "https://example.com", + }, + "medium": { + text: "This is a medium length link text", + url: "https://example.com/path/to/some/resource", + }, + "long": { + text: strings.Repeat("Very long link text with lots of content ", 10), + url: "https://very-long-domain-name.example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3", + }, + "unicode": { + text: "Unicode: δΈ–η•Œ 🌍 Example", + url: "https://unicode.example.com/δΈ–η•Œ", + }, + } + + for name, scenario := range scenarios { + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + formatHyperlink(scenario.text, scenario.url) + } + }) + } +} + +// TestOSC8SequenceEdgeCases tests edge cases in sequence generation +func TestOSC8SequenceEdgeCases(t *testing.T) { + tests := []struct { + name string + text string + url string + shouldPanic bool + description string + }{ + { + name: "both empty", + text: "", + url: "", + shouldPanic: false, + description: "Both empty should return empty string", + }, + { + name: "text with escape sequences", + text: "\x1b[31mRed Text\x1b[0m", + url: "https://example.com", + shouldPanic: false, + description: "Text with ANSI should be preserved", + }, + { + name: "url with escape sequences", + text: "Link", + url: "https://example.com\x1b[31m", + shouldPanic: false, + description: "URL with ANSI should be preserved", + }, + { + name: "very long inputs", + text: strings.Repeat("A", 100000), + url: "https://example.com/" + strings.Repeat("segment/", 1000), + shouldPanic: false, + description: "Very long inputs should work", + }, + { + name: "null bytes", + text: "Text\x00with\x00nulls", + url: "https://example.com\x00/path", + shouldPanic: false, + description: "Null bytes should be preserved", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.shouldPanic { + t.Errorf("%s: unexpected panic: %v", tt.description, r) + } + } else if tt.shouldPanic { + t.Errorf("%s: expected panic but didn't get one", tt.description) + } + }() + + result := formatHyperlink(tt.text, tt.url) + + // Basic validation that result has expected structure + if tt.url == "" && result != tt.text { + t.Errorf("%s: expected %q when URL empty, got %q", tt.description, tt.text, result) + } else if tt.url != "" && !strings.Contains(result, tt.text) { + t.Errorf("%s: result should contain text %q", tt.description, tt.text) + } + }) + } +} + +// TestOSC8SequenceUTF8Validity ensures sequences maintain UTF-8 validity +func TestOSC8SequenceUTF8Validity(t *testing.T) { + tests := []struct { + name string + text string + url string + }{ + {"ascii", "Hello World", "https://example.com"}, + {"utf8", "Hello δΈ–η•Œ", "https://δΎ‹γˆ.com"}, + {"emoji", "Click πŸ‘‰ here", "https://emoji.example.com"}, + {"mixed", "ASCIIδΈ­ζ–‡πŸŒ", "https://mixed.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatHyperlink(tt.text, tt.url) + + if !utf8.ValidString(result) { + t.Errorf("Result is not valid UTF-8: %q", result) + } + + if !utf8.ValidString(tt.text) { + t.Errorf("Input text is not valid UTF-8: %q", tt.text) + } + + if !utf8.ValidString(tt.url) { + t.Errorf("Input URL is not valid UTF-8: %q", tt.url) + } + }) + } +} diff --git a/ansi/renderer.go b/ansi/renderer.go index 56ebfa50..23034924 100644 --- a/ansi/renderer.go +++ b/ansi/renderer.go @@ -24,6 +24,7 @@ type Options struct { ColorProfile termenv.Profile Styles StyleConfig ChromaFormatter string + LinkFormatter LinkFormatter // Custom link formatter (optional, nil = default behavior) } // ANSIRenderer renders markdown content as ANSI escaped sequences. diff --git a/ansi/smart_fallback_test.go b/ansi/smart_fallback_test.go new file mode 100644 index 00000000..75088406 --- /dev/null +++ b/ansi/smart_fallback_test.go @@ -0,0 +1,501 @@ +package ansi + +import ( + "strings" + "testing" +) + +// TestSmartHyperlinkFormatterFallback tests the smart formatter's fallback behavior +func TestSmartHyperlinkFormatterFallback(t *testing.T) { + tests := []struct { + name string + data LinkData + termProgram string + term string + expectHyperlink bool + expectPlainText bool + description string + }{ + { + name: "iTerm2 - should use hyperlinks", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "iTerm.app", + expectHyperlink: true, + expectPlainText: false, + description: "iTerm2 supports OSC 8, should generate hyperlink sequence", + }, + { + name: "VS Code - should use hyperlinks", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "vscode", + expectHyperlink: true, + expectPlainText: false, + description: "VS Code supports OSC 8, should generate hyperlink sequence", + }, + { + name: "Unknown terminal - should fallback to plain text", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "unknown", + term: "dumb", + expectHyperlink: false, + expectPlainText: true, + description: "Unknown terminal should fallback to text + URL format", + }, + { + name: "Basic xterm - should fallback to plain text", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + term: "xterm", + expectHyperlink: false, + expectPlainText: true, + description: "Basic xterm doesn't support hyperlinks, should fallback", + }, + { + name: "Empty environment - should fallback to plain text", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + expectHyperlink: false, + expectPlainText: true, + description: "Empty environment should fallback to plain text", + }, + { + name: "Text only with hyperlink support", + data: LinkData{ + URL: "https://example.com", + Text: "", + }, + termProgram: "iTerm.app", + expectHyperlink: false, + expectPlainText: false, + description: "Empty text should return empty result even with hyperlink support", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up environment + if tt.termProgram != "" { + t.Setenv("TERM_PROGRAM", tt.termProgram) + } else { + t.Setenv("TERM_PROGRAM", "") + } + + if tt.term != "" { + t.Setenv("TERM", tt.term) + } else { + t.Setenv("TERM", "") + } + + // Clear other terminal detection variables + t.Setenv("KITTY_WINDOW_ID", "") + t.Setenv("ALACRITTY_LOG", "") + t.Setenv("ALACRITTY_SOCKET", "") + + ctx := NewRenderContext(Options{}) + result, err := SmartHyperlinkFormatter.FormatLink(tt.data, ctx) + + if err != nil { + t.Errorf("%s: unexpected error: %v", tt.description, err) + } + + if tt.expectHyperlink { + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("%s: expected OSC 8 hyperlink sequence, got: %q", tt.description, result) + } + // Should not contain URL separately when using hyperlinks (unless fallback) + plainResult := stripANSISequences(result) + if strings.Contains(plainResult, tt.data.URL) && tt.data.URL != "" { + t.Errorf("%s: hyperlink mode should not show URL separately, got: %q", tt.description, plainResult) + } + } + + if tt.expectPlainText { + if strings.Contains(result, "\x1b]8;;") { + t.Errorf("%s: should not contain hyperlink sequences in fallback mode, got: %q", tt.description, result) + } + + // Should contain both text and URL for fallback (if both exist) + plainResult := stripANSISequences(result) + if tt.data.Text != "" && !strings.Contains(plainResult, tt.data.Text) { + t.Errorf("%s: fallback should contain text %q, got: %q", tt.description, tt.data.Text, plainResult) + } + if tt.data.URL != "" && !strings.Contains(plainResult, tt.data.URL) { + t.Errorf("%s: fallback should contain URL %q, got: %q", tt.description, tt.data.URL, plainResult) + } + } + + // Handle case where empty result is expected + if !tt.expectHyperlink && !tt.expectPlainText { + plainResult := stripANSISequences(result) + if plainResult != "" { + t.Errorf("%s: expected empty result, got: %q", tt.description, plainResult) + } + } + }) + } +} + +// TestTextOnlyFormatterFallback tests TextOnlyFormatter's fallback behavior +func TestTextOnlyFormatterFallback(t *testing.T) { + tests := []struct { + name string + data LinkData + termProgram string + expectHyperlink bool + description string + }{ + { + name: "With hyperlink support - should hide URL", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "iTerm.app", + expectHyperlink: true, + description: "TextOnly formatter should use hyperlinks when supported", + }, + { + name: "Without hyperlink support - should show only text", + data: LinkData{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "unknown", + expectHyperlink: false, + description: "TextOnly formatter should show only text without support", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.termProgram != "" { + t.Setenv("TERM_PROGRAM", tt.termProgram) + } else { + t.Setenv("TERM_PROGRAM", "") + } + t.Setenv("TERM", "") + + ctx := NewRenderContext(Options{}) + result, err := TextOnlyFormatter.FormatLink(tt.data, ctx) + + if err != nil { + t.Errorf("%s: unexpected error: %v", tt.description, err) + } + + plainResult := stripANSISequences(result) + + if tt.expectHyperlink { + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("%s: expected hyperlink sequence, got: %q", tt.description, result) + } + // Should not show URL separately + if strings.Contains(plainResult, tt.data.URL) { + t.Errorf("%s: should not show URL in hyperlink mode, got: %q", tt.description, plainResult) + } + } else { + // Should not show URL at all in text-only mode + if strings.Contains(plainResult, tt.data.URL) { + t.Errorf("%s: should not show URL in text-only fallback, got: %q", tt.description, plainResult) + } + } + + // Should always show text (if present) + if tt.data.Text != "" && !strings.Contains(plainResult, tt.data.Text) { + t.Errorf("%s: should always show text, got: %q", tt.description, plainResult) + } + }) + } +} + +// TestHyperlinkStructSmartRendering tests Hyperlink struct's smart rendering +func TestHyperlinkStructSmartRendering(t *testing.T) { + tests := []struct { + name string + hyperlink *Hyperlink + termProgram string + expectHyperlink bool + expectPlain bool + description string + }{ + { + name: "Smart rendering with hyperlink support", + hyperlink: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "iTerm.app", + expectHyperlink: true, + expectPlain: false, + description: "Should use OSC 8 when supported", + }, + { + name: "Smart rendering without hyperlink support", + hyperlink: &Hyperlink{ + URL: "https://example.com", + Text: "Example", + }, + termProgram: "unknown", + expectHyperlink: false, + expectPlain: true, + description: "Should fallback to plain text format", + }, + { + name: "Smart rendering with empty text", + hyperlink: &Hyperlink{ + URL: "https://example.com", + Text: "", + }, + termProgram: "iTerm.app", + expectHyperlink: true, // Still generates hyperlink with empty text + expectPlain: false, + description: "Should generate hyperlink even with empty text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.termProgram != "" { + t.Setenv("TERM_PROGRAM", tt.termProgram) + } else { + t.Setenv("TERM_PROGRAM", "") + } + t.Setenv("TERM", "") + + ctx := NewRenderContext(Options{}) + result := tt.hyperlink.RenderSmart(ctx) + + if tt.expectHyperlink { + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("%s: expected OSC 8 sequence, got: %q", tt.description, result) + } + } + + if tt.expectPlain { + if strings.Contains(result, "\x1b]8;;") { + t.Errorf("%s: should not contain OSC 8 in plain mode, got: %q", tt.description, result) + } + + // Plain mode should show both text and URL (if both exist) + if tt.hyperlink.Text != "" && tt.hyperlink.URL != "" { + expected := tt.hyperlink.Text + " (" + tt.hyperlink.URL + ")" + if result != expected { + t.Errorf("%s: expected %q, got %q", tt.description, expected, result) + } + } + } + }) + } +} + +// TestFallbackWithStyling tests that fallback still applies styling correctly +func TestFallbackWithStyling(t *testing.T) { + data := LinkData{ + URL: "https://example.com", + Text: "Example", + LinkStyle: StylePrimitive{ + Color: stringPtr("#ff0000"), + }, + TextStyle: StylePrimitive{ + Color: stringPtr("#00ff00"), + }, + } + + // Test with unsupported terminal + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "dumb") + + ctx := NewRenderContext(Options{}) + result, err := SmartHyperlinkFormatter.FormatLink(data, ctx) + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Should fallback to default formatter but still apply styling + if strings.Contains(result, "\x1b]8;;") { + t.Error("Should not contain hyperlink sequences in fallback") + } + + // Result should contain styled content (ANSI color codes) + if !strings.Contains(result, "\x1b[") { + t.Error("Fallback should still apply styling with ANSI codes") + } + + // Should contain both text and URL + plainResult := stripANSISequences(result) + if !strings.Contains(plainResult, "Example") { + t.Errorf("Should contain text, got: %q", plainResult) + } + if !strings.Contains(plainResult, "https://example.com") { + t.Errorf("Should contain URL, got: %q", plainResult) + } +} + +// TestFallbackEdgeCases tests edge cases in fallback behavior +func TestFallbackEdgeCases(t *testing.T) { + tests := []struct { + name string + data LinkData + description string + expectEmpty bool + }{ + { + name: "Empty text and URL", + data: LinkData{ + URL: "", + Text: "", + }, + description: "Both empty should produce empty result", + expectEmpty: true, + }, + { + name: "Only URL", + data: LinkData{ + URL: "https://example.com", + Text: "", + }, + description: "URL only should show just URL", + expectEmpty: false, + }, + { + name: "Only text", + data: LinkData{ + URL: "", + Text: "Just text", + }, + description: "Text only should show just text", + expectEmpty: false, + }, + { + name: "Fragment URL", + data: LinkData{ + URL: "#fragment", + Text: "Fragment", + }, + description: "Fragment URLs should be handled specially", + expectEmpty: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use unsupported terminal to force fallback + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "dumb") + + ctx := NewRenderContext(Options{}) + result, err := SmartHyperlinkFormatter.FormatLink(tt.data, ctx) + + if err != nil { + t.Errorf("%s: unexpected error: %v", tt.description, err) + } + + plainResult := stripANSISequences(result) + + if tt.expectEmpty && plainResult != "" { + t.Errorf("%s: expected empty result, got %q", tt.description, plainResult) + } + + if !tt.expectEmpty && plainResult == "" { + t.Errorf("%s: expected non-empty result, got empty", tt.description) + } + }) + } +} + +// TestTerminalDetectionConsistency ensures consistent behavior across formatters +func TestTerminalDetectionConsistency(t *testing.T) { + data := LinkData{ + URL: "https://example.com", + Text: "Example", + } + + terminals := []struct { + name string + termProgram string + term string + shouldSupport bool + }{ + {"iTerm2", "iTerm.app", "", true}, + {"VS Code", "vscode", "", true}, + {"Windows Terminal", "Windows Terminal", "", true}, + {"xterm-256color", "", "xterm-256color", true}, + {"basic xterm", "", "xterm", false}, + {"dumb terminal", "", "dumb", false}, + {"empty env", "", "", false}, + } + + for _, term := range terminals { + t.Run(term.name, func(t *testing.T) { + t.Setenv("TERM_PROGRAM", term.termProgram) + t.Setenv("TERM", term.term) + t.Setenv("KITTY_WINDOW_ID", "") + t.Setenv("ALACRITTY_LOG", "") + + ctx := NewRenderContext(Options{}) + + // Test direct detection + detected := supportsHyperlinks(ctx) + if detected != term.shouldSupport { + t.Errorf("Detection mismatch for %s: got %v, expected %v", term.name, detected, term.shouldSupport) + } + + // Test SmartHyperlinkFormatter consistency + smartResult, _ := SmartHyperlinkFormatter.FormatLink(data, ctx) + containsHyperlink := strings.Contains(smartResult, "\x1b]8;;") + + if containsHyperlink != term.shouldSupport { + t.Errorf("SmartHyperlinkFormatter inconsistent for %s: hyperlink=%v, shouldSupport=%v", + term.name, containsHyperlink, term.shouldSupport) + } + + // Test Hyperlink struct consistency + h := NewHyperlink(data.URL, data.Text, "") + structResult := h.RenderSmart(ctx) + structContainsHyperlink := strings.Contains(structResult, "\x1b]8;;") + + if structContainsHyperlink != term.shouldSupport { + t.Errorf("Hyperlink.RenderSmart inconsistent for %s: hyperlink=%v, shouldSupport=%v", + term.name, structContainsHyperlink, term.shouldSupport) + } + }) + } +} + +// BenchmarkSmartFallbackPerformance benchmarks the performance of smart fallback +func BenchmarkSmartFallbackPerformance(b *testing.B) { + data := LinkData{ + URL: "https://example.com", + Text: "Example Link", + } + + scenarios := map[string]string{ + "with_hyperlinks": "iTerm.app", + "without_hyperlinks": "unknown", + } + + for name, termProgram := range scenarios { + b.Run(name, func(b *testing.B) { + b.Setenv("TERM_PROGRAM", termProgram) + ctx := NewRenderContext(Options{}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + SmartHyperlinkFormatter.FormatLink(data, ctx) + } + }) + } +} diff --git a/ansi/terminal_detection_test.go b/ansi/terminal_detection_test.go new file mode 100644 index 00000000..8eadf1f1 --- /dev/null +++ b/ansi/terminal_detection_test.go @@ -0,0 +1,399 @@ +package ansi + +import ( + "os" + "testing" +) + +// TestTerminalDetectionComprehensive tests supportsHyperlinks with comprehensive environment combinations +func TestTerminalDetectionComprehensive(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + category string + }{ + // TERM_PROGRAM based detection + { + name: "iTerm2", + envVars: map[string]string{"TERM_PROGRAM": "iTerm.app"}, + expected: true, + category: "TERM_PROGRAM", + }, + { + name: "VS Code integrated terminal", + envVars: map[string]string{"TERM_PROGRAM": "vscode"}, + expected: true, + category: "TERM_PROGRAM", + }, + { + name: "Windows Terminal", + envVars: map[string]string{"TERM_PROGRAM": "Windows Terminal"}, + expected: true, + category: "TERM_PROGRAM", + }, + { + name: "WezTerm", + envVars: map[string]string{"TERM_PROGRAM": "WezTerm"}, + expected: true, + category: "TERM_PROGRAM", + }, + { + name: "Hyper terminal", + envVars: map[string]string{"TERM_PROGRAM": "Hyper"}, + expected: true, + category: "TERM_PROGRAM", + }, + { + name: "Unknown TERM_PROGRAM", + envVars: map[string]string{"TERM_PROGRAM": "unknown-terminal"}, + expected: false, + category: "TERM_PROGRAM", + }, + + // TERM variable based detection + { + name: "xterm-256color", + envVars: map[string]string{"TERM": "xterm-256color"}, + expected: true, + category: "TERM", + }, + { + name: "screen-256color", + envVars: map[string]string{"TERM": "screen-256color"}, + expected: true, + category: "TERM", + }, + { + name: "tmux-256color", + envVars: map[string]string{"TERM": "tmux-256color"}, + expected: true, + category: "TERM", + }, + { + name: "alacritty", + envVars: map[string]string{"TERM": "alacritty"}, + expected: true, + category: "TERM", + }, + { + name: "xterm-kitty", + envVars: map[string]string{"TERM": "xterm-kitty"}, + expected: true, + category: "TERM", + }, + { + name: "basic xterm", + envVars: map[string]string{"TERM": "xterm"}, + expected: false, + category: "TERM", + }, + { + name: "dumb terminal", + envVars: map[string]string{"TERM": "dumb"}, + expected: false, + category: "TERM", + }, + + // Special environment variables + { + name: "Kitty terminal by KITTY_WINDOW_ID", + envVars: map[string]string{"KITTY_WINDOW_ID": "1"}, + expected: true, + category: "SPECIAL_ENV", + }, + { + name: "Alacritty by ALACRITTY_LOG", + envVars: map[string]string{"ALACRITTY_LOG": "/tmp/alacritty.log"}, + expected: true, + category: "SPECIAL_ENV", + }, + { + name: "Alacritty by ALACRITTY_SOCKET", + envVars: map[string]string{"ALACRITTY_SOCKET": "/tmp/alacritty.sock"}, + expected: true, + category: "SPECIAL_ENV", + }, + + // Priority testing - TERM_PROGRAM should take precedence + { + name: "iTerm2 overrides basic TERM", + envVars: map[string]string{ + "TERM_PROGRAM": "iTerm.app", + "TERM": "xterm", + }, + expected: true, + category: "PRIORITY", + }, + { + name: "Unknown TERM_PROGRAM with supported TERM", + envVars: map[string]string{ + "TERM_PROGRAM": "unknown", + "TERM": "xterm-256color", + }, + expected: true, + category: "PRIORITY", + }, + + // Edge cases + { + name: "Empty environment", + envVars: map[string]string{}, + expected: false, + category: "EDGE_CASE", + }, + { + name: "Multiple indicators - all support", + envVars: map[string]string{ + "TERM_PROGRAM": "vscode", + "TERM": "xterm-256color", + "KITTY_WINDOW_ID": "1", + }, + expected: true, + category: "EDGE_CASE", + }, + { + name: "Mixed support indicators", + envVars: map[string]string{ + "TERM_PROGRAM": "unknown", + "TERM": "dumb", + "KITTY_WINDOW_ID": "1", + }, + expected: true, // KITTY_WINDOW_ID should trigger support + category: "EDGE_CASE", + }, + + // Case sensitivity tests + { + name: "Case sensitive TERM_PROGRAM - correct case", + envVars: map[string]string{"TERM_PROGRAM": "iTerm.app"}, + expected: true, + category: "CASE_SENSITIVITY", + }, + { + name: "Case sensitive TERM_PROGRAM - wrong case", + envVars: map[string]string{"TERM_PROGRAM": "iterm.app"}, + expected: false, + category: "CASE_SENSITIVITY", + }, + + // Real-world scenarios + { + name: "macOS iTerm2 typical setup", + envVars: map[string]string{ + "TERM_PROGRAM": "iTerm.app", + "TERM_PROGRAM_VERSION": "3.4.16", + "TERM": "xterm-256color", + }, + expected: true, + category: "REAL_WORLD", + }, + { + name: "VS Code integrated terminal typical setup", + envVars: map[string]string{ + "TERM_PROGRAM": "vscode", + "TERM_PROGRAM_VERSION": "1.74.0", + "TERM": "xterm-256color", + }, + expected: true, + category: "REAL_WORLD", + }, + { + name: "SSH session with screen", + envVars: map[string]string{ + "TERM": "screen-256color", + "SSH_CONNECTION": "192.168.1.100 12345 192.168.1.1 22", + }, + expected: true, + category: "REAL_WORLD", + }, + { + name: "WSL with Windows Terminal", + envVars: map[string]string{ + "TERM_PROGRAM": "Windows Terminal", + "TERM": "xterm-256color", + "WSL_DISTRO_NAME": "Ubuntu", + }, + expected: true, + category: "REAL_WORLD", + }, + { + name: "Docker container with basic terminal", + envVars: map[string]string{ + "TERM": "xterm", + "container": "docker", + }, + expected: false, + category: "REAL_WORLD", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear all relevant environment variables first + clearTerminalEnvVars(t) + + // Set the test environment variables + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + // Test the function + ctx := RenderContext{} // Empty context is fine for this test + result := supportsHyperlinks(ctx) + + if result != tt.expected { + t.Errorf("supportsHyperlinks() = %v, expected %v for test case %q (category: %s)", + result, tt.expected, tt.name, tt.category) + t.Logf("Environment variables set: %+v", tt.envVars) + } + }) + } +} + +// TestTerminalDetectionEdgeCases tests edge cases and error conditions +func TestTerminalDetectionEdgeCases(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + desc string + }{ + { + name: "Empty string values", + envVars: map[string]string{ + "TERM_PROGRAM": "", + "TERM": "", + }, + expected: false, + desc: "Empty environment variable values should not trigger support", + }, + { + name: "Whitespace in values", + envVars: map[string]string{ + "TERM_PROGRAM": " iTerm.app ", + }, + expected: false, // Current implementation doesn't trim whitespace + desc: "Whitespace around values should not match", + }, + { + name: "Partial matches in TERM", + envVars: map[string]string{ + "TERM": "my-xterm-256color-custom", + }, + expected: true, // Uses strings.Contains() + desc: "Partial matches should work for TERM variable", + }, + { + name: "Multiple TERM patterns", + envVars: map[string]string{ + "TERM": "screen-256color-tmux", + }, + expected: true, // Should match both screen-256color and tmux patterns + desc: "TERM with multiple supported patterns should match", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearTerminalEnvVars(t) + + for key, value := range tt.envVars { + t.Setenv(key, value) + } + + result := supportsHyperlinks(RenderContext{}) + if result != tt.expected { + t.Errorf("%s: got %v, expected %v", tt.desc, result, tt.expected) + } + }) + } +} + +// TestTerminalDetectionPerformance tests that detection is fast +func TestTerminalDetectionPerformance(t *testing.T) { + // Set up a typical environment + t.Setenv("TERM_PROGRAM", "iTerm.app") + t.Setenv("TERM", "xterm-256color") + + ctx := RenderContext{} + + // Run detection many times to check for performance issues + for i := 0; i < 1000; i++ { + result := supportsHyperlinks(ctx) + if !result { + t.Errorf("Expected hyperlink support in iteration %d", i) + } + } +} + +// TestTerminalDetectionWithContext tests that RenderContext doesn't affect detection +func TestTerminalDetectionWithContext(t *testing.T) { + t.Setenv("TERM_PROGRAM", "vscode") + + // Test with different context configurations + contexts := []RenderContext{ + {}, // Empty context + {options: Options{WordWrap: 80}}, + {options: Options{Styles: StyleConfig{}}}, + } + + for i, ctx := range contexts { + result := supportsHyperlinks(ctx) + if !result { + t.Errorf("Context %d should not affect hyperlink detection", i) + } + } +} + +// clearTerminalEnvVars clears all terminal-related environment variables for clean testing +func clearTerminalEnvVars(t *testing.T) { + t.Helper() + + terminalEnvVars := []string{ + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TERM", + "TERMINAL_EMULATOR", + "KITTY_WINDOW_ID", + "ALACRITTY_LOG", + "ALACRITTY_SOCKET", + "COLORTERM", + "WT_SESSION", + "SSH_CONNECTION", + "WSL_DISTRO_NAME", + "container", + } + + for _, envVar := range terminalEnvVars { + t.Setenv(envVar, "") + } +} + +// BenchmarkTerminalDetection benchmarks the hyperlink detection function +func BenchmarkTerminalDetection(b *testing.B) { + scenarios := map[string]map[string]string{ + "iTerm2": {"TERM_PROGRAM": "iTerm.app"}, + "VSCode": {"TERM_PROGRAM": "vscode"}, + "xterm-256color": {"TERM": "xterm-256color"}, + "kitty": {"KITTY_WINDOW_ID": "1"}, + "unsupported": {"TERM": "dumb"}, + "empty": {}, + } + + for name, envVars := range scenarios { + b.Run(name, func(b *testing.B) { + // Set up environment + for key, value := range envVars { + os.Setenv(key, value) + } + + ctx := RenderContext{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + supportsHyperlinks(ctx) + } + }) + } +} diff --git a/examples/custom_link_formatting/README.md b/examples/custom_link_formatting/README.md new file mode 100644 index 00000000..c6903796 --- /dev/null +++ b/examples/custom_link_formatting/README.md @@ -0,0 +1,176 @@ +# Custom Link Formatting Examples + +This example demonstrates the comprehensive custom link formatting capabilities of Glamour, showcasing all available built-in formatters and several custom formatter implementations. + +## Overview + +Glamour provides flexible link formatting through the `LinkFormatter` interface, allowing you to customize how links are rendered in terminal output. This example covers: + +- **Built-in formatters**: Ready-to-use formatters for common use cases +- **Custom formatters**: Examples of implementing your own formatting logic +- **Advanced patterns**: Plugin systems, context awareness, and defensive programming + +## Running the Example + +```bash +# From the custom_link_formatting directory +go run main.go + +# Or build and run +go build -o demo main.go +./demo +``` + +## Built-in Formatters + +### 1. Default Formatter +The standard Glamour behavior showing both text and URL with styling. +``` +Visit Google https://google.com for searching. +``` + +### 2. Text-Only Links (`WithTextOnlyLinks()`) +Shows only clickable text in smart terminals that support hyperlinks. +``` +Visit Google for searching. (clickable in compatible terminals) +``` + +### 3. URL-Only Links (`WithURLOnlyLinks()`) +Shows only URLs, hiding the descriptive text. +``` +Visit https://google.com for searching. +``` + +### 4. Hyperlink Formatter (`WithHyperlinks()`) +Uses OSC 8 hyperlinks to make text clickable while hiding URLs. +``` +Visit Google for searching. (text is clickable, URL hidden) +``` + +### 5. Smart Hyperlinks (`WithSmartHyperlinks()`) +OSC 8 hyperlinks with intelligent fallback to default format. +``` +Modern terminals: Google (clickable) +Older terminals: Google https://google.com +``` + +## Custom Formatter Examples + +### 6. Markdown-Style Formatter +Outputs links in markdown format: +```go +markdownFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil +}) +``` + +### 7. Domain-Based Formatter +Different icons based on the website domain: +``` +πŸ™ GitHub Repo +πŸ” Google Search +πŸ“š Stack Overflow +πŸ”— Other Site (example.com) +``` + +### 8. Length-Aware Formatter +Truncates long URLs to keep output clean: +``` +Short: Google (https://google.com) +Long: Very Long URL [example.com...] +``` + +### 9. Context-Aware Formatter +Different formatting based on link context (tables, autolinks, etc.): +- **Tables**: Text only to save space +- **Autolinks**: `` format +- **Regular**: `text β†’ URL` format + +### 10. Error-Safe Formatter +Demonstrates defensive programming with graceful error handling. + +### 11. Plugin-Style Formatter +Extensible system allowing multiple formatting plugins with priority ordering. + +## Terminal Compatibility + +### Hyperlink Support (OSC 8) +- βœ… **iTerm2** (macOS) +- βœ… **Windows Terminal** +- βœ… **VS Code integrated terminal** +- βœ… **Hyper** +- βœ… **Terminology** +- ❌ **macOS Terminal.app** (basic) +- ❌ **Most SSH sessions** + +### Testing Hyperlinks +To test if your terminal supports hyperlinks, look for clickable text in the hyperlink formatter examples. In unsupported terminals, you may see escape sequences or plain text. + +## Usage Patterns + +### Basic Usage +```go +// Create renderer with custom formatter +renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithLinkFormatter(customFormatter), +) + +// Render markdown +output, err := renderer.Render(markdown) +fmt.Print(output) +``` + +### Creating Custom Formatters +```go +// Function-based formatter +customFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + // Your custom formatting logic here + return fmt.Sprintf("CUSTOM: %s -> %s", data.Text, data.URL), nil +}) + +// Struct-based formatter implementing LinkFormatter interface +type MyFormatter struct { + prefix string +} + +func (f *MyFormatter) FormatLink(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("%s%s (%s)", f.prefix, data.Text, data.URL), nil +} +``` + +### Available LinkData Fields +```go +type LinkData struct { + URL string // The destination URL + Text string // The link text + Title string // Optional title attribute + BaseURL string // Base URL for relative links + IsAutoLink bool // Whether this is an autolink + IsInTable bool // Whether link appears in a table + Children []ElementRenderer // Original child elements + LinkStyle StylePrimitive // Style for URL portion + TextStyle StylePrimitive // Style for text portion +} +``` + +## Best Practices + +1. **Graceful Fallbacks**: Always handle edge cases where URL or text might be empty +2. **Terminal Detection**: Use context to detect terminal capabilities +3. **Performance**: Pre-allocate string builders for complex formatting +4. **Error Handling**: Return meaningful errors and provide safe fallbacks +5. **Testing**: Structure formatters to be easily testable with dependency injection + +## Contributing + +When creating custom formatters: +1. Implement proper error handling +2. Consider terminal compatibility +3. Test with various markdown inputs +4. Document expected behavior +5. Provide usage examples + +## License + +Same as the main Glamour project. diff --git a/examples/custom_link_formatting/go.mod b/examples/custom_link_formatting/go.mod new file mode 100644 index 00000000..3c4b4872 --- /dev/null +++ b/examples/custom_link_formatting/go.mod @@ -0,0 +1,37 @@ +module custom_link_formatting + +go 1.23.0 + +toolchain go1.24.4 + +replace github.com/charmbracelet/glamour => ../../ + +require github.com/charmbracelet/glamour v0.8.0 + +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/examples/custom_link_formatting/go.sum b/examples/custom_link_formatting/go.sum new file mode 100644 index 00000000..9408d8c9 --- /dev/null +++ b/examples/custom_link_formatting/go.sum @@ -0,0 +1,66 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/examples/custom_link_formatting/main.go b/examples/custom_link_formatting/main.go new file mode 100644 index 00000000..f5d10850 --- /dev/null +++ b/examples/custom_link_formatting/main.go @@ -0,0 +1,307 @@ +package main + +import ( + "fmt" + "log" + "net/url" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" +) + +func main() { + // Sample markdown with various link types + markdown := `# Custom Link Formatting Demo + +## Basic Links +Here's a [regular link](https://example.com) and another [Google search](https://google.com "Google Search Engine"). + +## Autolinks +Visit for repositories. + +## Different Contexts +The following table shows some links: + +| Site | URL | +|------|-----| +| [GitHub](https://github.com) | Repository hosting | +| [Stack Overflow](https://stackoverflow.com) | Q&A platform | + +## Long URLs +Here's a [very long URL](https://example.com/very/long/path/to/some/resource?with=many&query=parameters&and=more&stuff=here) for testing. +` + + fmt.Println("=== BUILT-IN FORMATTERS ===\n") + + // 1. Default behavior (unchanged) + fmt.Println("1. Default Formatter") + fmt.Println(" Shows both text and URL with styling") + renderWithFormatter(markdown, nil, "Default") + + // 2. Text-only links (clickable in smart terminals) + fmt.Println("2. Text-Only Links") + fmt.Println(" Shows only clickable text in smart terminals") + renderer2, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + glamour.WithTextOnlyLinks(), + ) + if err != nil { + log.Fatal(err) + } + renderWithRenderer(markdown, renderer2, "TextOnly") + + // 3. URL-only links + fmt.Println("3. URL-Only Links") + fmt.Println(" Shows only URLs, hiding link text") + renderer3, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + glamour.WithURLOnlyLinks(), + ) + if err != nil { + log.Fatal(err) + } + renderWithRenderer(markdown, renderer3, "URLOnly") + + // 4. Hyperlinks (OSC 8) + fmt.Println("4. Hyperlink Formatter") + fmt.Println(" Uses OSC 8 hyperlinks (clickable text, hidden URLs)") + renderer4, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + glamour.WithHyperlinks(), + ) + if err != nil { + log.Fatal(err) + } + renderWithRenderer(markdown, renderer4, "Hyperlinks") + + // 5. Smart hyperlinks with fallback + fmt.Println("5. Smart Hyperlinks") + fmt.Println(" OSC 8 hyperlinks with fallback to default format") + renderer5, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + glamour.WithSmartHyperlinks(), + ) + if err != nil { + log.Fatal(err) + } + renderWithRenderer(markdown, renderer5, "SmartHyperlinks") + + fmt.Println("\n=== CUSTOM FORMATTERS ===\n") + + // 6. Markdown-style formatter + fmt.Println("6. Markdown-Style Formatter") + fmt.Println(" Outputs markdown-style links [text](url)") + markdownFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + if data.Title != "" { + return fmt.Sprintf("[%s](%s \"%s\")", data.Text, data.URL, data.Title), nil + } + return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil + }) + renderWithFormatter(markdown, markdownFormatter, "Markdown") + + // 7. Domain-based formatter with emojis + fmt.Println("7. Domain-Based Formatter") + fmt.Println(" Different icons based on domain") + domainFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + u, err := url.Parse(data.URL) + if err != nil { + return fmt.Sprintf("%s [%s]", data.Text, data.URL), nil + } + + domain := strings.ToLower(u.Hostname()) + switch { + case strings.Contains(domain, "github.com"): + return fmt.Sprintf("πŸ™ %s", data.Text), nil + case strings.Contains(domain, "google.com"): + return fmt.Sprintf("πŸ” %s", data.Text), nil + case strings.Contains(domain, "stackoverflow.com"): + return fmt.Sprintf("πŸ“š %s", data.Text), nil + default: + return fmt.Sprintf("πŸ”— %s (%s)", data.Text, u.Hostname()), nil + } + }) + renderWithFormatter(markdown, domainFormatter, "Domain") + + // 8. Length-aware formatter + fmt.Println("8. Length-Aware Formatter") + fmt.Println(" Truncates long URLs to keep output clean") + lengthFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + const maxURLLength = 50 + + if len(data.URL) <= maxURLLength { + // Short URLs: show both text and URL + return fmt.Sprintf("%s (%s)", data.Text, data.URL), nil + } + + // Long URLs: show only text with domain + u, err := url.Parse(data.URL) + if err != nil { + return data.Text, nil + } + + return fmt.Sprintf("%s [%s...]", data.Text, u.Hostname()), nil + }) + renderWithFormatter(markdown, lengthFormatter, "Length") + + // 9. Context-aware formatter + fmt.Println("9. Context-Aware Formatter") + fmt.Println(" Different formatting based on link context") + contextFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + switch { + case data.IsInTable: + // Tables: just show text to save space + return data.Text, nil + case data.IsAutoLink: + // Autolinks: show in angle brackets + return fmt.Sprintf("<%s>", data.URL), nil + default: + // Regular links: show both with arrow + return fmt.Sprintf("%s β†’ %s", data.Text, data.URL), nil + } + }) + renderWithFormatter(markdown, contextFormatter, "Context") + + // 10. Error-handling formatter + fmt.Println("10. Error-Safe Formatter") + fmt.Println(" Demonstrates defensive programming") + safeFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + // Always validate inputs + if data.URL == "" { + return data.Text, nil // fallback to text + } + + if data.Text == "" { + data.Text = data.URL // fallback to URL + } + + // Handle URL parsing errors gracefully + u, err := url.Parse(data.URL) + if err != nil { + return fmt.Sprintf("%s [invalid URL]", data.Text), nil + } + + // Safe formatting with validation + return fmt.Sprintf("%s <%s>", data.Text, u.String()), nil + }) + renderWithFormatter(markdown, safeFormatter, "Safe") + + fmt.Println("\n=== ADVANCED EXAMPLES ===\n") + + // 11. Plugin-style formatter + fmt.Println("11. Plugin-Style Formatter") + fmt.Println(" Extensible formatter system") + pluginFormatter := createPluginFormatter() + renderWithFormatter(markdown, pluginFormatter, "Plugin") + + fmt.Println("\nβœ… All examples completed successfully!") + fmt.Println("\nNote: Hyperlink support depends on your terminal.") + fmt.Println("Try these examples in different terminals to see the differences!") +} + +// Helper function to render with a specific formatter +func renderWithFormatter(markdown string, formatter ansi.LinkFormatter, name string) { + var renderer *glamour.TermRenderer + var err error + + if formatter == nil { + // Default formatter + renderer, err = glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + ) + } else { + renderer, err = glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(80), + glamour.WithLinkFormatter(formatter), + ) + } + + if err != nil { + log.Printf("Error creating renderer for %s: %v", name, err) + return + } + + output, err := renderer.Render(markdown) + if err != nil { + log.Printf("Error rendering with %s formatter: %v", name, err) + return + } + + fmt.Print(output) + fmt.Println(strings.Repeat("-", 60)) + fmt.Println() +} + +// Helper function to render with a pre-configured renderer +func renderWithRenderer(markdown string, renderer *glamour.TermRenderer, name string) { + output, err := renderer.Render(markdown) + if err != nil { + log.Printf("Error rendering with %s renderer: %v", name, err) + return + } + + fmt.Print(output) + fmt.Println(strings.Repeat("-", 60)) + fmt.Println() +} + +// Plugin-style formatter implementation +type FormatterPlugin interface { + Name() string + Priority() int + CanHandle(data ansi.LinkData) bool + Format(data ansi.LinkData, ctx ansi.RenderContext) (string, error) +} + +type GitHubPlugin struct{} + +func (p *GitHubPlugin) Name() string { return "github" } +func (p *GitHubPlugin) Priority() int { return 10 } +func (p *GitHubPlugin) CanHandle(data ansi.LinkData) bool { + return strings.Contains(strings.ToLower(data.URL), "github.com") +} +func (p *GitHubPlugin) Format(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("πŸ™ %s [GitHub]", data.Text), nil +} + +type GooglePlugin struct{} + +func (p *GooglePlugin) Name() string { return "google" } +func (p *GooglePlugin) Priority() int { return 5 } +func (p *GooglePlugin) CanHandle(data ansi.LinkData) bool { + return strings.Contains(strings.ToLower(data.URL), "google.com") +} +func (p *GooglePlugin) Format(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("πŸ” %s [Google]", data.Text), nil +} + +type PluginFormatter struct { + plugins []FormatterPlugin +} + +func (pf *PluginFormatter) FormatLink(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + // Find the first plugin that can handle this link + for _, plugin := range pf.plugins { + if plugin.CanHandle(data) { + return plugin.Format(data, ctx) + } + } + + // Fallback to default format + return fmt.Sprintf("%s β†’ %s", data.Text, data.URL), nil +} + +func createPluginFormatter() *PluginFormatter { + return &PluginFormatter{ + plugins: []FormatterPlugin{ + &GitHubPlugin{}, + &GooglePlugin{}, + }, + } +} diff --git a/examples/custom_link_formatting/test_build b/examples/custom_link_formatting/test_build new file mode 100755 index 00000000..8cc8cc28 Binary files /dev/null and b/examples/custom_link_formatting/test_build differ diff --git a/glamour.go b/glamour.go index bc1870cc..8de90972 100644 --- a/glamour.go +++ b/glamour.go @@ -79,8 +79,9 @@ func NewTermRenderer(options ...TermRendererOption) (*TermRenderer, error) { ), ), ansiOptions: ansi.Options{ - WordWrap: defaultWidth, - ColorProfile: termenv.TrueColor, + WordWrap: defaultWidth, + ColorProfile: termenv.TrueColor, + LinkFormatter: ansi.DefaultFormatter, // Ensure consistent rendering path }, } for _, o := range options { @@ -250,6 +251,118 @@ func WithOptions(options ...TermRendererOption) TermRendererOption { } } +// WithLinkFormatter sets a TermRenderer's custom link formatter. +// +// Link formatters control how markdown links are rendered in the terminal output. +// When set to nil (default), the standard Glamour link formatting is used. +// +// Example: +// +// // Use a custom formatter +// customFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { +// return fmt.Sprintf("[%s](%s)", data.Text, data.URL), nil +// }) +// renderer, err := glamour.NewTermRenderer( +// glamour.WithLinkFormatter(customFormatter), +// ) +// +// See the ansi package documentation for available built-in formatters and +// examples of creating custom formatters. +func WithLinkFormatter(formatter ansi.LinkFormatter) TermRendererOption { + return func(tr *TermRenderer) error { + tr.ansiOptions.LinkFormatter = formatter + return nil + } +} + +// WithTextOnlyLinks configures the TermRenderer to show only link text. +// +// This formatter renders links showing only their text content, hiding URLs. +// In terminals that support OSC 8 hyperlinks, the text becomes clickable. +// In other terminals, only the styled text is displayed. +// +// This is useful when you want a cleaner appearance without visible URLs, +// especially in terminals that support hyperlink functionality. +// +// Example: +// +// renderer, err := glamour.NewTermRenderer( +// glamour.WithTextOnlyLinks(), +// ) +// +// Terminal compatibility: +// - Terminals with OSC 8 support: Clickable text links +// - Other terminals: Text only, URLs hidden +func WithTextOnlyLinks() TermRendererOption { + return WithLinkFormatter(ansi.TextOnlyFormatter) +} + +// WithURLOnlyLinks configures the TermRenderer to show only URLs. +// +// This formatter renders links showing only the URL, hiding link text. +// This is useful when space is limited or when the URL itself is more +// important than descriptive text. +// +// Fragment-only URLs (like "#section") are not displayed as they typically +// refer to document anchors rather than external resources. +// +// Example: +// +// renderer, err := glamour.NewTermRenderer( +// glamour.WithURLOnlyLinks(), +// ) +// +// Terminal compatibility: Works in all terminals +func WithURLOnlyLinks() TermRendererOption { + return WithLinkFormatter(ansi.URLOnlyFormatter) +} + +// WithHyperlinks configures the TermRenderer to use OSC 8 hyperlinks. +// +// This formatter renders links as OSC 8 hyperlinks, making the link text +// clickable in supporting terminals while keeping URLs hidden from view. +// This provides the cleanest visual appearance when terminal support is available. +// +// WARNING: This formatter does not provide fallback for terminals without +// OSC 8 support, which may result in escape sequences being displayed. +// Use WithSmartHyperlinks() for automatic fallback behavior. +// +// Example: +// +// renderer, err := glamour.NewTermRenderer( +// glamour.WithHyperlinks(), +// ) +// +// Terminal compatibility: +// - Modern terminals (iTerm2, Windows Terminal, etc.): Clickable hyperlinks +// - Legacy terminals: May display escape sequences +func WithHyperlinks() TermRendererOption { + return WithLinkFormatter(ansi.HyperlinkFormatter) +} + +// WithSmartHyperlinks configures the TermRenderer with intelligent hyperlink handling. +// +// This formatter automatically detects terminal hyperlink support: +// - In terminals that support OSC 8: Shows clickable hyperlinks (text only) +// - In other terminals: Falls back to standard "text url" format +// +// This provides the best user experience across different terminal environments +// by combining modern hyperlink capabilities with universal fallback support. +// +// Example: +// +// renderer, err := glamour.NewTermRenderer( +// glamour.WithSmartHyperlinks(), +// ) +// +// Terminal compatibility: +// - Modern terminals: Clickable hyperlinks with hidden URLs +// - Legacy terminals: Standard "text url" format +// - All terminals: Graceful degradation +func WithSmartHyperlinks() TermRendererOption { + return WithLinkFormatter(ansi.SmartHyperlinkFormatter) +} + func (tr *TermRenderer) Read(b []byte) (int, error) { n, err := tr.renderBuf.Read(b) if err == io.EOF { diff --git a/glamour_test.go b/glamour_test.go index 46fed567..b56236cd 100644 --- a/glamour_test.go +++ b/glamour_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/x/exp/golden" ) @@ -325,3 +326,451 @@ func TestWithChromaFormatterCustom(t *testing.T) { golden.RequireEqual(t, []byte(b)) } + +func TestWithLinkFormatter(t *testing.T) { + customFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("CUSTOM[%s](%s)", data.Text, data.URL), nil + }) + + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithLinkFormatter(customFormatter), + ) + if err != nil { + t.Fatal(err) + } + + markdown := "[example](https://example.com)" + result, err := r.Render(markdown) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(result, "CUSTOM[example](https://example.com)") { + t.Errorf("expected custom formatter output, got: %s", result) + } +} + +func TestWithTextOnlyLinks(t *testing.T) { + tests := []struct { + name string + markdown string + supportsHyperlinks bool + wantContains []string + wantNotContains []string + }{ + { + name: "hyperlink support", + markdown: "[example](https://example.com)", + supportsHyperlinks: true, + wantContains: []string{"example", "\x1b]8;;https://example.com\x1b\\"}, + wantNotContains: []string{}, + }, + { + name: "no hyperlink support", + markdown: "[example](https://example.com)", + supportsHyperlinks: false, + wantContains: []string{"example"}, + wantNotContains: []string{"https://example.com"}, + }, + { + name: "multiple links", + markdown: "[first](https://first.com) and [second](https://second.com)", + supportsHyperlinks: false, + wantContains: []string{"first", "second"}, + wantNotContains: []string{"https://first.com", "https://second.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up terminal environment + if tt.supportsHyperlinks { + t.Setenv("TERM_PROGRAM", "iTerm.app") + } else { + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") + } + + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithTextOnlyLinks(), + ) + if err != nil { + t.Fatal(err) + } + + result, err := r.Render(tt.markdown) + if err != nil { + t.Fatal(err) + } + + for _, expected := range tt.wantContains { + if !strings.Contains(result, expected) { + t.Errorf("expected result to contain %q, got: %s", expected, result) + } + } + + for _, notExpected := range tt.wantNotContains { + if strings.Contains(result, notExpected) { + t.Errorf("expected result NOT to contain %q, got: %s", notExpected, result) + } + } + }) + } +} + +func TestWithURLOnlyLinks(t *testing.T) { + tests := []struct { + name string + markdown string + wantContains []string + wantNotContains []string + }{ + { + name: "normal link", + markdown: "[example text](https://example.com)", + wantContains: []string{"https://example.com"}, + wantNotContains: []string{"example text"}, + }, + { + name: "fragment link ignored", + markdown: "[section](#fragment)", + wantContains: []string{}, + wantNotContains: []string{"#fragment", "section"}, + }, + { + name: "multiple links", + markdown: "[Click here](https://example.com) and [Visit site](https://test.org)", + wantContains: []string{"https://example.com", "https://test.org"}, + wantNotContains: []string{"Click here", "Visit site"}, + }, + { + name: "relative URL with base", + markdown: "[path](/relative)", + wantContains: []string{"/relative"}, // Base URL would be resolved by formatter + wantNotContains: []string{"path"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithURLOnlyLinks(), + ) + if err != nil { + t.Fatal(err) + } + + result, err := r.Render(tt.markdown) + if err != nil { + t.Fatal(err) + } + + // Strip ANSI codes for easier checking + plainResult := stripANSISequences(result) + + for _, expected := range tt.wantContains { + if !strings.Contains(plainResult, expected) { + t.Errorf("expected result to contain %q, got: %s", expected, plainResult) + } + } + + for _, notExpected := range tt.wantNotContains { + if strings.Contains(plainResult, notExpected) { + t.Errorf("expected result NOT to contain %q, got: %s", notExpected, plainResult) + } + } + }) + } +} + +func TestWithHyperlinks(t *testing.T) { + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithHyperlinks(), + ) + if err != nil { + t.Fatal(err) + } + + markdown := "[example](https://example.com)" + result, err := r.Render(markdown) + if err != nil { + t.Fatal(err) + } + + // Should contain OSC 8 sequences regardless of terminal support + if !strings.Contains(result, "\x1b]8;;https://example.com\x1b\\") { + t.Errorf("expected OSC 8 hyperlink sequences, got: %s", result) + } + if !strings.Contains(result, "example") { + t.Errorf("expected link text, got: %s", result) + } + if !strings.Contains(result, "\x1b]8;;\x1b\\") { + t.Errorf("expected OSC 8 end sequence, got: %s", result) + } +} + +func TestWithSmartHyperlinks(t *testing.T) { + tests := []struct { + name string + supportsHyperlinks bool + wantHyperlink bool + wantFallback bool + }{ + { + name: "modern terminal", + supportsHyperlinks: true, + wantHyperlink: true, + wantFallback: false, + }, + { + name: "legacy terminal", + supportsHyperlinks: false, + wantHyperlink: false, + wantFallback: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up terminal environment + if tt.supportsHyperlinks { + t.Setenv("TERM_PROGRAM", "iTerm.app") + } else { + t.Setenv("TERM_PROGRAM", "") + t.Setenv("TERM", "") + } + + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithSmartHyperlinks(), + ) + if err != nil { + t.Fatal(err) + } + + markdown := "[example](https://example.com)" + result, err := r.Render(markdown) + if err != nil { + t.Fatal(err) + } + + if tt.wantHyperlink { + if !strings.Contains(result, "\x1b]8;;") { + t.Errorf("expected hyperlink sequences in modern terminal, got: %s", result) + } + } + + if tt.wantFallback { + plainResult := stripANSISequences(result) + if !strings.Contains(plainResult, "example") { + t.Errorf("expected link text in fallback, got: %s", plainResult) + } + if !strings.Contains(plainResult, "https://example.com") { + t.Errorf("expected URL in fallback, got: %s", plainResult) + } + } + }) + } +} + +func TestLinkFormatterIntegration(t *testing.T) { + // Test complex markdown with multiple link types + complexMarkdown := `# Test Document + +Regular link: [GitHub](https://github.com) +Autolink: +Reference link: [Google][1] + +[1]: https://google.com "Google Search" + +## In Lists + +* [Link 1](https://one.com) +* [Link 2](https://two.com) + +## In Tables + +| Name | URL | +|------|-----| +| [Site 1](https://site1.com) | Description 1 | +| [Site 2](https://site2.com) | Description 2 | +` + + tests := []struct { + name string + option TermRendererOption + check func(t *testing.T, result string) + }{ + { + name: "default behavior", + option: WithStandardStyle("dark"), + check: func(t *testing.T, result string) { + plain := stripANSISequences(result) + // Should contain both text and URLs + if !strings.Contains(plain, "GitHub") || !strings.Contains(plain, "https://github.com") { + t.Error("default should show both text and URL") + } + }, + }, + { + name: "text only", + option: WithOptions(WithStandardStyle("dark"), WithTextOnlyLinks()), + check: func(t *testing.T, result string) { + plain := stripANSISequences(result) + // Should contain text but not visible URLs (unless in hyperlinks) + if !strings.Contains(plain, "GitHub") { + t.Error("should contain link text") + } + }, + }, + { + name: "URL only", + option: WithOptions(WithStandardStyle("dark"), WithURLOnlyLinks()), + check: func(t *testing.T, result string) { + plain := stripANSISequences(result) + // Should contain URLs but not descriptive text + if !strings.Contains(plain, "https://github.com") { + t.Error("should contain URLs") + } + if strings.Contains(plain, "GitHub") { + t.Error("should not contain link text") + } + }, + }, + { + name: "custom formatter", + option: WithOptions(WithStandardStyle("dark"), WithLinkFormatter(ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("{%s->%s}", data.Text, data.URL), nil + }))), + check: func(t *testing.T, result string) { + plain := stripANSISequences(result) + if !strings.Contains(plain, "{GitHub->https://github.com}") { + t.Error("should contain custom format") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, err := NewTermRenderer(tt.option) + if err != nil { + t.Fatal(err) + } + + result, err := r.Render(complexMarkdown) + if err != nil { + t.Fatal(err) + } + + tt.check(t, result) + }) + } +} + +func TestLinkFormatterErrorHandling(t *testing.T) { + errorFormatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return "", fmt.Errorf("formatter error") + }) + + r, err := NewTermRenderer( + WithStandardStyle("dark"), + WithLinkFormatter(errorFormatter), + ) + if err != nil { + t.Fatal(err) + } + + markdown := "[example](https://example.com)" + _, err = r.Render(markdown) + if err == nil { + t.Error("expected formatter error to be propagated") + } + if !strings.Contains(err.Error(), "formatter error") { + t.Errorf("expected formatter error in message, got: %s", err.Error()) + } +} + +func TestBackwardCompatibility(t *testing.T) { + // Test that existing behavior is preserved when no custom formatter is set + markdown := "[GitHub](https://github.com) and " + + // Render without custom formatter (current behavior) + r1, err := NewTermRenderer(WithStandardStyle("dark")) + if err != nil { + t.Fatal(err) + } + result1, err := r1.Render(markdown) + if err != nil { + t.Fatal(err) + } + + // Render with explicit default formatter + r2, err := NewTermRenderer( + WithStandardStyle("dark"), + WithLinkFormatter(ansi.DefaultFormatter), + ) + if err != nil { + t.Fatal(err) + } + result2, err := r2.Render(markdown) + if err != nil { + t.Fatal(err) + } + + // Results should be identical + if result1 != result2 { + t.Error("default behavior should match explicit DefaultFormatter") + } + + // Both should contain text and URLs + plain := stripANSISequences(result1) + if !strings.Contains(plain, "GitHub") { + t.Error("should contain link text") + } + if !strings.Contains(plain, "https://github.com") { + t.Error("should contain GitHub URL") + } + if !strings.Contains(plain, "https://example.com") { + t.Error("should contain example URL") + } +} + +// Helper function to strip ANSI sequences for testing +func stripANSISequences(text string) string { + // Use the same regex as in hyperlink.go + re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[a-zA-Z]`) + return re.ReplaceAllString(text, "") +} + +// Performance benchmarks +func BenchmarkLinkRenderingDefault(b *testing.B) { + r, _ := NewTermRenderer(WithStandardStyle("dark")) + markdown := "[example](https://example.com)" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + r.Render(markdown) + } +} + +func BenchmarkLinkRenderingCustomFormatter(b *testing.B) { + formatter := ansi.LinkFormatterFunc(func(data ansi.LinkData, ctx ansi.RenderContext) (string, error) { + return fmt.Sprintf("%s %s", data.Text, data.URL), nil + }) + + r, _ := NewTermRenderer( + WithStandardStyle("dark"), + WithLinkFormatter(formatter), + ) + markdown := "[example](https://example.com)" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + r.Render(markdown) + } +}