cowsay / cowsay.go

@ v1.1.0 | history


/*
Package Cowsay implements the functionality of the classic [cowsay] Perl
script.

All classic cowfiles have been converted from the original Perl syntax into
Go's text/template format in order to help spread ASCII art cow culture ever
further into the computer systems of the world.

[cowsay]: https://en.wikipedia.org/wiki/Cowsay
*/
package cowsay

import (
	"embed"
	"fmt"
	"io"
	"os"
	"regexp"
	"strings"
	"text/template"
)

//go:embed cows
var cowdir embed.FS

// Cowfiles is associated with templates of all the builtin cowfiles.
var Cowfiles *template.Template

func init() {
	var err error
	Cowfiles, err = template.ParseFS(cowdir, "cows/*")
	if err != nil {
		panic(err.Error())
	}
}

const defaultWidth = 40

// Cow can be configured to say things in different ways.
type Cow struct {

	// Mood determines the appearance of the cow's eyes and tongue.
	Mood Mood

	// Width determines the wrap width of the text inside the speech
	// bubble. Setting Width < 0 results in no word wrap. A value of 0
	// will use the default width (40-ish).
	Width int

	body *template.Template
}

type bubble struct {
	width      int
	startL     rune
	startR     rune
	bigSideL   rune
	bigSideR   rune
	endL       rune
	endR       rune
	smallSideL rune
	smallSideR rune
	voice      string
}

var (
	sayBubble = &bubble{0,
		'/', '\\',
		'|', '|',
		'\\', '/',
		'<', '>',
		`\`}
	thinkBubble = &bubble{0,
		'(', ')',
		'(', ')',
		'(', ')',
		'(', ')',
		`o`}
)

// Say prints an ASCII art Cow speaking the input string to stdout, followed by
// a newline.
func (c Cow) Say(s string) {
	fmt.Fprintln(os.Stdout, c.moo(s, false))
}

// Think prints an ASCII art Cow thinking the input string to stdout, followed
// by a newline.
func (c Cow) Think(s string) {
	fmt.Fprintln(os.Stdout, c.moo(s, true))
}

// Write writes an ASCII art Cow speaking or thinking the input string to the
// provided io.Writer.  If think == true, then the Cow will appear thinking;
// otherwise speaking.
func (c Cow) Write(w io.Writer, s []byte, think bool) (int, error) {
	return w.Write([]byte(c.moo(string(s), think)))
}

// Cowsay is provided for convenience. It is identical to Cow{}.Say.  It prints
// the default cow speaking the input string to stdout, followed by a newline.
func Cowsay(s string) {
	Cow{}.Say(s)
}

// Mood determines the appearance of the Cow's eyes and tongue.
type Mood struct {

	// Eyes contains a string representation of a Cow's eyes. The first two
	// runes of Eyes are used as .Eyes in cowfiles, and the rest is
	// ignored.  If uninitialized, "oo" will be used. If Eyes has a length
	// of 1, it will be right-padded with a space.
	Eyes string

	// Tongue contains a string representation of a Cow's tongue. The first
	// two runes of Tongue are used as .Tongue in cowfiles, and the rest is
	// ignored.  If uninitialized, "  " (two spaces) will be used. If
	// Tongue has a length of 1, it will be right-padded with a space.
	Tongue string
}

var (
	Default  = Mood{"oo", ""}
	Borg     = Mood{"==", ""}
	Greedy   = Mood{"$$", ""}
	Paranoid = Mood{"@@", ""}
	Stoned   = Mood{"**", "U "}
	Tired    = Mood{"--", ""}
	Wired    = Mood{"OO", ""}
	Youthful = Mood{"..", ""}
	Dead     = Mood{"xx", "U "}
)

func (c *Cow) moo(speech string, think bool) string {
	var body *template.Template
	if c.body == nil {
		body = Cowfiles.Lookup("default.cow")
	} else {
		body = c.body
	}

	var bubble *bubble
	if think {
		bubble = thinkBubble
	} else {
		bubble = sayBubble
	}

	bubble.width = c.Width

	var eyes string
	if c.Mood.Eyes == "" {
		eyes = "oo"
	} else {
		eyes = fmt.Sprintf("%-2s", c.Mood.Eyes)[:2]
	}

	tongue := fmt.Sprintf("%-2s", c.Mood.Tongue)[:2]

	data := struct {
		Mood
		Thoughts string
	}{
		Mood{eyes, tongue},
		bubble.voice,
	}
	var cowtput strings.Builder
	_, _ = cowtput.WriteString(bubble.of(speech))
	_ = cowtput.WriteByte('\n')
	_ = body.Execute(&cowtput, data)
	return cowtput.String()
}

func (b *bubble) of(speech string) string {
	var (
		noWrap    bool
		longest   int
		udderance = make([]string, 0)
	)

	switch {
	case b.width < 0:
		noWrap = true
	case b.width == 0:
		b.width = defaultWidth
	default:
	}

	pad := " "
	innerTextWidth := b.width - 1 // adjustment to match original cowsay

	if !noWrap {
		hspace := regexp.MustCompile(`[ \t]+`)
		vspace := regexp.MustCompile(`[\r\n]{2,}`)
		speech = hspace.ReplaceAllString(speech, " ")
		speech = vspace.ReplaceAllString(speech, "\n\n")
	}
	lines := strings.Split(speech, "\n")

	// first wrap long lines and determine the length of the longest
	for _, line := range lines {
		if noWrap {
			if len(line) > longest {
				longest = len(line)
			}
			udderance = append(udderance, line)
		} else {
			wlns := wrap(line, innerTextWidth)
			for _, wln := range wlns {
				if len(wln) > longest {
					longest = len(wln)
				}
				udderance = append(udderance, wln)
			}
		}
	}

	// then pad each line to the length of the longest and add bubble sides
	if len(udderance) == 1 {
		wln := udderance[0]
		l, r := b.smallSideL, b.smallSideR
		var b strings.Builder
		b.WriteRune(l)
		b.WriteString(pad)
		b.WriteString(wln)
		b.WriteString(pad)
		b.WriteRune(r)
		udderance[0] = b.String()
	} else {
		for i, wln := range udderance {
			var l, r rune
			switch {
			case i == 0:
				l, r = b.startL, b.startR
			case i == len(udderance)-1:
				l, r = b.endL, b.endR
			default:
				l, r = b.bigSideL, b.bigSideR
			}
			room := strings.Repeat(pad, longest-len(wln))
			var b strings.Builder
			b.WriteRune(l)
			b.WriteString(pad)
			b.WriteString(wln)
			b.WriteString(room)
			b.WriteString(pad)
			b.WriteRune(r)
			udderance[i] = b.String()
		}
	}

	// append top & bottom of bubble
	var o strings.Builder
	w := longest + 2*len(pad)
	o.WriteString(pad)
	o.WriteString(strings.Repeat("_", w))
	o.WriteByte('\n')
	o.WriteString(strings.Join(udderance, "\n"))
	o.WriteByte('\n')
	o.WriteString(pad)
	o.WriteString(strings.Repeat("-", w))
	return o.String()
}

// This is a great little recursive word wrapping algo, courtesy of Peter
// Mortensen: https://stackoverflow.com/a/857770
func wrap(text string, width int) []string {
	text = strings.TrimSpace(text)
	if len(text) <= width {
		return []string{text}
	} else {
		isplit := width
		for i := width; i > 0; i-- {
			if text[i] == ' ' {
				isplit = i
				break
			}
		}
		before := strings.TrimRight(text[:isplit], " ")
		after := strings.TrimLeft(text[isplit:], " ")
		return append([]string{before}, wrap(after, width)...)
	}
}

// Load loads a cowfile from the provided path. Builtin cowfiles can be
// specified by either the basename, or basename with extension removed; e.g.
// Load("default") and Load("default.cow") both load the included default
// cowfile.
func Load(path string) (Cow, error) {
	var tmpl *template.Template
	if t, err := template.ParseFiles(path); err == nil {
		tmpl = t
	} else if t := Cowfiles.Lookup(fmt.Sprintf("%s", path)); t != nil {
		tmpl = t
	} else if t := Cowfiles.Lookup(fmt.Sprintf("%s.cow", path)); t != nil {
		tmpl = t
	} else {
		return Cow{}, fmt.Errorf("cowsay: could not find %s cowfile", path)
	}
	return Cow{body: tmpl}, nil
}