Comment proofreader CLI

I’ve learned a lot by building simple productivity tools. One that I wrote a bit ago and use pretty often is a basic CLI that pulls out single line comments from Go source files so I can proofread whether the lines are clear, words are spelled right, and that there are no stray test comments before I push my code.

I used cobra to build the first version of the CLI, but wanted to write about building a version without it. I think it’s cool to learn how things work without libraries if you can, and having less dependencies is a good goal to have for keeping things simple and maintainable. The tool is also for personal use, so building it bare-bones gives me more freedom.

Let’s gö…

func main() {
	// Example call: proofreader main.go 
}

To start out, I created a main.go file with a main function and added a plan for how I wanted to call the CLI. Most simply, I want to be able to call a binary named proofreader with a file name to see the single line comments it contains.

func main() {

	// Example call: proofreader main.go 
    
	if len(os.Args) < 2 {
		log.Fatal("please name a file to proofread comments for")
	}

	fname := os.Args[1]
}

We’ll get the first command line argument passed in by accessing os.Args. We get the item at index 1 because os.Args[0] will always contain the path to the program. We check that there is at least one argument to avoid a panic when accessing os.Args[1] if no file name is given.

If you add fmt.Println(fname) and run the program with go run main.go someargument, you’ll be able to see the name of the first argument passed in, someargument, or an error if you pass in nothing.

func main() {

	// Example call: proofreader main.go 
    
    if len(os.Args) < 2 {
        log.Fatal("please name a file to proofread comments for")
    }

    fname := os.Args[1]
    
    var fb []byte
    if fb, err = ioutil.ReadFile(fname); nil != err {
        log.Fatalf("error reading file with name %s: %s\n", fname, err)
    }
}

After getting the file name, it’s time to get the bytes for the file. If the file isn’t found from the path or name passed in, we’ll exit out with log.Fatalf and print the error. ioutil’s ReadFile is a nifty method for getting the bytes of a file all in one step. There’s no need to defer closing the file like we would if we used os.Open because we can see from the code for the method that closing is handled for us.

You can build the executable in the current directory by using go build -o proofreader (the “o” flag lets you specify output location). If you add fmt.Println(string(fb)) and call the executable on the source code for the main file using ./proofreader main.go, you should see your source code printed to standard output. If you have modules enabled, you may need to run go mod init before building so your code compiles.

Using go run main.go main.go to do the same thing won’t work here because go run takes multiple arguments and will interpret the second argument as a file with the same name you also want to run and throw an error.

func main() {

    // Example call: proofreader main.go
    
    if len(os.Args) < 2 {
        log.Fatal("please name a file to proofread comments for")
    }

    fname := os.Args[1]
    
    var fb []byte
    if fb, err = ioutil.ReadFile(fname); nil != err {
        log.Fatalf("error reading file with name %s: %s\n", fname, err)
    }

    lines := strings.Split(string(fb), "\n")
    
    var comments []string
    for i, ln := range lines {
        ln = strings.TrimSpace(ln)
        if strings.HasPrefix(ln, "//") {
            c := fmt.Sprintf("%s: %d %s", fname, i+1, ln)
            comments = append(comments, c)
        }
    }
}

Next, we want to get the comments from the source code lines. This code first breaks up the file into individual lines with strings.Split, with the newline string as the separator. The comments slice will hold the lines from the code that are considered to be comments.

We then range over each line, deciding which to append to the slice. The criteria for a line here is that, after leading and trailing space is trimmed, the line is prefixed with //, the syntax for single line comments in Go.

Before adding the comment line to the slice, some information is added using fmt.Sprintf. This includes the name of the file, the line number (offset by 1 because lines in a file start with 1 vs the variable i which starts at 0), and the comment line.

You can add fmt.Println(comments) and rebuild with go build -o proofreader to check everything is building, and use ./proofreader main.go to see that // Example call: proofreader main.go and any other comments are logged to stdout.

func main() {

    // Example call: proofreader main.go
    
    if len(os.Args) < 2 {
        log.Fatal("please name a file to proofread comments for")
    }

    fname := os.Args[1]
    
    var fb []byte
    if fb, err = ioutil.ReadFile(fname); nil != err {
        log.Fatalf("error reading file with name %s: %s\n", fname, err)
    }

    lines := strings.Split(string(fb), "\n")
    
    var comments []string
    for i, ln := range lines {
        ln = strings.TrimSpace(ln)
        if strings.HasPrefix(ln, "//") {
            c := fmt.Sprintf("%s: %d %s", fname, i+1, ln)
            comments = append(comments, c)
        }
    }

    if len(comments) == 0 {
		fmt.Printf("no comments found for file %s", fname)
		return
	}

    fmt.Printf(`
    found %d comments in %d lines of code, logging comments for proofreading
    `, len(comments), len(lines),
    )

	for _, c := range comments {
		fmt.Println(c)
	}
}

The last step to complete the basic version of the CLI checks that we found comments, logs out that none were found if we didn’t, adds some logging about how many total lines were searched, and also how many comments were found. The final range statement ranges over the comments and logs them out one by one.

Running go install in the project directory will install the program globally so that calling proofreader from any directory will log out the single line Go comments found in the input file. Note that go install doesn’t accept the “o” flag, so if you want control over the name you can change your package directory name, and if you want control over the output location you can run go build -o $GOPATH/bin/proofreader or wherever else you’d like the binary to go.

Accepting multiple input files

func main() {
	var err error

	// Example call: proofreader main.go

	if len(os.Args) < 2 {
		log.Fatal("please name a file to proofread comments for")
	}

	fnames := os.Args[1:]

	for _, fname := range fnames {
		var fb []byte
		if fb, err = ioutil.ReadFile(fname); nil != err {
			fmt.Printf("error reading file with name %s: %s, continuing", fname, err)
			continue
		}

		lines := strings.Split(string(fb), "\n")

		var comments []string
		for i, ln := range lines {
			ln = strings.TrimSpace(ln)
			if strings.HasPrefix(ln, "//") {
				c := fmt.Sprintf("%s:%d %s", fname, i+1, ln)
				comments = append(comments, c)
			}
		}

		if len(comments) == 0 {
			fmt.Printf("no comments found for file %s", fname)
			continue
		}

		fmt.Printf(`
found %d comments in %d lines of code for file %s, logging comments for proofreading
    `, len(comments), len(lines), fname,
		)
		for _, c := range comments {
			fmt.Println(c)
		}
	}
}

To add multiple files, we now get all os.Args after the first with os.Arg[1:] and range over them. Another change from the last version of the code to this one is to continue on to next file if there’s an error reading the file (we could get more specific here if we want, as it would probably be best to only continue on not found errors) or if no comments are found. Some of the logs have also been modified to note which file we’re looking at, because now that we’re looking at multiple files it isn’t clear otherwise.

Linking to a 3rd party service

    fmt.Println("*spell check service: https://grademiners.com/spell-checker")

A minimal change: it’s literally just a link to a 3rd party service right at the end of the program. I chose this site because I like being able to copy and paste all of the single line comments into a textarea which adds underlining where the spell checker suspects a mistake was made.

Initially when I made the CLI I used the spell check website every time I ran it. However, I recently read an article (that I really wish I could find and link to) about the importance of being conscious about when we decide to use 3rd party web services with sensitive data, and it hit me pretty hard. So I think logging out the service and giving the option of using the spell checker is an extra option that could save you some time, but maybe shouldn’t be used every single time depending on the type of data you’re working with.

Colorizing output with ANSI codes

if strings.HasPrefix(ln, "//") {
    const (
        ansiEscapeGreen   = "\033[0;32m"
        ansiEscapeNoColor = "\033[0m"
    )
    greenLine := fmt.Sprintf("%s %s %s", ansiEscapeGreen, ln, ansiEscapeNoColor)

    c := fmt.Sprintf("%s:%d %s", fname, i+1, greenLine)
    comments = append(comments, c)
}

This should replace the code where the comment is formatted and added to the slice.

When I first made the CLI I used fatih’s color package to colorize the comment text. This time, I was looking for a really simple way to avoid pulling in that whole extra dependency just to use one color. After some searching, I found information on using ANSI escape codes (platform support). So the code above uses ANSI escape sequences to colorize just the comments in green to make them stand out when they’re logged for proofreading.

To break down the ones used in the code, we’ve got:

ansiEscapeGreen   = "\033[0;32m"

\033[ is the control sequence introducer. \033 is ESC in octal. 0;32 is the code for a green foreground. m specifies that we want to set the graphics mode, which has to do with things like boldness, foreground color (what we’re using it for), and background color.

ansiEscapeNoColor = "\033[0m"

This sequence is structured the same way, with the 0m standing for “reset all attributes” around the graphics mode.

End

and voila, when we install and run the code…

src/scratch/proofreader                                                                                                                                            
▶ proofreader main.go test.go

found 1 comments in 65 lines of code for file main.go, logging comments for proofreading
main.go:14  // Example call: proofreader main.go 

found 4 comments in 23 lines of code for file test.go, logging comments for proofreading
test.go:10  // Here's an example comment.
test.go:11  // This one is beneath it. 
test.go:17  // Sample comment explaining something about 
test.go:18  // the variable below. 

* spell check service: https://grademiners.com/spell-checker

(due to my Very Adequate Hugo Knowledge™ you’ll have to make-believe the green in)

Other possibilities

Some other things you could do, based on your needs: