diff --git a/README.md b/README.md index d87255f..25a8bc4 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Call graph roots can be narrowed with `--file` or `path/to/file.go:Name` when mu | `canopy search grep` | Structural selector queries (e.g. `function_definition[name=/^Test/]`) | | `canopy search refs` | Find references by symbol name or regex | | `canopy search query` | Raw tree-sitter S-expression queries | -| `canopy search scope` | Resolve symbols in scope at file + line | +| `canopy search scope` | Resolve lexical scope at file + line, including Rust, Java, and C++ bindings | | `canopy search context` | Pack focused context for agent token budgets. `--concept` for concept-aware packing | | `canopy search symbols` | Search symbols by pattern | | `canopy search imports` | Analyze import patterns | diff --git a/cmd/canopy/main_test.go b/cmd/canopy/main_test.go index 441539f..c4b3b58 100644 --- a/cmd/canopy/main_test.go +++ b/cmd/canopy/main_test.go @@ -946,7 +946,7 @@ func work(input string) { t.Fatalf("ReadFrom failed: %v", err) } text := output.String() - for _, expected := range []string{"package: sample", "input (param)", "value (local_var)", "fmt (import)"} { + for _, expected := range []string{"language: go", "package: sample", "input (param)", "value (local_var)", "fmt (import)"} { if !strings.Contains(text, expected) { t.Fatalf("expected output to contain %q, got:\n%s", expected, text) } diff --git a/cmd/canopy/scope_cmd.go b/cmd/canopy/scope_cmd.go index 407f748..e85d659 100644 --- a/cmd/canopy/scope_cmd.go +++ b/cmd/canopy/scope_cmd.go @@ -19,7 +19,7 @@ func newScopeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "scope ", Aliases: []string{"gtsscope"}, - Short: "Resolve symbols in scope for a file and line", + Short: "Resolve lexical scope for a file and line", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { filePath := args[0] @@ -47,6 +47,7 @@ func newScopeCmd() *cobra.Command { fmt.Printf("file: %s\n", report.File) fmt.Printf("line: %d\n", report.Line) + fmt.Printf("language: %s\n", report.Language) fmt.Printf("package: %s\n", report.Package) if report.Focus != nil { fmt.Printf("focus: %s %s [%d:%d]\n", report.Focus.Kind, symbolLabel(report.Focus.Name, report.Focus.Signature), report.Focus.StartLine, report.Focus.EndLine) diff --git a/internal/mcp/call_scope_test.go b/internal/mcp/call_scope_test.go new file mode 100644 index 0000000..d54be0d --- /dev/null +++ b/internal/mcp/call_scope_test.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + + gtsscope "m31labs.dev/canopy/internal/scope" +) + +func TestCallScopeReportsJavaLexicalBindings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "Service.java") + const source = `package demo; +import java.util.List; + +class Service { + int run(String input) { + for (String item : List.of(input)) { + return item.length(); + } + return 0; + } +} +` + if err := os.WriteFile(path, []byte(source), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + result, err := NewService(dir, "").Call("gts_scope", map[string]any{ + "file": path, + "line": 7, + }) + if err != nil { + t.Fatalf("gts_scope: %v", err) + } + report, ok := result.(gtsscope.Report) + if !ok { + t.Fatalf("gts_scope result type = %T, want scope.Report", result) + } + if report.Language != "java" || report.Package != "demo" { + t.Fatalf("report language/package = %q/%q, want java/demo", report.Language, report.Package) + } + for _, want := range []string{"List", "Service", "run", "input", "item"} { + found := false + for _, symbol := range report.Symbols { + if symbol.Name == want { + found = true + break + } + } + if !found { + t.Fatalf("missing %q in gts_scope result: %+v", want, report.Symbols) + } + } +} diff --git a/internal/mcp/service.go b/internal/mcp/service.go index e5762e3..9834386 100644 --- a/internal/mcp/service.go +++ b/internal/mcp/service.go @@ -140,7 +140,7 @@ func searchTools() []Tool { }, { Name: "gts_scope", - Description: "Resolve symbols in scope for a file and line", + Description: "Resolve lexical scope for a file and line, including imports, parameters, destructuring, and control-flow bindings", InputSchema: Schema{ Properties: map[string]Property{ "file": {Type: "string"}, diff --git a/internal/scope/bindings.go b/internal/scope/bindings.go new file mode 100644 index 0000000..4afa477 --- /dev/null +++ b/internal/scope/bindings.go @@ -0,0 +1,265 @@ +package scope + +import ( + "strings" + + "github.com/odvcencio/gotreesitter" +) + +type lexicalBinding struct { + name string + line int +} + +func collectParameterNode(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node, kind string) { + detail := nodeFieldText(bound, node, "type") + targets := parameterBindingNodes(bound, node) + seen := make(map[string]struct{}) + for _, target := range targets { + for _, binding := range bindingNames(bound, target) { + if _, ok := seen[binding.name]; ok { + continue + } + seen[binding.name] = struct{}{} + collector.add(binding.name, kind, detail, binding.line) + } + } +} + +func parameterBindingNodes(bound *gotreesitter.BoundTree, node *gotreesitter.Node) []*gotreesitter.Node { + if node == nil { + return nil + } + var targets []*gotreesitter.Node + seen := make(map[*gotreesitter.Node]struct{}) + add := func(candidate *gotreesitter.Node) { + if candidate == nil { + return + } + if _, ok := seen[candidate]; ok { + return + } + seen[candidate] = struct{}{} + targets = append(targets, candidate) + } + for _, field := range []string{"name", "pattern", "declarator"} { + add(bound.ChildByField(node, field)) + } + + typeNode := bound.ChildByField(node, "type") + for i := 0; i < node.ChildCount(); i++ { + child := node.Child(i) + if child == nil || !child.IsNamed() || child == typeNode { + continue + } + if isBindingShape(bound.NodeType(child)) { + add(child) + } + } + return targets +} + +func collectLocalDeclaration(collector *symbolCollector, bound *gotreesitter.BoundTree, declaration *gotreesitter.Node) { + if declaration == nil { + return + } + detail := nodeFieldText(bound, declaration, "type") + found := false + for i := 0; i < declaration.ChildCount(); i++ { + child := declaration.Child(i) + if child == nil || !child.IsNamed() { + continue + } + switch bound.NodeType(child) { + case "variable_declarator", "init_declarator": + found = addDeclaratorBindings(collector, bound, child, detail) || found + } + } + if found { + return + } + addDeclaratorBindings(collector, bound, declaration, detail) +} + +func addDeclaratorBindings(collector *symbolCollector, bound *gotreesitter.BoundTree, declarator *gotreesitter.Node, detail string) bool { + if declarator == nil { + return false + } + var target *gotreesitter.Node + for _, field := range []string{"name", "declarator", "pattern"} { + if target = bound.ChildByField(declarator, field); target != nil { + break + } + } + if target == nil { + target = firstDirectBindingNode(bound, declarator) + } + bindings := bindingNames(bound, target) + for _, binding := range bindings { + collector.add(binding.name, "local_var", detail, binding.line) + } + return len(bindings) > 0 +} + +func collectEnclosingStmtDecls(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node) { + if node == nil { + return + } + switch bound.NodeType(node) { + case "for_statement": + collectGoForDecls(collector, bound, node) + collectHeaderDeclarations(collector, bound, node) + case "enhanced_for_statement": + addFieldBinding(collector, bound, node, "name", "local_var", nodeFieldText(bound, node, "type")) + case "for_range_loop": + addFieldBinding(collector, bound, node, "declarator", "local_var", nodeFieldText(bound, node, "type")) + case "for_expression": + addFieldBinding(collector, bound, node, "pattern", "local_var", "") + case "catch_clause": + collectCatchBinding(collector, bound, node) + case "if_statement", "while_statement", "switch_statement": + collectHeaderDeclarations(collector, bound, node) + case "if_expression", "while_expression", "match_expression": + collectHeaderDeclarations(collector, bound, node) + } +} + +func collectHeaderDeclarations(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node) { + for i := 0; i < node.ChildCount(); i++ { + child := node.Child(i) + if child == nil || !child.IsNamed() || isBlockNode(bound.NodeType(child)) { + continue + } + collectHeaderNode(collector, bound, child) + } +} + +func collectHeaderNode(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node) { + if node == nil || isBlockNode(bound.NodeType(node)) { + return + } + switch bound.NodeType(node) { + case "short_var_declaration": + collectShortVarDecl(collector, bound, node) + return + case "range_clause": + collectRangeClauseDecls(collector, bound, node) + return + case "let_declaration": + collectRustLetDecl(collector, bound, node) + return + case "local_variable_declaration", "declaration": + collectLocalDeclaration(collector, bound, node) + return + } + for i := 0; i < node.ChildCount(); i++ { + child := node.Child(i) + if child != nil && child.IsNamed() { + collectHeaderNode(collector, bound, child) + } + } +} + +func collectCatchBinding(collector *symbolCollector, bound *gotreesitter.BoundTree, catch *gotreesitter.Node) { + for i := 0; i < catch.ChildCount(); i++ { + child := catch.Child(i) + if child == nil || !child.IsNamed() { + continue + } + switch bound.NodeType(child) { + case "catch_formal_parameter", "formal_parameter", "parameter_declaration": + collectParameterNode(collector, bound, child, "local_var") + return + } + } +} + +func addFieldBinding(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node, field, kind, detail string) { + target := bound.ChildByField(node, field) + if target == nil { + target = firstDirectBindingNode(bound, node) + } + for _, binding := range bindingNames(bound, target) { + collector.add(binding.name, kind, detail, binding.line) + } +} + +func firstDirectBindingNode(bound *gotreesitter.BoundTree, node *gotreesitter.Node) *gotreesitter.Node { + if node == nil { + return nil + } + typeNode := bound.ChildByField(node, "type") + for i := 0; i < node.ChildCount(); i++ { + child := node.Child(i) + if child == nil || !child.IsNamed() || child == typeNode { + continue + } + if isBindingShape(bound.NodeType(child)) { + return child + } + } + return nil +} + +func bindingNames(bound *gotreesitter.BoundTree, node *gotreesitter.Node) []lexicalBinding { + if node == nil { + return nil + } + var out []lexicalBinding + seen := make(map[string]struct{}) + var visit func(*gotreesitter.Node) + visit = func(current *gotreesitter.Node) { + if current == nil || !current.IsNamed() { + return + } + nodeType := bound.NodeType(current) + switch nodeType { + case "identifier", "field_identifier", "shorthand_field_identifier_pattern", + "shorthand_property_identifier_pattern": + name := strings.TrimSpace(bound.NodeText(current)) + if name == "" || name == "_" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + out = append(out, lexicalBinding{name: name, line: int(current.StartPoint().Row) + 1}) + return + } + if isTypeOnlyNode(nodeType) { + return + } + for i := 0; i < current.ChildCount(); i++ { + visit(current.Child(i)) + } + } + visit(node) + return out +} + +func isBindingShape(nodeType string) bool { + if nodeType == "identifier" || nodeType == "field_identifier" { + return true + } + return strings.Contains(nodeType, "declarator") || strings.Contains(nodeType, "pattern") +} + +func isTypeOnlyNode(nodeType string) bool { + if strings.Contains(nodeType, "type") && !strings.Contains(nodeType, "pattern") { + return true + } + switch nodeType { + case "namespace_identifier", "primitive_type", "integral_type", "floating_point_type": + return true + } + return false +} + +func nodeFieldText(bound *gotreesitter.BoundTree, node *gotreesitter.Node, field string) string { + child := bound.ChildByField(node, field) + if child == nil { + return "" + } + return strings.TrimSpace(bound.NodeText(child)) +} diff --git a/internal/scope/imports.go b/internal/scope/imports.go new file mode 100644 index 0000000..71c47cb --- /dev/null +++ b/internal/scope/imports.go @@ -0,0 +1,276 @@ +package scope + +import ( + "path" + "strings" + + "m31labs.dev/canopy/pkg/model" +) + +func addImportsFromIndex(collector *symbolCollector, summary model.FileSummary) { + for _, declaration := range summary.Imports { + for _, name := range importBindingNames(summary.Language, declaration) { + collector.add(name, "import", declaration, 0) + } + } +} + +// importBindingNames extracts the names a source-level import introduces into +// lexical scope. Index imports preserve their original syntax, so treating the +// whole declaration as a name produces unusable scope results outside Go. +func importBindingNames(language, declaration string) []string { + language = strings.ToLower(strings.TrimSpace(language)) + declaration = strings.TrimSpace(declaration) + if declaration == "" { + return nil + } + + var names []string + switch language { + case "go": + names = goImportBindings(declaration) + case "python": + names = pythonImportBindings(declaration) + case "javascript", "typescript", "tsx": + names = ecmaImportBindings(declaration) + case "rust": + names = rustImportBindings(declaration) + case "java": + names = javaImportBindings(declaration) + case "kotlin": + names = kotlinImportBindings(declaration) + case "c", "cpp", "c++": + // #include makes declarations available through preprocessing; it does + // not introduce a lexical binding with the header spelling. + return nil + default: + name := strings.Trim(strings.TrimSpace(declaration), "\"'`") + if strings.ContainsAny(name, " \t") { + return nil + } + names = []string{path.Base(name)} + } + return uniqueImportNames(names) +} + +func goImportBindings(declaration string) []string { + s := trimStatement(declaration) + s = strings.TrimSpace(strings.TrimPrefix(s, "import")) + fields := strings.Fields(s) + if len(fields) > 1 { + alias := fields[0] + if alias == "_" || alias == "." { + return nil + } + return []string{alias} + } + if len(fields) == 1 { + name := strings.Trim(fields[0], "\"'`") + return []string{path.Base(name)} + } + return nil +} + +func pythonImportBindings(declaration string) []string { + s := trimStatement(declaration) + if strings.HasPrefix(s, "from ") { + marker := strings.Index(s, " import ") + if marker < 0 { + return nil + } + return aliasedBindings(s[marker+len(" import "):], false) + } + if !strings.HasPrefix(s, "import ") { + return nil + } + items := splitImportList(strings.TrimSpace(strings.TrimPrefix(s, "import "))) + out := make([]string, 0, len(items)) + for _, item := range items { + base, alias := splitAlias(item) + if alias != "" { + out = append(out, alias) + continue + } + if first, _, ok := strings.Cut(base, "."); ok { + base = first + } + out = append(out, base) + } + return out +} + +func ecmaImportBindings(declaration string) []string { + s := trimStatement(declaration) + s = strings.TrimSpace(strings.TrimPrefix(s, "import")) + s = strings.TrimSpace(strings.TrimPrefix(s, "type")) + if s == "" || strings.HasPrefix(s, "\"") || strings.HasPrefix(s, "'") { + return nil + } + if before, _, ok := strings.Cut(s, " from "); ok { + s = strings.TrimSpace(before) + } + + var out []string + if brace := strings.IndexByte(s, '{'); brace >= 0 { + if close := strings.LastIndexByte(s, '}'); close > brace { + out = append(out, aliasedBindings(s[brace+1:close], false)...) + } + prefix := strings.Trim(strings.TrimSpace(s[:brace]), ",") + if prefix != "" { + out = append(out, prefix) + } + return out + } + if marker := strings.Index(s, "* as "); marker >= 0 { + return []string{strings.TrimSpace(s[marker+len("* as "):])} + } + if first, _, ok := strings.Cut(s, ","); ok { + return []string{strings.TrimSpace(first)} + } + return []string{strings.TrimSpace(s)} +} + +func rustImportBindings(declaration string) []string { + s := trimStatement(declaration) + if marker := strings.Index(s, "use "); marker >= 0 { + s = strings.TrimSpace(s[marker+len("use "):]) + } else { + return nil + } + + if open := strings.IndexByte(s, '{'); open >= 0 { + close := strings.LastIndexByte(s, '}') + if close <= open { + return nil + } + prefix := strings.TrimSuffix(strings.TrimSpace(s[:open]), "::") + prefixName := lastPathSegment(prefix, "::") + items := splitImportList(s[open+1 : close]) + out := make([]string, 0, len(items)) + for _, item := range items { + base, alias := splitAlias(item) + if alias != "" { + out = append(out, alias) + continue + } + if base == "self" { + out = append(out, prefixName) + continue + } + if base == "*" { + continue + } + out = append(out, lastPathSegment(base, "::")) + } + return out + } + + base, alias := splitAlias(s) + if alias != "" { + return []string{alias} + } + if strings.HasSuffix(base, "::*") { + return nil + } + return []string{lastPathSegment(base, "::")} +} + +func javaImportBindings(declaration string) []string { + s := trimStatement(declaration) + s = strings.TrimSpace(strings.TrimPrefix(s, "import")) + s = strings.TrimSpace(strings.TrimPrefix(s, "static")) + if strings.HasSuffix(s, ".*") { + return nil + } + return []string{lastPathSegment(s, ".")} +} + +func kotlinImportBindings(declaration string) []string { + s := trimStatement(declaration) + s = strings.TrimSpace(strings.TrimPrefix(s, "import")) + base, alias := splitAlias(s) + if alias != "" { + return []string{alias} + } + if strings.HasSuffix(base, ".*") { + return nil + } + return []string{lastPathSegment(base, ".")} +} + +func aliasedBindings(list string, firstSegment bool) []string { + items := splitImportList(list) + out := make([]string, 0, len(items)) + for _, item := range items { + base, alias := splitAlias(item) + if alias != "" { + out = append(out, alias) + continue + } + if base == "*" { + continue + } + if firstSegment { + if first, _, ok := strings.Cut(base, "."); ok { + base = first + } + } else { + base = lastPathSegment(base, ".") + } + out = append(out, base) + } + return out +} + +func splitAlias(item string) (string, string) { + item = strings.TrimSpace(strings.Trim(item, "()")) + if before, after, ok := strings.Cut(item, " as "); ok { + return strings.TrimSpace(before), strings.TrimSpace(after) + } + return item, "" +} + +func splitImportList(list string) []string { + list = strings.TrimSpace(strings.Trim(list, "()")) + parts := strings.Split(list, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func trimStatement(s string) string { + return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), ";")) +} + +func lastPathSegment(value, separator string) string { + value = strings.TrimSpace(value) + if idx := strings.LastIndex(value, separator); idx >= 0 { + value = value[idx+len(separator):] + } + return strings.TrimSpace(value) +} + +func uniqueImportNames(names []string) []string { + seen := make(map[string]struct{}, len(names)) + out := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(strings.Trim(name, "{}()")) + if name == "" || name == "_" || name == "." || name == "*" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/scope/imports_test.go b/internal/scope/imports_test.go new file mode 100644 index 0000000..8245ab2 --- /dev/null +++ b/internal/scope/imports_test.go @@ -0,0 +1,37 @@ +package scope + +import ( + "reflect" + "testing" +) + +func TestImportBindingNames(t *testing.T) { + tests := []struct { + name string + language string + declaration string + want []string + }{ + {name: "go path", language: "go", declaration: "github.com/acme/log", want: []string{"log"}}, + {name: "go alias", language: "go", declaration: `trace "github.com/acme/log"`, want: []string{"trace"}}, + {name: "go blank", language: "go", declaration: `_ "github.com/acme/driver"`}, + {name: "python modules", language: "python", declaration: "import os, pathlib as paths", want: []string{"os", "paths"}}, + {name: "python from", language: "python", declaration: "from pkg.models import User, Team as Group", want: []string{"User", "Group"}}, + {name: "javascript default and named", language: "javascript", declaration: `import React, {useState, useMemo as memo} from "react";`, want: []string{"useState", "memo", "React"}}, + {name: "typescript namespace", language: "typescript", declaration: `import * as schema from "./schema";`, want: []string{"schema"}}, + {name: "rust alias", language: "rust", declaration: "pub use crate::service::Worker as ServiceWorker;", want: []string{"ServiceWorker"}}, + {name: "rust group", language: "rust", declaration: "use std::io::{self, Read, Write as Writer};", want: []string{"io", "Read", "Writer"}}, + {name: "java type", language: "java", declaration: "import java.util.List;", want: []string{"List"}}, + {name: "java wildcard", language: "java", declaration: "import java.util.*;"}, + {name: "kotlin alias", language: "kotlin", declaration: "import foo.bar.Baz as Qux", want: []string{"Qux"}}, + {name: "cpp include", language: "cpp", declaration: "#include "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := importBindingNames(tt.language, tt.declaration); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("importBindingNames(%q, %q) = %v, want %v", tt.language, tt.declaration, got, tt.want) + } + }) + } +} diff --git a/internal/scope/scope.go b/internal/scope/scope.go index 00377cc..45e80c1 100644 --- a/internal/scope/scope.go +++ b/internal/scope/scope.go @@ -27,11 +27,12 @@ type Symbol struct { } type Report struct { - File string `json:"file"` - Line int `json:"line"` - Package string `json:"package"` - Focus *model.Symbol `json:"focus,omitempty"` - Symbols []Symbol `json:"symbols,omitempty"` + File string `json:"file"` + Line int `json:"line"` + Language string `json:"language"` + Package string `json:"package"` + Focus *model.Symbol `json:"focus,omitempty"` + Symbols []Symbol `json:"symbols,omitempty"` } func Build(idx *model.Index, opts Options) (Report, error) { @@ -77,9 +78,10 @@ func Build(idx *model.Index, opts Options) (Report, error) { } report := Report{ - File: fileSummary.Path, - Line: opts.Line, - Package: inferPackageName(bound, root, fileSummary), + File: fileSummary.Path, + Line: opts.Line, + Language: fileSummary.Language, + Package: inferPackageName(bound, root, fileSummary), } focus := findFocusSymbol(fileSummary.Symbols, opts.Line) @@ -149,18 +151,28 @@ func findFocusSymbol(symbols []model.Symbol, line int) *model.Symbol { } func inferPackageName(bound *gotreesitter.BoundTree, root *gotreesitter.Node, summary model.FileSummary) string { - // For Go files, extract package name from package_clause - if summary.Language == "go" { - for i := 0; i < root.ChildCount(); i++ { - child := root.Child(i) - if bound.NodeType(child) == "package_clause" { - for j := 0; j < child.ChildCount(); j++ { - gc := child.Child(j) - if bound.NodeType(gc) == "package_identifier" { - return strings.TrimSpace(bound.NodeText(gc)) - } + for i := 0; i < root.ChildCount(); i++ { + child := root.Child(i) + switch bound.NodeType(child) { + case "package_clause": + if summary.Language != "go" { + continue + } + for j := 0; j < child.ChildCount(); j++ { + name := child.Child(j) + if bound.NodeType(name) == "package_identifier" { + return strings.TrimSpace(bound.NodeText(name)) } } + case "package_declaration": + if summary.Language != "java" && summary.Language != "kotlin" { + continue + } + name := strings.TrimSpace(bound.NodeText(child)) + name = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(name, "package"), ";")) + if name != "" { + return name + } } } // Fallback: use directory name @@ -171,22 +183,12 @@ func inferPackageName(bound *gotreesitter.BoundTree, root *gotreesitter.Node, su return filepath.Base(dir) } -func addImportsFromIndex(collector *symbolCollector, summary model.FileSummary) { - for _, imp := range summary.Imports { - name := importBase(imp) - if name == "" || name == "_" { - continue - } - collector.add(name, "import", imp, 0) - } -} - func addIndexedPackageSymbols(collector *symbolCollector, idx *model.Index, fileSummary model.FileSummary) { dir := filepath.ToSlash(filepath.Dir(filepath.Clean(fileSummary.Path))) isTest := strings.HasSuffix(filepath.ToSlash(filepath.Clean(fileSummary.Path)), "_test.go") for _, file := range idx.Files { fileDir := filepath.ToSlash(filepath.Dir(filepath.Clean(file.Path))) - if fileDir != dir { + if fileDir != dir || !sameScopeLanguage(file.Language, fileSummary.Language) { continue } if strings.HasSuffix(filepath.ToSlash(filepath.Clean(file.Path)), "_test.go") != isTest { @@ -196,15 +198,37 @@ func addIndexedPackageSymbols(collector *symbolCollector, idx *model.Index, file switch symbol.Kind { case "function_definition": collector.add(symbol.Name, "package_function", symbol.Signature, symbol.StartLine) - case "method_definition": + case "method_definition", "constructor_definition": collector.add(symbol.Name, "package_method", symbol.Signature, symbol.StartLine) - case "type_definition": + case "type_definition", "class_definition", "interface_definition", + "struct_definition", "enum_definition": collector.add(symbol.Name, "package_type", symbol.Signature, symbol.StartLine) + case "constant_definition": + collector.add(symbol.Name, "package_const", symbol.Signature, symbol.StartLine) } } } } +func sameScopeLanguage(left, right string) bool { + left = strings.ToLower(strings.TrimSpace(left)) + right = strings.ToLower(strings.TrimSpace(right)) + if left == right { + return true + } + family := func(language string) string { + switch language { + case "javascript", "typescript", "tsx": + return "ecmascript" + case "c", "cpp", "c++": + return "c-family" + default: + return language + } + } + return family(left) == family(right) +} + // addLocalScope walks the tree-sitter AST to find declarations visible at the target line. // It finds the innermost scope containing the line and collects all declarations // visible from that point: function parameters, local variables, and block-scoped names. @@ -262,27 +286,28 @@ func isFunctionDecl(nodeType string) bool { // and the third is results. For regular functions, the first is params. func collectFunctionParams(collector *symbolCollector, bound *gotreesitter.BoundTree, funcNode *gotreesitter.Node) { funcType := bound.NodeType(funcNode) - isGoMethod := funcType == "method_declaration" - paramListIndex := 0 - for i := 0; i < funcNode.ChildCount(); i++ { - child := funcNode.Child(i) - nodeType := bound.NodeType(child) - - switch nodeType { - case "parameter_list": - if isGoMethod && paramListIndex == 0 { - // Go method receiver - collectReceiverParam(collector, bound, child) - } else { - // Regular params or result params - collectParamList(collector, bound, child) - } - paramListIndex++ - case "parameters", "formal_parameters", + var lists []*gotreesitter.Node + gotreesitter.Walk(funcNode, func(node *gotreesitter.Node, _ int) gotreesitter.WalkAction { + if node != funcNode && isBlockNode(bound.NodeType(node)) { + return gotreesitter.WalkSkipChildren + } + switch bound.NodeType(node) { + case "parameter_list", "parameters", "formal_parameters", "function_params", "lambda_parameters": - collectParamList(collector, bound, child) + lists = append(lists, node) + return gotreesitter.WalkSkipChildren } + return gotreesitter.WalkContinue + }) + + hasGoReceiver := funcType == "method_declaration" && len(lists) > 1 && bound.NodeType(lists[0]) == "parameter_list" + for i, list := range lists { + if hasGoReceiver && i == 0 { + collectReceiverParam(collector, bound, list) + continue + } + collectParamList(collector, bound, list) } } @@ -290,17 +315,19 @@ func collectParamList(collector *symbolCollector, bound *gotreesitter.BoundTree, gotreesitter.Walk(paramList, func(node *gotreesitter.Node, depth int) gotreesitter.WalkAction { nodeType := bound.NodeType(node) switch nodeType { - case "parameter_declaration", "parameter", "required_parameter", - "optional_parameter", "rest_parameter": - name, detail := extractParamNameAndType(bound, node) - if name != "" && name != "_" { - collector.add(name, "param", detail, int(node.StartPoint().Row)+1) - } + case "parameter_declaration", "parameter", "formal_parameter", + "spread_parameter", "required_parameter", "optional_parameter", + "rest_parameter", "typed_parameter", "typed_default_parameter": + collectParameterNode(collector, bound, node, "param") + return gotreesitter.WalkSkipChildren + case "self_parameter": + collector.add("self", "receiver", strings.TrimSpace(bound.NodeText(node)), int(node.StartPoint().Row)+1) + return gotreesitter.WalkSkipChildren case "identifier": // For Python-style simple params (just identifiers in the param list) if depth == 1 { name := strings.TrimSpace(bound.NodeText(node)) - if name != "" && name != "self" && name != "cls" && name != "_" { + if name != "" && name != "_" { collector.add(name, "param", "", int(node.StartPoint().Row)+1) } } @@ -309,31 +336,6 @@ func collectParamList(collector *symbolCollector, bound *gotreesitter.BoundTree, }) } -func extractParamNameAndType(bound *gotreesitter.BoundTree, paramNode *gotreesitter.Node) (string, string) { - name := "" - typeStr := "" - for i := 0; i < paramNode.ChildCount(); i++ { - child := paramNode.Child(i) - if !child.IsNamed() { - continue - } - childType := bound.NodeType(child) - switch childType { - case "identifier", "field_identifier", "name": - if name == "" { - name = strings.TrimSpace(bound.NodeText(child)) - } - case "type_identifier", "pointer_type", "slice_type", - "array_type", "map_type", "channel_type", - "interface_type", "struct_type", "function_type", - "qualified_type", "generic_type", - "type_annotation", "type": - typeStr = strings.TrimSpace(bound.NodeText(child)) - } - } - return name, typeStr -} - func collectReceiverParam(collector *symbolCollector, bound *gotreesitter.BoundTree, paramList *gotreesitter.Node) { for i := 0; i < paramList.ChildCount(); i++ { child := paramList.Child(i) @@ -342,11 +344,8 @@ func collectReceiverParam(collector *symbolCollector, bound *gotreesitter.BoundT } nodeType := bound.NodeType(child) if nodeType == "parameter_declaration" { - name, detail := extractParamNameAndType(bound, child) - if name != "" && name != "_" { - collector.add(name, "receiver", detail, int(child.StartPoint().Row)+1) - return - } + collectParameterNode(collector, bound, child, "receiver") + return } } } @@ -390,8 +389,11 @@ func collectBlockScope(collector *symbolCollector, bound *gotreesitter.BoundTree continue } - // We're inside this statement — collect its init-clause decls and recurse + // We're inside this statement. Direct declarations are visible from + // their declaration onward; loop/condition/catch bindings are visible + // only while descending through the containing statement. collectDeclsFromStmt(collector, bound, child) + collectEnclosingStmtDecls(collector, bound, child) recurseIntoContainingBlock(collector, bound, child, line) return } @@ -444,11 +446,9 @@ func collectDeclsFromStmt(collector *symbolCollector, bound *gotreesitter.BoundT // Rust let bindings case "let_declaration": collectRustLetDecl(collector, bound, stmt) - // Go range statements - case "for_statement": - collectGoForDecls(collector, bound, stmt) - case "range_clause": - collectRangeClauseDecls(collector, bound, stmt) + // Java and C/C++ local declarations + case "local_variable_declaration", "declaration": + collectLocalDeclaration(collector, bound, stmt) // Labeled statements — recurse to inner stmt case "labeled_statement": for i := 0; i < stmt.ChildCount(); i++ { @@ -531,16 +531,17 @@ func collectJSVarDecl(collector *symbolCollector, bound *gotreesitter.BoundTree, gotreesitter.Walk(node, func(child *gotreesitter.Node, depth int) gotreesitter.WalkAction { childType := bound.NodeType(child) if childType == "variable_declarator" { - for i := 0; i < child.ChildCount(); i++ { - gc := child.Child(i) - if bound.NodeType(gc) == "identifier" { - name := strings.TrimSpace(bound.NodeText(gc)) - if name != "" { - collector.add(name, "local_var", "", int(gc.StartPoint().Row)+1) - } - break - } + target := bound.ChildByField(child, "name") + if target == nil { + target = bound.ChildByField(child, "pattern") + } + if target == nil { + target = firstDirectBindingNode(bound, child) + } + for _, binding := range bindingNames(bound, target) { + collector.add(binding.name, "local_var", "", binding.line) } + return gotreesitter.WalkSkipChildren } return gotreesitter.WalkContinue }) @@ -551,25 +552,23 @@ func collectPythonAssignment(collector *symbolCollector, bound *gotreesitter.Bou if node.ChildCount() == 0 { return } - lhs := node.Child(0) - if lhs != nil && bound.NodeType(lhs) == "identifier" { - name := strings.TrimSpace(bound.NodeText(lhs)) - if name != "" && name != "_" { - collector.add(name, "local_var", "", int(lhs.StartPoint().Row)+1) - } + lhs := bound.ChildByField(node, "left") + if lhs == nil { + lhs = node.Child(0) + } + for _, binding := range bindingNames(bound, lhs) { + collector.add(binding.name, "local_var", "", binding.line) } } func collectRustLetDecl(collector *symbolCollector, bound *gotreesitter.BoundTree, node *gotreesitter.Node) { - for i := 0; i < node.ChildCount(); i++ { - child := node.Child(i) - if bound.NodeType(child) == "identifier" { - name := strings.TrimSpace(bound.NodeText(child)) - if name != "" && name != "_" { - collector.add(name, "local_var", "", int(child.StartPoint().Row)+1) - } - return - } + pattern := bound.ChildByField(node, "pattern") + if pattern == nil { + pattern = firstDirectBindingNode(bound, node) + } + detail := nodeFieldText(bound, node, "type") + for _, binding := range bindingNames(bound, pattern) { + collector.add(binding.name, "local_var", detail, binding.line) } } @@ -620,6 +619,7 @@ func recurseIntoContainingBlock(collector *symbolCollector, bound *gotreesitter. } nodeType := bound.NodeType(child) + collectEnclosingStmtDecls(collector, bound, child) if isBlockNode(nodeType) { collectBlockScope(collector, bound, child, line) return @@ -629,15 +629,6 @@ func recurseIntoContainingBlock(collector *symbolCollector, bound *gotreesitter. } } -func importBase(path string) string { - trimmed := strings.TrimSpace(path) - if trimmed == "" { - return "" - } - parts := strings.Split(trimmed, "/") - return parts[len(parts)-1] -} - type symbolCollector struct { items []Symbol byName map[string]int diff --git a/internal/scope/scope_multilang_test.go b/internal/scope/scope_multilang_test.go new file mode 100644 index 0000000..3e3c2f6 --- /dev/null +++ b/internal/scope/scope_multilang_test.go @@ -0,0 +1,352 @@ +package scope + +import ( + "os" + "path/filepath" + "testing" + + "m31labs.dev/canopy/pkg/index" +) + +func TestBuild_RustLexicalScope(t *testing.T) { + const source = `use std::collections::HashMap; +use crate::service::Worker as ServiceWorker; + +fn run(input: i32, (left, right): (i32, i32)) -> i32 { + let count: i32 = input; + let (first, second) = (1, 2); + if count > 0 { + let nested = count; + return nested; + } + let future = count; + future +} +` + report := buildScopeReport(t, "sample.rs", source, 9) + if report.Language != "rust" { + t.Fatalf("Language = %q, want rust", report.Language) + } + + for name, kind := range map[string]string{ + "HashMap": "import", + "ServiceWorker": "import", + "run": "package_function", + "input": "param", + "left": "param", + "right": "param", + "count": "local_var", + "first": "local_var", + "second": "local_var", + "nested": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "future") +} + +func TestBuild_JavaLexicalScope(t *testing.T) { + const source = `package demo; +import java.util.List; +import static java.util.Collections.emptyList; + +class Service { + int run(String input, int limit) { + int count = limit; + for (String item : emptyList()) { + int size = item.length(); + return size; + } + try { + return count; + } catch (RuntimeException err) { + String message = err.getMessage(); + return message.length(); + } + int future = count; + return future; + } +} +` + report := buildScopeReport(t, "Service.java", source, 9) + + if report.Language != "java" { + t.Fatalf("Language = %q, want java", report.Language) + } + if report.Package != "demo" { + t.Fatalf("Package = %q, want demo", report.Package) + } + for name, kind := range map[string]string{ + "List": "import", + "emptyList": "import", + "Service": "package_type", + "run": "package_method", + "input": "param", + "limit": "param", + "count": "local_var", + "item": "local_var", + "size": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "future") +} + +func TestBuild_JavaNestedBindingsDoNotLeak(t *testing.T) { + const source = `package demo; + +class Service { + int run(int limit) { + int count = limit; + for (String item : items) { + int size = item.length(); + } + try { + return count; + } catch (RuntimeException err) { + String message = err.getMessage(); + return message.length(); + } + } +} +` + report := buildScopeReport(t, "Service.java", source, 12) + + for name, kind := range map[string]string{ + "limit": "param", + "count": "local_var", + "err": "local_var", + "message": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "item") + assertScopeMissing(t, report, "size") +} + +func TestBuild_CPPLexicalScope(t *testing.T) { + const source = `#include +#include + +int run(const std::string& input, int limit) { + int count = limit; + auto [left, right] = std::pair{1, 2}; + for (const auto& item : items) { + int size = item.size(); + return size; + } + if (auto value = lookup()) { + return value; + } + int future = count; + return future; +} +` + report := buildScopeReport(t, "sample.cpp", source, 8) + if report.Language != "cpp" { + t.Fatalf("Language = %q, want cpp", report.Language) + } + + for name, kind := range map[string]string{ + "run": "package_function", + "input": "param", + "limit": "param", + "count": "local_var", + "left": "local_var", + "right": "local_var", + "item": "local_var", + "size": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "#include ") + assertScopeMissing(t, report, "future") +} + +func TestBuild_CPPControlBindingsDoNotLeak(t *testing.T) { + const source = `int run(int limit) { + int count = limit; + for (const auto& item : items) { + int size = item.size(); + } + if (auto value = lookup()) { + return value; + } + return count; +} +` + report := buildScopeReport(t, "sample.cpp", source, 7) + + for name, kind := range map[string]string{ + "limit": "param", + "count": "local_var", + "value": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "item") + assertScopeMissing(t, report, "size") +} + +func TestBuild_TypeScriptDestructuringScope(t *testing.T) { + const source = `import {Config as Settings} from "./config"; + +function work(input: {left: number; right: number}) { + const {left, right: renamed} = input; + const [first, second] = [1, 2]; + return left + renamed + first + second; +} +` + report := buildScopeReport(t, "sample.ts", source, 6) + + for name, kind := range map[string]string{ + "Settings": "import", + "work": "package_function", + "input": "param", + "left": "local_var", + "renamed": "local_var", + "first": "local_var", + "second": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } +} + +func TestBuild_PythonDestructuringScope(t *testing.T) { + const source = `def work(value): + left, right = value + [first, second] = value + return left + right + first + second +` + report := buildScopeReport(t, "sample.py", source, 4) + + for name, kind := range map[string]string{ + "work": "package_function", + "value": "param", + "left": "local_var", + "right": "local_var", + "first": "local_var", + "second": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } +} + +func TestBuild_GoLoopBindingsDoNotLeak(t *testing.T) { + const source = `package sample + +func work(items []int) int { + total := 0 + for i, value := range items { + total += i + value + } + return total +} +` + report := buildScopeReport(t, "sample.go", source, 8) + + for name, kind := range map[string]string{ + "items": "param", + "total": "local_var", + } { + assertScopeSymbol(t, report, name, kind) + } + assertScopeMissing(t, report, "i") + assertScopeMissing(t, report, "value") +} + +func TestBuild_PackageSymbolsDoNotCrossLanguageFamilies(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "sample.rs": `fn run(input: i32) -> i32 { + input +} +`, + "Foreign.java": `class Foreign { + int unrelated() { return 1; } +} +`, + "foreign.py": `def python_only(): + return 1 +`, + } + for name, source := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(source), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", name, err) + } + } + idx, err := index.NewBuilder().BuildPath(dir) + if err != nil { + t.Fatalf("BuildPath: %v", err) + } + report, err := Build(idx, Options{FilePath: filepath.Join(dir, "sample.rs"), Line: 2}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + assertScopeSymbol(t, report, "run", "package_function") + assertScopeMissing(t, report, "Foreign") + assertScopeMissing(t, report, "unrelated") + assertScopeMissing(t, report, "python_only") +} + +func TestSameScopeLanguage(t *testing.T) { + tests := []struct { + left string + right string + want bool + }{ + {left: "rust", right: "rust", want: true}, + {left: "javascript", right: "typescript", want: true}, + {left: "typescript", right: "tsx", want: true}, + {left: "c", right: "cpp", want: true}, + {left: "rust", right: "java", want: false}, + {left: "cpp", right: "java", want: false}, + } + for _, tt := range tests { + if got := sameScopeLanguage(tt.left, tt.right); got != tt.want { + t.Errorf("sameScopeLanguage(%q, %q) = %v, want %v", tt.left, tt.right, got, tt.want) + } + } +} + +func buildScopeReport(t *testing.T, filename, source string, line int) Report { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, []byte(source), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", filename, err) + } + idx, err := index.NewBuilder().BuildPath(dir) + if err != nil { + t.Fatalf("BuildPath(%s): %v", filename, err) + } + report, err := Build(idx, Options{FilePath: path, Line: line}) + if err != nil { + t.Fatalf("Build(%s): %v", filename, err) + } + return report +} + +func assertScopeSymbol(t *testing.T, report Report, name, kind string) { + t.Helper() + for _, symbol := range report.Symbols { + if symbol.Name != name { + continue + } + if symbol.Kind != kind { + t.Fatalf("symbol %q kind = %q, want %q; symbols=%+v", name, symbol.Kind, kind, report.Symbols) + } + return + } + t.Fatalf("missing symbol %q (%s); symbols=%+v", name, kind, report.Symbols) +} + +func assertScopeMissing(t *testing.T, report Report, name string) { + t.Helper() + for _, symbol := range report.Symbols { + if symbol.Name == name { + t.Fatalf("unexpected symbol %q in scope; symbols=%+v", name, report.Symbols) + } + } +}