Files
zzet--gortex/internal/resolver/gdscript_autoload_test.go
Andrey Kumanyaev 3710daf84d Bind Godot autoload singleton calls to their declared script
An `[autoload]` entry in project.godot binds a project-global identifier
to a script: `Game="*res://src/core/Game.gd"` makes `Game.set_speed()`
legal in every script in the project with no import, no preload and no
`class_name` on the target. That manifest is the only place the mapping
exists, and it was not indexed — so every cross-script autoload call was
unresolvable by construction, leaving `Game.set_speed()` with no edges
but the in-file self-call.

Index project.godot for its autoload section, emitting each singleton as
a type node carrying the script it names, and add a resolver pass that
binds residual member calls whose receiver names an autoload to that
script's member of the same name. A singleton whose script is not
indexed, is ambiguous across two indexed scripts, or does not declare
the called name is left unresolved — a wrong cross-directory binding is
worse than none.

The end-to-end test indexes a project shaped like a real one and asserts
all three cases bind: the global class over its same-named neighbour,
the cross-directory class_name call, and the autoload singleton.
2026-08-05 11:38:44 +02:00

124 lines
4.3 KiB
Go

package resolver
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
func gdAutoloadNode(g *graph.Graph, name, script string) {
g.AddNode(&graph.Node{
ID: "project.godot::" + name, Kind: graph.KindType, Name: name,
FilePath: "project.godot", Language: "godot_project",
Meta: map[string]any{"gd_kind": "autoload", "gd_script": script},
})
}
func gdScriptFile(g *graph.Graph, path string, methods ...string) {
g.AddNode(&graph.Node{
ID: path, Kind: graph.KindFile, Name: path,
FilePath: path, Language: "gdscript",
})
for _, m := range methods {
g.AddNode(&graph.Node{
ID: path + "::" + m, Kind: graph.KindMethod, Name: m,
FilePath: path, Language: "gdscript",
})
}
}
func gdRecvCall(g *graph.Graph, from, file, method, recv string) {
g.AddEdge(&graph.Edge{
From: from, To: "unresolved::*." + method, Kind: graph.EdgeCalls, FilePath: file,
Meta: map[string]any{"receiver_type": recv},
})
}
func gdCallEdge(g graph.Store, from, to string) *graph.Edge {
for e := range g.EdgesByKind(graph.EdgeCalls) {
if e != nil && e.From == from && e.To == to {
return e
}
}
return nil
}
func gdAutoloadGraph(t *testing.T) *graph.Graph {
t.Helper()
g := graph.New()
gdAutoloadNode(g, "Game", "src/core/Game.gd")
gdScriptFile(g, "src/core/Game.gd", "set_speed", "pause")
gdScriptFile(g, "src/ui/Main.gd", "_ready")
return g
}
// `project.godot` is the only place that says the global name `Game`
// means `src/core/Game.gd` — no import, preload or `class_name` in any
// script carries that mapping.
func TestResolveGDScriptAutoloads_BindsSingletonCall(t *testing.T) {
g := gdAutoloadGraph(t)
const caller = "src/ui/Main.gd::_ready"
gdRecvCall(g, caller, "src/ui/Main.gd", "set_speed", "Game")
require.Equal(t, 1, ResolveGDScriptAutoloads(g))
e := gdCallEdge(g, caller, "src/core/Game.gd::set_speed")
require.NotNil(t, e, "the call binds across directories")
assert.Equal(t, graph.OriginASTResolved, e.Origin)
assert.Equal(t, SynthGodotAutoload, e.Meta[MetaSynthesizedBy])
}
func TestResolveGDScriptAutoloads_UnknownMemberLeftAlone(t *testing.T) {
g := gdAutoloadGraph(t)
gdRecvCall(g, "src/ui/Main.gd::_ready", "src/ui/Main.gd", "no_such_method", "Game")
assert.Equal(t, 0, ResolveGDScriptAutoloads(g),
"a name the autoload script does not declare must not bind to it")
}
func TestResolveGDScriptAutoloads_NonAutoloadReceiverLeftAlone(t *testing.T) {
g := gdAutoloadGraph(t)
gdRecvCall(g, "src/ui/Main.gd::_ready", "src/ui/Main.gd", "set_speed", "Input")
assert.Equal(t, 0, ResolveGDScriptAutoloads(g))
}
func TestResolveGDScriptAutoloads_ResolvedEdgeNotTouched(t *testing.T) {
g := gdAutoloadGraph(t)
const caller = "src/ui/Main.gd::_ready"
g.AddEdge(&graph.Edge{
From: caller, To: "src/core/Game.gd::pause", Kind: graph.EdgeCalls,
FilePath: "src/ui/Main.gd", Origin: graph.OriginLSPResolved,
Meta: map[string]any{"receiver_type": "Game"},
})
assert.Equal(t, 0, ResolveGDScriptAutoloads(g))
assert.Equal(t, graph.OriginLSPResolved, gdCallEdge(g, caller, "src/core/Game.gd::pause").Origin)
}
// A singleton whose script two indexed files could satisfy is dropped:
// a wrong cross-directory binding is worse than an unresolved call.
func TestResolveGDScriptAutoloads_AmbiguousScriptDropped(t *testing.T) {
g := gdAutoloadGraph(t)
gdScriptFile(g, "addons/vendor/src/core/Game.gd", "set_speed")
gdRecvCall(g, "src/ui/Main.gd::_ready", "src/ui/Main.gd", "set_speed", "Game")
assert.Equal(t, 0, ResolveGDScriptAutoloads(g))
}
func TestResolveGDScriptAutoloads_NoManifest(t *testing.T) {
g := graph.New()
gdScriptFile(g, "src/core/Game.gd", "set_speed")
gdRecvCall(g, "src/ui/Main.gd::_ready", "src/ui/Main.gd", "set_speed", "Game")
assert.Equal(t, 0, ResolveGDScriptAutoloads(g))
assert.Equal(t, 0, ResolveGDScriptAutoloads(nil))
}
func TestGDPathMatches(t *testing.T) {
assert.True(t, gdPathMatches("src/core/Game.gd", "src/core/Game.gd"))
assert.True(t, gdPathMatches("game/src/core/Game.gd", "src/core/Game.gd"))
assert.True(t, gdPathMatches(`game\src\core\Game.gd`, "src/core/Game.gd"))
assert.False(t, gdPathMatches("src/core/NotGame.gd", "src/core/Game.gd"),
"the suffix must land on a path-separator boundary")
assert.False(t, gdPathMatches("src/core/Game.gd", "other/Game.gd"))
}