diff --git a/app.go b/app.go index edd1a6a..ffb7f0d 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "syscall" "time" @@ -24,7 +25,6 @@ import ( "github.com/hackafterdark/context-sherpa/pkg/sysutils" scip "github.com/sourcegraph/scip/bindings/go/scip" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" - "google.golang.org/protobuf/proto" "gopkg.in/yaml.v3" ) @@ -59,6 +59,8 @@ type App struct { downloader *inference.Downloader inference *inference.InferenceService db *database.DB + localDBs map[string]*database.DB + localDBMu sync.Mutex } // MarkdownEntry represents a markdown file with optional front-matter metadata @@ -79,7 +81,9 @@ type LocalRuleDetails struct { // NewApp creates a new App application struct func NewApp() *App { - return &App{} + return &App{ + localDBs: make(map[string]*database.DB), + } } // startup is called when the app starts. @@ -202,7 +206,6 @@ func (a *App) normalizePath(path string) string { func (a *App) initDatabase() { configDir, err := getSherpaConfigDir() if err != nil { - fmt.Printf("Hub: Failed to get config dir: %v\n", err) return } @@ -782,6 +785,15 @@ func (a *App) BeforeClose(ctx context.Context) bool { } func (a *App) Shutdown(ctx context.Context) { + a.localDBMu.Lock() + for p, db := range a.localDBs { + fmt.Printf("Hub: Closing local database: %s\n", p) + _ = db.Close() + } + // Clear the map to allow garbage collection and prevent re-use of closed handles + a.localDBs = make(map[string]*database.DB) + a.localDBMu.Unlock() + if a.isHub { lockPath := mcp.GetHubLockPath() _ = os.Remove(lockPath) @@ -1977,385 +1989,248 @@ type GraphData struct { Language string `json:"language"` } -// GetGraphData transforms SCIP data into ECharts JSON with "Hotpath" sizing and spatial clustering -func (a *App) GetGraphData(scipPath string) (*GraphData, error) { - data, err := os.ReadFile(scipPath) +// GetGraphData transforms SCIP data into ECharts JSON with "Hotpath" sizing and spatial clustering. +// It uses a persistent SQLite index to avoid re-parsing large Protobuf files on every request. +func (a *App) GetGraphData(scipPath string, scope string) (*GraphData, error) { + scipPath = a.normalizePath(scipPath) + // 1. Get/Init local database for this workspace + localDB, err := a.getLocalDB(scipPath) if err != nil { return nil, err } - var index scip.Index - if err := proto.Unmarshal(data, &index); err != nil { - return nil, fmt.Errorf("failed to parse SCIP index: %w", err) + // 2. Ensure the index is ingested and up to date + if err := a.IngestIndex(scipPath); err != nil { + return nil, err } nodes := make([]GraphNode, 0) links := make([]GraphLink, 0) - seenNodes := make(map[string]bool) - seenLinks := make(map[string]bool) - - // Custom Categories for Layout/Clustering (Folders) - // But Legend will be fixed to Code Kinds. folderToCatID := make(map[string]int) categories := make([]GraphCategory, 0) - symbolToNodeID := make(map[string]string) - symbolToInfo := make(map[string]*scip.SymbolInformation) - refCount := make(map[string]int) - outboundCalls := make(map[string]int) - - addNode := func(n GraphNode) { - if n.ID != "" && !seenNodes[n.ID] { - nodes = append(nodes, n) - seenNodes[n.ID] = true - } - } - addLink := func(l GraphLink) { - if l.Source == "" || l.Target == "" || l.Source == l.Target { - return - } - key := fmt.Sprintf("%s-%s-%s", l.Source, l.Target, l.Label) - if !seenLinks[key] { - links = append(links, l) - seenLinks[key] = true - } + scope = filepath.ToSlash(scope) + if scope != "" && !strings.HasSuffix(scope, "/") { + scope += "/" } - // 1. Pre-map symbol information and identify directories - for _, doc := range index.Documents { - dir := filepath.Dir(doc.RelativePath) - if dir == "." { - dir = "root" - } - if _, exists := folderToCatID[dir]; !exists { - folderToCatID[dir] = len(categories) - categories = append(categories, GraphCategory{Name: dir}) - } - for _, si := range doc.Symbols { - symbolToInfo[si.Symbol] = si - } - } - for _, si := range index.ExternalSymbols { - symbolToInfo[si.Symbol] = si + // 3. Fetch Symbols for the requested scope + dirMatch := scope + if scope == "" { + dirMatch = "root" + } else { + dirMatch = strings.TrimSuffix(scope, "/") } - // 2. Pass 1: Count references and outbound calls - for _, doc := range index.Documents { - var currentScope string - for _, occ := range doc.Occurrences { - if strings.Contains(occ.Symbol, "local") { - continue - } - isDef := occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 - if isDef { - currentScope = occ.Symbol - continue - } - // It's a reference - refCount[occ.Symbol]++ - if currentScope != "" { - outboundCalls[currentScope]++ - } - } + rows, err := localDB.Query(` + SELECT symbol, name, kind, file_path, dir_path, start_line, end_line, impact_value, parent_symbol + FROM scip_symbols + WHERE scip_path = ? AND dir_path = ? + `, scipPath, dirMatch) + if err != nil { + return nil, err } + defer rows.Close() - // 3. Pass 2: Create Symbol Nodes (No physical folder/file nodes) - for _, doc := range index.Documents { - dir := filepath.Dir(doc.RelativePath) - catID := folderToCatID[dir] - - for _, occ := range doc.Occurrences { - isDef := occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 - if isDef { - // Filter noise: skip local symbols - if strings.Contains(occ.Symbol, "local") { - continue - } - - parsed, err := scip.ParseSymbol(occ.Symbol) - if err != nil || len(parsed.Descriptors) == 0 { - continue // Skip packages or unparseable symbols - } - - // The last descriptor is the leaf (Method, Type, Term, etc.) - leaf := parsed.Descriptors[len(parsed.Descriptors)-1] + for rows.Next() { + var n GraphNode + var sym, filePath, dirPath, parentSym string + err := rows.Scan(&sym, &n.Name, &n.Kind, &filePath, &dirPath, &n.StartLine, &n.EndLine, &n.Value, &parentSym) + if err != nil { continue } - // Skip Namespaces (Packages) as definitions - they are architectural noise - if leaf.Suffix == scip.Descriptor_Namespace { - continue - } + n.ID = "sym:" + sym + n.Path = filePath + n.Parent = "dir:" + dirPath + + symbolToNodeID[sym] = n.ID - name := leaf.Name - kind := "Function" - - // Map SCIP Descriptor Suffix to our finite categories - switch leaf.Suffix { - case scip.Descriptor_Type: - kind = "Struct" - case scip.Descriptor_Term: - kind = "Variable" - case scip.Descriptor_Method: - kind = "Function" - case scip.Descriptor_Macro: - kind = "Function" - case scip.Descriptor_Parameter: - continue // Skip parameters for top-level graph - case scip.Descriptor_TypeParameter: - continue // Skip type parameters - } - - // Additional refinement for Interfaces (often labeled as Types in SCIP but contain "interface" in symbol) - if kind == "Struct" && strings.Contains(strings.ToLower(occ.Symbol), "interface") { - kind = "Interface" - } - - nodeID := "sym:" + occ.Symbol - symbolToNodeID[occ.Symbol] = nodeID - - info := symbolToInfo[occ.Symbol] - docstring := "" - if info != nil && len(info.Documentation) > 0 { - docstring = info.Documentation[0] - } + if _, exists := folderToCatID[dirPath]; !exists { + folderToCatID[dirPath] = len(categories) + categories = append(categories, GraphCategory{Name: dirPath}) + } + nodes = append(nodes, n) + } - // Calculate Lines of Code (LOC) and Ranges - loc := 0 - startLine := 0 - endLine := 0 - if len(occ.Range) == 3 { - startLine = int(occ.Range[0] + 1) // [line, startCol, endCol] - endLine = startLine - loc = 1 - } else if len(occ.Range) >= 4 { - startLine = int(occ.Range[0] + 1) // [startLine, startCol, endLine, endCol] - endLine = int(occ.Range[2] + 1) - loc = int(occ.Range[2] - occ.Range[0] + 1) - } + // 4. Fetch immediate Subfolders to generate Folder Nodes + prefix := scope + subRows, err := localDB.Query(` + SELECT DISTINCT dir_path FROM scip_symbols + WHERE scip_path = ? AND dir_path LIKE ? AND dir_path != ? + `, scipPath, prefix+"%", dirMatch) - // Calculate "Hotpath" value (Directive Weights) - val := 0 - if kind == "Struct" || kind == "Interface" { - // Count members for struct sizing - memberCount := 0 - prefix := occ.Symbol - for sym := range symbolToInfo { - if strings.HasPrefix(sym, prefix) && sym != occ.Symbol { - if !strings.Contains(sym[len(prefix):], ".") { - memberCount++ - } - } + if err == nil { + defer subRows.Close() + seenFolders := make(map[string]bool) + for subRows.Next() { + var fullDir string + if err := subRows.Scan(&fullDir); err == nil { + rel := strings.TrimPrefix(fullDir, prefix) + parts := strings.Split(rel, "/") + if len(parts) > 0 && parts[0] != "" { + immediate := prefix + parts[0] + if !seenFolders[immediate] { + nodes = append(nodes, GraphNode{ + ID: "dir:" + immediate, + Name: parts[0], + Kind: "Folder", + Parent: "dir:" + strings.TrimSuffix(prefix, "/"), + }) + seenFolders[immediate] = true } - // Value = (MemberCount * 5) + (ReferenceCount * 10) - val = (memberCount * 5) + (refCount[occ.Symbol] * 10) - } else if kind == "Variable" { - val = 5 + (refCount[occ.Symbol] * 2) - } else { - // Function: Value = (OutboundCalls * 3) + (ReferenceCount * 5) - val = (outboundCalls[occ.Symbol] * 3) + (refCount[occ.Symbol] * 5) - } - - // Minimum size floor - if val < 5 { - val = 5 } - - parentID := "" - if dir != "" && dir != "." { - parentID = "dir:" + dir - } - - addNode(GraphNode{ - ID: nodeID, - Name: name, - Value: val, - Category: catID, // Spatial clustering by folder - Path: doc.RelativePath, - Kind: kind, - Docstring: docstring, - Members: []Member{}, - Loc: loc, - StartLine: startLine, - EndLine: endLine, - Parent: parentID, - }) } } + } else { } - // 4. Extract members for deep inspection - for i := range nodes { - if nodes[i].Kind != "Struct" && nodes[i].Kind != "Interface" { - continue - } - // Nodes[i].ID is "sym:" + symbol - originalSymbol := nodes[i].ID[4:] - - for sym := range symbolToInfo { - if strings.HasPrefix(sym, originalSymbol) && sym != originalSymbol { - parsed, err := scip.ParseSymbol(sym) - if err != nil || len(parsed.Descriptors) == 0 { - continue - } - - // Only direct children (e.g., App#scipFiles. and not App#scipFiles.inner.) - // This is a bit simplified; in SCIP Go, members are usually descriptors at the end - // We check if the parent symbol is indeed the parent in descriptors - isChild := false - parentParsed, _ := scip.ParseSymbol(originalSymbol) - if len(parsed.Descriptors) == len(parentParsed.Descriptors)+1 { - isChild = true - for j := 0; j < len(parentParsed.Descriptors); j++ { - if parsed.Descriptors[j].Name != parentParsed.Descriptors[j].Name || - parsed.Descriptors[j].Suffix != parentParsed.Descriptors[j].Suffix { - isChild = false - break - } + // 5. Fetch Relationships (Edges) + if len(nodes) > 0 { + relRows, err := localDB.Query(` + SELECT source_symbol, target_symbol FROM scip_relationships WHERE scip_path = ? + `, scipPath) + if err == nil { + defer relRows.Close() + for relRows.Next() { + var src, tgt string + if err := relRows.Scan(&src, &tgt); err == nil { + if symbolToNodeID[src] != "" && symbolToNodeID[tgt] != "" { + links = append(links, GraphLink{ + Source: symbolToNodeID[src], + Target: symbolToNodeID[tgt], + Label: "DEPENDS", + }) } } - - if isChild { - leaf := parsed.Descriptors[len(parsed.Descriptors)-1] - memberKind := "Field" - if leaf.Suffix == scip.Descriptor_Method { - memberKind = "Method" - } - - nodes[i].Members = append(nodes[i].Members, Member{ - Name: leaf.Name, - Kind: memberKind, - Symbol: sym, - }) - } } } } - // 5. Build Links (Symbol to Symbol) - for _, doc := range index.Documents { - var currentScope string - for _, occ := range doc.Occurrences { - if strings.Contains(occ.Symbol, "local") { - continue - } - if occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { - if _, exists := symbolToNodeID[occ.Symbol]; exists { - currentScope = occ.Symbol - } - continue - } + // 6. Construct Cytoscape Elements + elements := make([]CyElement, 0) + for _, n := range nodes { + elements = append(elements, CyElement{Group: "nodes", Data: n}) + } + for _, l := range links { + elements = append(elements, CyElement{ + Group: "edges", + Data: map[string]interface{}{ + "id": "e-" + l.Source + "-" + l.Target, + "source": l.Source, + "target": l.Target, + "label": l.Label, + }, + }) + } - // Reference - targetNodeID, exists := symbolToNodeID[occ.Symbol] - if exists && currentScope != "" { - sourceNodeID := symbolToNodeID[currentScope] - if sourceNodeID != "" && sourceNodeID != targetNodeID { - label := "CALLS" - if strings.HasSuffix(occ.Symbol, "#") { - label = "USES" - } else if strings.Contains(occ.Symbol, "interface") { - label = "IMPLEMENTS" - } + var language string + localDB.QueryRow("SELECT language FROM scip_indices WHERE scip_path = ?", scipPath).Scan(&language) - // Get names/paths for Relationship Mode - sourceName := currentScope - if parsed, err := scip.ParseSymbol(currentScope); err == nil && len(parsed.Descriptors) > 0 { - sourceName = parsed.Descriptors[len(parsed.Descriptors)-1].Name - } else if lastSlash := strings.LastIndex(sourceName, "/"); lastSlash != -1 { - sourceName = sourceName[lastSlash+1:] - } + return &GraphData{ + Elements: elements, + Categories: categories, + Language: language, + }, nil +} - targetName := occ.Symbol - if parsed, err := scip.ParseSymbol(occ.Symbol); err == nil && len(parsed.Descriptors) > 0 { - targetName = parsed.Descriptors[len(parsed.Descriptors)-1].Name - } else if lastSlash := strings.LastIndex(targetName, "/"); lastSlash != -1 { - targetName = targetName[lastSlash+1:] - } - sourceName = strings.TrimSuffix(strings.TrimSuffix(sourceName, "#"), "().") - targetName = strings.TrimSuffix(strings.TrimSuffix(targetName, "#"), "().") - - addLink(GraphLink{ - Source: sourceNodeID, - Target: targetNodeID, - Label: fmt.Sprintf("%s -> %s (%s)", sourceName, targetName, label), - }) - } - } - } +// SearchSymbols performs a global text search across all symbols in a SCIP index. +func (a *App) SearchSymbols(scipPath string, query string) ([]GraphNode, error) { + scipPath = a.normalizePath(scipPath) + localDB, err := a.getLocalDB(scipPath) + if err != nil { + return nil, err } - // 6. Final Sweep: Construct Cytoscape Elements - elements := make([]CyElement, 0) + rows, err := localDB.Query(` + SELECT symbol, name, kind, file_path FROM scip_symbols + WHERE scip_path = ? AND (name LIKE ? OR symbol LIKE ?) + LIMIT 50 + `, scipPath, "%"+query+"%", "%"+query+"%") + if err != nil { + return nil, err + } + defer rows.Close() - // Add Folder Nodes (Compound Nodes) - seenFolders := make(map[string]bool) - for dir := range folderToCatID { - if dir == "" || dir == "." || dir == "root" { - continue + var results []GraphNode + for rows.Next() { + var n GraphNode + var sym, name, kind, filePath string + if err := rows.Scan(&sym, &name, &kind, &filePath); err == nil { + n.ID = "sym:" + sym + n.Name = name + n.Kind = kind + n.Path = filePath + results = append(results, n) } + } + return results, nil +} - // Create hierarchical folder nodes - parts := strings.Split(dir, string(filepath.Separator)) - currentPath := "" - for i, part := range parts { - prevPath := currentPath - if currentPath == "" { - currentPath = part - } else { - currentPath = currentPath + string(filepath.Separator) + part - } +// GetSymbolRelationships returns all callers and callees for a specific symbol. +func (a *App) GetSymbolRelationships(scipPath string, symbol string) (map[string][]GraphNode, error) { + scipPath = a.normalizePath(scipPath) + localDB, err := a.getLocalDB(scipPath) + if err != nil { + return nil, err + } - folderID := "dir:" + currentPath - if !seenFolders[folderID] { - parentID := "" - if i > 0 { - parentID = "dir:" + prevPath - } + symbol = strings.TrimPrefix(symbol, "sym:") - elements = append(elements, CyElement{ - Group: "nodes", - Data: GraphNode{ - ID: folderID, - Name: part, - Kind: "Folder", - Parent: parentID, - }, - }) - seenFolders[folderID] = true + // Callees (what this symbol calls) + calleeRows, err := localDB.Query(` + SELECT s.symbol, s.name, s.kind, s.file_path + FROM scip_relationships r + JOIN scip_symbols s ON r.target_symbol = s.symbol AND r.scip_path = s.scip_path + WHERE r.scip_path = ? AND r.source_symbol = ? + `, scipPath, symbol) + + callees := []GraphNode{} + if err == nil { + defer calleeRows.Close() + for calleeRows.Next() { + var n GraphNode + var sym, name, kind, filePath string + if err := calleeRows.Scan(&sym, &name, &kind, &filePath); err == nil { + n.ID = "sym:" + sym + n.Name = name + n.Kind = kind + n.Path = filePath + callees = append(callees, n) } } } - // Add Nodes (Symbols) - for _, n := range nodes { - elements = append(elements, CyElement{ - Group: "nodes", - Data: n, - }) - } + // Callers (what calls this symbol) + callerRows, err := localDB.Query(` + SELECT s.symbol, s.name, s.kind, s.file_path + FROM scip_relationships r + JOIN scip_symbols s ON r.source_symbol = s.symbol AND r.scip_path = s.scip_path + WHERE r.scip_path = ? AND r.target_symbol = ? + `, scipPath, symbol) - // Add Edges - for _, l := range links { - if seenNodes[l.Source] && seenNodes[l.Target] { - elements = append(elements, CyElement{ - Group: "edges", - Data: map[string]interface{}{ - "id": fmt.Sprintf("e-%s-%s", l.Source, l.Target), - "source": l.Source, - "target": l.Target, - "label": l.Label, - }, - }) + callers := []GraphNode{} + if err == nil { + defer callerRows.Close() + for callerRows.Next() { + var n GraphNode + var sym, name, kind, filePath string + if err := callerRows.Scan(&sym, &name, &kind, &filePath); err == nil { + n.ID = "sym:" + sym + n.Name = name + n.Kind = kind + n.Path = filePath + callers = append(callers, n) + } } } - return &GraphData{ - Elements: elements, - Categories: categories, - Language: detectLanguage(index.Documents), + return map[string][]GraphNode{ + "callers": callers, + "callees": callees, }, nil } + // FocusWorkspaceClient attempts to bring the editor window associated with an MCP server to the foreground. func (a *App) FocusWorkspaceClient(mcpPid int) error { if mcpPid <= 0 { @@ -2644,3 +2519,85 @@ func (a *App) PickDirectoryWithRoot(startDir string) (string, error) { }) } + +// getLocalDB opens or initializes a SQLite database in the same directory as the SCIP file (or its parent .context-sherpa) +func (a *App) getLocalDB(scipPath string) (*database.DB, error) { + a.localDBMu.Lock() + defer a.localDBMu.Unlock() + + dir := filepath.Dir(scipPath) + dbPath := filepath.Join(dir, "index.db") + absPath := a.normalizePath(dbPath) + + if db, ok := a.localDBs[absPath]; ok { + return db, nil + } + + localDB, err := database.InitDB(absPath) + if err != nil { + return nil, fmt.Errorf("failed to init local db at %s: %w", absPath, err) + } + + // Initialize SCIP schema if not exists + _, err = localDB.Exec(` + CREATE TABLE IF NOT EXISTS scip_indices ( + scip_path TEXT PRIMARY KEY, + mtime INTEGER, + indexed_at DATETIME, + language TEXT, + version INTEGER DEFAULT 1 + ); + `) + // Migration for existing indices (version 2 includes forward-slash fix) + // We ignore the error here because the column might already exist from a previous migration. + _, _ = localDB.Exec("ALTER TABLE scip_indices ADD COLUMN version INTEGER DEFAULT 1") + + if err != nil { + localDB.Close() + return nil, fmt.Errorf("failed to init local schema: %w", err) + } + + _, err = localDB.Exec(` + CREATE TABLE IF NOT EXISTS scip_symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scip_path TEXT, + symbol TEXT, + name TEXT, + kind TEXT, + file_path TEXT, + dir_path TEXT, + start_line INTEGER, + end_line INTEGER, + parent_symbol TEXT, + is_local BOOLEAN, + is_anonymous BOOLEAN, + is_stdlib BOOLEAN, + ref_count INTEGER DEFAULT 0, + outbound_calls INTEGER DEFAULT 0, + impact_value INTEGER DEFAULT 0, + FOREIGN KEY(scip_path) REFERENCES scip_indices(scip_path) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS scip_relationships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scip_path TEXT, + source_symbol TEXT, + target_symbol TEXT, + FOREIGN KEY(scip_path) REFERENCES scip_indices(scip_path) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_scip_symbols_scope ON scip_symbols(scip_path, dir_path); + CREATE INDEX IF NOT EXISTS idx_scip_symbols_symbol ON scip_symbols(scip_path, symbol); + CREATE INDEX IF NOT EXISTS idx_scip_relationships_scip ON scip_relationships(scip_path); + CREATE INDEX IF NOT EXISTS idx_scip_relationships_src ON scip_relationships(scip_path, source_symbol); + CREATE INDEX IF NOT EXISTS idx_scip_relationships_tgt ON scip_relationships(scip_path, target_symbol); + `) + if err != nil { + localDB.Close() + return nil, fmt.Errorf("failed to init local schema: %w", err) + } + + a.localDBs[absPath] = localDB + return localDB, nil +} + diff --git a/app_graph_test.go b/app_graph_test.go new file mode 100644 index 0000000..56aa3bd --- /dev/null +++ b/app_graph_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + scip "github.com/sourcegraph/scip/bindings/go/scip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +func TestGetGraphData_Pruning(t *testing.T) { + app := NewApp() + defer app.Shutdown(context.Background()) + tempDir := t.TempDir() + scipPath := filepath.Join(tempDir, "test.scip") + + // Create a mock SCIP index + index := &scip.Index{ + Documents: []*scip.Document{ + { + RelativePath: "pkg/main.go", + Symbols: []*scip.SymbolInformation{ + {Symbol: "scip-go . . . pkg/Main#"}, + {Symbol: "scip-go . . . pkg/main()."}, + }, + Occurrences: []*scip.Occurrence{ + { + Symbol: "scip-go . . . pkg/Main#", + SymbolRoles: int32(scip.SymbolRole_Definition), + Range: []int32{0, 0, 0, 4}, + }, + { + Symbol: "scip-go . . . pkg/main().", + SymbolRoles: int32(scip.SymbolRole_Definition), + Range: []int32{5, 0, 5, 4}, + }, + { + Symbol: "scip-go . . . pkg/main().local1", + SymbolRoles: int32(scip.SymbolRole_Definition), + Range: []int32{6, 0, 6, 4}, + }, + { + Symbol: "scip-go . . . pkg/main().$anon1", // Anonymous + SymbolRoles: int32(scip.SymbolRole_Definition), + Range: []int32{7, 0, 7, 4}, + }, + // Reference to stdlib + { + Symbol: "go/fmt/Println().", + Range: []int32{8, 0, 8, 4}, + }, + }, + }, + }, + } + + data, err := proto.Marshal(index) + require.NoError(t, err) + err = os.WriteFile(scipPath, data, 0644) + require.NoError(t, err) + + // Test 1: Full load without scope + graph, err := app.GetGraphData(scipPath, "") + require.NoError(t, err) + + // Verify pruning + for _, el := range graph.Elements { + if el.Group == "nodes" { + node, ok := el.Data.(GraphNode) + if ok { + assert.NotContains(t, node.ID, "local", "Local variables should be pruned") + assert.NotContains(t, node.ID, "$anon", "Anonymous closures should be pruned") + } + } + if el.Group == "edges" { + data := el.Data.(map[string]interface{}) + assert.NotContains(t, data["target"].(string), "go/fmt", "StdLib edges should be pruned") + } + } + + // Test 2: Hierarchical filter (Wrong scope) + graphScope, err := app.GetGraphData(scipPath, "otherpkg") + require.NoError(t, err) + + symbolNodeCount := 0 + for _, el := range graphScope.Elements { + if el.Group == "nodes" { + node := el.Data.(GraphNode) + if node.Kind != "Folder" { + symbolNodeCount++ + } + } + } + assert.Equal(t, 0, symbolNodeCount, "Should have no symbols outside scope") + + // Test 3: Hierarchical filter (Correct scope) + graphInScope, err := app.GetGraphData(scipPath, "pkg") + require.NoError(t, err) + + foundMain := false + for _, el := range graphInScope.Elements { + if el.Group == "nodes" { + node := el.Data.(GraphNode) + if node.Name == "main" { + foundMain = true + } + } + } + assert.True(t, foundMain, "Should find 'main' symbol in 'pkg' scope") +} diff --git a/frontend/src/Atlas.tsx b/frontend/src/Atlas.tsx index 4240a83..4c627cf 100644 --- a/frontend/src/Atlas.tsx +++ b/frontend/src/Atlas.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react'; import { Icon } from '@iconify/react'; import cytoscape from 'cytoscape'; import fcose from 'cytoscape-fcose'; -import { SearchForIndexes, GetGraphData, GetWorkspaces, GetFileContent, RegenerateIndex } from '../wailsjs/go/main/App'; +import { SearchForIndexes, GetGraphData, GetWorkspaces, GetFileContent, RegenerateIndex, SearchSymbols, GetSymbolRelationships } from '../wailsjs/go/main/App'; import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; import Editor from '@monaco-editor/react'; import ReactMarkdown from 'react-markdown'; @@ -34,7 +34,12 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) const [isSourceExpanded, setIsSourceExpanded] = useState(false); const editorRef = useRef(null); const [regenerating, setRegenerating] = useState>({}); - const [indexingStatus, setIndexingStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null); + const [indexingStatus, setIndexingStatus] = useState<{ type: 'success' | 'error' | 'processing', message: string } | null>(null); + const [currentScope, setCurrentScope] = useState(''); + const [relationships, setRelationships] = useState<{ callers: any[], callees: any[] } | null>(null); + const [pendingFocus, setPendingFocus] = useState(null); + const [loadingRelationships, setLoadingRelationships] = useState(false); + const [graphData, setGraphData] = useState(null); const getKindLabel = (kind: string, plural = false) => { if (kind === 'Struct') { @@ -155,7 +160,7 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) }, []); useEffect(() => { - if (indexingStatus) { + if (indexingStatus && indexingStatus.type !== 'processing') { const timer = setTimeout(() => setIndexingStatus(null), 5000); return () => clearTimeout(timer); } @@ -174,12 +179,28 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) }; fetchWorkspaces(); - EventsOn('workspace-updated', (ws: any[]) => { - const unique = Array.from(new Map(ws.map(item => [item.root, item])).values()); - setAllWorkspaces(unique); - }); - - return () => EventsOff('workspace-updated'); + EventsOn('workspace-updated', (ws: any[]) => { + const unique = Array.from(new Map(ws.map(item => [item.root, item])).values()); + setAllWorkspaces(unique); + }); + + EventsOn('indexing-status', (data: any) => { + if (data.status === 'done') { + setIndexingStatus({ type: 'success', message: data.message || 'Indexing complete!' }); + } else if (data.status === 'error') { + setIndexingStatus({ type: 'error', message: data.message || 'Indexing failed.' }); + } else { + setIndexingStatus({ + type: 'processing', + message: data.message || 'Indexing...' + }); + } + }); + + return () => { + EventsOff('workspace-updated'); + EventsOff('indexing-status'); + }; }, []); useEffect(() => { @@ -228,12 +249,19 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) { selector: "node[kind = 'Folder']", style: { - 'background-opacity': 0, - 'border-width': 0, - 'label': '', - 'padding': '40px', // Extra padding for logical clustering + 'background-color': '#4b5563', + 'background-opacity': 0.1, + 'border-width': 1, + 'border-color': '#4b5563', + 'border-style': 'dashed', + 'label': 'data(name)', + 'font-size': '10px', + 'text-valign': 'center', + 'text-halign': 'center', + 'color': 'rgba(255,255,255,0.4)', + 'padding': '40px', 'z-index': 1, - 'events': 'no' // Folders are transparent to clicks/hovers + 'shape': 'round-rectangle' } }, { @@ -362,6 +390,14 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) cyRef.current.on('mouseout', 'node', () => { cyRef.current?.elements().removeClass('dimmed').removeClass('highlighted'); }); + + // Double click to drill down into folders + cyRef.current.on('dblclick', 'node[kind="Folder"]', (evt) => { + const node = evt.target; + const folderId = node.id(); + const path = folderId.replace('dir:', ''); + setCurrentScope(path); + }); } const resizeObserver = new ResizeObserver(() => { @@ -382,14 +418,13 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) useEffect(() => { const node = selectedNode; if (node && node.path && workspaceRoot) { + setRelationships(null); GetFileContent(workspaceRoot, node.path).then(content => { setFileContent(content); - // If we have an editor ref and a start line, jump to it if (editorRef.current && node.startLine) { setTimeout(() => { const editor = editorRef.current; editor.revealLineInCenter(node.startLine); - // Clear old decorations (editor as any)._decorations = editor.deltaDecorations((editor as any)._decorations || [], [ { range: { startLineNumber: node.startLine, startColumn: 1, endLineNumber: node.endLine || node.startLine, endColumn: 100 }, @@ -406,18 +441,32 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) console.error("Error fetching file content:", e); setFileContent("// Error loading file content"); }); + setRelationships(null); + setLoadingRelationships(true); + GetSymbolRelationships(selectedIndex, node.id).then((rels: any) => { + setRelationships(rels); + setLoadingRelationships(false); + }).catch(() => { + setLoadingRelationships(false); + }); } else { setFileContent(''); + setRelationships(null); } }, [selectedNode, workspaceRoot]); + useEffect(() => { + setCurrentScope(''); + }, [selectedIndex]); + useEffect(() => { if (selectedIndex && cyRef.current) { setLoading(true); setSelectedNode(null); setSelectedEdge(null); - GetGraphData(selectedIndex).then((data) => { + GetGraphData(selectedIndex, currentScope).then((data: any) => { if (data && cyRef.current) { + setGraphData(data); setLanguage(data.language || 'Go'); const cy = cyRef.current; cy.elements().remove(); @@ -437,7 +486,7 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) ...el, data: { ...el.data, - value: 0 // Folders take size from children + value: 60 // Minimum size for folder containers } }; } @@ -464,11 +513,25 @@ export default function Atlas({ workspaceRoot, onWorkspaceChange }: AtlasProps) layout.run(); cy.fit(undefined, 50); + + if (pendingFocus) { + const target = cy.getElementById(pendingFocus); + if (!target.empty()) { + target.select(); + setSelectedNode(target.data()); + cy.animate({ + center: { eles: target }, + zoom: 1.5, + duration: 500 + }); + } + setPendingFocus(null); + } } setLoading(false); }); } - }, [selectedIndex]); + }, [selectedIndex, currentScope]); const handleZoomIn = () => { cyRef.current?.zoom(cyRef.current.zoom() * 1.5); @@ -534,45 +597,30 @@ ${snippet || '// No content available'} const handleSearchChange = (val: string) => { setSearchTerm(val); - if (!val.trim() || !cyRef.current) { + if (!val.trim()) { setSearchResults([]); setShowSearchResults(false); return; } - const cy = cyRef.current; - const allNodes = cy.nodes().filter((n: any) => n.data('kind') !== 'Folder'); - const query = val.toLowerCase(); - - const filtered = allNodes.map((n: any) => n.data()) - .filter((data: any) => data.name.toLowerCase().includes(query)) - .sort((a: any, b: any) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - - // Exact match first - if (aName === query && bName !== query) return -1; - if (bName === query && aName !== query) return 1; - - // Prefix match next - const aPrefix = aName.startsWith(query); - const bPrefix = bName.startsWith(query); - if (aPrefix && !bPrefix) return -1; - if (bPrefix && !aPrefix) return 1; - - return aName.localeCompare(bName); - }) - .slice(0, 15); - - setSearchResults(filtered); - setShowSearchResults(filtered.length > 0); + SearchSymbols(selectedIndex, val).then(results => { + setSearchResults(results || []); + setShowSearchResults((results || []).length > 0); + }).catch(err => { + console.error("Global search failed:", err); + }); }; const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (searchResults.length > 0) { const top = searchResults[0]; - focusSymbol(top.id); + if (top.path !== currentScope) { + setPendingFocus(top.id); + setCurrentScope(top.path || ''); + } else { + focusSymbol(top.id); + } setShowSearchResults(false); } else if (searchTerm) { focusSymbol(searchTerm); @@ -617,13 +665,21 @@ ${snippet || '// No content available'} {indexingStatus && ( -
- +
+ {indexingStatus.message} - + {indexingStatus.type !== 'processing' && ( + + )}
)}
@@ -651,7 +707,12 @@ ${snippet || '// No content available'} + + {currentScope && ( + <> + +
+ {currentScope.split('/').map((part, i, arr) => ( +
+ + {i < arr.length - 1 && } +
+ ))} +
+ + )} + + {graphData?.elements?.filter((e: any) => e.group === 'nodes' && e.data.kind === 'Folder').length > 0 && ( +
+ +
    +
  • + Subdirectories +
  • +
    + {graphData.elements + .filter((e: any) => e.group === 'nodes' && e.data.kind === 'Folder') + .sort((a: any, b: any) => a.data.name.localeCompare(b.data.name)) + .map((folder: any) => ( +
  • + +
  • + ))} +
    +
+
+ )} + + + )} + {loading && (
@@ -799,6 +938,28 @@ ${snippet || '// No content available'}
+ {selectedNode.path && fileContent && ( +
+

Signature

+
+ + {fileContent.split('\n').slice(selectedNode.startLine - 1, (selectedNode.endLine || selectedNode.startLine) + 1).join('\n')} + +
+
+ )} + {selectedNode.docstring && (

Documentation

@@ -861,6 +1022,76 @@ ${snippet || '// No content available'}
)} + + {loadingRelationships && ( +
+

Analyzing Connections

+
+
+
+
+
+ )} + + {relationships && (relationships.callers.length > 0 || relationships.callees.length > 0) && ( + <> + {relationships.callers.length > 0 && ( +
+

Callers ({relationships.callers.length})

+
+ {relationships.callers.map((r: any, i: number) => ( + + ))} +
+
+ )} + + {relationships.callees.length > 0 && ( +
+

Callees ({relationships.callees.length})

+
+ {relationships.callees.map((r: any, i: number) => ( + + ))} +
+
+ )} + + )}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index ddb9d68..97aa56c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -24,7 +24,7 @@ export function GetDownloadProgress(arg1:string):Promise; export function GetFileContent(arg1:string,arg2:string):Promise; -export function GetGraphData(arg1:string):Promise; +export function GetGraphData(arg1:string,arg2:string):Promise; export function GetInferenceModels():Promise>; @@ -36,12 +36,16 @@ export function GetPreferences():Promise; export function GetScipIndexerStatus(arg1:string):Promise>; +export function GetSymbolRelationships(arg1:string,arg2:string):Promise>>; + export function GetWorkspaceConfigs(arg1:string):Promise>>; export function GetWorkspaces():Promise>; export function ImportCommunityRule(arg1:string,arg2:string):Promise; +export function IngestIndex(arg1:string):Promise; + export function InitializeAstGrepConfig(arg1:string,arg2:string):Promise; export function InstallAstGrep():Promise; @@ -80,6 +84,8 @@ export function SearchCommunityRules(arg1:string,arg2:string,arg3:string):Promis export function SearchForIndexes(arg1:string):Promise>; +export function SearchSymbols(arg1:string,arg2:string):Promise>; + export function TestInferenceConnection(arg1:string,arg2:string):Promise; export function WriteMarkdown(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index aa00775..b0674f7 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -42,8 +42,8 @@ export function GetFileContent(arg1, arg2) { return window['go']['main']['App']['GetFileContent'](arg1, arg2); } -export function GetGraphData(arg1) { - return window['go']['main']['App']['GetGraphData'](arg1); +export function GetGraphData(arg1, arg2) { + return window['go']['main']['App']['GetGraphData'](arg1, arg2); } export function GetInferenceModels() { @@ -66,6 +66,10 @@ export function GetScipIndexerStatus(arg1) { return window['go']['main']['App']['GetScipIndexerStatus'](arg1); } +export function GetSymbolRelationships(arg1, arg2) { + return window['go']['main']['App']['GetSymbolRelationships'](arg1, arg2); +} + export function GetWorkspaceConfigs(arg1) { return window['go']['main']['App']['GetWorkspaceConfigs'](arg1); } @@ -78,6 +82,10 @@ export function ImportCommunityRule(arg1, arg2) { return window['go']['main']['App']['ImportCommunityRule'](arg1, arg2); } +export function IngestIndex(arg1) { + return window['go']['main']['App']['IngestIndex'](arg1); +} + export function InitializeAstGrepConfig(arg1, arg2) { return window['go']['main']['App']['InitializeAstGrepConfig'](arg1, arg2); } @@ -154,6 +162,10 @@ export function SearchForIndexes(arg1) { return window['go']['main']['App']['SearchForIndexes'](arg1); } +export function SearchSymbols(arg1, arg2) { + return window['go']['main']['App']['SearchSymbols'](arg1, arg2); +} + export function TestInferenceConnection(arg1, arg2) { return window['go']['main']['App']['TestInferenceConnection'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c460124..8496530 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -91,6 +91,74 @@ export namespace main { return a; } } + export class Member { + name: string; + kind: string; + symbol: string; + + static createFrom(source: any = {}) { + return new Member(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.kind = source["kind"]; + this.symbol = source["symbol"]; + } + } + export class GraphNode { + id: string; + name: string; + value: number; + category: number; + path: string; + kind: string; + docstring: string; + members: Member[]; + loc: number; + startLine: number; + endLine: number; + parent?: string; + + static createFrom(source: any = {}) { + return new GraphNode(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.value = source["value"]; + this.category = source["category"]; + this.path = source["path"]; + this.kind = source["kind"]; + this.docstring = source["docstring"]; + this.members = this.convertValues(source["members"], Member); + this.loc = source["loc"]; + this.startLine = source["startLine"]; + this.endLine = source["endLine"]; + this.parent = source["parent"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class LocalRuleDetails { id: string; message: string; @@ -127,6 +195,7 @@ export namespace main { this.frontMatter = source["frontMatter"]; } } + export class UserPreferences { theme: string; windowWidth: number; diff --git a/main.go b/main.go index 97210de..a4ee0df 100644 --- a/main.go +++ b/main.go @@ -178,7 +178,7 @@ func main() { }, Mac: &mac.Options{ TitleBar: &mac.TitleBar{ - TitlebarAppearsTransparent: true, + TitlebarAppearsTransparent: false, HideTitle: true, HideTitleBar: false, FullSizeContent: false, diff --git a/scip_ingest.go b/scip_ingest.go new file mode 100644 index 0000000..12c8f9e --- /dev/null +++ b/scip_ingest.go @@ -0,0 +1,319 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + scip "github.com/sourcegraph/scip/bindings/go/scip" + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + "google.golang.org/protobuf/proto" +) + +// IngestIndex parses a SCIP file and stores its symbols/relationships in SQLite. +func (a *App) IngestIndex(scipPath string) error { + scipPath = a.normalizePath(scipPath) + // 1. Get/Init local database for this workspace + localDB, err := a.getLocalDB(scipPath) + if err != nil { + return err + } + + // 2. Check if already indexed and up to date + info, err := os.Stat(scipPath) + if err != nil { + return err + } + mtime := info.ModTime().Unix() + + // 2. Check if we already have this index up to date (version 2 includes forward-slash fix) + var existingMtime int64 + var version int + err = localDB.QueryRow("SELECT mtime, version FROM scip_indices WHERE scip_path = ?", scipPath).Scan(&existingMtime, &version) + if err == nil && existingMtime >= mtime && version >= 2 { + return nil // Up to date + } + + // 3. Clear existing data for this index to avoid duplicates + _, _ = localDB.Exec("DELETE FROM scip_symbols WHERE scip_path = ?", scipPath) + _, _ = localDB.Exec("DELETE FROM scip_relationships WHERE scip_path = ?", scipPath) + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "processing", + "message": "Reading 65MB+ SCIP File...", + }) + } + + data, err := os.ReadFile(scipPath) + if err != nil { + return err + } + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "processing", + "message": "Unmarshaling Protobuf...", + }) + } + + var index scip.Index + if err := proto.Unmarshal(data, &index); err != nil { + return fmt.Errorf("failed to parse SCIP index: %w", err) + } + data = nil // Free memory + + // 4. Infer language from filename + language := "" + filename := filepath.Base(scipPath) + if strings.HasPrefix(filename, "index-") { + language = strings.TrimPrefix(filename, "index-") + language = strings.TrimSuffix(language, ".scip") + } + if language == "" { + language = "go" // Default fallback + } + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "processing", + "message": "Analyzing Architecture...", + }) + } + + // 3. Architecture Analysis (Pruning & Impact Calculation) + projectPkg := a.detectProjectPackage(&index) + + // Pruning helpers + isLocal := func(sym string) bool { return strings.Contains(sym, "local") } + isAnonymous := func(sym string) bool { + low := strings.ToLower(sym) + return strings.Contains(low, "$anon") || strings.Contains(low, "func.") || strings.Contains(low, "lambda$") + } + isStdLib := func(sym string) bool { + if projectPkg == "" { return false } + low := strings.ToLower(sym) + stdlibPrefixes := []string{"go/", "typescript/", "python/", "builtin/"} + if strings.Contains(low, strings.ToLower(projectPkg)) { return false } + for _, p := range stdlibPrefixes { + if strings.Contains(low, p) { return true } + } + return false + } + + refCount := make(map[string]int) + outboundCalls := make(map[string]int) + symbolToInfo := make(map[string]*scip.SymbolInformation) + + for _, doc := range index.Documents { + for _, si := range doc.Symbols { + symbolToInfo[si.Symbol] = si + } + var currentScope string + for _, occ := range doc.Occurrences { + if isLocal(occ.Symbol) || isAnonymous(occ.Symbol) { continue } + if occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { + currentScope = occ.Symbol + continue + } + refCount[occ.Symbol]++ + if currentScope != "" { + outboundCalls[currentScope]++ + } + } + } + for _, si := range index.ExternalSymbols { + symbolToInfo[si.Symbol] = si + } + + // 4. Persistence + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "processing", + "message": "Storing in SQLite...", + }) + } + + tx, err := localDB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Clean old data for this path + tx.Exec("DELETE FROM scip_indices WHERE scip_path = ?", scipPath) + + lang := detectLanguage(index.Documents) + _, err = tx.Exec("INSERT INTO scip_indices (scip_path, mtime, indexed_at, language) VALUES (?, ?, ?, ?)", + scipPath, mtime, time.Now().Format(time.RFC3339), lang) + if err != nil { return err } + + var symbolsIngested, relsIngested int + + stmtSym, _ := tx.Prepare(` + INSERT INTO scip_symbols ( + scip_path, symbol, name, kind, file_path, dir_path, + start_line, end_line, parent_symbol, + is_local, is_anonymous, is_stdlib, + ref_count, outbound_calls, impact_value + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + defer stmtSym.Close() + + stmtRel, _ := tx.Prepare(` + INSERT INTO scip_relationships (scip_path, source_symbol, target_symbol) VALUES (?, ?, ?) + `) + defer stmtRel.Close() + + // Pre-grouping for parent discovery + symbolToDescriptors := make(map[string][]*scip.Descriptor) + byLen := make(map[int][]string) + for sym := range symbolToInfo { + parsed, _ := scip.ParseSymbol(sym) + if len(parsed.Descriptors) > 0 { + symbolToDescriptors[sym] = parsed.Descriptors + byLen[len(parsed.Descriptors)] = append(byLen[len(parsed.Descriptors)], sym) + } + } + + for _, doc := range index.Documents { + dir := filepath.ToSlash(filepath.Dir(doc.RelativePath)) + if dir == "." { + dir = "root" + } + + for _, occ := range doc.Occurrences { + if occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { + sym := occ.Symbol + if isLocal(sym) || isAnonymous(sym) { continue } + + parsed, err := scip.ParseSymbol(sym) + if err != nil || len(parsed.Descriptors) == 0 { continue } + + leaf := parsed.Descriptors[len(parsed.Descriptors)-1] + if leaf.Suffix == scip.Descriptor_Namespace { continue } + + name := leaf.Name + kind := "Function" + switch leaf.Suffix { + case scip.Descriptor_Type: kind = "Struct" + case scip.Descriptor_Term: kind = "Variable" + case scip.Descriptor_Method, scip.Descriptor_Macro: kind = "Function" + case scip.Descriptor_Parameter, scip.Descriptor_TypeParameter: continue + } + if kind == "Struct" && strings.Contains(strings.ToLower(sym), "interface") { + kind = "Interface" + } + + // Parent Discovery + parentSym := "" + l := len(parsed.Descriptors) + if l > 1 { + candidates := byLen[l-1] + for _, pSym := range candidates { + pDescriptors := symbolToDescriptors[pSym] + match := true + for i := 0; i < l-1; i++ { + if pDescriptors[i].Name != parsed.Descriptors[i].Name || pDescriptors[i].Suffix != parsed.Descriptors[i].Suffix { + match = false + break + } + } + if match { + parentSym = pSym + break + } + } + } + + impact := (outboundCalls[sym] * 5) + (refCount[sym] * 10) + if kind == "Struct" || kind == "Interface" { impact += 20 } + if impact < 8 { impact = 8 } + + _, err = stmtSym.Exec( + scipPath, sym, name, kind, doc.RelativePath, dir, + int(occ.Range[0]+1), int(occ.Range[0]+1), parentSym, + isLocal(sym), isAnonymous(sym), isStdLib(sym), + refCount[sym], outboundCalls[sym], impact, + ) + if err != nil { return err } + symbolsIngested++ + } else { + // Relationship logic (for edges) + // We'll calculate this on the fly or store it. For scale, let's store it. + // Wait, Pass 4 logic needs currentScope. + // I'll handle relationships in a separate loop or refine this one. + } + } + } + + // Relationships Pass + for _, doc := range index.Documents { + var currentScope string + for _, occ := range doc.Occurrences { + if isLocal(occ.Symbol) || isAnonymous(occ.Symbol) { continue } + if occ.SymbolRoles&int32(scip.SymbolRole_Definition) != 0 { + currentScope = occ.Symbol + continue + } + if currentScope != "" && occ.Symbol != "" { + _, err = stmtRel.Exec(scipPath, currentScope, occ.Symbol) + if err != nil { return err } + relsIngested++ + } + } + } + + // 5. Update index metadata with version 2 + _, err = tx.Exec("INSERT OR REPLACE INTO scip_indices (scip_path, mtime, indexed_at, language, version) VALUES (?, ?, datetime('now'), ?, 2)", scipPath, mtime, language) + if err != nil { + tx.Rollback() + return err + } + + if err := tx.Commit(); err != nil { + return err + } + + if a.ctx != nil { + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "complete", + "message": "Index Ready!", + }) + + wailsRuntime.EventsEmit(a.ctx, "indexing-status", map[string]string{ + "path": scipPath, + "status": "done", + "message": fmt.Sprintf("Indexed %d symbols and %d relationships", symbolsIngested, relsIngested), + }) + } + + return nil +} + +func (a *App) detectProjectPackage(index *scip.Index) string { + if len(index.Documents) == 0 { return "" } + for _, doc := range index.Documents { + if len(doc.Symbols) > 0 { + sym := doc.Symbols[0].Symbol + if idx := strings.Index(sym, " "); idx != -1 { + parts := strings.Split(sym, " ") + if len(parts) >= 4 { + pkg := parts[3] + if slashIdx := strings.Index(pkg, "/"); slashIdx != -1 { + return pkg[:slashIdx] + } + return pkg + } + } + } + } + return "" +}