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

feat(engine): Caching the Linting Results #57

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
27 changes: 25 additions & 2 deletions cmd/tlin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import (
"go.uber.org/zap"
)

const defaultTimeout = 5 * time.Minute
const (
defaultTimeout = 5 * time.Minute
defaultCacheDir = ".tlin-cache"
)

type Config struct {
Timeout time.Duration
Expand All @@ -31,11 +34,17 @@ type Config struct {
Paths []string
CFGAnalysis bool
FuncName string
UseCache bool
CacheDir string
CacheMaxAge time.Duration
InvalidateCache bool
}

type LintEngine interface {
Run(filePath string) ([]tt.Issue, error)
IgnoreRule(rule string)
SetCacheOptions(useCache bool, cacheDir string, maxAge time.Duration)
InvalidateCache() error
}

func main() {
Expand All @@ -47,11 +56,21 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), config.Timeout)
defer cancel()

engine, err := internal.NewEngine(".")
engine, err := internal.NewEngine(".", config.UseCache, config.CacheDir)
if err != nil {
logger.Fatal("Failed to initialize lint engine", zap.Error(err))
}

engine.SetCacheOptions(config.UseCache, config.CacheDir, config.CacheMaxAge)

if config.InvalidateCache {
if err := engine.InvalidateCache(); err != nil {
logger.Error("failed to invalidate cache", zap.Error(err))
} else {
logger.Info("cache invalidated successfully")
}
}

if config.IgnoreRules != "" {
rules := strings.Split(config.IgnoreRules, ",")
for _, rule := range rules {
Expand Down Expand Up @@ -82,6 +101,10 @@ func parseFlags() Config {
flag.StringVar(&config.IgnoreRules, "ignore", "", "Comma-separated list of lint rules to ignore")
flag.BoolVar(&config.CFGAnalysis, "cfg", false, "Run control flow graph analysis")
flag.StringVar(&config.FuncName, "func", "", "Function name for CFG analysis")
flag.BoolVar(&config.UseCache, "cache", true, "Use caching for lint results")
flag.StringVar(&config.CacheDir, "cache-dir", defaultCacheDir, "Directory to store cache files")
flag.DurationVar(&config.CacheMaxAge, "cache-max-age", 24*time.Hour, "Maximum age of cache entries")
flag.BoolVar(&config.InvalidateCache, "invalidate-cache", false, "Invalidate the entire cache")

flag.Parse()

Expand Down
23 changes: 16 additions & 7 deletions cmd/tlin/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,29 @@ import (
"go.uber.org/zap"
)

// MockLintEngine is a mock implementation of the LintEngine interface
type MockLintEngine struct {
// mockLintEngine is a mock implementation of the LintEngine interface
type mockLintEngine struct {
mock.Mock
}

func (m *MockLintEngine) Run(filePath string) ([]types.Issue, error) {
func (m *mockLintEngine) Run(filePath string) ([]types.Issue, error) {
args := m.Called(filePath)
return args.Get(0).([]types.Issue), args.Error(1)
}

func (m *MockLintEngine) IgnoreRule(rule string) {
func (m *mockLintEngine) IgnoreRule(rule string) {
m.Called(rule)
}

func (m *mockLintEngine) InvalidateCache() error {
args := m.Called()
return args.Error(0)
}

func (m *mockLintEngine) SetCacheOptions(useCache bool, cacheDir string, maxAge time.Duration) {
m.Called(useCache, cacheDir, maxAge)
}

func TestParseFlags(t *testing.T) {
t.Parallel()
oldArgs := os.Args
Expand All @@ -48,7 +57,7 @@ func TestParseFlags(t *testing.T) {

func TestProcessFile(t *testing.T) {
t.Parallel()
mockEngine := new(MockLintEngine)
mockEngine := new(mockLintEngine)
expectedIssues := []types.Issue{
{
Rule: "test-rule",
Expand All @@ -70,7 +79,7 @@ func TestProcessFile(t *testing.T) {
func TestProcessPath(t *testing.T) {
t.Parallel()
logger, _ := zap.NewProduction()
mockEngine := new(MockLintEngine)
mockEngine := new(mockLintEngine)
ctx := context.Background()

tempDir, err := os.MkdirTemp("", "test")
Expand Down Expand Up @@ -118,7 +127,7 @@ func TestProcessPath(t *testing.T) {
func TestProcessFiles(t *testing.T) {
t.Parallel()
logger, _ := zap.NewProduction()
mockEngine := new(MockLintEngine)
mockEngine := new(mockLintEngine)
ctx := context.Background()

tempFile1, err := os.CreateTemp("", "test1*.go")
Expand Down
224 changes: 224 additions & 0 deletions internal/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package internal

import (
"crypto/md5"
"encoding/gob"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"

tt "github.com/gnoswap-labs/tlin/internal/types"
)

type fileMetadata struct {
Hash string
LastModified time.Time
}

type CacheEntry struct {
Metadata fileMetadata
Issues []tt.Issue
CreatedAt time.Time
LastAccessed time.Time
}

type Cache struct {
CacheDir string
entries map[string]CacheEntry
mutex sync.RWMutex
maxAge time.Duration
dependencyFiles []string
dependencyHashes map[string]string
}

func NewCache(cacheDir string) (*Cache, error) {
if err := os.Mkdir(cacheDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}

cache := &Cache{
CacheDir: cacheDir,
entries: make(map[string]CacheEntry),
}

if err := cache.load(); err != nil {
return nil, fmt.Errorf("failed to load cache: %w", err)
}

return cache, nil
}

func (c *Cache) load() error {
// on memory?
cacheFile := filepath.Join(c.CacheDir, "lint_cache.gob")
file, err := os.Open(cacheFile)
if os.IsNotExist(err) {
return nil // cache file doesn't exist yet. This is fine.
}
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()

decoder := gob.NewDecoder(file)
if err := decoder.Decode(&c.entries); err != nil {
return fmt.Errorf("failed to decode cache file: %w", err)
}

return nil
}

func (c *Cache) save() error {
cacheFile := filepath.Join(c.CacheDir, "lint_cache.gob")
file, err := os.Create(cacheFile)
if err != nil {
return fmt.Errorf("failed to create cache file: %w", err)
}
defer file.Close()

encoder := gob.NewEncoder(file)
if err := encoder.Encode(c.entries); err != nil {
return fmt.Errorf("failed to encode cache file: %w", err)
}

return nil
}

func (c *Cache) Set(filename string, issues []tt.Issue) error {
c.mutex.Lock()
defer c.mutex.Unlock()

metadata, err := getFileMetadata(filename)
if err != nil {
return fmt.Errorf("failed to get file metadata: %w", err)
}

c.entries[filename] = CacheEntry{
Metadata: metadata,
Issues: issues,
CreatedAt: time.Now(),
LastAccessed: time.Now(),
}

return c.save()
}

func (c *Cache) Get(filename string) ([]tt.Issue, bool) {
c.mutex.Lock()
defer c.mutex.Unlock()

entry, exists := c.entries[filename]
if !exists {
return nil, false
}

if c.isEntryInvalid(filename, entry) {
delete(c.entries, filename)
return nil, false
}

entry.LastAccessed = time.Now()
c.entries[filename] = entry

return entry.Issues, true
}

func (c *Cache) isEntryInvalid(filename string, entry CacheEntry) bool {
// too old
if time.Since(entry.CreatedAt) > c.maxAge {
return true
}

currentMetadata, err := getFileMetadata(filename)
if err != nil || currentMetadata != entry.Metadata {
return true
}

if c.haveDependenciesChanged() {
return true
}

return false
}

func (c *Cache) haveDependenciesChanged() bool {
for _, file := range c.dependencyFiles {
hash, err := getFileHash(file)
if err != nil {
return true
}

if hash != c.dependencyHashes[file] {
return true
}
}

return false
}

func (c *Cache) updateDependencyHashes() error {
for _, file := range c.dependencyFiles {
hash, err := getFileHash(file)
if err != nil {
return fmt.Errorf("failed to get hash for %s: %w", file, err)
}
c.dependencyHashes[file] = hash
}
return nil
}

func (c *Cache) SetMaxAge(duration time.Duration) {
c.mutex.Lock()
defer c.mutex.Unlock()

c.maxAge = duration
}

func (c *Cache) InvalidateAll() {
c.mutex.Lock()
defer c.mutex.Unlock()

c.entries = make(map[string]CacheEntry)
_ = c.save() // ignore error aas this is a manual operation
}

func getFileMetadata(filename string) (fileMetadata, error) {
file, err := os.Open(filename)
if err != nil {
return fileMetadata{}, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return fileMetadata{}, fmt.Errorf("failed to calculate hash: %w", err)
}

info, err := file.Stat()
if err != nil {
return fileMetadata{}, fmt.Errorf("failed to get file info: %w", err)
}

return fileMetadata{
Hash: fmt.Sprintf("%x", hash.Sum(nil)),
LastModified: info.ModTime(),
}, nil
}

func getFileHash(filename string) (string, error) {
file, err := os.Open(filename)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()

hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}

return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
Loading
Loading