Skip to content

Commit

Permalink
feat: add graphviz output (#77)
Browse files Browse the repository at this point in the history
This PR adds the functionnality to render a cfg analyis to a graphviz
svg file.

closes #37
  • Loading branch information
0xtekgrinder authored Sep 30, 2024
1 parent 71473fa commit 1a394a8
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 29 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ tlin supports several flags to customize its behavior:
- `-fix`: Automatically fix issues
- `-dry-run`: Run in dry-run mode (show fixes without applying them)
- `-confidence <float>`: Set confidence threshold for auto-fixing (0.0 to 1.0, default: 0.75)
- `-output-json <path>`: Write output in JSON format to the specified file
- `-o <path>`: Write output to a file instead of stdout
- `-json-output`: Output results in JSON format

## Contributing

Expand Down
59 changes: 36 additions & 23 deletions cmd/tlin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ type Config struct {
FuncName string
AutoFix bool
DryRun bool
JsonOutput string
JsonOutput bool
Output string
ConfidenceThreshold float64
}

Expand All @@ -64,19 +65,19 @@ func main() {

if config.CFGAnalysis {
runWithTimeout(ctx, func() {
runCFGAnalysis(ctx, logger, config.Paths, config.FuncName)
runCFGAnalysis(ctx, logger, config.Paths, config.FuncName, config.Output)
})
} else if config.CyclomaticComplexity {
runWithTimeout(ctx, func() {
runCyclomaticComplexityAnalysis(ctx, logger, config.Paths, config.CyclomaticThreshold, config.JsonOutput)
runCyclomaticComplexityAnalysis(ctx, logger, config.Paths, config.CyclomaticThreshold, config.JsonOutput, config.Output)
})
} else if config.AutoFix {
runWithTimeout(ctx, func() {
runAutoFix(ctx, logger, engine, config.Paths, config.DryRun, config.ConfidenceThreshold)
})
} else {
runWithTimeout(ctx, func() {
runNormalLintProcess(ctx, logger, engine, config.Paths, config.JsonOutput)
runNormalLintProcess(ctx, logger, engine, config.Paths, config.JsonOutput, config.Output)
})
}
}
Expand All @@ -92,8 +93,9 @@ func parseFlags(args []string) Config {
flagSet.BoolVar(&config.CFGAnalysis, "cfg", false, "Run control flow graph analysis")
flagSet.StringVar(&config.FuncName, "func", "", "Function name for CFG analysis")
flagSet.BoolVar(&config.AutoFix, "fix", false, "Automatically fix issues")
flagSet.StringVar(&config.Output, "o", "", "Output path")
flagSet.BoolVar(&config.DryRun, "dry-run", false, "Run in dry-run mode (show fixes without applying them)")
flagSet.StringVar(&config.JsonOutput, "json-output", "", "Output issues in JSON format to the specified file")
flagSet.BoolVar(&config.JsonOutput, "json", false, "Output issues in JSON format")
flagSet.Float64Var(&config.ConfidenceThreshold, "confidence", defaultConfidenceThreshold, "Confidence threshold for auto-fixing (0.0 to 1.0)")

err := flagSet.Parse(args)
Expand Down Expand Up @@ -127,21 +129,21 @@ func runWithTimeout(ctx context.Context, f func()) {
}
}

func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, jsonOutput string) {
func runNormalLintProcess(ctx context.Context, logger *zap.Logger, engine lint.LintEngine, paths []string, isJson bool, jsonOutput string) {
issues, err := lint.ProcessFiles(ctx, logger, engine, paths, lint.ProcessFile)
if err != nil {
logger.Error("Error processing files", zap.Error(err))
os.Exit(1)
}

printIssues(logger, issues, jsonOutput)
printIssues(logger, issues, isJson, jsonOutput)

if len(issues) > 0 {
os.Exit(1)
}
}

func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, paths []string, threshold int, jsonOutput string) {
func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, paths []string, threshold int, isJson bool, jsonOutput string) {
issues, err := lint.ProcessFiles(ctx, logger, nil, paths, func(_ lint.LintEngine, path string) ([]tt.Issue, error) {
return lint.ProcessCyclomaticComplexity(path, threshold)
})
Expand All @@ -150,14 +152,14 @@ func runCyclomaticComplexityAnalysis(ctx context.Context, logger *zap.Logger, pa
os.Exit(1)
}

printIssues(logger, issues, jsonOutput)
printIssues(logger, issues, isJson, jsonOutput)

if len(issues) > 0 {
os.Exit(1)
}
}

func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcName string) {
func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcName string, output string) {
functionFound := false
for _, path := range paths {
fset := token.NewFileSet()
Expand All @@ -173,7 +175,14 @@ func runCFGAnalysis(_ context.Context, logger *zap.Logger, paths []string, funcN
cfgGraph := cfg.FromFunc(fn)
var buf strings.Builder
cfgGraph.PrintDot(&buf, fset, func(n ast.Stmt) string { return "" })
fmt.Printf("CFG for function %s in file %s:\n%s\n", funcName, path, buf.String())
if output != "" {
err := cfg.RenderToGraphVizFile([]byte(buf.String()), output)
if err != nil {
logger.Error("Failed to render CFG to GraphViz file", zap.Error(err))
}
} else {
fmt.Printf("CFG for function %s in file %s:\n%s\n", funcName, path, buf.String())
}
functionFound = true
return
}
Expand Down Expand Up @@ -203,7 +212,7 @@ func runAutoFix(ctx context.Context, logger *zap.Logger, engine lint.LintEngine,
}
}

func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) {
func printIssues(logger *zap.Logger, issues []tt.Issue, isJson bool, jsonOutput string) {
issuesByFile := make(map[string][]tt.Issue)
for _, issue := range issues {
issuesByFile[issue.Filename] = append(issuesByFile[issue.Filename], issue)
Expand All @@ -215,7 +224,7 @@ func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) {
}
sort.Strings(sortedFiles)

if jsonOutput == "" {
if !isJson {
for _, filename := range sortedFiles {
fileIssues := issuesByFile[filename]
sourceCode, err := internal.ReadSourceCode(filename)
Expand All @@ -232,16 +241,20 @@ func printIssues(logger *zap.Logger, issues []tt.Issue, jsonOutput string) {
logger.Error("Error marshalling issues to JSON", zap.Error(err))
return
}
f, err := os.Create(jsonOutput)
if err != nil {
logger.Error("Error creating JSON output file", zap.Error(err))
return
}
defer f.Close()
_, err = f.Write(d)
if err != nil {
logger.Error("Error writing JSON output file", zap.Error(err))
return
if jsonOutput == "" {
fmt.Println(string(d))
} else {
f, err := os.Create(jsonOutput)
if err != nil {
logger.Error("Error creating JSON output file", zap.Error(err))
return
}
defer f.Close()
_, err = f.Write(d)
if err != nil {
logger.Error("Error writing JSON output file", zap.Error(err))
return
}
}
}
}
20 changes: 15 additions & 5 deletions cmd/tlin/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,19 @@ func TestParseFlags(t *testing.T) {
},
{
name: "JsonOutput",
args: []string{"-json-output", "output.json", "file.go"},
args: []string{"-json", "file.go"},
expected: Config{
Paths: []string{"file.go"},
JsonOutput: "output.json",
JsonOutput: true,
ConfidenceThreshold: defaultConfidenceThreshold,
},
},
{
name: "Output",
args: []string{"-o", "output.svg", "file.go"},
expected: Config{
Paths: []string{"file.go"},
Output: "output.svg",
ConfidenceThreshold: defaultConfidenceThreshold,
},
},
Expand All @@ -101,6 +110,7 @@ func TestParseFlags(t *testing.T) {
assert.Equal(t, tt.expected.ConfidenceThreshold, config.ConfidenceThreshold)
assert.Equal(t, tt.expected.Paths, config.Paths)
assert.Equal(t, tt.expected.JsonOutput, config.JsonOutput)
assert.Equal(t, tt.expected.Output, config.Output)
})
}
}
Expand Down Expand Up @@ -159,7 +169,7 @@ func ignoredFunc() { // 19
ctx := context.Background()

output := captureOutput(t, func() {
runCFGAnalysis(ctx, logger, []string{tempFile}, "targetFunc")
runCFGAnalysis(ctx, logger, []string{tempFile}, "targetFunc", "")
})

assert.Contains(t, output, "CFG for function targetFunc in file")
Expand All @@ -172,7 +182,7 @@ func ignoredFunc() { // 19
t.Logf("output: %s", output)

output = captureOutput(t, func() {
runCFGAnalysis(ctx, logger, []string{tempFile}, "nonExistentFunc")
runCFGAnalysis(ctx, logger, []string{tempFile}, "nonExistentFunc", "")
})

assert.Contains(t, output, "Function not found: nonExistentFunc")
Expand Down Expand Up @@ -307,7 +317,7 @@ func TestRunJsonOutput(t *testing.T) {
mockEngine := setupMockEngine(expectedIssues, testFile)

jsonOutput := filepath.Join(tempDir, "output.json")
runNormalLintProcess(ctx, logger, mockEngine, []string{testFile}, jsonOutput)
runNormalLintProcess(ctx, logger, mockEngine, []string{testFile}, true, jsonOutput)
}

func createTempFiles(t *testing.T, dir string, fileNames ...string) []string {
Expand Down
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ require (
)

require (
github.com/fogleman/gg v1.3.0 // indirect
github.com/goccy/go-graphviz v0.1.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/goccy/go-graphviz v0.1.3 h1:Pkt8y4FBnBNI9tfSobpoN5qy1qMNqRXPQYvLhaSUasY=
github.com/goccy/go-graphviz v0.1.3/go.mod h1:pMYpbAqJT10V8dzV1JN/g/wUlG/0imKPzn3ZsrchGCI=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
Expand All @@ -21,6 +29,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
Expand Down
16 changes: 16 additions & 0 deletions internal/analysis/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sort"
"strings"

"github.com/goccy/go-graphviz"
"golang.org/x/tools/go/ast/astutil"
)

Expand Down Expand Up @@ -49,6 +50,21 @@ func FromFunc(f *ast.FuncDecl) *CFG {
return NewBuilder().BuildFromFunc(f)
}

// RenderToGraphVizFile renders the given DOT content to a GraphViz file.
func RenderToGraphVizFile(dotContent []byte, filename string) error {
graph, err := graphviz.ParseBytes(dotContent)
if err != nil {
return err
}
g := graphviz.New()
defer g.Close()
err = g.RenderFilename(graph, graphviz.SVG, filename)
if err != nil {
return err
}
return nil
}

func AnalyzeFunction(file *ast.File, fname string) *CFG {
for _, decl := range file.Decls {
if funcDecl, ok := decl.(*ast.FuncDecl); ok {
Expand Down
38 changes: 38 additions & 0 deletions internal/analysis/cfg/cfg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import (
"go/parser"
"go/token"
"go/types"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestFromStmts(t *testing.T) {
Expand Down Expand Up @@ -991,6 +995,40 @@ splines="ortho";
}
}

func TestRenderToGraphVizFile(t *testing.T) {
t.Parallel()
tmpDir, err := os.MkdirTemp("", "cfg_test")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)

c := getWrapper(t, `
package main
func main() {
i := 5 //1
i++ //2
}`)

var buf bytes.Buffer
c.cfg.PrintDot(&buf, c.fset, func(s ast.Stmt) string {
if _, ok := s.(*ast.AssignStmt); ok {
return "!"
} else {
return ""
}
})
dot := buf.String()

svgFile := filepath.Join(tmpDir, "test.svg")
err = RenderToGraphVizFile([]byte(dot), svgFile)
assert.NoError(t, err)

content, err := os.ReadFile(svgFile)
assert.NoError(t, err)

assert.NotEmpty(t, content)
}

func normalizeDotOutput(dot string) string {
lines := strings.Split(dot, "\n")
var normalized []string
Expand Down

0 comments on commit 1a394a8

Please sign in to comment.