use tree_sitter::{Node, Parser};
use crate::error::{CodegraphError, Result};
use crate::graph::types::{
Binding, BindingKind, ByteSpan, EntryPoint, FileFacts, RefRole, Reference, Scope, ScopeId,
ScopeKind, Symbol, SymbolKind, TypeRefContext, Visibility,
};
use crate::lang::Language;
use crate::symbol::Descriptor;
use super::{
ExtractCtx, Extractor, MIN_REF_LEN, attach_reference_scopes, child_text,
collect_call_references, definition_bindings, field_text, import_bindings, innermost_scope,
make_symbol, node_span, node_text, one_line_signature, push_binding, push_ref, push_scope,
push_type_ref, simple_type_name,
};
const CALL_QUERY: &str = r#"
(call_expression
function: [
(identifier) @callee
(selector_expression operand: (_) @qualifier field: (field_identifier) @callee)
]
)
"#;
pub struct GoExtractor;
impl Extractor for GoExtractor {
fn lang(&self) -> Language {
Language::Go
}
fn extract(&self, source: &str, file: &str) -> Result<FileFacts> {
let ts_language = crate::grammar::go();
let mut parser = Parser::new();
parser
.set_language(&ts_language)
.map_err(|_| CodegraphError::Parse {
path: file.to_owned(),
})?;
let tree = parser
.parse(source, None)
.ok_or_else(|| CodegraphError::Parse {
path: file.to_owned(),
})?;
let root = tree.root_node();
let bytes = source.as_bytes();
let namespaces = go_namespaces(&root, bytes, file);
let ctx = ExtractCtx {
bytes,
file,
lang: Language::Go,
};
let is_pkg_main = go_package_name(&root, bytes).as_deref() == Some("main");
let defs = collect_symbols(&root, &ctx, &namespaces, is_pkg_main);
let def_bindings = definition_bindings(&defs);
let mut symbols = defs;
symbols.push(super::module_symbol(
Language::Go,
&namespaces,
file,
source.len(),
));
let mut references =
collect_call_references(&root, &ts_language, CALL_QUERY, Language::Go, bytes, file)?;
collect_imports(&root, bytes, file, &mut references);
collect_type_references(&root, bytes, file, &mut references);
collect_read_references(&root, bytes, file, &mut references);
collect_write_references(&root, bytes, file, &mut references);
let scopes = collect_scopes(&root, source.len());
attach_reference_scopes(&mut references, &scopes);
let mut bindings = collect_bindings(&root, bytes, &scopes);
bindings.extend(def_bindings);
bindings.extend(import_bindings(&references, &scopes));
Ok(FileFacts {
file: file.to_owned(),
lang: Language::Go.as_str().to_owned(),
symbols,
references,
scopes,
bindings,
ffi_exports: Vec::new(),
})
}
}
fn go_namespaces(root: &Node, bytes: &[u8], file: &str) -> Vec<String> {
let p = file.strip_prefix("src/").unwrap_or(file);
if let Some((dir, _)) = p.rsplit_once('/') {
let segs: Vec<String> = dir
.split('/')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if !segs.is_empty() {
return segs;
}
}
if let Some(pkg) = go_package_name(root, bytes) {
return vec![pkg];
}
let stem = p.strip_suffix(".go").unwrap_or(p);
if stem.is_empty() {
Vec::new()
} else {
vec![stem.to_owned()]
}
}
fn go_package_name(root: &Node, bytes: &[u8]) -> Option<String> {
let clause = root
.children(&mut root.walk())
.find(|c| c.kind() == "package_clause")?;
let name = child_text(&clause, "package_identifier", bytes)?;
if name.is_empty() { None } else { Some(name) }
}
fn entry_points_for_go(name: &str, is_method: bool, is_pkg_main: bool) -> Vec<EntryPoint> {
let mut markers: Vec<EntryPoint> = Vec::new();
if !is_method && is_pkg_main && name == "main" {
markers.push(EntryPoint::Main);
}
if is_method && name == "ServeHTTP" {
markers.push(EntryPoint::HttpRoute("ServeHTTP".to_owned()));
}
markers
}
fn collect_symbols(
root: &Node,
ctx: &ExtractCtx,
namespaces: &[String],
is_pkg_main: bool,
) -> Vec<Symbol> {
let mut out = Vec::new();
let push = |out: &mut Vec<Symbol>,
node: &Node,
name: String,
kind: SymbolKind,
visibility: Visibility,
leaf: Descriptor| {
let mut descriptors: Vec<Descriptor> = namespaces
.iter()
.cloned()
.map(Descriptor::Namespace)
.collect();
descriptors.push(leaf);
out.push(make_symbol(
ctx,
node,
name,
kind,
visibility,
descriptors,
one_line_signature(node_text(node, ctx.bytes), &['{']),
));
};
for child in root.children(&mut root.walk()) {
match child.kind() {
"function_declaration" => {
let Some(name) = field_text(&child, "name", ctx.bytes) else {
continue;
};
let vis = name_visibility(&name);
let mut descriptors: Vec<Descriptor> = namespaces
.iter()
.cloned()
.map(Descriptor::Namespace)
.collect();
descriptors.push(Descriptor::Method {
name: name.clone(),
disambiguator: String::new(),
});
let mut sym = make_symbol(
ctx,
&child,
name.clone(),
SymbolKind::Function,
vis,
descriptors,
one_line_signature(node_text(&child, ctx.bytes), &['{']),
);
sym.entry_points = entry_points_for_go(&name, false, is_pkg_main);
out.push(sym);
}
"method_declaration" => {
let Some(name) = field_text(&child, "name", ctx.bytes) else {
continue;
};
let vis = name_visibility(&name);
let mut descriptors: Vec<Descriptor> = namespaces
.iter()
.cloned()
.map(Descriptor::Namespace)
.collect();
descriptors.push(Descriptor::Method {
name: name.clone(),
disambiguator: String::new(),
});
let mut sym = make_symbol(
ctx,
&child,
name.clone(),
SymbolKind::Method,
vis,
descriptors,
one_line_signature(node_text(&child, ctx.bytes), &['{']),
);
sym.entry_points = entry_points_for_go(&name, true, is_pkg_main);
out.push(sym);
}
"type_declaration" => {
for spec in child.children(&mut child.walk()) {
let (kind, name) = match spec.kind() {
"type_spec" => {
let Some(name) = field_text(&spec, "name", ctx.bytes) else {
continue;
};
let kind = spec.child_by_field_name("type").map_or(
SymbolKind::TypeAlias,
|t| match t.kind() {
"struct_type" => SymbolKind::Struct,
"interface_type" => SymbolKind::Interface,
_ => SymbolKind::TypeAlias,
},
);
(kind, name)
}
"type_alias" => {
let Some(name) = field_text(&spec, "name", ctx.bytes) else {
continue;
};
(SymbolKind::TypeAlias, name)
}
_ => continue,
};
let vis = name_visibility(&name);
push(
&mut out,
&spec,
name.clone(),
kind,
vis,
Descriptor::Type(name),
);
}
}
"const_declaration" => {
for spec in child.children(&mut child.walk()) {
if spec.kind() != "const_spec" {
continue;
}
for ident in spec.children(&mut spec.walk()) {
if ident.kind() != "identifier" {
continue;
}
let name = node_text(&ident, ctx.bytes).to_owned();
let vis = name_visibility(&name);
push(
&mut out,
&spec,
name.clone(),
SymbolKind::Const,
vis,
Descriptor::Term(name),
);
}
}
}
"var_declaration" => {
for spec in child.children(&mut child.walk()) {
if spec.kind() != "var_spec" {
continue;
}
for ident in spec.children(&mut spec.walk()) {
if ident.kind() != "identifier" {
continue;
}
let name = node_text(&ident, ctx.bytes).to_owned();
let vis = name_visibility(&name);
push(
&mut out,
&spec,
name.clone(),
SymbolKind::Static,
vis,
Descriptor::Term(name),
);
}
}
}
_ => continue,
}
}
out
}
fn collect_imports(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "import_spec" {
if let Some(path_node) = node.child_by_field_name("path") {
let raw = node_text(&path_node, bytes);
let dequoted = raw.trim_matches('"').trim_matches('`');
let leaf = simple_type_name(dequoted, "/");
push_ref(out, leaf, &path_node, file, RefRole::Import);
}
return;
}
for child in node.children(&mut node.walk()) {
collect_imports(&child, bytes, file, out);
}
}
fn is_non_read_position(node: &Node) -> bool {
let Some(parent) = node.parent() else {
return true; };
match parent.kind() {
"call_expression" => parent.child_by_field_name("function").as_ref() == Some(node),
"function_declaration" | "method_declaration" | "func_literal" => {
parent.child_by_field_name("name").as_ref() == Some(node)
}
"const_spec" | "var_spec" => {
parent
.children_by_field_name("name", &mut parent.walk())
.any(|c| c == *node)
}
"parameter_declaration" | "variadic_parameter_declaration" => parent
.children_by_field_name("name", &mut parent.walk())
.any(|c| c == *node),
"expression_list" => {
if let Some(gp) = parent.parent() {
let left = gp.child_by_field_name("left");
match gp.kind() {
"short_var_declaration" => left.as_ref() == Some(&parent),
"assignment_statement" => left.as_ref() == Some(&parent),
"range_clause" => left.as_ref() == Some(&parent),
_ => false,
}
} else {
false
}
}
_ => false,
}
}
fn collect_read_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "identifier" {
let name = node_text(node, bytes);
if name.len() >= MIN_REF_LEN && !is_non_read_position(node) {
push_ref(out, name, node, file, RefRole::Read);
}
return;
}
for child in node.children(&mut node.walk()) {
collect_read_references(&child, bytes, file, out);
}
}
fn collect_write_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "assignment_statement" {
if let Some(lhs) = node.child_by_field_name("left") {
for child in lhs.children(&mut lhs.walk()) {
if child.kind() == "identifier" {
let name = node_text(&child, bytes);
if name.len() >= MIN_REF_LEN {
push_ref(out, name, &child, file, RefRole::Write);
}
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_write_references(&child, bytes, file, out);
}
}
fn type_leaf(node: &Node, bytes: &[u8], file: &str, ctx: TypeRefContext, out: &mut Vec<Reference>) {
match node.kind() {
"type_identifier" => {
let name = node_text(node, bytes);
push_type_ref(out, name, node, file, ctx);
}
"qualified_type" => {
if let Some(name_node) = node.child_by_field_name("name") {
type_leaf(&name_node, bytes, file, ctx, out);
}
}
"pointer_type" => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
"slice_type" => {
if let Some(elem) = node.child_by_field_name("element") {
type_leaf(&elem, bytes, file, ctx, out);
}
}
"array_type" => {
if let Some(elem) = node.child_by_field_name("element") {
type_leaf(&elem, bytes, file, ctx, out);
}
}
"map_type" => {
if let Some(key) = node.child_by_field_name("key") {
type_leaf(&key, bytes, file, ctx, out);
}
if let Some(val) = node.child_by_field_name("value") {
type_leaf(&val, bytes, file, ctx, out);
}
}
"generic_type" => {
if let Some(base) = node.child_by_field_name("type") {
type_leaf(&base, bytes, file, ctx, out);
}
if let Some(args) = node.child_by_field_name("type_arguments") {
for child in args.named_children(&mut args.walk()) {
type_leaf(&child, bytes, file, TypeRefContext::GenericArg, out);
}
}
}
"channel_type" => {
if let Some(val) = node.child_by_field_name("value") {
type_leaf(&val, bytes, file, ctx, out);
}
}
_ => {
for child in node.named_children(&mut node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
}
}
fn collect_type_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
match node.kind() {
"parameter_declaration" | "variadic_parameter_declaration" => {
if let Some(type_node) = node.child_by_field_name("type") {
type_leaf(&type_node, bytes, file, TypeRefContext::ParameterType, out);
}
return;
}
"function_declaration" | "method_declaration" => {
if let Some(result) = node.child_by_field_name("result") {
if result.kind() == "parameter_list" {
for child in result.named_children(&mut result.walk()) {
if child.kind() == "parameter_declaration" {
if let Some(t) = child.child_by_field_name("type") {
type_leaf(&t, bytes, file, TypeRefContext::ReturnType, out);
}
} else {
type_leaf(&child, bytes, file, TypeRefContext::ReturnType, out);
}
}
} else {
type_leaf(&result, bytes, file, TypeRefContext::ReturnType, out);
}
}
}
"field_declaration" => {
if let Some(type_node) = node.child_by_field_name("type") {
type_leaf(&type_node, bytes, file, TypeRefContext::Field, out);
}
return;
}
_ => {}
}
for child in node.children(&mut node.walk()) {
collect_type_references(&child, bytes, file, out);
}
}
fn collect_scopes(root: &Node, source_len: usize) -> Vec<Scope> {
let mut scopes = Vec::new();
push_scope(
&mut scopes,
None,
ByteSpan {
start: 0,
end: source_len,
},
ScopeKind::Module,
);
for child in root.children(&mut root.walk()) {
scope_dfs(&child, 0, &mut scopes);
}
scopes
}
fn scope_dfs(node: &Node, parent_id: ScopeId, scopes: &mut Vec<Scope>) {
match node.kind() {
"function_declaration" | "method_declaration" | "func_literal" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
if let Some(body) = node.child_by_field_name("body") {
for child in body.children(&mut body.walk()) {
scope_dfs(&child, fn_id, scopes);
}
}
}
"block" => {
let block_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Block);
for child in node.children(&mut node.walk()) {
scope_dfs(&child, block_id, scopes);
}
}
_ => {
for child in node.children(&mut node.walk()) {
scope_dfs(&child, parent_id, scopes);
}
}
}
}
fn collect_bindings(root: &Node, bytes: &[u8], scopes: &[Scope]) -> Vec<Binding> {
let mut out = Vec::new();
collect_bindings_dfs(root, bytes, scopes, &mut out);
out
}
fn collect_bindings_dfs(node: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
match node.kind() {
"function_declaration" | "method_declaration" | "func_literal" => {
if let Some(params) = node.child_by_field_name("parameters") {
collect_params(¶ms, bytes, scopes, out);
}
if let Some(recv) = node.child_by_field_name("receiver") {
collect_params(&recv, bytes, scopes, out);
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"short_var_declaration" => {
if let Some(left) = node.child_by_field_name("left") {
for ident in left.children(&mut left.walk()) {
if ident.kind() != "identifier" {
continue;
}
let name = node_text(&ident, bytes);
if name == "_" {
continue;
}
let intro = ident.start_byte();
if innermost_scope(intro, scopes) != Some(0) {
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"var_spec" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "identifier" {
let name = node_text(&child, bytes);
if name != "_" {
let intro = child.start_byte();
if innermost_scope(intro, scopes) != Some(0) {
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
} else {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
}
"const_spec" => {
for child in node.children(&mut node.walk()) {
if child.kind() == "identifier" {
let name = node_text(&child, bytes);
if name != "_" {
let intro = child.start_byte();
if innermost_scope(intro, scopes) != Some(0) {
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
} else {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
}
"range_clause" => {
if let Some(left) = node.child_by_field_name("left") {
for ident in left.children(&mut left.walk()) {
if ident.kind() != "identifier" {
continue;
}
let name = node_text(&ident, bytes);
if name == "_" {
continue;
}
let intro = ident.start_byte();
if innermost_scope(intro, scopes) != Some(0) {
push_binding(out, name.to_owned(), intro, BindingKind::Local, scopes);
}
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
_ => {
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
}
}
fn collect_params(params: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
for child in params.named_children(&mut params.walk()) {
if matches!(
child.kind(),
"parameter_declaration" | "variadic_parameter_declaration"
) {
for ident in child.children(&mut child.walk()) {
if ident.kind() != "identifier" {
continue;
}
let name = node_text(&ident, bytes);
if name == "_" {
continue;
}
let intro = ident.start_byte();
push_binding(out, name.to_owned(), intro, BindingKind::Param, scopes);
}
}
}
}
fn name_visibility(name: &str) -> Visibility {
if name.chars().next().is_some_and(|c| c.is_uppercase()) {
Visibility::Public
} else {
Visibility::Internal
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_exported_defs() {
let src = r#"
package auth
func Validate(tok string) bool { return true }
type Config struct { Timeout int }
type Reader interface { Read() error }
const Max = 3
func helper() {}
"#;
let facts = GoExtractor.extract(src, "src/auth/session.go").unwrap();
let by_name = |n: &str| facts.symbols.iter().find(|s| s.name == n).cloned();
let vt = by_name("Validate").unwrap();
assert_eq!(vt.id.to_scip_string(), "codegraph . . . auth/Validate().");
assert_eq!(vt.kind, SymbolKind::Function);
assert_eq!(vt.visibility, Visibility::Public);
assert_eq!(by_name("Config").unwrap().kind, SymbolKind::Struct);
assert_eq!(by_name("Config").unwrap().visibility, Visibility::Public);
assert_eq!(by_name("Reader").unwrap().kind, SymbolKind::Interface);
assert_eq!(by_name("Max").unwrap().kind, SymbolKind::Const);
let helper = by_name("helper").expect("helper must now be emitted");
assert_eq!(helper.kind, SymbolKind::Function);
assert_eq!(helper.visibility, Visibility::Internal);
assert_eq!(facts.lang, "go");
}
#[test]
fn flat_files_same_package_share_namespace() {
let util = GoExtractor
.extract("package main\nfunc Helper() {}\n", "util.go")
.unwrap();
let main = GoExtractor
.extract("package main\nfunc Run() { Helper() }\n", "main.go")
.unwrap();
let helper = util.symbols.iter().find(|s| s.name == "Helper").unwrap();
let run = main.symbols.iter().find(|s| s.name == "Run").unwrap();
assert_eq!(helper.id.to_scip_string(), "codegraph . . . main/Helper().");
assert_eq!(run.id.to_scip_string(), "codegraph . . . main/Run().");
}
#[test]
fn different_directories_are_different_packages() {
let a = GoExtractor
.extract("package x\nfunc F() {}\n", "pkg_a/x.go")
.unwrap();
let b = GoExtractor
.extract("package x\nfunc F() {}\n", "pkg_b/x.go")
.unwrap();
let fa = a.symbols.iter().find(|s| s.name == "F").unwrap();
let fb = b.symbols.iter().find(|s| s.name == "F").unwrap();
assert_eq!(fa.id.to_scip_string(), "codegraph . . . pkg_a/F().");
assert_eq!(fb.id.to_scip_string(), "codegraph . . . pkg_b/F().");
assert_ne!(fa.id.to_scip_string(), fb.id.to_scip_string());
}
#[test]
fn namespace_falls_back_to_file_stem_without_package_clause() {
let facts = GoExtractor
.extract("func Helper() {}\n", "src/util.go")
.unwrap();
let helper = facts.symbols.iter().find(|s| s.name == "Helper").unwrap();
assert_eq!(helper.id.to_scip_string(), "codegraph . . . util/Helper().");
}
#[test]
fn extracts_method_declaration() {
let src = r#"
package run
type Server struct{}
func (s *Server) Start() { }
"#;
let facts = GoExtractor.extract(src, "src/run.go").unwrap();
let start = facts.symbols.iter().find(|s| s.name == "Start").unwrap();
assert_eq!(start.kind, SymbolKind::Method);
assert_eq!(start.id.to_scip_string(), "codegraph . . . run/Start().");
}
#[test]
fn extracts_call_references() {
let src = r#"
package main
func main() {
Validate("t")
obj.Close()
}
"#;
let facts = GoExtractor.extract(src, "src/main.go").unwrap();
let names: Vec<&str> = facts.references.iter().map(|r| r.name.as_str()).collect();
assert!(names.contains(&"Validate"));
assert!(names.contains(&"Close"));
}
#[test]
fn import_single_stdlib() {
let src = "package main\nimport \"fmt\"\n";
let facts = GoExtractor.extract(src, "src/main.go").unwrap();
let imports: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert_eq!(imports, vec!["fmt"]);
}
#[test]
fn import_deep_path_leaf() {
let src = "package main\nimport \"github.com/x/y/pkg\"\n";
let facts = GoExtractor.extract(src, "src/main.go").unwrap();
let imports: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert_eq!(imports, vec!["pkg"]);
}
#[test]
fn import_grouped() {
let src = "package main\nimport (\n \"os\"\n \"io/ioutil\"\n)\n";
let facts = GoExtractor.extract(src, "src/main.go").unwrap();
let mut imports: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
imports.sort_unstable();
assert_eq!(imports, vec!["ioutil", "os"]);
}
#[test]
fn import_aliased_emits_leaf_not_alias() {
let src = "package main\nimport f \"fmt\"\n";
let facts = GoExtractor.extract(src, "src/main.go").unwrap();
let imports: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert_eq!(imports, vec!["fmt"]);
let aliases: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import && r.name == "f")
.map(|r| r.name.as_str())
.collect();
assert!(
aliases.is_empty(),
"alias 'f' should not appear as an Import ref"
);
}
#[test]
fn func_params_emit_param_bindings() {
let src = "package p\nfunc f(a int, b int) {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope");
let mut param_names: Vec<(&str, ScopeId)> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Param)
.map(|b| (b.name.as_str(), b.scope))
.collect();
param_names.sort_by_key(|(n, _)| *n);
assert_eq!(
param_names,
vec![("a", fn_scope_id), ("b", fn_scope_id)],
"expected Param bindings for a and b, got {param_names:?}"
);
}
#[test]
fn method_receiver_emits_param_binding() {
let src = "package p\ntype Server struct{}\nfunc (s *Server) Start() {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let s_binding = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Param && b.name == "s")
.expect("expected a Param binding named 's'");
assert_eq!(
facts.scopes[s_binding.scope].kind,
ScopeKind::Function,
"receiver binding 's' should be in a Function scope"
);
}
#[test]
fn short_var_decl_emits_local() {
let src = "package p\nfunc f() { x := 1 }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let x = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "x")
.expect("expected a Local binding for 'x'");
assert_eq!(
facts.scopes[x.scope].kind,
ScopeKind::Function,
"x should be in a Function scope"
);
}
#[test]
fn multi_name_short_var_emits_two_locals() {
let src = "package p\nfunc f() { a, b := 1, 2 }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let locals: Vec<&str> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Local)
.map(|b| b.name.as_str())
.collect();
assert!(
locals.contains(&"a"),
"expected Local for 'a', got {locals:?}"
);
assert!(
locals.contains(&"b"),
"expected Local for 'b', got {locals:?}"
);
}
#[test]
fn blank_identifier_skipped() {
let src = "package p\nfunc g() (int, int) { return 0, 0 }\nfunc f() { _, b := g() }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "_"),
"blank '_' must not produce a Local binding"
);
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "b"),
"expected Local binding for 'b'"
);
}
#[test]
fn var_spec_inside_func_emits_local() {
let src = "package p\nfunc f() { var x int }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let x = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "x")
.expect("expected a Local binding for 'x'");
assert_eq!(
facts.scopes[x.scope].kind,
ScopeKind::Function,
"var inside func should bind in a Function scope"
);
}
#[test]
fn for_range_vars_emit_locals() {
let src = "package p\nfunc f() { var xs []int\nfor i, v := range xs { _ = i; _ = v } }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let locals: Vec<&str> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Local)
.map(|b| b.name.as_str())
.collect();
assert!(
locals.contains(&"i"),
"expected Local for 'i', got {locals:?}"
);
assert!(
locals.contains(&"v"),
"expected Local for 'v', got {locals:?}"
);
}
#[test]
fn top_level_var_is_not_local_but_is_definition() {
let src = "package p\nvar Config int\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "Config"),
"top-level var must NOT be a Local binding"
);
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Definition && b.name == "Config"),
"top-level var must have a Definition binding"
);
}
#[test]
fn nested_block_produces_block_scope_and_ref_attaches_to_it() {
let src = "package p\nfunc f() { { helper() } }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
assert_eq!(
facts.scopes[0].kind,
ScopeKind::Module,
"scopes[0] must be Module"
);
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope");
let block_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Block)
.expect("expected a Block scope");
assert_eq!(
facts.scopes[block_scope_id].parent,
Some(fn_scope_id),
"Block scope parent should be the Function scope"
);
let h_ref = facts
.references
.iter()
.find(|r| r.role == RefRole::Call && r.name == "helper")
.expect("expected a Call ref for 'helper'");
assert_eq!(
h_ref.scope,
Some(block_scope_id),
"helper() call should be in the Block scope ({}), got {:?}",
block_scope_id,
h_ref.scope
);
}
#[test]
fn selector_call_captures_package_qualifier() {
let facts = GoExtractor
.extract(
"package main\nfunc Run() {\n\talpha.Helper()\n}\n",
"main.go",
)
.unwrap();
let call = facts
.references
.iter()
.find(|r| r.role == RefRole::Call && r.name == "Helper")
.expect("expected a Call ref for 'Helper'");
assert_eq!(call.qualifier.as_deref(), Some("alpha"));
let bare = GoExtractor
.extract(
"package main\nfunc Helper() {}\nfunc Run() {\n\tHelper()\n}\n",
"main.go",
)
.unwrap();
let bare_call = bare
.references
.iter()
.find(|r| r.role == RefRole::Call && r.name == "Helper")
.expect("expected a Call ref for 'Helper'");
assert_eq!(bare_call.qualifier, None);
}
#[test]
fn top_level_func_emits_definition_binding() {
let src = "package p\nfunc Helper() {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let b = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Definition && b.name == "Helper")
.expect("expected a Definition binding for 'Helper'");
assert_eq!(b.scope, 0, "top-level def must bind in scope 0");
}
#[test]
fn read_ref_at_use_not_at_decl() {
let src = "package p\nfunc f() int { base := 1; return base }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "base")
.collect();
assert!(
!read_refs.is_empty(),
"expected at least one Read ref for 'base', got none"
);
for r in &read_refs {
assert!(
r.occ.byte > 20,
"Read ref for 'base' must be at the use site (byte > 20), got byte {}",
r.occ.byte
);
}
}
#[test]
fn write_ref_for_assignment_statement() {
let src = "package p\nfunc f() { cnt := 0; cnt = 5 }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let write_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Write && r.name == "cnt")
.collect();
assert!(
!write_refs.is_empty(),
"expected at least one Write ref for 'cnt', got none — all refs: {:?}",
facts
.references
.iter()
.map(|r| (&r.name, r.role))
.collect::<Vec<_>>()
);
}
#[test]
fn call_not_also_read() {
let src = "package p\nfunc f() { helper() }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let call_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Call && r.name == "helper")
.collect();
assert!(!call_refs.is_empty(), "expected a Call ref for 'helper'");
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "helper")
.collect();
assert!(
read_refs.is_empty(),
"helper() must NOT produce a Read ref; got: {read_refs:?}"
);
}
#[test]
fn type_ref_param_type() {
let src = "package p\nfunc f(c Config) {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let found = facts
.references
.iter()
.find(|r| {
r.role == RefRole::TypeRef
&& r.name == "Config"
&& r.type_ref_ctx == Some(TypeRefContext::ParameterType)
})
.is_some();
assert!(
found,
"expected TypeRef 'Config' with ParameterType context"
);
}
#[test]
fn type_ref_pointer_param_type() {
let src = "package p\nfunc f(c *Config) {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let found = facts
.references
.iter()
.find(|r| {
r.role == RefRole::TypeRef
&& r.name == "Config"
&& r.type_ref_ctx == Some(TypeRefContext::ParameterType)
})
.is_some();
assert!(
found,
"expected TypeRef 'Config' with ParameterType from pointer param; refs: {:?}",
facts
.references
.iter()
.filter(|r| r.role == RefRole::TypeRef)
.map(|r| (&r.name, r.type_ref_ctx))
.collect::<Vec<_>>()
);
}
#[test]
fn type_ref_return_type() {
let src = "package p\nfunc f() Config { return Config{} }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let found = facts
.references
.iter()
.find(|r| {
r.role == RefRole::TypeRef
&& r.name == "Config"
&& r.type_ref_ctx == Some(TypeRefContext::ReturnType)
})
.is_some();
assert!(
found,
"expected TypeRef 'Config' with ReturnType context; refs: {:?}",
facts
.references
.iter()
.filter(|r| r.role == RefRole::TypeRef)
.map(|r| (&r.name, r.type_ref_ctx))
.collect::<Vec<_>>()
);
}
#[test]
fn type_ref_struct_field_type() {
let src = "package p\ntype T struct { conf Config }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let found = facts
.references
.iter()
.find(|r| {
r.role == RefRole::TypeRef
&& r.name == "Config"
&& r.type_ref_ctx == Some(TypeRefContext::Field)
})
.is_some();
assert!(
found,
"expected TypeRef 'Config' with Field context; refs: {:?}",
facts
.references
.iter()
.filter(|r| r.role == RefRole::TypeRef)
.map(|r| (&r.name, r.type_ref_ctx))
.collect::<Vec<_>>()
);
}
#[test]
fn exported_func_has_public_visibility() {
let src = "package p\nfunc Exported() {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "Exported")
.expect("Exported must be emitted");
assert_eq!(
sym.visibility,
Visibility::Public,
"capitalized func must be Public"
);
}
#[test]
fn unexported_func_emitted_as_internal() {
let src = "package p\nfunc unexported() {}\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "unexported")
.expect("unexported func must now be emitted");
assert_eq!(
sym.kind,
SymbolKind::Function,
"unexported func kind must be Function"
);
assert_eq!(
sym.visibility,
Visibility::Internal,
"lowercase func must be Internal (package-private)"
);
}
#[test]
fn unexported_type_emitted_as_internal() {
let src = "package p\ntype internalState struct { x int }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "internalState")
.expect("internalState type must be emitted");
assert_eq!(sym.kind, SymbolKind::Struct);
assert_eq!(
sym.visibility,
Visibility::Internal,
"lowercase type must be Internal"
);
}
#[test]
fn unexported_const_emitted_as_internal() {
let src = "package p\nconst maxRetries = 3\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "maxRetries")
.expect("maxRetries const must be emitted");
assert_eq!(sym.kind, SymbolKind::Const);
assert_eq!(
sym.visibility,
Visibility::Internal,
"lowercase const must be Internal"
);
}
#[test]
fn selector_receiver_is_read_field_is_not() {
let src = "package p\ntype Conn struct{}\nfunc f(conn Conn) { _ = conn.field }\n";
let facts = GoExtractor.extract(src, "src/p.go").unwrap();
let field_reads: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "field")
.collect();
assert!(
field_reads.is_empty(),
"selector field 'field' must NOT be a Read ref; got: {field_reads:?}"
);
let conn_reads: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "conn")
.collect();
assert!(
!conn_reads.is_empty(),
"expected a Read ref for the receiver 'conn'"
);
}
#[test]
fn entry_point_main_function() {
let src = "package main\n\nfunc main() {}\n";
let facts = GoExtractor.extract(src, "main.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "main")
.unwrap_or_else(|| {
panic!(
"symbol 'main' not found; symbols: {:?}",
facts.symbols.iter().map(|s| &s.name).collect::<Vec<_>>()
)
});
assert_eq!(
sym.entry_points.len(),
1,
"expected exactly 1 entry point for 'main', got {:?}",
sym.entry_points
);
assert!(
matches!(sym.entry_points[0], EntryPoint::Main),
"expected EntryPoint::Main, got {:?}",
sym.entry_points
);
}
#[test]
fn entry_point_serve_http_method() {
let src = "package main\n\ntype Handler struct{}\n\nfunc (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}\n";
let facts = GoExtractor.extract(src, "main.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "ServeHTTP")
.unwrap_or_else(|| {
panic!(
"symbol 'ServeHTTP' not found; symbols: {:?}",
facts.symbols.iter().map(|s| &s.name).collect::<Vec<_>>()
)
});
assert_eq!(sym.kind, SymbolKind::Method, "ServeHTTP must be a Method");
assert_eq!(
sym.entry_points.len(),
1,
"expected exactly 1 entry point for 'ServeHTTP', got {:?}",
sym.entry_points
);
assert!(
matches!(&sym.entry_points[0], EntryPoint::HttpRoute(m) if m == "ServeHTTP"),
"expected EntryPoint::HttpRoute(\"ServeHTTP\"), got {:?}",
sym.entry_points
);
}
#[test]
fn entry_point_plain_function_empty() {
let src = "package main\n\nfunc process() {}\n";
let facts = GoExtractor.extract(src, "main.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "process")
.expect("symbol 'process' not found");
assert!(
sym.entry_points.is_empty(),
"plain non-main function must have no entry points; got {:?}",
sym.entry_points
);
}
#[test]
fn entry_point_other_method_empty() {
let src = "package main\n\ntype Handler struct{}\n\nfunc (h *Handler) Other() {}\n";
let facts = GoExtractor.extract(src, "main.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "Other")
.expect("symbol 'Other' not found");
assert!(
sym.entry_points.is_empty(),
"non-ServeHTTP method must have no entry points; got {:?}",
sym.entry_points
);
}
#[test]
fn entry_point_main_in_non_main_package_empty() {
let src = "package foo\n\nfunc main() {}\n";
let facts = GoExtractor.extract(src, "foo.go").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "main")
.expect("symbol 'main' not found");
assert!(
sym.entry_points.is_empty(),
"func main in non-main package must have no entry points; got {:?}",
sym.entry_points
);
}
}