Skip to content

Commit

Permalink
feat: Add native linter and formatter (#522)
Browse files Browse the repository at this point in the history
* feat: Add lint command to pixlet.

This commit adds a lint command to pixlet that wraps buildifier. Right
now, it works as a drop in replacement for buildifier, but it's not very
user friendly from a pixlet perspective. I'll follow up with another
commit to make this easier to use from a pixlet lens.

* feat: Break apart lint and format.

This commit splits linting and formatting into two commands and cleans
up all of the buildifier flags so that it's easier to use in pixlet.

* Clean up lint command and interface.

This commit cleans up the lint command, help, and examples to behave as
discussed in Discord.

* Clean up format command and interface.

This commit cleans up the format command and interface to behave has we
disscussed in Discord.

* Update format to output.

When I was typing these commands, I felt the urge to add `-o json`
instead of --format. Given it felt more natural to me, I updated the
command arg.

* Fix warnings list to include all warnings.

This commit updates the linter to include all warnings. I originally
left this out by mistake, so this is a bug fix. We may consider a
different list here in the future but it will require more testing.

* Drop output for format.

The output format doesn't really make sense for formatting. As a pixlet
user, I only care about formatting or a diff. The json format only
spits out the files processed, which I don't really care about at this
time.

* Fix formatting when fix flag is set.

When linting, we need to fix the formatting in order to also fix the
resolvable lint issues. This commit ensures we do both if the --fix flag
is set.

* Set default warning list.

This commit sets up a default warning list with a disabledWarnings map
to be able to control which warnings we enable.
  • Loading branch information
betterengineering authored Jan 9, 2023
1 parent 5b78d95 commit 41c811f
Show file tree
Hide file tree
Showing 6 changed files with 394 additions and 0 deletions.
278 changes: 278 additions & 0 deletions cmd/buildifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/*
Copyright 2016 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package cmd

import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"runtime"

"github.com/bazelbuild/buildtools/build"
"github.com/bazelbuild/buildtools/buildifier/utils"
"github.com/bazelbuild/buildtools/differ"
"github.com/bazelbuild/buildtools/warn"
"github.com/bazelbuild/buildtools/wspace"
)

var (
vflag bool
rflag bool
dryRunFlag bool
fixFlag bool
outputFormat string
)

func runBuildifier(args []string, lint string, mode string, format string, recursive bool, verbose bool) int {
tf := &utils.TempFile{}
defer tf.Clean()

exitCode := 0
var diagnostics *utils.Diagnostics
if len(args) == 0 || (len(args) == 1 && (args)[0] == "-") {
// Read from stdin, write to stdout.
data, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "buildifier: reading stdin: %v\n", err)
return 2
}
if mode == "fix" {
mode = "pipe"
}
var fileDiagnostics *utils.FileDiagnostics
fileDiagnostics, exitCode = processFile("", data, lint, false, tf, mode, verbose)
diagnostics = utils.NewDiagnostics(fileDiagnostics)
} else {
files := args
if recursive {
var err error
files, err = utils.ExpandDirectories(&args)
if err != nil {
fmt.Fprintf(os.Stderr, "buildifier: %v\n", err)
return 3
}
}
diagnostics, exitCode = processFiles(files, lint, tf, mode, verbose)
}

diagnosticsOutput := diagnostics.Format(format, verbose)
if format != "" {
// Explicitly provided --format means the diagnostics are printed to stdout
fmt.Printf(diagnosticsOutput)
// Exit code should be set to 0 so that other tools know they can safely parse the json
exitCode = 0
} else {
// --format is not provided, stdout is reserved for file contents
fmt.Fprint(os.Stderr, diagnosticsOutput)
}

if err := diff.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
return 2
}

return exitCode
}

func processFiles(files []string, lint string, tf *utils.TempFile, mode string, verbose bool) (*utils.Diagnostics, int) {
// Decide how many file reads to run in parallel.
// At most 100, and at most one per 10 input files.
nworker := 100
if n := (len(files) + 9) / 10; nworker > n {
nworker = n
}
runtime.GOMAXPROCS(nworker + 1)

// Start nworker workers reading stripes of the input
// argument list and sending the resulting data on
// separate channels. file[k] is read by worker k%nworker
// and delivered on ch[k%nworker].
type result struct {
file string
data []byte
err error
}

ch := make([]chan result, nworker)
for i := 0; i < nworker; i++ {
ch[i] = make(chan result, 1)
go func(i int) {
for j := i; j < len(files); j += nworker {
file := files[j]
data, err := os.ReadFile(file)
ch[i] <- result{file, data, err}
}
}(i)
}

exitCode := 0
fileDiagnostics := []*utils.FileDiagnostics{}

// Process files. The processing still runs in a single goroutine
// in sequence. Only the reading of the files has been parallelized.
// The goal is to optimize for runs where most files are already
// formatted correctly, so that reading is the bulk of the I/O.
for i, file := range files {
res := <-ch[i%nworker]
if res.file != file {
fmt.Fprintf(os.Stderr, "buildifier: internal phase error: got %s for %s", res.file, file)
os.Exit(3)
}
if res.err != nil {
fmt.Fprintf(os.Stderr, "buildifier: %v\n", res.err)
exitCode = 3
continue
}
fd, newExitCode := processFile(file, res.data, lint, len(files) > 1, tf, mode, verbose)
if fd != nil {
fileDiagnostics = append(fileDiagnostics, fd)
}
if newExitCode != 0 {
exitCode = newExitCode
}
}
return utils.NewDiagnostics(fileDiagnostics...), exitCode
}

// diff is the differ to use when *mode == "diff".
var diff *differ.Differ

func defaultWarnings() []string {
warnings := []string{}
for _, warning := range warn.AllWarnings {
if !disabledWarnings[warning] {
warnings = append(warnings, warning)
}
}
return warnings
}

var disabledWarnings = map[string]bool{
"function-docstring": true, // disables docstring warnings
"function-docstring-header": true, // disables docstring warnings
"function-docstring-args": true, // disables docstring warnings
"function-docstring-return": true, // disables docstring warnings
"native-android": true, // disables native android rules
"native-cc": true, // disables native cc rules
"native-java": true, // disables native java rules
"native-proto": true, // disables native proto rules
"native-py": true, // disables native python rules
}

// processFile processes a single file containing data.
// It has been read from filename and should be written back if fixing.
func processFile(filename string, data []byte, lint string, displayFileNames bool, tf *utils.TempFile, mode string, verbose bool) (*utils.FileDiagnostics, int) {
var exitCode int

displayFilename := filename
parser := utils.GetParser("auto")

f, err := parser(displayFilename, data)
if err != nil {
// Do not use buildifier: prefix on this error.
// Since it is a parse error, it begins with file:line:
// and we want that to be the first thing in the error.
fmt.Fprintf(os.Stderr, "%v\n", err)
if exitCode < 1 {
exitCode = 1
}
return utils.InvalidFileDiagnostics(displayFilename), exitCode
}

if absoluteFilename, err := filepath.Abs(displayFilename); err == nil {
f.WorkspaceRoot, f.Pkg, f.Label = wspace.SplitFilePath(absoluteFilename)
}

enabledWarnings := defaultWarnings()
warnings := utils.Lint(f, lint, &enabledWarnings, verbose)
if len(warnings) > 0 {
exitCode = 4
}
fileDiagnostics := utils.NewFileDiagnostics(f.DisplayPath(), warnings)

ndata := build.Format(f)

switch mode {
case "check":
// check mode: print names of files that need formatting.
if !bytes.Equal(data, ndata) {
fileDiagnostics.Formatted = false
return fileDiagnostics, 4
}

case "diff":
// diff mode: run diff on old and new.
if bytes.Equal(data, ndata) {
return fileDiagnostics, exitCode
}
outfile, err := tf.WriteTemp(ndata)
if err != nil {
fmt.Fprintf(os.Stderr, "buildifier: %v\n", err)
return fileDiagnostics, 3
}
infile := filename
if filename == "" {
// data was read from standard filename.
// Write it to a temporary file so diff can read it.
infile, err = tf.WriteTemp(data)
if err != nil {
fmt.Fprintf(os.Stderr, "buildifier: %v\n", err)
return fileDiagnostics, 3
}
}
if displayFileNames {
fmt.Fprintf(os.Stderr, "%v:\n", f.DisplayPath())
}
if err := diff.Show(infile, outfile); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
return fileDiagnostics, 4
}

case "pipe":
// pipe mode - reading from stdin, writing to stdout.
// ("pipe" is not from the command line; it is set above in main.)
os.Stdout.Write(ndata)

case "fix":
// fix mode: update files in place as needed.
if bytes.Equal(data, ndata) {
return fileDiagnostics, exitCode
}

err := os.WriteFile(filename, ndata, 0666)
if err != nil {
fmt.Fprintf(os.Stderr, "buildifier: %s\n", err)
return fileDiagnostics, 3
}

if verbose {
fmt.Fprintf(os.Stderr, "fixed %s\n", f.DisplayPath())
}
case "print_if_changed":
if bytes.Equal(data, ndata) {
return fileDiagnostics, exitCode
}

if _, err := os.Stdout.Write(ndata); err != nil {
fmt.Fprintf(os.Stderr, "buildifier: error writing output: %v\n", err)
return fileDiagnostics, 3
}
}
return fileDiagnostics, exitCode
}
51 changes: 51 additions & 0 deletions cmd/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"os"

"github.com/bazelbuild/buildtools/differ"
"github.com/spf13/cobra"
)

func init() {
FormatCmd.Flags().BoolVarP(&vflag, "verbose", "v", false, "print verbose information to standard error")
FormatCmd.Flags().BoolVarP(&rflag, "recursive", "r", false, "find starlark files recursively")
FormatCmd.Flags().BoolVarP(&dryRunFlag, "dry-run", "d", false, "display a diff of formatting changes without modification")
}

var FormatCmd = &cobra.Command{
Use: "format <pathspec>...",
Short: "Formats Tidbyt apps",
Example: ` pixlet format app.star
pixlet format app.star --dry-run
pixlet format --recursive ./`,
Long: `The format command provides a code formatter for Tidbyt apps. By default, it
will format your starlark source code in line. If you wish you see the output
before applying, add the --dry-run flag.`,
Args: cobra.MinimumNArgs(1),
Run: formatCmd,
}

func formatCmd(cmd *cobra.Command, args []string) {
// Lint refers to the lint mode for buildifier, with the options being off,
// warn, or fix. For pixlet format, we don't want to lint at all.
lint := "off"

// Mode refers to formatting mode for buildifier, with the options being
// check, diff, or fix. For the pixlet format command, we want to fix the
// resolvable issue by default and provide a dry run flag to be able to
// diff the changes before fixing them.
mode := "fix"
if dryRunFlag {
mode = "diff"
}

// Copied from the buildifier source, we need to supply a diff program for
// the differ.
differ, _ := differ.Find()
diff = differ

// Run buildifier and exit with the returned exit code.
exitCode := runBuildifier(args, lint, mode, "text", rflag, vflag)
os.Exit(exitCode)
}
59 changes: 59 additions & 0 deletions cmd/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cmd

import (
"os"

"github.com/bazelbuild/buildtools/differ"
"github.com/spf13/cobra"
)

func init() {
LintCmd.Flags().BoolVarP(&vflag, "verbose", "v", false, "print verbose information to standard error")
LintCmd.Flags().BoolVarP(&rflag, "recursive", "r", false, "find starlark files recursively")
LintCmd.Flags().BoolVarP(&fixFlag, "fix", "f", false, "automatically fix resolvable lint issues")
LintCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "output format, text or json")
}

var LintCmd = &cobra.Command{
Use: "lint <pathspec>...",
Example: ` pixlet lint app.star
pixlet lint --recursive --fix ./`,
Short: "Lints Tidbyt apps",
Long: `The lint command provides a linter for Tidbyt apps. It's capable of linting a
file, a list of files, or directory with the recursive option. Additionally, it
provides an option to automatically fix resolvable linter issues.`,
Args: cobra.MinimumNArgs(1),
Run: lintCmd,
}

func lintCmd(cmd *cobra.Command, args []string) {
// Mode refers to formatting mode for buildifier, with the options being
// check, diff, or fix. For the pixlet lint command, we only want to check
// formatting.
mode := "check"

// Lint refers to the lint mode for buildifier, with the options being off,
// warn, or fix. For pixlet lint, we want to warn by default but offer a
// flag to automatically fix resolvable issues.
lint := "warn"

// If the fix flag is enabled, the lint command should both format and lint.
if fixFlag {
mode = "fix"
lint = "fix"
}

// Copied from the buildifier source, we need to supply a diff program for
// the differ.
differ, _ := differ.Find()
diff = differ

// TODO: We currently offer misspelling protection in the community repo
// for app manifests. We'll want to consider adding additional spelling
// support to pixlet lint to ensure typos in apps don't make it to
// production.

// Run buildifier and exit with the returned exit code.
exitCode := runBuildifier(args, lint, mode, outputFormat, rflag, vflag)
os.Exit(exitCode)
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.19
require (
github.com/Code-Hex/Neo-cowsay/v2 v2.0.4
github.com/antchfx/xmlquery v1.3.13
github.com/bazelbuild/buildtools v0.0.0-20221110131218-762712d8ce3f
github.com/dustin/go-humanize v1.0.0
github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4
github.com/fsnotify/fsnotify v1.6.0
Expand Down
Loading

0 comments on commit 41c811f

Please sign in to comment.