grokkit/internal/linter/linter.go

315 lines
7.2 KiB
Go
Raw Permalink Normal View History

package linter
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/internal/logger"
)
// Language represents a programming language
type Language struct {
Name string
Extensions []string
Linters []Linter
}
// Linter represents a linting tool
type Linter struct {
Name string
Command string
Args []string
InstallInfo string
}
// LintResult contains linting results
type LintResult struct {
Language string
LinterUsed string
Output string
ExitCode int
HasIssues bool
LinterExists bool
}
// Supported languages and their linters
var languages = []Language{
{
Name: "Go",
Extensions: []string{".go"},
Linters: []Linter{
{
Name: "golangci-lint",
Command: "golangci-lint",
Args: []string{"run"},
InstallInfo: "https://golangci-lint.run/usage/install/",
},
{
Name: "go vet",
Command: "go",
Args: []string{"vet"},
InstallInfo: "Built-in with Go",
},
},
},
{
Name: "Python",
Extensions: []string{".py"},
Linters: []Linter{
{
Name: "pylint",
Command: "pylint",
Args: []string{},
InstallInfo: "pip install pylint",
},
{
Name: "flake8",
Command: "flake8",
Args: []string{},
InstallInfo: "pip install flake8",
},
{
Name: "ruff",
Command: "ruff",
Args: []string{"check"},
InstallInfo: "pip install ruff",
},
},
},
{
Name: "JavaScript",
Extensions: []string{".js", ".jsx"},
Linters: []Linter{
{
Name: "eslint",
Command: "eslint",
Args: []string{},
InstallInfo: "npm install -g eslint",
},
},
},
{
Name: "TypeScript",
Extensions: []string{".ts", ".tsx"},
Linters: []Linter{
{
Name: "eslint",
Command: "eslint",
Args: []string{},
InstallInfo: "npm install -g eslint @typescript-eslint/parser",
},
{
Name: "tsc",
Command: "tsc",
Args: []string{"--noEmit"},
InstallInfo: "npm install -g typescript",
},
},
},
{
Name: "Rust",
Extensions: []string{".rs"},
Linters: []Linter{
{
Name: "clippy",
Command: "cargo",
Args: []string{"clippy", "--"},
InstallInfo: "rustup component add clippy",
},
},
},
{
Name: "Ruby",
Extensions: []string{".rb"},
Linters: []Linter{
{
Name: "rubocop",
Command: "rubocop",
Args: []string{},
InstallInfo: "gem install rubocop",
},
},
},
{
Name: "Java",
Extensions: []string{".java"},
Linters: []Linter{
{
Name: "checkstyle",
Command: "checkstyle",
Args: []string{"-c", "/google_checks.xml"},
InstallInfo: "https://checkstyle.org/",
},
},
},
{
Name: "C/C++",
Extensions: []string{".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"},
Linters: []Linter{
{
Name: "clang-tidy",
Command: "clang-tidy",
Args: []string{},
InstallInfo: "apt install clang-tidy or brew install llvm",
},
},
},
{
Name: "Shell",
Extensions: []string{".sh", ".bash"},
Linters: []Linter{
{
Name: "shellcheck",
Command: "shellcheck",
Args: []string{},
InstallInfo: "apt install shellcheck or brew install shellcheck",
},
},
},
}
// DetectLanguage detects the programming language based on file extension
func DetectLanguage(filePath string) (*Language, error) {
ext := strings.ToLower(filepath.Ext(filePath))
logger.Debug("detecting language", "file", filePath, "extension", ext)
for i := range languages {
for _, langExt := range languages[i].Extensions {
if ext == langExt {
logger.Info("language detected", "file", filePath, "language", languages[i].Name)
return &languages[i], nil
}
}
}
return nil, fmt.Errorf("unsupported file type: %s", ext)
}
// CheckLinterAvailable checks if a linter command is available
func CheckLinterAvailable(linter Linter) bool {
_, err := exec.LookPath(linter.Command)
available := err == nil
logger.Debug("checking linter availability",
"linter", linter.Name,
"command", linter.Command,
"available", available)
return available
}
// FindAvailableLinter finds the first available linter for a language
func FindAvailableLinter(lang *Language) (*Linter, error) {
logger.Info("searching for available linter", "language", lang.Name, "options", len(lang.Linters))
for i := range lang.Linters {
if CheckLinterAvailable(lang.Linters[i]) {
logger.Info("found available linter",
"language", lang.Name,
"linter", lang.Linters[i].Name)
return &lang.Linters[i], nil
}
}
// Build install instructions
installOptions := make([]string, 0, len(lang.Linters))
for _, linter := range lang.Linters {
installOptions = append(installOptions,
fmt.Sprintf(" - %s: %s", linter.Name, linter.InstallInfo))
}
return nil, fmt.Errorf("no linter found for %s\n\nInstall one of:\n%s",
lang.Name, strings.Join(installOptions, "\n"))
}
// RunLinter executes the linter on the specified file
func RunLinter(filePath string, linter Linter) (*LintResult, error) {
logger.Info("running linter",
"file", filePath,
"linter", linter.Name,
"command", linter.Command)
// Build command arguments
linterArgs := append([]string{}, linter.Args...)
linterArgs = append(linterArgs, filePath)
// Execute linter
// nolint:gosec // intentional subprocess for linter
cmd := exec.Command(linter.Command, linterArgs...)
output, err := cmd.CombinedOutput()
result := &LintResult{
LinterUsed: linter.Name,
Output: string(output),
LinterExists: true,
}
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
result.ExitCode = exitErr.ExitCode()
result.HasIssues = true
logger.Info("linter found issues",
"linter", linter.Name,
"exit_code", result.ExitCode,
"output_length", len(output))
} else {
logger.Error("linter execution failed",
"linter", linter.Name,
"error", err)
return nil, fmt.Errorf("failed to execute %s: %w", linter.Name, err)
}
} else {
result.ExitCode = 0
result.HasIssues = false
logger.Info("linter passed with no issues", "linter", linter.Name)
}
return result, nil
}
// LintFile detects language and runs appropriate linter
func LintFile(filePath string) (*LintResult, error) {
// Check file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
// Detect language
lang, err := DetectLanguage(filePath)
if err != nil {
return nil, err
}
// Find available linter
linter, err := FindAvailableLinter(lang)
if err != nil {
return &LintResult{
Language: lang.Name,
LinterExists: false,
Output: err.Error(),
}, err
}
// Run linter
result, err := RunLinter(filePath, *linter)
if err != nil {
return nil, err
}
result.Language = lang.Name
return result, nil
}
// GetSupportedLanguages returns a list of all supported languages
func GetSupportedLanguages() []string {
langs := make([]string, 0, len(languages))
for _, lang := range languages {
langs = append(langs, fmt.Sprintf("%s (%s)", lang.Name, strings.Join(lang.Extensions, ", ")))
}
return langs
}