Skip to content
Open
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
253 changes: 252 additions & 1 deletion internal/daemon/brain/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package brain

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"

"github.com/get-vix/vix/internal/config"
"github.com/get-vix/vix/internal/daemon/brain/lsp"
Expand Down Expand Up @@ -81,5 +84,253 @@ func doBrainInit(data map[string]any, cred config.Credential) (map[string]any, e
}

func doBrainUpdateFiles(data map[string]any, cred config.Credential) (map[string]any, error) {
return map[string]any{"status": "ok"}, nil
start := time.Now()
params, _ := data["params"].(map[string]any)

projectPath, _ := params["project_path"].(string)
if projectPath == "" {
projectPath = "."
}
root, _ := filepath.Abs(projectPath)
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return map[string]any{"status": "error", "message": fmt.Sprintf("Not a directory: %s", root)}, nil
}

brainDir, _ := params["brain_dir"].(string)
if brainDir == "" {
brainDir = filepath.Join(root, ".vix")
}
if err := os.MkdirAll(brainDir, 0o755); err != nil {
return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to create brain dir: %v", err)}, nil
}

files := stringSliceParam(params["files"])
if len(files) == 0 {
return map[string]any{"status": "error", "message": "missing 'files'"}, nil
}

index, err := loadBrainIndex(brainDir)
if err != nil {
return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to load brain index: %v", err)}, nil
}
if index.Project.RootPath == "" {
index.Project.RootPath = root
}
if index.Project.Name == "" {
index.Project.Name = filepath.Base(root)
}

updated := 0
removed := 0
for _, filePath := range files {
relPath, ok := normalizeProjectPath(root, filePath)
if !ok {
continue
}

fileInfo := ScanSingleFile(root, relPath)
if fileInfo == nil {
if removeFileFromIndex(index, relPath) {
removed++
}
continue
}

upsertFileInfo(index, *fileInfo)
refreshFileAnalysis(index, root, *fileInfo)
updated++
}

recomputeProjectMetadata(index, root)
if err := saveBrainIndex(brainDir, index); err != nil {
return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to save brain index: %v", err)}, nil
}

return map[string]any{
"status": "ok",
"data": map[string]any{
"updated_files": updated,
"removed_files": removed,
"duration_seconds": time.Since(start).Seconds(),
},
}, nil
}

func stringSliceParam(raw any) []string {
switch v := raw.(type) {
case []string:
return v
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
default:
return nil
}
}

func normalizeProjectPath(root, filePath string) (string, bool) {
if filePath == "" {
return "", false
}
var absPath string
if filepath.IsAbs(filePath) {
absPath = filepath.Clean(filePath)
} else {
absPath = filepath.Join(root, filepath.Clean(filePath))
}
relPath, err := filepath.Rel(root, absPath)
if err != nil || relPath == "." || relPath == "" || relPath == ".." || len(relPath) >= 3 && relPath[:3] == "../" {
return "", false
}
return relPath, true
}

func loadBrainIndex(brainDir string) (*BrainIndex, error) {
path := filepath.Join(brainDir, "index.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &BrainIndex{}, nil
}
return nil, err
}
var index BrainIndex
if err := json.Unmarshal(data, &index); err != nil {
return nil, err
}
return &index, nil
}

func saveBrainIndex(brainDir string, index *BrainIndex) error {
data, err := json.MarshalIndent(index, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(brainDir, "index.json"), append(data, '\n'), 0o644)
}

func upsertFileInfo(index *BrainIndex, fileInfo FileInfo) {
for i, existing := range index.Files {
if existing.Path == fileInfo.Path {
index.Files[i] = fileInfo
return
}
}
index.Files = append(index.Files, fileInfo)
}

func removeFileFromIndex(index *BrainIndex, relPath string) bool {
found := false
files := index.Files[:0]
for _, fileInfo := range index.Files {
if fileInfo.Path == relPath {
found = true
continue
}
files = append(files, fileInfo)
}
index.Files = files
removeFileAnalysis(index, relPath)
return found
}

func refreshFileAnalysis(index *BrainIndex, root string, fileInfo FileInfo) {
removeFileAnalysis(index, fileInfo.Path)
if fileInfo.Language == "" {
return
}

symbols, calls := ParseFile(fileInfo.Path, root, fileInfo.Language)
index.Symbols = append(index.Symbols, symbols...)
index.Calls = append(index.Calls, calls...)

data, err := os.ReadFile(filepath.Join(root, fileInfo.Path))
if err != nil {
return
}
filePaths := make(map[string]bool, len(index.Files))
for _, file := range index.Files {
filePaths[file.Path] = true
}
index.Imports = append(index.Imports, ExtractFileImports(string(data), fileInfo.Path, filePaths, root, fileInfo.Language)...)
}

func removeFileAnalysis(index *BrainIndex, relPath string) {
symbols := index.Symbols[:0]
for _, symbol := range index.Symbols {
if symbol.FilePath != relPath {
symbols = append(symbols, symbol)
}
}
index.Symbols = symbols

calls := index.Calls[:0]
for _, call := range index.Calls {
if call.FilePath != relPath {
calls = append(calls, call)
}
}
index.Calls = calls

imports := index.Imports[:0]
for _, imp := range index.Imports {
if imp.SourceFile != relPath && imp.TargetFile != relPath {
imports = append(imports, imp)
}
}
index.Imports = imports
}

func recomputeProjectMetadata(index *BrainIndex, root string) {
sort.Slice(index.Files, func(i, j int) bool { return index.Files[i].Path < index.Files[j].Path })
sort.Slice(index.Symbols, func(i, j int) bool { return index.Symbols[i].ID < index.Symbols[j].ID })
sort.Slice(index.Imports, func(i, j int) bool {
if index.Imports[i].SourceFile == index.Imports[j].SourceFile {
return index.Imports[i].Module < index.Imports[j].Module
}
return index.Imports[i].SourceFile < index.Imports[j].SourceFile
})

languages := make(map[string]int)
totalLines := 0
entryPoints := make([]string, 0)
configFiles := make([]string, 0)
for _, fileInfo := range index.Files {
if fileInfo.Language != "" {
languages[fileInfo.Language]++
}
totalLines += fileInfo.LineCount
if fileInfo.IsEntryPoint {
entryPoints = append(entryPoints, fileInfo.Path)
}
if fileInfo.IsConfig {
configFiles = append(configFiles, fileInfo.Path)
}
}
sort.Strings(entryPoints)
sort.Strings(configFiles)

externalDeps := ParseDependencies(root)
frameworks := DetectFrameworks(root, index.Files, externalDeps)
index.Project = ProjectMeta{
Name: filepath.Base(root),
RootPath: root,
TotalFiles: len(index.Files),
TotalLines: totalLines,
Languages: languages,
EntryPoints: entryPoints,
ConfigFiles: configFiles,
ExternalDeps: externalDeps,
Frameworks: frameworks.Frameworks,
Patterns: frameworks.Patterns,
TestingFrameworks: frameworks.Testing,
CICD: frameworks.CICD,
}
index.HubFiles = FindHubFiles(index.Imports, 10)
}
118 changes: 118 additions & 0 deletions internal/daemon/brain/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package brain

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/get-vix/vix/internal/config"
"github.com/get-vix/vix/internal/daemon/brain/lsp"
)

func resetLanguageMapForTest(t *testing.T) {
t.Helper()
extMapMu.Lock()
extMap = nil
extMapMu.Unlock()
InitLanguageMapFromConfigs([]lsp.LanguageConfig{{Name: "go", Extensions: []string{".go"}}})
}

func readTestIndex(t *testing.T, brainDir string) BrainIndex {
t.Helper()
data, err := os.ReadFile(filepath.Join(brainDir, "index.json"))
if err != nil {
t.Fatalf("read index: %v", err)
}
var index BrainIndex
if err := json.Unmarshal(data, &index); err != nil {
t.Fatalf("unmarshal index: %v", err)
}
return index
}

func TestBrainUpdateFilesUpsertsIndex(t *testing.T) {
resetLanguageMapForTest(t)
root := t.TempDir()
brainDir := filepath.Join(root, ".vix")
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}

resp, err := doBrainUpdateFiles(map[string]any{"params": map[string]any{
"project_path": root,
"brain_dir": brainDir,
"files": []any{"main.go"},
}}, config.Credential{})
if err != nil {
t.Fatalf("update files returned error: %v", err)
}
if resp["status"] != "ok" {
t.Fatalf("status = %v, want ok (resp=%v)", resp["status"], resp)
}

index := readTestIndex(t, brainDir)
if got, want := index.Project.TotalFiles, 1; got != want {
t.Fatalf("TotalFiles = %d, want %d", got, want)
}
if len(index.Files) != 1 || index.Files[0].Path != "main.go" {
t.Fatalf("files = %#v, want main.go", index.Files)
}
if got, want := index.Project.Languages["go"], 1; got != want {
t.Fatalf("go language count = %d, want %d", got, want)
}

if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() { println(1) }\n"), 0o644); err != nil {
t.Fatalf("rewrite source: %v", err)
}
resp, err = doBrainUpdateFiles(map[string]any{"params": map[string]any{
"project_path": root,
"brain_dir": brainDir,
"files": []string{"main.go"},
}}, config.Credential{})
if err != nil || resp["status"] != "ok" {
t.Fatalf("second update resp=%v err=%v", resp, err)
}
index = readTestIndex(t, brainDir)
if len(index.Files) != 1 {
t.Fatalf("duplicate file entries after upsert: %#v", index.Files)
}
if index.Files[0].SizeBytes == len("package main\n\nfunc main() {}\n") {
t.Fatalf("file metadata did not refresh: %#v", index.Files[0])
}
}

func TestBrainUpdateFilesRemovesDeletedFile(t *testing.T) {
resetLanguageMapForTest(t)
root := t.TempDir()
brainDir := filepath.Join(root, ".vix")
if err := os.MkdirAll(brainDir, 0o755); err != nil {
t.Fatalf("mkdir brain dir: %v", err)
}
index := BrainIndex{
Project: ProjectMeta{Name: filepath.Base(root), RootPath: root, TotalFiles: 1, Languages: map[string]int{"go": 1}},
Files: []FileInfo{{Path: "gone.go", Language: "go", SizeBytes: 10, LineCount: 1}},
Symbols: []SymbolInfo{{ID: "gone.go:f", Name: "f", FilePath: "gone.go"}},
Imports: []ImportInfo{{SourceFile: "gone.go", Module: "fmt", IsExternal: true}},
}
if err := saveBrainIndex(brainDir, &index); err != nil {
t.Fatalf("seed index: %v", err)
}

resp, err := doBrainUpdateFiles(map[string]any{"params": map[string]any{
"project_path": root,
"brain_dir": brainDir,
"files": []string{"gone.go"},
}}, config.Credential{})
if err != nil || resp["status"] != "ok" {
t.Fatalf("delete update resp=%v err=%v", resp, err)
}

updated := readTestIndex(t, brainDir)
if len(updated.Files) != 0 || len(updated.Symbols) != 0 || len(updated.Imports) != 0 {
t.Fatalf("deleted file references remain: %#v", updated)
}
if updated.Project.TotalFiles != 0 {
t.Fatalf("TotalFiles = %d, want 0", updated.Project.TotalFiles)
}
}