Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

formatter factory #7

Merged
merged 2 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,46 @@ if err != nil {
filtered = append(filtered, newRuleIssues...)
```

3. If necessary, define an output format for the new rule in `internal/print.go`.
3. (Optional) Create a new formatter for the new rule in the `formatter` pacakge.
a. Create a new file named after your lint rule (e.g., `new_rule.go`) in the `formatter` package.

b. Implement the `IssueFormatter` interface for your new rule:

```go
type NewRuleFormatter struct{}

func (f *NewRuleFormatter) Format(
issue internal.Issue,
snippet *internal.SourceCode,
) string {
// Implementation of the formatting logic for the new rule
}
```

c. Add the new formatter to the `GetFormatter` function in `formatter/fmt.go`:

```go
// rule set
const (
// ...
NewRule = "new_rule" // <- define the new rule as constant
)

func GetFormatter(rule string) IssueFormatter {
switch rule {
// ...
case NewRule:
return &NewRuleFormatter{}
default:
return &DefaultFormatter{}
}
}
```

4. If necessary, update the `FormatIssueWithArrow` function in `formatter/fmt.go` to handle any special formatting requirements for your new rule.

By following these steps, you can add new lint rules and ensure they are properly formatted when displayed in the CLI.


## Contributing

Expand Down
3 changes: 2 additions & 1 deletion cmd/tlin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"sort"

"github.com/gnoswap-labs/lint/formatter"
lint "github.com/gnoswap-labs/lint/internal"
)

Expand Down Expand Up @@ -86,7 +87,7 @@ func main() {
fmt.Printf("error reading source file %s: %v\n", filename, err)
continue
}
output := lint.FormatIssuesWithArrows(issues, sourceCode)
output := formatter.FormatIssuesWithArrows(issues, sourceCode)
fmt.Println(output)
}

Expand Down
4 changes: 4 additions & 0 deletions formatter/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Package formatter provides functionality for formatting lint issues
// in a human-readable format. It includes various formatters for different
// types of issues and utility functions for text manipulation.
package formatter
49 changes: 49 additions & 0 deletions formatter/fmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package formatter

import (
"strings"

"github.com/gnoswap-labs/lint/internal"
)

// rule set
const (
UnnecessaryElse = "unnecessary-else"
)

// IssueFormatter is the interface that wraps the Format method.
// Implementations of this interface are responsible for formatting specific types of lint issues.
type IssueFormatter interface {
Format(issue internal.Issue, snippet *internal.SourceCode) string
}

// GetFormatter is a factory function that returns the appropriate IssueFormatter
// based on the given rule.
// If no specific formatter is found for the given rule, it returns a GeneralIssueFormatter.
func GetFormatter(rule string) IssueFormatter {
switch rule {
case UnnecessaryElse:
return &UnnecessaryElseFormatter{}
default:
return &GeneralIssueFormatter{}
}
}

// FormatIssuesWithArrows formats a slice of issues into a human-readable string.
// It uses the appropriate formatter for each issue based on its rule.
func FormatIssuesWithArrows(issues []internal.Issue, snippet *internal.SourceCode) string {
var builder strings.Builder
for _, issue := range issues {
builder.WriteString(formatIssueHeader(issue))
formatter := GetFormatter(issue.Rule)
builder.WriteString(formatter.Format(issue, snippet))
}
return builder.String()
}

// formatIssueHeader creates a formatted header string for a given issue.
// The header includes the rule and the filename. (e.g. "error: unused-variable\n --> test.go")
func formatIssueHeader(issue internal.Issue) string {
return errorStyle.Sprint("error: ") + ruleStyle.Sprint(issue.Rule) + "\n" +
lineStyle.Sprint(" --> ") + fileStyle.Sprint(issue.Filename) + "\n"
}
113 changes: 104 additions & 9 deletions internal/print_test.go → formatter/fmt_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package internal
package formatter

import (
"go/token"
"os"
"path/filepath"
"strings"
"testing"

"github.com/gnoswap-labs/lint/internal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFormatIssuesWithArrows(t *testing.T) {
sourceCode := &SourceCode{
sourceCode := &internal.SourceCode{
Lines: []string{
"package main",
"",
Expand All @@ -19,7 +24,7 @@ func TestFormatIssuesWithArrows(t *testing.T) {
},
}

issues := []Issue{
issues := []internal.Issue{
{
Rule: "unused-variable",
Filename: "test.go",
Expand Down Expand Up @@ -55,7 +60,7 @@ error: empty-if
assert.Equal(t, expected, result, "Formatted output does not match expected")

// Test with tab characters
sourceCodeWithTabs := &SourceCode{
sourceCodeWithTabs := &internal.SourceCode{
Lines: []string{
"package main",
"",
Expand Down Expand Up @@ -86,7 +91,7 @@ error: empty-if
}

func TestFormatIssuesWithArrows_MultipleDigitsLineNumbers(t *testing.T) {
sourceCode := &SourceCode{
sourceCode := &internal.SourceCode{
Lines: []string{
"package main",
"",
Expand All @@ -101,7 +106,7 @@ func TestFormatIssuesWithArrows_MultipleDigitsLineNumbers(t *testing.T) {
},
}

issues := []Issue{
issues := []internal.Issue{
{
Rule: "unused-variable",
Filename: "test.go",
Expand Down Expand Up @@ -151,7 +156,7 @@ error: example
}

func TestFormatIssuesWithArrows_UnnecessaryElse(t *testing.T) {
sourceCode := &SourceCode{
sourceCode := &internal.SourceCode{
Lines: []string{
"package main",
"",
Expand All @@ -165,7 +170,7 @@ func TestFormatIssuesWithArrows_UnnecessaryElse(t *testing.T) {
},
}

issues := []Issue{
issues := []internal.Issue{
{
Rule: "unnecessary-else",
Filename: "test.go",
Expand All @@ -189,6 +194,96 @@ func TestFormatIssuesWithArrows_UnnecessaryElse(t *testing.T) {
`

result := FormatIssuesWithArrows(issues, sourceCode)

assert.Equal(t, expected, result, "Formatted output does not match expected for unnecessary else")
}

func TestIntegratedLintEngine(t *testing.T) {
t.Skip("skipping integrated lint engine test")
tests := []struct {
name string
code string
expected []string
}{
{
name: "Detect unused issues",
code: `
package main

import (
"fmt"
)

func main() {
x := 1
fmt.Println("Hello")
}
`,
expected: []string{
"x declared and not used",
},
},
{
name: "Detect multiple issues",
code: `
package main

import (
"fmt"
"strings"
)

func main() {
x := 1
y := "unused"
fmt.Println("Hello")
}
`,
expected: []string{
"x declared and not used",
"y declared and not used",
`"strings" imported and not used`,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "lint-test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

tmpfile := filepath.Join(tmpDir, "test.go")
err = os.WriteFile(tmpfile, []byte(tt.code), 0o644)
require.NoError(t, err)

rootDir := "."
engine, err := internal.NewEngine(rootDir)
if err != nil {
t.Fatalf("unexpected error initializing lint engine: %v", err)
}

issues, err := engine.Run(tmpfile)
require.NoError(t, err)

assert.Equal(t, len(tt.expected), len(issues), "Number of issues doesn't match")

for _, exp := range tt.expected {
found := false
for _, issue := range issues {
if strings.Contains(issue.Message, exp) {
found = true
break
}
}
assert.True(t, found, "Expected issue not found: "+exp)
}

if len(issues) > 0 {
sourceCode, err := internal.ReadSourceCode(tmpfile)
require.NoError(t, err)
formattedIssues := FormatIssuesWithArrows(issues, sourceCode)
t.Logf("Found issues with arrows:\n%s", formattedIssues)
}
})
}
}
78 changes: 78 additions & 0 deletions formatter/general.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package formatter

import (
"fmt"
"strings"

"github.com/fatih/color"
"github.com/gnoswap-labs/lint/internal"
)

const tabWidth = 8

var (
errorStyle = color.New(color.FgRed, color.Bold)
ruleStyle = color.New(color.FgYellow, color.Bold)
fileStyle = color.New(color.FgCyan, color.Bold)
lineStyle = color.New(color.FgBlue, color.Bold)
messageStyle = color.New(color.FgRed, color.Bold)
)

// GeneralIssueFormatter is a formatter for general lint issues.
type GeneralIssueFormatter struct{}

// Format formats a general lint issue into a human-readable string.
// It takes an Issue and a SourceCode snippet as input and returns a formatted string.
func (f *GeneralIssueFormatter) Format(
issue internal.Issue,
snippet *internal.SourceCode,
) string {
var result strings.Builder

lineNumberStr := fmt.Sprintf("%d", issue.Start.Line)
padding := strings.Repeat(" ", len(lineNumberStr)-1)
result.WriteString(lineStyle.Sprintf(" %s|\n", padding))

line := expandTabs(snippet.Lines[issue.Start.Line-1])
result.WriteString(lineStyle.Sprintf("%d | ", issue.Start.Line))
result.WriteString(line + "\n")

visualColumn := calculateVisualColumn(line, issue.Start.Column)
result.WriteString(lineStyle.Sprintf(" %s| ", padding))
result.WriteString(strings.Repeat(" ", visualColumn))
result.WriteString(messageStyle.Sprintf("^ %s\n\n", issue.Message))

return result.String()
}

// expandTabs replaces tab characters('\t') with spaces.
// Assuming a table width of 8.
func expandTabs(line string) string {
var expanded strings.Builder
for i, ch := range line {
if ch == '\t' {
spaceCount := tabWidth - (i % tabWidth)
expanded.WriteString(strings.Repeat(" ", spaceCount))
} else {
expanded.WriteRune(ch)
}
}
return expanded.String()
}

// calculateVisualColumn calculates the visual column position
// in a string. taking into account tab characters.
func calculateVisualColumn(line string, column int) int {
visualColumn := 0
for i, ch := range line {
if i+1 == column {
break
}
if ch == '\t' {
visualColumn += tabWidth - (visualColumn % tabWidth)
} else {
visualColumn++
}
}
return visualColumn
}
Loading
Loading