Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@ export class FileBindings {
private map = new Map<string, string>();

set(scopePath: string, name: string, type: string): void {
this.map.set(`${scopePath}|${name}`, type);
const key = `${scopePath}|${name}`;
if (this.map.get(key) === "") return;
this.map.set(key, type);
}

/** `name` is declared in this scope with a type we cannot read, so it hides any outer
* binding of the same name and nothing binds it again. */
shadow(scopePath: string, name: string): void {
this.map.set(`${scopePath}|${name}`, "");
}

/** Innermost-first: for scope ["a","b"] name "x", tries `a.b|x`, `a|x`, `|x`. */
lookup(scope: string[], name: string): string | null {
for (let i = scope.length; i >= 0; i--) {
const hit = this.map.get(`${scope.slice(0, i).join(".")}|${name}`);
if (hit) return hit;
if (hit !== undefined) return hit || null;
}
return null;
}
Expand Down Expand Up @@ -712,6 +720,29 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo
const name = node.childForFieldName("name");
const type = goTypeName(node.childForFieldName("type"));
if (name?.type === "identifier" && type) bindings.set(scopePath, name.text, type);
else for (const id of node.namedChildren.filter((c) => c.type === "identifier")) bindings.shadow(scopePath, id.text);
return;
}
if (node.type === "parameter_declaration") {
// A declared function's or method's parameter, which is how Go code receives its
// collaborators (`func handle(s *Server)`). A func literal's parameters share the
// enclosing definition's scope here, so they hide the outer name instead of binding.
const owner = node.parent?.parent;
const declared = owner?.type === "function_declaration" || owner?.type === "method_declaration";
if (!declared && owner?.type !== "func_literal") return;
const type = declared ? goTypeName(node.childForFieldName("type")) : null;
for (const name of node.namedChildren.filter((c) => c.type === "identifier")) {
if (type) bindings.set(scopePath, name.text, type);
else bindings.shadow(scopePath, name.text);
}
return;
}
if (node.type === "range_clause" || node.type === "receive_statement") {
for (const id of node.childForFieldName("left")?.namedChildren ?? []) if (id.type === "identifier") bindings.shadow(scopePath, id.text);
return;
}
if (node.type === "type_switch_statement") {
for (const id of node.childForFieldName("alias")?.namedChildren ?? []) if (id.type === "identifier") bindings.shadow(scopePath, id.text);
return;
}
if (node.type === "field_declaration") {
Expand All @@ -729,7 +760,11 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo
for (let i = 0; i < names.length; i++) {
const nameNode = names[i];
let expr = exprs[i];
if (!nameNode || nameNode.type !== "identifier" || !expr) continue;
if (!nameNode || nameNode.type !== "identifier") continue;
if (!expr) {
bindings.shadow(scopePath, nameNode.text);
continue;
}
if (expr.type === "unary_expression") {
expr = expr.namedChildren.find((c) => c.type === "composite_literal") ?? expr;
}
Expand All @@ -743,6 +778,7 @@ function handleGo(node: SyntaxNode, scope: string[], bindings: FileBindings): vo
if (fn?.type === "identifier" && /^New[A-Z]/.test(fn.text)) typeName = fn.text.slice(3);
}
if (typeName) bindings.set(scopePath, nameNode.text, typeName);
else bindings.shadow(scopePath, nameNode.text);
}
}

Expand Down
85 changes: 85 additions & 0 deletions test/graph-go-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Go call receivers typed from the enclosing function's parameters in the Tier-1 code graph.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { buildGraph } from "../src/graph/build.js";
import { readGraph, wiringPath } from "../src/graph/write.js";

test("Go extraction: a call through a typed parameter resolves to the parameter type's method", async () => {
const dir = mkdtempSync(join(tmpdir(), "graft-go-param-"));
try {
writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n");
writeFileSync(
join(dir, "server.go"),
"package main\n\ntype Server struct{}\n\nfunc (s *Server) Start() {}\nfunc (s Server) Stop() {}\n\n" +
"type Other struct{}\n\nfunc (o *Other) Start() {}\n",
);
writeFileSync(
join(dir, "use.go"),
"package main\n\n" +
"func pointer(s *Server) { s.Start() }\n" +
"func value(n int, s Server) { s.Stop() }\n" +
"func grouped(a, b *Server) { a.Start(); b.Stop() }\n" +
"func (o *Other) method(s *Server) { s.Start() }\n" +
"func named() (s *Server, err error) { s.Start(); return }\n" +
"func closure() { f := func(s *Server) { s.Start() }; f(nil) }\n" +
"func untyped(s []Server) { s.Start() }\n",
);

await buildGraph(dir);
const graph = readGraph(wiringPath(join(dir, "graft")))!;

const calls = (source: string) =>
graph.edges.filter((e) => e.relation === "calls" && e.source === source).map((e) => e.target).sort();
assert.deepEqual(calls("use.go#pointer"), ["server.go#Server.Start"]);
assert.deepEqual(calls("use.go#value"), ["server.go#Server.Stop"]);
assert.deepEqual(calls("use.go#grouped"), ["server.go#Server.Start", "server.go#Server.Stop"]);
assert.deepEqual(calls("use.go#Other.method"), ["server.go#Server.Start"]);
assert.deepEqual(calls("use.go#named"), ["server.go#Server.Start"]);
assert.deepEqual(calls("use.go#untyped"), []);
// a func literal shares its enclosing definition's scope, so its parameters stay unbound
assert.deepEqual(calls("use.go#closure"), []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("Go extraction: a func literal parameter, an untyped local or a range variable hides the typed parameter of that name", async () => {
const dir = mkdtempSync(join(tmpdir(), "graft-go-param-shadow-"));
try {
writeFileSync(join(dir, "go.mod"), "module example.com/app\n\ngo 1.22\n");
writeFileSync(
join(dir, "server.go"),
"package main\n\ntype Server struct{}\n\nfunc (s *Server) Start() {}\n\n" +
"type Other struct{}\n\nfunc (o *Other) Start() {}\n\nfunc make2() *Other { return nil }\n",
);
writeFileSync(
join(dir, "use.go"),
"package main\n\n" +
"func literal(s *Server) { run(func(s *Other) { s.Start() }) }\n" +
"func local(s *Server) { if true { s := make2(); s.Start() } }\n" +
"func ranged(s *Server, xs []*Other) { for _, s := range xs { s.Start() } }\n" +
"func multi(s *Server) { if true { s, err := two(); _ = err; s.Start() } }\n" +
"func plain(s *Server) { s.Start() }\n" +
"func run(f func(*Other)) {}\n" +
"func two() (*Other, error) { return nil, nil }\n",
);

await buildGraph(dir);
const graph = readGraph(wiringPath(join(dir, "graft")))!;

const calls = (source: string) =>
graph.edges.filter((e) => e.relation === "calls" && e.source === source && e.target.includes("Start")).map((e) => e.target);
assert.deepEqual(calls("use.go#literal"), []);
assert.deepEqual(calls("use.go#local"), []);
assert.deepEqual(calls("use.go#ranged"), []);
assert.deepEqual(calls("use.go#multi"), []);
assert.deepEqual(calls("use.go#plain"), ["server.go#Server.Start"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
Loading