package main import ( "bytes" "embed" "flag" "fmt" "io" "log" "os" "strconv" "strings" "golang.org/x/term" ) const DEBUG = false var ( // upper-left, upper-right, left side, right side, bottom-left, bottom-right SAY_BORDER = []rune{'/', '\\', '|', '|', '\\', '/'} SINGLE_BORDER = []rune{'<', '>'} THINK_BORDER = []rune{'(', ')', '(', ')', '(', ')'} ) type options struct { thinking bool tongue string eyes string leftEye string rightEye string wrapWidth int } // the cowfiles // //go:embed cows/* var cowTemplates embed.FS func main() { // basic flags cowFile := flag.String("cow", "default.cow", "the cowfile to use") tongue := flag.String("T", " ", "the tongue to print") eyesFlag := flag.String("e", "oo", "the eyes") width := flag.Int("W", 40, "approximate column width for the word-wrap") // also need to check for binary name here thinkFlag := flag.Bool("think", false, "if the cow is thinking the input instead") // flags for special conditions // these flags override -e and -T if set borgFlag := flag.Bool("b", false, "borg cow") // == deadFlag := flag.Bool("d", false, "dead cow") // xx AND tongue "U " greedyFlag := flag.Bool("g", false, "greedy cow") // $$ paranoidFlag := flag.Bool("p", false, "paranoid cow") // @@ stonedFlag := flag.Bool("s", false, "stoned cow") // ** AND tongue "U " tiredFlag := flag.Bool("t", false, "tired cow") // -- wiredFlag := flag.Bool("w", false, "wired cow") // OO youngFlag := flag.Bool("y", false, "young cow") // .. flag.Parse() input := flag.Arg(0) if len(*tongue) < 2 { *tongue = *tongue + strings.Repeat(" ", 2-len(*tongue)) } if len(*eyesFlag) < 2 { *eyesFlag = *eyesFlag + strings.Repeat(" ", 2-len(*eyesFlag)) } opts := options{ thinking: *thinkFlag, tongue: (*tongue)[0:2], eyes: (*eyesFlag)[0:2], wrapWidth: *width, } switch { case *borgFlag: opts.eyes = "==" case *deadFlag: opts.eyes = "xx" opts.tongue = "U " case *greedyFlag: opts.eyes = "$$" case *paranoidFlag: opts.eyes = "@@" case *stonedFlag: opts.eyes = "**" opts.tongue = "U " case *tiredFlag: opts.eyes = "--" case *wiredFlag: opts.eyes = "OO" case *youngFlag: opts.eyes = ".." } opts.leftEye = strings.Split(*eyesFlag, "")[0] opts.rightEye = strings.Split(*eyesFlag, "")[1] // if there is command-line input, ignore stdin. if !term.IsTerminal(0) && input != "" { pipedInput, err := io.ReadAll(os.Stdin) if err != nil { log.Fatalf("error reading from stdin: %v", err) } input = string(bytes.TrimSpace(pipedInput)) } if input == "" { log.Fatal("no input; cow can't say anything") } b, err := cowTemplates.ReadFile("cows/" + *cowFile) if err != nil { log.Fatal(err) } fmt.Print(generateCow(string(b), input, opts)) } func generateCow(cow, say string, opt options) string { cowSlice := strings.Split(cow, "\n") offset := 0 // Remove comments i := 0 for _, s := range cowSlice { if strings.HasPrefix(s, "##") && strings.Contains(s, "OFFSET") { _, off, found := strings.Cut(s, "OFFSET ") if found { offset, _ = strconv.Atoi(off) } } if !strings.HasPrefix(s, "##") { cowSlice[i] = s i++ } } cow = strings.Join(cowSlice[:i], "\n") // do replaces tail := `\` if opt.thinking { tail = `o` } cow = strings.ReplaceAll(cow, "$thoughts", tail) cow = strings.ReplaceAll(cow, "$eyes", opt.eyes) cow = strings.ReplaceAll(cow, "$el", opt.leftEye) cow = strings.ReplaceAll(cow, "$er", opt.rightEye) cow = strings.ReplaceAll(cow, "$tongue", opt.tongue) out := &bytes.Buffer{} fmt.Fprint(out, bubble(say, opt.thinking, opt.wrapWidth, offset)) fmt.Fprintln(out) fmt.Fprint(out, cow) fmt.Fprintln(out) return out.String() } // this is an attempt to emulate the rather poorly-documented behavior of Text::Wrap::fill // from CPAN: https://github.com/ap/Text-Tabs/blob/master/lib.modern/Text/Wrap.pm // The basic idea is: take an input. Format each "paragraph" of text independently, // then merge with empty lines between. // // What is under-defined in the documentation is what fill() considers a "paragraph". // From testing and reviewing the rather obtuse code, a paragraph is any number of lines // that start with a line that has optional whitespace at its start, then all subsequent lines // that have no whitespace at the start, until either reaching another line that has leading // whitespace, or a blank line. fill() also "destroy[s] any whitespace in the original text", // by which it appearently means all tabs and multiple-spaces are replaced with single-spaces. // // So, for example, `text\ntext` is one paragraph, `text\n text` is two paragraphs. Since for // our purposes we don't want any indentation of paragraphs, the output of these two examples // should be `text text` and `text\n\ntext`, respectively. // // This is a pretty gnarly! Might be easier in a multi-pass model: collect together each paragraph, // collapse them into single strings and format in a sub-function, then merge them all together. // That's closer to how the perl module does it. func wordWrap(text []string, column int) ([]string, int) { out := []string{} length := 0 var b strings.Builder var inParagraph bool for beginning, line := range text { // remove control character whitespace line = strings.ReplaceAll(line, "\t", " ") line = strings.ReplaceAll(line, "\v", " ") line = strings.ReplaceAll(line, "\f", " ") line = strings.ReplaceAll(line, "\r", " ") words := strings.Split(line, " ") // skip empty newlines if not in a paragraph, but start a new paragraph if we are. if strings.TrimSpace(line) == "" { if inParagraph { length = max(length, b.Len()-1) out = append(out, b.String()[0:b.Len()-1]) out = append(out, "") b = strings.Builder{} inParagraph = false } continue } // we -1 all over the place to clean up the loose space on the end of the builder for i, word := range words { // split into words if DEBUG { log.Printf("%d, `%s`", i, b.String()) } if inParagraph && i == 0 && word == "" && len(words) != 0 { // we've found a new paragraph while we were still processing the old one. // (that is, the new line we're parsing had leading whitespace) length = max(length, b.Len()-1) out = append(out, b.String()[0:b.Len()-1]) out = append(out, "") b = strings.Builder{} log.Println("here") } // bizarrely, cowsay allows for indentation to survive in the /first/ paragraph, // but only up to two spaces worth. if beginning == 0 && (b.Len() == 0 || b.String() == " ") && word == "" { b.WriteRune(' ') } if b.Len() == 0 && word != "" { inParagraph = true } if b.Len() >= 0 && word == "" && i+1 != len(words) { // skip multiple spaces in a row... // ...but a single trailing space on a line is OK?? continue } if b.Len() > 0 && b.Len()+len(word)+1 > column { // if the word we want to append is bigger than we have room for, // save off the maximum length, save the current line to the output, // and start a new builder. length = max(length, b.Len()-1) out = append(out, b.String()[0:b.Len()-1]) b = strings.Builder{} } for b.Len() == 0 && len(word) >= column { // our word is longer than our maximum column size. let's break it up. // we loop until we've consumed the full overly-long word. length = max(length, column-1) out = append(out, word[0:column-1]) word = word[column-1:] b = strings.Builder{} } // actually append the word and a space b.WriteString(word) b.WriteRune(' ') if DEBUG { log.Printf("%d, `%s`", i, b.String()) } } } // out of words! save off our last line. length = max(length, b.Len()-1) out = append(out, b.String()[0:b.Len()-1]) return out, length } func padText(text []string, maxLength int) []string { for i, line := range text { if len(line) < maxLength { text[i] = text[i] + strings.Repeat(" ", maxLength-len(line)) } } return text } func bubble(text string, think bool, column int, offset int) string { s, column := wordWrap(strings.Split(text, "\n"), column) s = padText(s, column) b := strings.Builder{} padding := strings.Repeat(" ", offset) b.WriteString(padding) b.WriteString(" ") b.WriteString(strings.Repeat("_", column+2)) b.WriteString(" \n") var borderStyle []rune if think { borderStyle = THINK_BORDER } else if len(s) == 1 { borderStyle = SINGLE_BORDER } else { borderStyle = SAY_BORDER } for i := range s { b.WriteString(padding) if i == 0 { b.WriteRune(borderStyle[0]) } else if i == len(s)-1 { b.WriteRune(borderStyle[4]) } else { b.WriteRune(borderStyle[2]) } b.WriteRune(' ') b.WriteString(s[i]) b.WriteRune(' ') if i == 0 { b.WriteRune(borderStyle[1]) } else if i == len(s)-1 { b.WriteRune(borderStyle[5]) } else { b.WriteRune(borderStyle[3]) } b.WriteRune('\n') } b.WriteString(padding) b.WriteString(" ") b.WriteString(strings.Repeat("-", column+2)) b.WriteString(" ") return b.String() }