๐ŸŒˆ Building Command Line Tools with Go: Lolcat ๐Ÿฆ„

Lolcat in Go: With more Rainbows and Unicorns!

ยท

6 min read

๐ŸŒˆ Building Command Line Tools with Go:  Lolcat ๐Ÿฆ„

Hey everyone ๐Ÿ‘‹

This is UltiRequiem. I'm a 15-year-old, Peruvian ๐Ÿ‡ต๐Ÿ‡ช, full-stack web and system developer, and I have the intention to become a successful developer.

I'm a big fan of CLI Tools, I have a lot of them hosted on GitHub.

So naturally, when I started to learn Go (also known as Golang), I wanted to do CLI tools in Go too.

I consider myself a "Linux Rice Master", and as one I know a lot of terminal utilities, one of my favorites is lolcat.

Today I will teach you how to do it on Go! ๐Ÿฑ

I'm Ready GIF

Result

You can watch the final result to motivate yourself, or leave it for the end if you want!

github.com/UltiRequiem/chigo

Our Clone Features

  • Parse from stdin

  • Parse from file(s)

  • Help Menu

Starting the project

File Structure

When I'm starting a project I like to do follow good practices from the start ๐ŸŽ“

  • Create a new directory where you will store the source code!
mkdir chigo

You can change Chigo for another thing if you want!

go mod init github.com/UltiRequiem/chigo

Change UltiRequiem by your name, chigo by what you previously put, and github for gitlab or your preferred code hosting service!

Inside chigo/ create some other directories to split correctly the code

mkdir internal pkg cmd

Create some files

touch pkg/root.go internal/root.go cmd/root.go

You should have a structure like this

.
โ”œโ”€โ”€ cmd
โ”‚   โ””โ”€โ”€ root.go
โ”œโ”€โ”€ go.mod
โ”œโ”€โ”€ internal
โ”‚   โ””โ”€โ”€ root.go
โ””โ”€โ”€ pkg
    โ””โ”€โ”€ root.go

Let me explain these directories ๐Ÿ‘‡

  • pkg/: Here we will put what we think can be re-utilized in other projects.

eg. a function that generates RGB

  • internal/: Internal logic of the program that is not intended to be used somewhere else.

  • cmd/: Here we will call all the logic from internal/ and setup the CLI Tool

If you are using Windows, or want to give Windows Support in your version, check the Adding Windows Support section!

Understanding the workflow

Let's write some pseudo to understand how or the program will work ๐Ÿ‘‡

from "internal" import printFromStdin, printWithColors

help, fileArguments, files = parametersAndFlags()

if help {
   printHelp()
   exit()
}

if fileArguments {
   filesText = joinFiles(files)
   printWitColors(filesText)
   exit()
}

printFromStdin()

Knowing this we can start to code!

Writing Code

๐Ÿ“ฆ pkg/

In our root.go file, let's make a function that generates an RGB coding receiving one number.

This was my first Idea ๐Ÿ‘‡

package chigo

import "math"

func RGB(i int) (red int, green int, blue int) {
    red = int(math.Sin(0.1*float64(i)+0)*127 + 128)
    green = int(math.Sin(0.1*float64(i)+2*math.Pi/3)*127 + 128)
    blue = int(math.Sin(0.1*float64(i)+4*math.Pi/3)*127 + 128)
    return
}

Here I'm using "named return values"

This would work well, but I would prefer using some OOP ๐Ÿ’ช

package chigo

import "math"

type RGB struct {
    Red, Green, Blue int
}

func NewRGB(i float64) RGB {
    return RGB{
        int(math.Sin(0.1*i)*127 + 128),
        int(math.Sin(0.1*i+2*math.Pi/3)*127 + 128),
        int(math.Sin(0.1*i+4*math.Pi/3)*127 + 128),
    }
}

This is just a personal preference, but I think it will make more readable the parts where I'm calling this function.

๐Ÿ™ˆ internal/

Remember this part of the pseudo code?

if fileArguments {
   filesText = joinFiles(files)
   printWitColors(filesText)
   exit()
}

First, we need a function that receives a list of files and gets the text from them ๐Ÿ“–

package internal

import "os"

func JoinFiles(files []string) (string, error) {
    text := ""

    for _, file := range files {
        fileText, err := os.ReadFile(file)

        if err != nil {
            return "", err
        }

        text += string(fileText) + "\n"
    }

    return text, nil
}

While this could be on the internal/root.go file I prefer to put it in internal/joinFiles.go because is the unique part when we are doing something related to files.

Now let's write the functions to print with colors text and stdin

package internal

import (
    "bufio"
    "fmt"
    "os"
    "strings"

    chigo "github.com/UltiRequiem/chigo/pkg"
)

func PrintWithScanner(text string) {
    scanner := bufio.NewScanner(strings.NewReader(text))

    for i := 1.0; scanner.Scan(); i++ {
        rgb := chigo.NewRGB(i)
        fmt.Printf("\033[38;2;%d;%d;%dm%s\033[0m\n", rgb.Red, rgb.Green, rgb.Blue, scanner.Text())
    }
}

func StartProcessFromStdin() {
    reader := bufio.NewReader(os.Stdin)

    for i := 1.0; true; i++ {
        input, _, err := reader.ReadRune()

        if err != nil {
            PrintWithScanner(err.Error())
            break
        }

        rgb := chigo.NewRGB(i)

        fmt.Printf("\033[38;2;%d;%d;%dm%s\033[0m", rgb.Red, rgb.Green, rgb.Blue, string(input))
    }
}

๐Ÿ–ฅ cmd/

Let's get the parameters and flags ๐Ÿดโ€โ˜ ๏ธ

package cmd

import (
    "flag"
)

func parametersAndFlags() (bool, bool, []string) {
    help := flag.Bool("help", false, "Display Help")
    helpShort := flag.Bool("h", false, "Display Help")

    flag.Parse()

    return *help || *helpShort, flag.NArg() > 0, flag.Args()
}

Also lets add a pretty help function ๐Ÿ’„

package cmd

import (
    "flag"
    "fmt"

    "github.com/UltiRequiem/chigo/internal"
)

func parametersAndFlags() (bool, bool, []string) {
    help := flag.Bool("help", false, "Display Help")
    helpShort := flag.Bool("h", false, "Display Help")

    flag.Usage = printHelp

    flag.Parse()

    return *help || *helpShort, flag.NArg() > 0, flag.Args()
}

func printHelp() {
    internal.PrintWithScanner(fmt.Sprintf(HELP_MESSAGE, VERSION))
}

const VERSION = "1.0.0"

const HELP_MESSAGE = `Chigo %s
Concatenate FILE(s) or standard input to standard output.
When no FILE is passed read standard input.

 Examples:
   chigo fOne fTwo           # Output fOne and fTwo contents.
   chigo                     # Copy standard input to standard output.
   echo "My Message" | chigo # Display "My message".
   fortune | chigo           # Display a rainbow cookie.

If you need more help, found a bug or want to suggest a new feature:
https://github.com/UltiRequiem/chigo`

While you can put all this part on cmd/root.go I decided to put it on cmd/parameters.go just to modularize the code ๐Ÿ‡น๐Ÿ‡ฐ

Now let's call all our functions to see the result of our work! ๐Ÿ˜Ž

package cmd

import (
    "github.com/UltiRequiem/chigo/internal"
)

func Main() {
    help, fileArguments, files := parametersAndFlags()

    if help {
        printHelp()
        return
    }

    if fileArguments {
        data, error := internal.JoinFiles(files)

        if error != nil {
            internal.PrintWithScanner(error.Error())
            return
        }

        internal.PrintWithScanner(data)
        return
    }

    internal.StartProcessFromStdin()
}

You cannot run this yet, to do it create a file main.go in the root of your project

touch main.go

And here we will simply import and call the Main function from cmd/

package main

import "github.com/UltiRequiem/chigo/cmd"

func main() {
    cmd.Main()
}

This is made so the user can

go install github.com/UltiRequiem/chigo@latest

Instead of

go install github.com/UltiRequiem/chigo/cmd@latest

If you don't care about this just rename the Main function of cmd/root.go to main ๐Ÿ‘€

Adding Windows Support ๐ŸชŸ

To support Windows, some extra work is needed ๐Ÿ˜ฉ

Fortunately, The great mattn, core Go contributor, made a pull request with all the work done ๐Ÿš€

github.com/UltiRequiem/chigo/pull/1

All you need to do is add this dependence ๐Ÿ‘‰๐Ÿฝ go-colorable

go get github.com/mattn/go-colorable

Check the pull request to know what you need to do, is just an extra line of code!

package cmd

import (
    "github.com/UltiRequiem/chigo/internal"

    "github.com/mattn/go-colorable"
)

func Main() {
    // Windows Support
    defer colorable.EnableColorsStdout(nil)()

    help, fileArguments, files := parametersAndFlags()

    if help {
        printHelp()
        return
    }

    if fileArguments {
        data, error := internal.JoinFiles(files)

        if error != nil {
            internal.PrintWithScanner(error.Error())
            return
        }

        internal.PrintWithScanner(data)
        return
    }

    internal.StartProcessFromStdin()
}

Notes

Connect with me on GitHub, Twitter, Instagram, Reddit and ProductHunt!

Visit my portfolio to see more of my projects!

Don't forget to follow me on Hashnode too!

Open an Issue on the repository or comment here if you find any errors!

ย