diff --git a/doc/gdcc_low_ir.md b/doc/gdcc_low_ir.md index 0d6e2224..43118958 100644 --- a/doc/gdcc_low_ir.md +++ b/doc/gdcc_low_ir.md @@ -413,7 +413,10 @@ Loads a static variable/property by name. ``` $ = load_static "" "" ``` -`` may be `@GlobalScope` for top-level Godot global constants. +`` may be `@GlobalScope` for top-level Godot global constants and +Godot singleton properties such as `Engine` or `Input`. A singleton property read +materializes the engine-owned singleton object; method calls on that receiver still +use ordinary `call_method`. #### store_static Stores a value to a static variable/property by name. diff --git a/doc/module_impl/backend/godot_binding_implementation.md b/doc/module_impl/backend/godot_binding_implementation.md index c6d0d541..35437b37 100644 --- a/doc/module_impl/backend/godot_binding_implementation.md +++ b/doc/module_impl/backend/godot_binding_implementation.md @@ -303,16 +303,27 @@ constructor wrapper 不属于 fixed runtime wrapper,也不属于 `ModuleLocalG module-local wrapper 只输出 `providedByRuntime = false` 的 symbol。当前两个 family 的行为为: - singleton getter: - - `static inline godot__singleton(void)` - - 使用 `godot_global_get_singleton(GD_STATIC_SN(u8""))` + - `static inline godot_ * godot__singleton(void)` + - 使用 `godot_global_get_singleton(GD_STATIC_SN(u8""))` + - `lookupName` 来自 `ExtensionSingleton.name()` / `LoadStaticInsn.staticName()`,只用于 Godot singleton registry + lookup、cache/C function identity 和 lookup diagnostic + - `returnTypeName` 来自已验证的 `ExtensionSingleton.type()`,用于 C return type、cast type、cache pointer type + 和 diagnostic `context.type` + - symbol owner 固定为 `"@GlobalScope"`,symbol name 固定为 `lookupName` - 只缓存非空结果;`NULL` 走 `gdcc_binding_lookup_fail(...)` - class constant: - `static inline godot_int godot__(void)` - 返回当前 metadata 中的 constant value module-local key 由 family、owner、name、C function name 和 signature 组成。同一 canonical -binding 的 metadata 必须兼容;当前 class constant value 漂移会 fail-fast。同一 C function -name 对应不同结构性 signature 或 ABI 也必须 fail-fast。 +binding 的 metadata 必须兼容;当前 singleton `returnTypeName`、class constant value 漂移都会 +fail-fast。同一 C function name 对应不同结构性 signature 或 ABI 也必须 fail-fast。 + +`Engine`、`ClassDB` 等 fixed singleton wrappers 属于 runtime-provided symbol set。发射 +`godot_Engine_singleton()` 这类调用的路径仍可以显式记录对应 module-local binding,但 usage session +会在提交前过滤 provided C function name,避免 `engine_method_binds.h` 再输出同名 `static inline` +wrapper。`GameSingleton -> Node` 这类 runtime 未提供的 singleton 才进入 module-local snapshot,并生成 +按 `GameSingleton` lookup、按 `Node` 返回的 wrapper。 ## 动态路径与固定 Helper 边界 diff --git a/doc/module_impl/backend/load_static_implementation.md b/doc/module_impl/backend/load_static_implementation.md index e1dd6279..47b2b316 100644 --- a/doc/module_impl/backend/load_static_implementation.md +++ b/doc/module_impl/backend/load_static_implementation.md @@ -6,7 +6,7 @@ ## 文档状态 - 状态:Implemented / Maintained -- 更新时间:2026-02-26 +- 更新时间:2026-06-27 - 适用范围:`C backend` 对 `load_static` / `store_static` 的生成与校验 --- @@ -17,12 +17,20 @@ `$r = load_static "" ""` -当前后端只支持四类静态读取: + 当前后端支持七类静态读取: -1. `@GlobalScope` 顶层 global constants -2. global enum 项 -3. builtin class constants -4. engine class integer constants +1. `@GlobalScope` singleton properties +2. `@GlobalScope` 顶层 global constants +3. global enum 项 +4. builtin class constants +5. builtin class enum values +6. engine class integer constants +7. engine class enum values + +engine class constants / enum values 按 `ClassRegistry` 的 inherited static lookup 解析:先查 receiver class +本身,再沿 `classes[].inherits` 近到远查父类,返回实际声明 owner 后再执行 literal materialization 与诊断。 +IR 中的 `class_name` 仍保留源码 receiver class,不在 frontend canonicalize 成 owner class。builtin metadata 当前没有 +superclass edge,因此 builtin constant / enum value 入口保持 direct-only。 不支持: @@ -47,7 +55,7 @@ - `LoadStaticInsnGen`: - 做 IR 层校验(result 存在、可写、非 ref) - - 完成四路分发(global constant / enum / builtin / engine-int) + - 完成多路分发(global constant / global enum / singleton / builtin constant / builtin enum / engine-int constant / engine enum) - 调用 `CBodyBuilder` 与 `CBuiltinBuilder` 发射代码 - `StoreStaticInsnGen`: - 对 `STORE_STATIC` 统一抛 `InvalidInsnException` @@ -70,6 +78,23 @@ - `ExtensionGlobalConstant.value` 使用 Java `long` 保存 Godot int64 metadata;后端输出十进制 `godot_int` literal,不按 Java `int` 截断。 +### 3.0.1 singleton properties + +- 从顶层 `singletons[]` 读取,`ExtensionSingleton.name()` 是 `@GlobalScope` property lookup name, + `ExtensionSingleton.type()` 是 declared object return type。 +- `ClassRegistry` 预先验证 singleton metadata。只有 `type` 能 strict resolve 为 `GdObjectType` 时, + `findSingletonType(lookupName)` 才返回有效类型;缺失、空白、unknown 或非 object type 都不会发布为 + singleton value。 +- Backend IR 使用 `load_static "@GlobalScope" ""` 显式表示 singleton property read。 + `LoadStaticInsnGen` 先检查 singleton property,再回退 global constant,避免 object singleton 被旧的 + global-constant-only `int` 校验误拒绝。 +- C backend 发射 `godot__singleton()` wrapper 调用。`Engine`、`ClassDB` 等 fixed-provided + wrappers 只作为 provided symbol 使用,不重复输出到 `engine_method_binds.h`;非 provided singleton 才生成 + module-local wrapper。 +- singleton getter 返回 Godot engine registry 中已存在的 object pointer,是 borrowed source。 + `LoadStaticInsnGen` 必须用 borrowed object assignment 路径,不能走 `callAssign(...)` 或任何 owned-result + producer 路径。 + ### 3.1 global enum values - 从 `global_enums[].values[]` 读取。 @@ -90,14 +115,34 @@ - 在 codegen 阶段做“常量声明类型 -> 目标变量类型”兼容校验 - 错误信息可定位到“常量声明类型”而不是仅凭 literal 推断 +### 3.2.1 builtin class enum values + +- 从 `builtin_classes[].enums[].values[]` 读取(`ExtensionBuiltinClass.ClassEnum` -> `ExtensionEnumValue`)。 +- 查找走 `ClassRegistry.findBuiltinClassEnumValueInHierarchy(className, valueName)`;由于 ExtensionAPI builtin + metadata 当前没有 superclass edge,这条入口保持 direct-only。`LoadStaticInsnGen` 在 builtin constant 未命中后回退到 enum value 分支。 +- enum value 恒为整数:静态类型 `GdIntType.INT`,后端以 `Long.toString(value)` 输出十进制 `godot_int` + literal,与 global enum value 路径一致,不经过 `CBuiltinBuilder.materializeStaticLiteralValue`。 + ### 3.3 engine class constants - 从 `classes[].constants[]` 读取 +- 查找走 `ClassRegistry.findEngineClassConstantInHierarchy(className, constantName)`,直接类优先,父类按 + `inherits` 链近到远查找;缺失 superclass metadata 或循环继承时 fail closed。 - 当前只接受可解析为整数的 `value` - 该值在模型中保持 Godot 原始字符串 literal;不要为了统一 enum value 宽度把 builtin / engine class constant 的 construct string 改成数值 carrier。 - 非整数常量直接 `InvalidInsnException` +### 3.3.1 engine class enum values + +- 从 `classes[].enums[].values[]` 读取(`ExtensionGdClass.ClassEnum` -> `ExtensionEnumValue`)。 +- 查找走 `ClassRegistry.findEngineClassEnumValueInHierarchy(className, valueName)`;`LoadStaticInsnGen` 在 engine + integer constant 未命中后回退到 enum value 分支,并保留 source receiver class 作为 IR 操作数。 +- enum value 恒为整数:静态类型 `GdIntType.INT`,后端以 `Long.toString(value)` 输出十进制 `godot_int` + literal,与 global enum value 路径一致。 +- 这与 engine class **常量** 的字符串 literal 校验(`INTEGER_LITERAL_PATTERN`)是两条独立分支;enum value + 走 long carrier,不经过该正则。 + --- ## 4. literal 物化约定(单一路径) @@ -141,19 +186,23 @@ 建议长期保留以下测试关注点: -1. `@GlobalScope` global constant 成功/失败 -2. global enum 成功/失败 -3. builtin constant 成功(普通值 + `INF`) -4. engine class integer constant 成功 -5. engine class non-integer constant 失败 -6. result 变量非法(缺失 / ref) -7. `store_static` 统一拒绝 -8. builtin constant `type` 元数据解析正确 +1. `@GlobalScope` singleton property 成功/失败 +2. `@GlobalScope` global constant 成功/失败 +3. global enum 成功/失败 +4. builtin constant 成功(普通值 + `INF`) +5. engine class integer constant 成功 +6. inherited engine class integer constant / enum value 成功,且直接类成员遮蔽父类成员 +7. engine class non-integer constant 失败 +8. inherited engine class non-integer constant 失败,诊断指向实际 owner class +9. result 变量非法(缺失 / ref) +10. `store_static` 统一拒绝 +11. builtin constant `type` 元数据解析正确 +12. fixed-provided singleton wrapper 不进入 module-local header,non-provided singleton 才进入 建议命令(按需 targeted): ```bash -./gradlew test --tests CLoadStaticInsnGenTest --tests CStoreStaticInsnGenTest --tests ExtensionApiLoaderTest --no-daemon --info --console=plain +script/run-gradle-targeted-tests.sh --tests CLoadStaticInsnGenTest,CStoreStaticInsnGenTest,ExtensionApiLoaderTest ``` --- diff --git a/doc/module_impl/frontend/frontend_chain_binding_expr_type_implementation.md b/doc/module_impl/frontend/frontend_chain_binding_expr_type_implementation.md index f48aaa1e..a9775cc4 100644 --- a/doc/module_impl/frontend/frontend_chain_binding_expr_type_implementation.md +++ b/doc/module_impl/frontend/frontend_chain_binding_expr_type_implementation.md @@ -5,7 +5,7 @@ ## 文档状态 - 状态:事实源维护中(`resolvedMembers()` / `resolvedCalls()` / `expressionTypes()`、shared expression semantic support、unary/binary expression semantics、class property initializer support island、subscript / assignment typed contract、explicit self assignment-target prefix publication、`:=` 局部类型稳定化与 expr-owned diagnostics 已落地) -- 更新时间:2026-04-26 +- 更新时间:2026-06-27 - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/sema/analyzer/**` @@ -281,7 +281,9 @@ - static load route: - `EnumType.VALUE` - builtin constant + - builtin class enum value - engine integer constant + - engine class enum value - method-as-value route: - `obj.method` - `Type.static_func` @@ -292,6 +294,12 @@ 这些 route 当前不仅可出现在 executable body,也可出现在 class property `var` initializer subtree;该支持岛会继承 property 自身的 static/instance restriction,但不会把整个 class body 打开成 executable region。 +engine `TYPE_META` static load 的 constant / enum value 查询必须走 `ClassRegistry` 的 inherited static lookup: +直接类优先,随后沿 engine metadata 的 `inherits` 链近到远查找,并在 resolved member 的 declaration metadata / +detail 中保留实际 owner。builtin metadata 当前没有 superclass edge,因此 builtin static load 继续 direct-only, +但通过同形状 registry 入口接入,避免 frontend 与 backend 漂移。GDCC script class static load 与 class-level +`const` / enum 继承仍不在当前支持面内,必须继续发布 `UNSUPPORTED`,不能回退成普通 miss 或 dynamic route。 + 当前 constructor route 的事实源合同已经闭合到 downstream: - `.new(...)` 与 bare builtin direct constructor 统一发布为 `FrontendResolvedCall(callKind = CONSTRUCTOR)` diff --git a/doc/module_impl/frontend/frontend_singleton_implementation.md b/doc/module_impl/frontend/frontend_singleton_implementation.md new file mode 100644 index 00000000..fa0e8757 --- /dev/null +++ b/doc/module_impl/frontend/frontend_singleton_implementation.md @@ -0,0 +1,567 @@ +# Frontend Singleton Receiver Implementation + +> Updated: 2026-06-27 +> +> 本文档是 frontend engine singleton receiver materialization 的事实源。 +> 不再记录阶段性步骤、完成进度或实施流水账;若合同变化,应直接改写当前状态。 + +## 1. 维护合同 + +- 本文档覆盖 shared metadata、frontend semantic、CFG / body lowering、backend codegen 与 runtime ABI 之间关于 engine singleton 的长期合同。 +- 本文档同时冻结 dual-role(singleton / engine class 同名)chain head route bias 与 inherited static member 解析的当前语义边界。 +- 本文档只描述已经冻结并由代码实现承担的事实,不描述历史修复步骤。 +- 若以下任一事实发生变化,至少要同步更新: + - 本文档 + - `frontend_rules.md` + - `frontend_top_binding_analyzer_implementation.md` + - `frontend_chain_binding_expr_type_implementation.md` + - `frontend_lowering_cfg_pass_implementation.md` + - `frontend_lowering_func_pre_pass_implementation.md` + - `doc/gdcc_low_ir.md` + - `doc/module_impl/backend/load_static_implementation.md` + - `doc/module_impl/backend/godot_binding_implementation.md` + - 与 singleton metadata validation、binding publication、chain head bias、body lowering、`LoadStaticInsnGen`、 + `ModuleLocalGodotBinding.Singleton`、`engine_method_binds.h.ftl` 直接相关的代码注释 + +--- + +## 2. 当前支持面 + +frontend 当前正式支持的 engine singleton surface 包括: + +### 2.1 Singleton instance call + +- 裸 `Engine` / `Input` 等 engine singleton 作为 ordinary value receiver +- singleton method call 必须是 instance-style,receiver slot 由 `LoadStaticInsn("@GlobalScope", "")` 物化 +- singleton method call 继续使用既有 `CallMethodInsn`,不引入 singleton-specific call route,也不降级成 `CallGlobalInsn` +- statement-position `RESOLVED(void)` singleton call 不生成 standalone void temp slot +- 带参数 singleton call 保持参数 materialization 顺序与 exact callable boundary + +### 2.2 Singleton-backed property initializer + +- `var frames: int = Engine.get_frames_drawn()` 这类 singleton-backed property initializer +- 进入 `FunctionLoweringContext.Kind.PROPERTY_INIT` 上下文 +- 通过隐藏的 `_field_init_` helper 物化真实 init func body +- helper body 中 receiver 物化同样使用 `LoadStaticInsn("@GlobalScope", "")` + `CallMethodInsn` +- 真实 `LirBasicBlock`、有效 `entryBlockId`、真实 `ReturnInsn` 由 body pass 物化,不是 shell-only 中间态 + +### 2.3 Dual-role chain head route bias + +- 同名 source-facing 名称同时存在于 `singletons` 与 `classes` / `builtin_classes` 时(如 `Engine` / `Input` / `IP` / `ResourceUID`), + `FrontendTopBindingAnalyzer` 在 walk `AttributeExpression` base 之前完成 dual-role 判断 +- 普通 singleton instance call 保持 `SINGLETON`(`receiverKind = INSTANCE`) +- engine class constant / class enum value / static method 切换为 `TYPE_META`(进入 static load / static method / constructor primary route) +- constructor-like `.new()` 优先 `TYPE_META`,但需通过 fail-closed 检查 + +### 2.4 Inherited static member resolution + +- `Node2D.NOTIFICATION_*` 这类继承自父类(`Node`)的 static constant / enum value 能被解析 +- engine metadata 通过 `inherits` 链查找 constant / enum value 并返回实际 owner class +- builtin metadata 当前无 superclass edge,因此保留 direct-only 同形状入口 +- dual-role bias、chain reduction、`LoadStaticInsnGen` 三层统一消费同一份继承查询语义 + +### 2.5 Provided vs module-local binding boundary + +- `Engine` / `ClassDB` 等 fixed runtime-provided singleton wrapper(`godot_Engine_singleton()` / `godot_ClassDB_singleton()`)由 + `FixedGodotBindings` 生成到 `godot_fixed_binding.h/.c`,并通过 `GodotBindingProvidedSymbols.forRegistry(...)` 进入 + runtime-provided C function name set +- provided fixed singleton wrapper 不重复出现在 `engine_method_binds.h` 的 module-local `static inline` 定义中 +- 过滤发生在 `ModuleLocalGodotBindingUsageSession` 的 buffer -> commit -> snapshot 流程内, + 不能推迟到模板渲染阶段 +- runtime 未提供的 singleton(`GameSingleton` 等)才进入 module-local binding,输出 + `godot__singleton()` wrapper + +### 2.6 当前覆盖的典型示例 + +- `Engine.get_frames_drawn()`、`Engine.set_time_scale(...)`、`Input.is_action_pressed("ui_accept")` 作为 singleton instance call +- `var frames: int = Engine.get_frames_drawn()` 作为 singleton-backed property initializer +- `IP.RESOLVER_MAX_QUERIES`、`IP.RESOLVER_INVALID_ID`、`ResourceUID.INVALID_ID`、`DisplayServer.MAIN_WINDOW_ID` + 作为 dual-role type-meta static load +- `Input.MOUSE_MODE_VISIBLE` 作为 dual-role class enum value static load +- `ResourceUID.path_to_uid(...)` 作为 dual-role static method call +- `Engine.new()` 作为 dual-role constructor-like route(受 fail-closed 约束) +- `Node2D.NOTIFICATION_ENTER_TREE` 作为 inherited static constant + +以下内容不属于当前合同: + +- source-level `@GlobalScope` / `GlobalScope` type-meta receiver +- autoload singleton 作为 first-class binding +- GDCC 脚本类 class-level `const` / `enum` 继承解析(仍属于后续边界) +- 把 singleton method call 表达成 `CallGlobalInsn` 或旧 `LoadSingletonInsn` / `load_singleton` surface +- 把 singleton getter 结果建模为 `OWNED` object producer + +--- + +## 3. Singleton Metadata Contract + +### 3.1 `ClassRegistry` 作为 singleton metadata 唯一 owner + +`ClassRegistry` 是 singleton metadata 的唯一 owner: + +- `ExtensionSingleton.name()` / `type()` 的关系 +- strict declared-type 解析 +- 有效 singleton type cache +- invalid singleton metadata fact + +`ScopeTypeResolver` 只提供 strict declared-type 解析能力,不拥有 singleton metadata diagnostic。 +`FrontendCompileCheckAnalyzer` 只消费已发布的 compile-surface facts,不充当 singleton metadata validator。 +`DiagnosticManager` 不进入 `ClassRegistry` / `Scope` / `ScopeTypeResolver`,registry 只暴露事实, +frontend compile / lowering 入口负责把这些事实翻译成诊断。 + +### 3.2 预计算与 validation + +`ClassRegistry` 构造完成所有 API map 后应预计算: + +- `singletonTypeByName: Map` — 有效 singleton declared type +- `invalidSingletonMetadataByName: Map` — invalid metadata fact + +`InvalidSingletonMetadata` 记录三类失败原因: + +- `MISSING_TYPE` — `ExtensionSingleton.type()` 为 `null` 或 blank +- `UNRESOLVED_TYPE` — 通过 `ClassRegistry.tryResolveDeclaredType(...)` strict resolve 失败 +- `NON_OBJECT_TYPE` — strict resolve 成功但结果不是 `GdObjectType`(singleton receiver 必须是 object receiver, + builtin / enum / container type 不能作为 singleton object receiver) + +### 3.3 公开查询合同 + +- `findSingletonType(lookupName)` 必须只返回已验证的 valid type cache;不得直接 + `new GdObjectType(ExtensionSingleton.type())`。 +- `findInvalidSingletonMetadata(lookupName)` 暴露 registry-owned invalid fact。 +- `resolveValueHere(...)` 继续只在 `findSingletonType(...) != null` 时发布 `ScopeValueKind.SINGLETON`。 + invalid metadata 不能被发布成普通 unknown identifier,也不能用 guessed object type 绕过 strict validation。 +- `resolveTypeMetaHere(...)` / `findType(...)` 仍不能把 singleton lookup name 当作 type-meta route。 + singleton 是普通 value binding,`@GlobalScope` 只是 LIR / backend owner string。 + +### 3.4 用户可见失败点 + +frontend compile / lowering 入口若发现 registry 暴露 invalid singleton metadata fact, +必须在进入 lowering 前发清晰诊断并停止。这不是 `FrontendCompileCheckAnalyzer` 的 published-fact scan 职责。 +compile gate 仍只扫描 supported executable body / supported property initializer island 上已经发布的 +`expressionTypes()` / `resolvedMembers()` / `resolvedCalls()` / `slotTypes()` 状态。 + +body lowering 只保留协议不变量 fail-fast:若已经拿到 `FrontendBindingKind.SINGLETON` 却无法从 registry +取得已验证 declared type,说明上游发布合同被破坏,由 `FrontendBodyLoweringSession.requireSingletonType(...)` +立即 fail-fast。 + +--- + +## 4. Top Binding / SymbolBindings Contract + +### 4.1 `FrontendBinding` resolved value payload + +`FrontendBinding` 必须在 top binding 阶段就把 resolved value 写入 payload,而不是只记录 `symbolName` / `kind` / +`declarationSite`: + +```text +record FrontendBinding( + String symbolName, + FrontendBindingKind kind, + @Nullable Object declarationSite, + @Nullable ScopeValue resolvedValue, + @Nullable ScopeLookupStatus valueAccessStatus +) +``` + +不变量: + +- `resolvedValue` 与 `valueAccessStatus` 必须同时为 `null` 或同时非 `null` +- `valueAccessStatus` 不能是 `NOT_FOUND` +- helper `withResolvedValue(ScopeValue)` 返回更新后的新 record + +对 ordinary value binding(`PARAMETER` / `LOCAL_VAR` / `CAPTURE` / `PROPERTY` / `SIGNAL` / `CONSTANT` / +`SINGLETON` / `GLOBAL_ENUM`),`FrontendTopBindingAnalyzer` 在 `publishScopeValueBinding(...)` / +`publishValueResolution(...)` 中把 `ScopeValue resolvedValue` 与 allowed / blocked 状态写进 `FrontendBinding`。 + +`declarationSite()` 继续保留给诊断和测试断言使用,但 value type / receiver type 的真源必须是 top binding +发布的 resolved value。这样 later local、initializer self-reference local、outer fallback、singleton / global enum +等路径都复用同一份 resolved value。 + +### 4.2 后续阶段消费合同 + +`FrontendExpressionSemanticSupport.resolveValueIdentifierExpressionType(...)` 必须优先消费 binding 中的 +`resolvedValue.type()` 与 access 状态。若 value-kind binding 缺少 resolved value,说明上游 publication contract +被破坏,应发布 `FAILED` / fail-fast detail,而不是回退到 `currentScope.resolveValue(...)` 重新竞争。 + +`FrontendChainHeadReceiverSupport.resolveValueReceiver(...)` 必须同样消费 binding 中的 exact resolved value +来构造 `ReceiverState.resolvedInstance(...)` / `blockedFrom(...)`。该 helper 只允许使用 `scopesByAst` 判断 +skipped subtree 这类缺失上下文;不能再把 scope lookup 当成 resolved value 恢复机制。 + +CFG / body lowering 侧按 `symbolBindings()` materialize identifier: +`SINGLETON` 降成 `load_static "@GlobalScope" "Engine"` / `load_static "@GlobalScope" "Input"`。 +如果 chain semantic 已经按 later local 类型解析 member / call,最终会出现 `resolvedCalls()` / `expressionTypes()` +与实际 receiver materialization 不一致;本消费合同从根上消除这种漂移。 + +### 4.3 Local type stabilization writeback + +local `:=` slot 类型稳定化与 expression type fallback backfill 若改写 `BlockScope` 中的 local `ScopeValue`, +必须按 declaration identity 刷新已发布 binding payload(`FrontendExprTypeAnalyzer.refreshPublishedLocalValues(...)`); +这不是重新解析 use-site,而是让同一个 resolved value slot 的类型与后续 slot writeback 保持同步。 + +需要这一步的原因是 `BlockScope.resetLocalType(...)` 会替换 immutable `ScopeValue`;expression type / +chain receiver 不再用 `currentScope.resolveValue(...)` 重新读取当前 slot,而是直接消费 +`FrontendBinding.resolvedValue()`。如果 fallback backfill 不调用 `refreshPublishedLocalValues(...)`, +已发布 use-site binding 会继续指向 backfill 前的 `Variant` payload,导致 `BlockScope` 中的 local slot 已经变窄、 +但后续语义仍按旧 payload 分析。 + +### 4.4 later-local / initializer self-reference 稳定性 + +`FrontendVisibleValueResolver` 仍是 executable body bare value 可见性合同的唯一 owner: +- 过滤 declaration-after-use local +- 过滤自引用 initializer local +- 在 filtered hit 之后继续向 outer / class / global scope 查找 + +shared `Scope.resolveValue(...)` 继续只表达 lexical inventory lookup,不表达 statement-order。 +因此 `Engine.get_frames_drawn()` 前方若存在 later local `var Engine`,top binding 仍可正确发布 +`FrontendBindingKind.SINGLETON`;后续 expression type / chain receiver 也必须继续消费该 SINGLETON payload, +不得被 later local / initializer self-reference local 漂移。 + +--- + +## 5. Chain Head & Reduction + +### 5.1 Dual-role 名称识别 + +`FrontendTopBindingAnalyzer.tryApplyDualRoleTypeMetaBias(...)` 在 walk `AttributeExpression` base 之前识别 dual-role +名称。识别规则: + +1. base 是裸 `IdentifierExpression` 且至少一个 step +2. 通过 `resolveVisibleValue(...)`(value-winner 权威)解析后是 `FOUND_ALLOWED` 且 `ScopeValueKind.SINGLETON` +3. 同名 source-facing type-meta 通过 `resolveSourceFacingTypeMeta(...)` 可解析为 `ENGINE_CLASS` 或 `GDCC_CLASS` +4. first suffix 在 type-meta static namespace 命中(inherited constant / enum value / static method) +5. first suffix 不在 singleton instance namespace 命中(instance method / instance property / signal) + +满足上述条件时,head 发布 `TYPE_META` 并跳过普通 identifier binding 路径;否则保持 `SINGLETON`。 + +### 5.2 Value-winner 权威 + +dual-role bias handler 必须先调用 `resolveVisibleValue(...)` 判定 value winner,与 +`bindTopLevelTypeMetaCandidate(...)` 的关键合同一致。 + +只有当 value resolution 状态为 `FOUND_ALLOWED` 且 `ScopeValueKind.SINGLETON` 时才继续 bias 判断。 +若 value winner 是 local / parameter / property / capture / signal / constant / global_enum, +或状态为 `FOUND_BLOCKED` / `DEFERRED_UNSUPPORTED` / `NOT_FOUND`,bias 必须 return false 并回退到 +普通 `bindTopLevelTypeMetaCandidate(...)` 流程,由该流程通过 `publishValueResolution(...)` 固化 value winner +并报告遮蔽诊断。不得用 `classRegistry.isSingleton(name)` 绕过 visible-value 解析。 + +### 5.3 Fail-closed 规则 + +`firstStep` 的判定必须 fail-closed: + +- `Input.MOUSE_MODE_VISIBLE` / `Input.CURSOR_ARROW` 这类 static constant / enum value access: + 若只在 type-meta static namespace 命中,head 发布 `TYPE_META`。 +- `Engine.new()` / `Input.new()` 这类 constructor-like route:head 发布 `TYPE_META`,构造合法性、 + 不可构造诊断仍由后续 chain / type-check route 决定。但 `.new()` 同样必须遵守 fail-closed: + 若 singleton declared type 上存在名为 `new` 的实例方法或 property,suffix 在 singleton instance namespace + 命中,head 保持 `SINGLETON`。 +- `Engine.get_frames_drawn()` / `Input.is_action_pressed(...)` 这类 singleton instance call:保持 `SINGLETON`。 +- 若 singleton instance route 与 type-meta static route 对同一 suffix 都可命中, + 不能静默改判为 `TYPE_META`;当前保持 `SINGLETON` 或后续专门诊断。 + "singleton instance route" 覆盖 instance method、instance property 和 signal 三类成员; + signal 当前虽不在 chain access 支持语法中,但 fail-closed 检查已预防性覆盖。 +- 裸 `Engine` / `Input` 不属于 `AttributeExpression` chain head,不受 dual-role bias 影响, + 仍发布 `SINGLETON` value binding。 + +### 5.4 Namespace 查询语义 + +- `resolvesInTypeMetaStaticNamespace(typeMeta, stepName)`:通过 + `ClassRegistry.findEngineClassConstantInHierarchy(...)` / + `findEngineClassEnumValueInHierarchy(...)` / `findBuiltinClassConstantInHierarchy(...)` / + `findBuiltinClassEnumValueInHierarchy(...)` 与 hierarchy-aware static method 查找判断。 +- `resolvesInSingletonInstanceNamespace(singletonType, stepName)`:沿 singleton declared type 继承链查找 + instance method(`hasInstanceMethodInHierarchy`)/ instance property(`hasInstancePropertyInHierarchy`)/ + signal(`hasSignalInHierarchy`)。 + +engine 查询 direct-first 走 `inherits` 链并返回实际 owner;builtin metadata 当前没有 superclass edge, +因此同形状入口保持 direct-only,避免虚构继承关系。 + +### 5.5 后续 pass 预期 + +- `FrontendChainHeadReceiverSupport` 继续只消费已发布的 `symbolBindings()`: + - `SINGLETON` -> ordinary value receiver / `receiverKind = INSTANCE` + - `TYPE_META` -> type-meta receiver / static load、static method、constructor primary route +- `FrontendCfgGraphBuilder.isTypeMetaHeadAttributeExpression(...)` 继续可通过 head binding 判断 type-meta CFG 形状 +- 不引入独立 final route fact;若 dual-role 规则继续扩展到更复杂语法,再重新评估是否拆出一等 use-site route fact + +--- + +## 6. Body Lowering + +### 6.1 Singleton identifier materialization + +`FrontendIdentifierOpaqueExprInsnLoweringProcessor.lower(...)` 在 `switch(binding.kind())` 中处理 `SINGLETON`: + +- `SINGLETON` 分支只消费 `FrontendBinding` 与 `ClassRegistry` 已发布事实,不做 scope lookup +- 调用 `session.requireSingletonType(binding)` 取回 `GdObjectType` +- 发射 `new LoadStaticInsn(resultSlotId, "@GlobalScope", binding.symbolName())` +- target 使用 `session.resultSlotId(item)`,保持 `cfg_tmp_` materialization 命名合同 + +`FrontendBodyLoweringSession.requireSingletonType(binding)` 是窄 helper,只消费已验证的 declared type, +并在缺失时作为协议不变量失真 fail-fast。 + +### 6.2 `PROPERTY_INIT` 上下文 + +attribute call base 的 singleton receiver slot 必须被后续 `CallMethodInsn.objectId()` 复用, +覆盖 `EXECUTABLE_BODY` 与 `PROPERTY_INIT` 两种上下文。property initializer 不是 executable body 的旁路; +它进入同一个 CFG / body lowering session,最终 helper body 中的 receiver materialization 也必须使用同一套 +`LoadStaticInsn` -> `CallMethodInsn` 形态。 + +singleton-backed property-init helper 在 body pass 之后必须有真实 basic block、有效 `entryBlockId` 和真实 +`ReturnInsn`;其 return value 来自 singleton method call result。 + +### 6.3 保持不变的部分 + +- `FrontendCfgGraphBuilder`:SINGLETON binding 继续产生 `OpaqueExprValueItem`,且因 singleton 是 immutable / read-only, + 不发布 writable-route payload(`null`) +- `FrontendCallInsnLoweringProcessor` 和 `materializeCallReceiverLeaf(...)`:不变 +- implicit self fallback、direct-slot alias、writable-route payload 逻辑:不变 +- `SELF` contract violation:仍保持 fail-fast + +--- + +## 7. Backend `load_static` `@GlobalScope` 语义 + +### 7.1 `LoadStaticInsn` 表面 + +`$ = load_static "" ""` 中 `` 可为 `@GlobalScope`。 +`@GlobalScope` owner 同时覆盖两类读取: + +- top-level global constants(如 `OK`、`PI` 这类 `int` result) +- singleton properties(如 `Engine` / `Input` / `IP` 这类 object result) + +不新增任何 LIR opcode、record、parser branch 或 serializer branch;LIR 文本 parser / serializer 继续通过现有 +`LoadStaticInsn` 通用路径处理 `$receiver = load_static "@GlobalScope" "";`。 + +### 7.2 分支顺序 + +`LoadStaticInsnGen` 必须先按 singleton metadata 判断是否为 `@GlobalScope` property,再回退 global constant。 +这样 object singleton 不会被现有 `int`-only global constant 校验提前拒绝,既有 global constant 也不会被 +singleton branch 抢走。 + +分支优先级: + +1. 若 `classRegistry().findSingletonType(staticName) != null`:进入 singleton property branch +2. 否则进入既有 top-level global constant branch + +### 7.3 Singleton property branch 实现细则 + +- 通过 `bodyBuilder.classRegistry().findSingletonType(staticName)` 获取已通过 registry validation 的 declared + object type;该方法返回 `null` 表示不存在 valid singleton type,backend 不能再从 raw + `ExtensionSingleton.type()` 重新包装或猜测 return type +- 校验 declared singleton type 可赋给 result variable type +- 发射调用前记录 `ModuleLocalGodotBinding.singleton(staticName, declaredType.getTypeName())` +- 发射调用时把 call expression 建模为 `BORROWED` object value,使用 `bodyBuilder.assignVar(...)` / + `assignExpr(...)` 加 `valueOfExpr(..., PtrKind.GODOT_PTR)` 这类 borrowed `ValueRef` +- 不得调用 `bodyBuilder.callAssign(...)` / `valueOfOwnedExpr(...)` 或任何把 `godot__singleton()` + 结果标成 `OwnershipKind.OWNED` 的路径 +- 不得为 `callAssign(...)` 增加 boolean / flag 分支 +- target 是 managed object slot 时,slot write 按普通 borrowed-source 规则处理:必要时为新 slot acquire / retain + 自己的引用,并由后续 cleanup 释放这一次 acquire + +### 7.4 Global constant branch + +- 沿用既有 `int` global constant 校验与 materialization 路径 +- 不回归 `@GlobalScope` global constant、global enum、builtin constant、engine class integer constant + +--- + +## 8. Module-Local Singleton Binding Contract + +### 8.1 数据模型 + +`ModuleLocalGodotBinding.Singleton` 显式区分三类事实: + +- `lookupName`:来自 `ExtensionSingleton.name()` / `LoadStaticInsn.staticName()`,唯一用途是 Godot singleton + registry lookup 与 lookup diagnostic。`engine_method_binds.h.ftl` 必须把它用于 + `godot_global_get_singleton(GD_STATIC_SN(u8""))`,不能通过 `symbol.owner()` 或 declared type 反推。 +- `returnTypeName`:来自 `ExtensionSingleton.type()` / `ClassRegistry.findSingletonType(lookupName)`, + 用于生成 C return type、cast type、result slot assignability check 和 diagnostic `context.type`。 + 例如 lookup name 为 `"GameSingleton"`、declared type 为 `"Node"` 时,返回类型必须是 `godot_Node *`。 +- C symbol identity:由 `GodotBindingSymbol` 承载,固定为 + `family = SINGLETON`、`owner = "@GlobalScope"`、`name = lookupName`、 + `cFunctionName = "godot__singleton"`。这样两个不同 singleton 即使 declared type 相同也不会 + 共享 wrapper / cache。 +- ABI signature:由 `GodotBindingSymbol.signatureKey()` 承载,固定为 + `returnType = "godot_ *"`、空参数列表、`vararg = false`。 + module-local usage session 的 canonical key / C-name conflict check 继续用它阻断不兼容合并。 + +### 8.2 `singleton(lookupName, returnTypeName)` 窄入口 + +backend 必须提供并使用 `ModuleLocalGodotBinding.singleton(lookupName, returnTypeName)` 这一窄入口: + +- 该入口构造的 `Singleton` 数据形态固定为 `record Singleton(GodotBindingSymbol symbol, String lookupName, String returnTypeName)` +- `returnTypeName` 不能只隐含在 `symbol.returnType()` 字符串里 +- 所有从 metadata / `LoadStaticInsnGen` 来的 production singleton binding 都必须调用 two-name 入口 +- 保留的 single-name test shorthand 只能 delegate 到 `singleton(name, name)` +- `Singleton.cacheName()` 必须从 `cFunctionName` 或 `lookupName` 派生,不能只从 `returnTypeName` 派生, + 避免多个 singleton 声明同一 return type 时共用缓存 + +### 8.3 Merge compatibility + +`ModuleLocalGodotBinding.Singleton` 的 merge compatibility 必须同时比较: + +- `family`、`owner="@GlobalScope"`、`name=lookupName`、`cFunctionName` +- `signatureKey()` +- 显式 `lookupName` +- 显式 `returnTypeName` + +同一 C function name 对应不同 lookup / return ABI 时必须 fail-fast。 + +### 8.4 Provided vs module-local 边界 + +- fixed wrapper 继续由 `FixedGodotBindings` / `Godot451FixedBindings` 生成到 `godot_fixed_binding.h/.c`, + 并通过 `GodotBindingProvidedSymbols.forRegistry(...)` 进入 runtime-provided C function name set +- 不要把 `godot_Engine_singleton()` / `godot_ClassDB_singleton()` 这类 fixed singleton wrapper 复制进 + `ModuleLocalGodotBinding` 的 module header 输出 +- module-local wrapper 只补 runtime 未提供的 singleton / class constant surface,并且只通过 + `GodotBindingUsageSession` 的 buffer -> commit -> snapshot 流程进入 `engine_method_binds.h.ftl` +- `engine_method_binds.h.ftl` 不能自行发现或补写 `godot_*` wrapper,也不能接收 provided C symbol + +`entry.h` 的 include 顺序下,fixed declarations 会先经 `godot_binding.h` 暴露,module-local header 后包含; +因此 provided filtering 是防止同名 fixed declaration 与 module-local `static inline` definition 冲突的必要条件。 + +### 8.5 Usage session 行为 + +- `recordCall(...)` / `recordUsedGodotBindingCall(...)` 只能验证 "provided 或已显式登记",不能从函数名反推并创建 + module-local binding +- provided fixed singleton C names(`godot_Engine_singleton` / `godot_ClassDB_singleton` 等)被 + `recordGodotCall(...)` 接受,但不会进入 `moduleLocalBindings()` / `moduleLocalCFunctionNames()` +- non-provided singleton(`GameSingleton -> Node`)通过 two-name binding 被提交,C name 是 + `godot_GameSingleton_singleton`,return ABI 是 `godot_Node *` +- 相同 C name 对应不同 `lookupName` / `returnTypeName` / `signatureKey()` 时,在 buffer 或 committed session + 层 fail-fast + +### 8.6 模板渲染合同 + +`engine_method_binds.h.ftl` 的 singleton branch 必须使用: + +- `${binding.returnType()}` / `${binding.returnTypeName()}` 渲染 signature、cache、cast 与 `context.type` +- `${binding.escapedLookupName()}` 渲染 `godot_global_get_singleton(GD_STATIC_SN(...))` 与 `context.lookup_name` +- `"@GlobalScope"` 或等价常量渲染 `context.owner` +- `${binding.cFunctionName()}` 渲染 wrapper function identity + +不再使用 `${binding.escapedOwner()}` 作为 singleton lookup string。 + +--- + +## 9. Ownership 合同 + +`godot__singleton()` module-local wrapper 只做 lookup / cache / fail-fast,不是 +`classdb_construct_object*`,也不向调用方转移对象所有权。`godot_global_get_singleton` 返回的是 engine registry +中已存在的 singleton object pointer;后续 cleanup 不得释放从未取得过的引用。 + +因此: + +- singleton getter 在 LIR 中必须以 `BORROWED` object value 进入 receiver slot +- 不得使用 `CBodyBuilder.callAssign(...)` 这类把返回值建模为 fresh `OWNED` producer 的路径 +- ptr conversion 也必须保持 ownership-neutral +- 若 target 是 managed object slot,slot write 按普通 borrowed-source 规则处理:必要时为新 slot acquire / retain + 自己的引用,并由后续 cleanup 释放这一次 acquire + +--- + +## 10. 风险与边界 + +### 10.1 当前已声明的非目标 + +- 不引入 source-level `@GlobalScope` / `GlobalScope` type-meta receiver;`@GlobalScope` 仍只作为 + LIR / backend owner string +- 不实现 autoload singleton 作为 first-class binding;若未来 autoload 出现,应单独设计 resolver 与 + materialization surface,不要复用 `SINGLETON` 名义偷偷扩大语义 +- 不改变 exact-call resolver;`FrontendResolvedCall.exactCallableBoundary()` 仍是参数边界真源 +- 不在 lowering 阶段重跑 chain reduction、call route 选择或表达式求值顺序推导 +- 不新增 intrinsic;本计划不使用 `doc/gdcc_lir_intrinsic.md` 任何能力 + +### 10.2 GDCC 脚本类 class-level 成员继承(后续边界) + +`ClassScope.resolveInheritedValueMember(...)` 当前只继承 property / signal,未把父类 class-level `const` 纳入 +value lookup;父类 `enum` / `enum value` 的可见性合同也尚未在 scope / type-meta 路线中冻结。 + +若当前 AST / skeleton 已能表达 class enum 或 enum value,按 Godot 语义将父类 enum type / value 作为 class members +纳入同一继承合同;若 enum declaration 尚未完整进入 scope model,应在文档和测试中明确留下受阻边界,不把它 +伪装成已支持。 + +### 10.3 共享命名空间风险 + +fixed singleton wrapper 与 module-local singleton wrapper 共享 `godot__singleton()` 命名空间; +风险点不在 C linker,而在 header 生成前的 provided / module-local 边界。任何绕过 `GodotBindingUsageSession` snapshot、 +直接把 provided fixed symbol 塞进 `engine_method_binds.h.ftl` 的实现都应视为计划外。 + +### 10.4 后续 surface 维护提示 + +- `load_static` 的 `@GlobalScope` 分支从 constant-only 扩展为 property-aware 后,分支顺序必须谨慎: + singleton object property 不能先被 global-constant-only `int` 校验拒绝,既有 global constant 也不能被 + singleton branch 抢走 +- singleton lookup name 与 declared type name 的关系由 metadata validation 决定;实现不得把 frontend binding + name 直接当成 return type,也不得把 declared type name 当成 `godot_global_get_singleton(...)` 的 lookup string +- module-local singleton wrapper 的 cache / C function identity 必须能区分两个 lookup name 不同但 declared type + 相同的 singleton;不能只用 `returnTypeName` 派生 identity + +--- + +## 11. Test Coverage + +### 11.1 单元测试入口 + +- `src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java` + - `runLowersSingletonValueReceiverAsInstanceReceiverInExecutableBody` + - `runLowersSingletonReceiverPropertyInitializerIntoExecutableInitFunction` + - `runLowersSingletonReceiverBeforeLaterLocalShadowAsGlobalScopeLoad` + - `runFailsFastWhenPublishedSingletonBindingLosesRegistryMetadata` +- `src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java` + - `runPublishesSingletonBackedPropertyInitContextAndKeepsShellOnly` +- `src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBuildCfgPassTest.java` + - `runPublishesSingletonBackedPropertyInitCfgGraph` +- `src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringPassManagerTest.java` + - `lowerToContextHandlesSingletonBackedPropertyInitializerEndToEnd` +- `src/test/java/gd/script/gdcc/scope/ClassRegistryTest.java` + - `singletonMetadataShouldResolveLookupNameToDeclaredObjectType` + - `invalidSingletonMetadataShouldNotPublishSingletonValue` + - `findTypeDoesNotReturnForSingletonEnumOrFunction` +- `src/test/java/gd/script/gdcc/scope/ClassRegistryScopeTest.java` + - `resolveValueAndFunctionsExposeGlobalBindings` + - `restrictionAwareLookupKeepsGlobalBindingsAllowed` + - `sameNameCanResolveIndependentlyInValueAndTypeNamespaces` +- `src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzerTest.java` + - dual-role 全部覆盖:singleton instance call、`.new()` fail-closed、signal fail-closed、 + engine class constant、class enum value、static method、混用场景、fail-closed 双 namespace、 + non-dual-role engine class static、property initializer、prior-declared local 遮蔽、 + parameter 遮蔽、later-local 不遮蔽、inherited static member +- `src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendChainBindingAnalyzerTest.java` + - dual-role downstream route:INSTANCE_METHOD / TYPE_META static load / STATIC_METHOD / class enum value + - inherited engine static load +- `src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelperTest.java` + - `reduceResolvesInheritedEngineClassConstantAndEnumValue` + - `reduceDirectEngineClassStaticMemberWinsOverInheritedStaticMember` + - `reduceFailsWhenInheritedEngineStaticMemberMissingAcrossHierarchy` + - `reduceRejectsInheritedEngineClassNonIntegerConstant` +- `src/test/java/gd/script/gdcc/backend/c/gen/CLoadStaticInsnGenTest.java` + - singleton property、inherited constant / enum value、missing、incompatible target type、 + non-integer inherited constant 负路径 +- `src/test/java/gd/script/gdcc/backend/c/gen/binding/usage/GodotBindingUsageSessionTest.java` + - `providedFixedSingletonsShouldBeAcceptedButNotCommitted` + - `nonProvidedSingletonShouldCommitLookupAndReturnTypeSeparately` + - `sameSingletonCNameWithDifferentReturnTypeShouldFailFast` +- `src/test/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBindingTemplateTest.java` + - `singletonWrapperShouldRenderOnlySingletonLookupHelperWithoutDesignatedInitializer` + - `singletonWrapperShouldKeepLookupNameSeparateFromReturnTypeName` +- `src/test/java/gd/script/gdcc/backend/c/gen/binding/FixedGodotBindingsTest.java` + - `fixedSymbolsShouldBeValidatedVersionedSourceList` + - `renderFixedSupportShouldUseFixedRuntimeWrappersWithoutEngineConstructors` +- `src/test/java/gd/script/gdcc/backend/c/gen/CCodegenEngineMethodUsageSessionTest.java` + - `generateShouldFilterFixedSingletonWrappersAndRenderOnlyNonProvidedSingletonWrappers` + +### 11.2 端到端 test-suite 资源 + +按 `doc/test_suite.md` 的资源配对规则新增的端到端 fixture: + +- `src/test/test_suite/unit_test/validation/runtime/singleton_receiver_calls.gd` + - 覆盖 singleton-backed property initializer、`Engine.get_frames_drawn()` 返回值调用、 + `Engine.set_time_scale(...)` statement-position void 调用、`Input.is_action_pressed(...)` 带参调用 +- `src/test/test_suite/unit_test/validation/runtime/singleton_receiver_binding_drift.gd` + - 覆盖 later-local 与 initializer self-reference drift 场景经过 frontend lowering、 + C backend、Godot runtime 后仍按 singleton receiver 执行 +- `src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_static_constant.gd` + - 覆盖 dual-role TYPE_META static load route:`IP.RESOLVER_MAX_QUERIES`、`IP.RESOLVER_INVALID_ID`、 + `ResourceUID.INVALID_ID`、`DisplayServer.MAIN_WINDOW_ID` 以及 property initializer +- `src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_mixed_use_sites.gd` + - 覆盖同一函数体内 `SINGLETON` 与 `TYPE_META` route 互不污染 diff --git a/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md b/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md index 00b573aa..32f6b184 100644 --- a/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md @@ -5,7 +5,7 @@ ## 文档状态 - 状态:事实源维护中(`symbolBindings()` 重建、builtin / global enum / class-like top-level `TYPE_META` 规则、value-position bare callable / bare `TYPE_META` ordinary-value misuse 合同、class property initializer support island、root-level skipped-subtree 恢复合同、usage-agnostic binding 模型与核心单元测试已落地) -- 更新时间:2026-03-19 +- 更新时间:2026-06-27 - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/sema/analyzer/**` @@ -242,12 +242,17 @@ - 直接发 deferred / unsupported diagnostic - 不得继续尝试 `TYPE_META` -唯一的当前例外是 global enum chain head: +当前例外只允许出现在明确的 static-route head 竞争中: - 若 ordinary value resolution 的 winning value 是 `GLOBAL_ENUM` - 且 `Scope.resolveTypeMeta(...)` 同名命中受支持的 global enum type-meta - analyzer 必须优先发布 `TYPE_META` - 这样 `EnumType.VALUE` 才能进入后续 static-load route,而不会被 ordinary value binding 永久吃掉 +- 若 ordinary value resolution 的 winning value 是 dual-role engine singleton,且同名 `TYPE_META` 的 first suffix + 只在 engine type-meta static namespace 命中 inherited constant / enum value / static method,analyzer 可以发布 + `TYPE_META` 作为 chain head,使 `Node2D.NOTIFICATION_*` 这类 inherited static load 进入后续 route。 + 如果 singleton instance namespace 中也存在同名 member / method / signal,则必须 fail closed 保持 ordinary + singleton 路线,不能因为 inherited static member 而静默改路由。 顶层 `global_constants[]` 不使用这个例外: @@ -268,6 +273,10 @@ - `ClassRegistry` 已注册的 global enum - 当前 lexical scope 可见的 inner class +对 engine class-like `TYPE_META`,first suffix 的 static namespace 判定必须使用 `ClassRegistry` 的 +inherited static lookup:constant / enum value 直接类优先,再沿 engine metadata `inherits` 链查父类。 +builtin static namespace 当前仍 direct-only,因为 ExtensionAPI builtin metadata 没有 superclass edge。 + 实现上,这对应以下约束: - `ScopeTypeMeta.kind()` 为 `GDCC_CLASS` / `ENGINE_CLASS` / `BUILTIN` 时 diff --git a/src/main/c/codegen/template_451/engine_method_binds.h.ftl b/src/main/c/codegen/template_451/engine_method_binds.h.ftl index 30e29a25..dac5ef22 100644 --- a/src/main/c/codegen/template_451/engine_method_binds.h.ftl +++ b/src/main/c/codegen/template_451/engine_method_binds.h.ftl @@ -39,13 +39,14 @@ static inline godot_${constructor.cIdentifier} *godot_new_${constructor.cIdentif static inline ${binding.returnType()} ${binding.cFunctionName()}(void) { static ${binding.returnType()} ${binding.cacheName()} = NULL; if (${binding.cacheName()} == NULL) { - ${binding.cacheName()} = (${binding.returnType()})godot_global_get_singleton(GD_STATIC_SN(u8"${binding.escapedOwner()}")); + ${binding.cacheName()} = (${binding.returnType()})godot_global_get_singleton(GD_STATIC_SN(u8"${binding.escapedLookupName()}")); if (${binding.cacheName()} == NULL) { gdcc_binding_lookup_context context = { 0 }; context.kind = "module_singleton"; context.function_name = "${binding.escapedCFunctionName()}"; context.lookup_name = "${binding.escapedLookupName()}"; context.owner = "${binding.escapedOwner()}"; + context.type = "${binding.escapedReturnTypeName()}"; gdcc_binding_lookup_fail(&context); return NULL; } diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java index 3c42cd6f..f38d8f69 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CBodyBuilder.java @@ -1324,7 +1324,9 @@ private boolean checkGlobalFuncRequireGodotRawPtr(@NotNull String funcName) { funcName.equals("gdcc_cmp_object"); } - private void recordUsedGodotBindingCall(@NotNull String funcName) { + /// Verifies that an emitted `godot_*` wrapper call is either runtime-provided or has already + /// been registered as module-local by the current lowering path. + public void recordUsedGodotBindingCall(@NotNull String funcName) { try { usageBuffer.recordGodotCall(funcName); } catch (IllegalStateException exception) { diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBinding.java b/src/main/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBinding.java index 7a4c1770..8078c4dc 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBinding.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBinding.java @@ -12,37 +12,86 @@ /// not appear here as generic wrapper kinds. public sealed interface ModuleLocalGodotBinding permits ModuleLocalGodotBinding.Singleton, ModuleLocalGodotBinding.ClassConstant { + /// Structural identity for the generated wrapper. + /// + /// `symbol` owns the C ABI shape, C function name, logical owner/name pair, and merge key. It + /// is intentionally separate from lookup metadata such as singleton registry names. @NotNull GodotBindingSymbol symbol(); + /// Godot metadata name used by the generated wrapper to perform the runtime lookup. + /// + /// For singleton bindings this comes from `ExtensionSingleton.name()` / `LoadStaticInsn.staticName()` + /// and is passed to `godot_global_get_singleton(...)`. For class constants this is the constant + /// member name used for diagnostics and wrapper identity. @NotNull String lookupName(); + /// Template-facing family discriminator derived from `GodotBindingSymbol.Family`. + /// + /// This selects the module-local renderer branch; it is not a Godot lookup name. default @NotNull String familyName() { return symbol().family().name(); } + /// Public wrapper C function name emitted into generated C. + /// + /// The name comes from `GodotBindingSymbol` so provided-symbol filtering and C-name conflict + /// checks all use the same identity. Singleton wrappers use `godot__singleton()`; + /// class constants use `godot__()`. default @NotNull String cFunctionName() { return symbol().cFunctionName(); } + /// `cFunctionName()` escaped for C string literal contexts such as lookup diagnostics. default @NotNull String escapedCFunctionName() { return GodotBindingSupport.escapeCString(symbol().cFunctionName()); } + /// C ABI return type for the wrapper. + /// + /// For singleton bindings this is derived from the registry-validated declared return type + /// (`ExtensionSingleton.type()`), not from the singleton lookup name. For class constants it is + /// currently `godot_int`. default @NotNull String returnType() { return symbol().returnType(); } + /// Logical owner escaped for C string literal diagnostics. + /// + /// Singleton bindings always use `@GlobalScope` as the symbol owner; class constants use the + /// declaring engine/builtin class name. This value is diagnostic metadata, not the singleton + /// registry lookup string. default @NotNull String escapedOwner() { return GodotBindingSupport.escapeCString(symbol().owner()); } + /// `lookupName()` escaped for C string literal contexts. + /// + /// Singleton templates must use this for `godot_global_get_singleton(...)`; using owner or return + /// type would break `lookupName != returnTypeName` metadata such as `GameSingleton -> Node`. default @NotNull String escapedLookupName() { return GodotBindingSupport.escapeCString(lookupName()); } + /// Declared singleton return type name escaped for lookup diagnostics. + /// + /// Singleton bindings store this explicitly because the Godot registry lookup name and returned + /// object type are independent metadata fields. Non-singleton bindings fall back to the full C + /// return type because they do not have a separate Godot type-name component. + default @NotNull String escapedReturnTypeName() { + return this instanceof Singleton singleton + ? GodotBindingSupport.escapeCString(singleton.returnTypeName()) + : GodotBindingSupport.escapeCString(symbol().returnType()); + } + default @NotNull ModuleLocalGodotBinding mergeCompatible(@NotNull ModuleLocalGodotBinding other) { - if (this instanceof Singleton && other instanceof Singleton) { - return checkCommonMetadata(other); + if (this instanceof Singleton singleton && other instanceof Singleton otherSingleton) { + checkCommonMetadata(other); + if (!singleton.returnTypeName().equals(otherSingleton.returnTypeName())) { + throw new IllegalStateException( + "Incompatible module-local Godot binding metadata for '" + symbol().cFunctionName() + "'" + ); + } + return this; } if (this instanceof ClassConstant constant && other instanceof ClassConstant otherConstant) { checkCommonMetadata(other); @@ -58,7 +107,7 @@ public sealed interface ModuleLocalGodotBinding ); } - private @NotNull ModuleLocalGodotBinding checkCommonMetadata(@NotNull ModuleLocalGodotBinding other) { + private void checkCommonMetadata(@NotNull ModuleLocalGodotBinding other) { if (!symbol().signatureKey().equals(other.symbol().signatureKey()) || !symbol().cFunctionName().equals(other.symbol().cFunctionName()) || !symbol().owner().equals(other.symbol().owner()) @@ -68,27 +117,41 @@ public sealed interface ModuleLocalGodotBinding "Incompatible module-local Godot binding metadata for '" + symbol().cFunctionName() + "'" ); } - return this; } static @NotNull Singleton singleton(@NotNull String className) { - var cName = GodotBindingSupport.cIdentifier(className); + return singleton(className, className); + } + + /// Create a singleton getter wrapper from the two independent singleton metadata names. + /// + /// `lookupName` is the `@GlobalScope` property / Godot singleton registry name used for lookup + /// and C symbol identity. `returnTypeName` is the registry-validated declared object type used + /// for the C ABI return/cast/cache type. + static @NotNull Singleton singleton(@NotNull String lookupName, @NotNull String returnTypeName) { + var lookupCName = GodotBindingSupport.cIdentifier(lookupName); + var returnTypeCName = GodotBindingSupport.cIdentifier(returnTypeName); return new Singleton( GodotBindingSupport.symbol( GodotBindingSymbol.Family.SINGLETON, - className, - "singleton", - "godot_" + cName + "_singleton", - "godot_" + cName + " *", + "@GlobalScope", + lookupName, + "godot_" + lookupCName + "_singleton", + "godot_" + returnTypeCName + " *", List.of(), false, null, List.of() ), - className + lookupName, + returnTypeName ); } + /// Create a class constant wrapper. + /// + /// `className` is the declaring class owner used for C symbol identity; `constantName` is the + /// metadata member/lookup name; `constantValue` is the literal returned by the wrapper. static @NotNull ClassConstant classConstant( @NotNull String className, @NotNull String constantName, @@ -113,23 +176,32 @@ public sealed interface ModuleLocalGodotBinding ); } + /// @param lookupName Godot singleton registry lookup name; source is `ExtensionSingleton.name()`. + /// @param returnTypeName Registry-validated declared object type name; source is `ExtensionSingleton.type()`. record Singleton( @NotNull GodotBindingSymbol symbol, - @NotNull String lookupName + @NotNull String lookupName, + @NotNull String returnTypeName ) implements ModuleLocalGodotBinding { public Singleton { Objects.requireNonNull(symbol); Objects.requireNonNull(lookupName); + Objects.requireNonNull(returnTypeName); if (symbol.family() != GodotBindingSymbol.Family.SINGLETON) { throw new IllegalArgumentException("Module-local singleton binding must use SINGLETON family"); } } + /// Static cache variable name for one singleton lookup wrapper. + /// + /// The cache is keyed by `lookupName`, not by returned type, so two singleton names with the + /// same declared object type never share cached pointers. public @NotNull String cacheName() { - return "gdcc_module_singleton_" + GodotBindingSupport.cIdentifier(symbol.owner()); + return "gdcc_module_singleton_" + GodotBindingSupport.cIdentifier(lookupName); } } + /// @param lookupName Class constant member name used in diagnostics and module-local wrapper metadata. record ClassConstant( @NotNull GodotBindingSymbol symbol, @NotNull String lookupName, diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/LoadStaticInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/LoadStaticInsnGen.java index 20065975..034dee7f 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/LoadStaticInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/LoadStaticInsnGen.java @@ -2,6 +2,7 @@ import gd.script.gdcc.backend.c.gen.CBodyBuilder; import gd.script.gdcc.backend.c.gen.CInsnGen; +import gd.script.gdcc.backend.c.gen.binding.ModuleLocalGodotBinding; import gd.script.gdcc.enums.GdInstruction; import gd.script.gdcc.gdextension.ExtensionBuiltinClass; import gd.script.gdcc.gdextension.ExtensionGdClass; @@ -45,6 +46,25 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { var staticName = insn.staticName(); if (GLOBAL_SCOPE_RECEIVER.equals(className)) { + var singletonType = classRegistry.findSingletonType(staticName); + if (singletonType != null) { + if (!classRegistry.checkAssignable(singletonType, resultVar.type())) { + throw bodyBuilder.invalidInsn( + "Static load target type '" + resultVar.type().getTypeName() + + "' is not assignable from singleton type '" + singletonType.getTypeName() + "'" + ); + } + var binding = ModuleLocalGodotBinding.singleton(staticName, singletonType.getTypeName()); + bodyBuilder.recordModuleLocalGodotBinding(binding); + bodyBuilder.recordUsedGodotBindingCall(binding.cFunctionName()); + bodyBuilder.assignExpr( + target, + binding.cFunctionName() + "()", + singletonType, + CBodyBuilder.PtrKind.GODOT_PTR + ); + return; + } if (!classRegistry.checkAssignable(GdIntType.INT, resultVar.type())) { throw bodyBuilder.invalidInsn( "Static load target type '" + resultVar.type().getTypeName() + @@ -62,41 +82,70 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { var builtinClass = classRegistry.findBuiltinClass(className); if (builtinClass != null) { - var constant = findBuiltinConstant(bodyBuilder, builtinClass, staticName); - var declaredType = parseBuiltinConstantType(bodyBuilder, constant, className, staticName); - if (!classRegistry.checkAssignable(declaredType, resultVar.type())) { - throw bodyBuilder.invalidInsn( - "Static load target type '" + resultVar.type().getTypeName() + - "' is not assignable from builtin constant type '" + declaredType.getTypeName() + "'" + var constantLookup = classRegistry.findBuiltinClassConstantInHierarchy(className, staticName); + if (constantLookup != null) { + var constant = constantLookup.constant(); + var constantOwnerName = constantLookup.ownerClass().name(); + var declaredType = parseBuiltinConstantType(bodyBuilder, constant, constantOwnerName, staticName); + if (!classRegistry.checkAssignable(declaredType, resultVar.type())) { + throw bodyBuilder.invalidInsn( + "Static load target type '" + resultVar.type().getTypeName() + + "' is not assignable from builtin constant type '" + declaredType.getTypeName() + "'" + ); + } + var literalValue = constant.value(); + if (literalValue == null || literalValue.isBlank()) { + throw bodyBuilder.invalidInsn( + "Builtin constant '" + staticName + "' not found in class '" + className + "'" + ); + } + bodyBuilder.helper().builtinBuilder().materializeStaticLiteralValue( + bodyBuilder, + target, + literalValue, + constantOwnerName, + staticName ); + return; } - var literalValue = constant.value(); - if (literalValue == null || literalValue.isBlank()) { - throw bodyBuilder.invalidInsn( - "Builtin constant '" + staticName + "' not found in class '" + className + "'" - ); + var enumValueLookup = classRegistry.findBuiltinClassEnumValueInHierarchy(className, staticName); + if (enumValueLookup != null) { + if (!classRegistry.checkAssignable(GdIntType.INT, resultVar.type())) { + throw bodyBuilder.invalidInsn( + "Static load target type '" + resultVar.type().getTypeName() + + "' is not assignable from builtin class enum value" + ); + } + bodyBuilder.assignExpr(target, Long.toString(enumValueLookup.enumValue().value()), GdIntType.INT); + return; } - bodyBuilder.helper().builtinBuilder().materializeStaticLiteralValue( - bodyBuilder, - target, - literalValue, - className, - staticName + throw bodyBuilder.invalidInsn( + "Builtin constant or enum value '" + staticName + + "' not found in class '" + className + "'" ); - return; } var classDef = classRegistry.getClassDef(new GdObjectType(className)); if (classDef instanceof ExtensionGdClass engineClass) { - var engineConstant = engineClass.constants().stream() - .filter(constant -> staticName.equals(constant.name())) - .findFirst() - .orElse(null); - if (engineConstant == null) { + var engineConstantLookup = classRegistry.findEngineClassConstantInHierarchy(engineClass.getName(), staticName); + if (engineConstantLookup == null) { + var enumValueLookup = classRegistry.findEngineClassEnumValueInHierarchy(className, staticName); + if (enumValueLookup != null) { + if (!classRegistry.checkAssignable(GdIntType.INT, resultVar.type())) { + throw bodyBuilder.invalidInsn( + "Static load target type '" + resultVar.type().getTypeName() + + "' is not assignable from engine class enum value" + ); + } + bodyBuilder.assignExpr(target, Long.toString(enumValueLookup.enumValue().value()), GdIntType.INT); + return; + } throw bodyBuilder.invalidInsn( - "Engine class constant '" + staticName + "' not found in class '" + className + "'" + "Engine class constant or enum value '" + staticName + + "' not found in class '" + className + "' or its superclasses" ); } + var engineConstant = engineConstantLookup.constant(); if (!classRegistry.checkAssignable(GdIntType.INT, resultVar.type())) { throw bodyBuilder.invalidInsn( "Static load target type '" + resultVar.type().getTypeName() + @@ -106,7 +155,7 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { var literal = engineConstant.value() == null ? "" : engineConstant.value().trim(); if (!INTEGER_LITERAL_PATTERN.matcher(literal).matches()) { throw bodyBuilder.invalidInsn( - "Engine class constant '" + staticName + "' in class '" + className + + "Engine class constant '" + staticName + "' in class '" + engineConstantLookup.ownerClass().getName() + "' is not an integer literal: '" + engineConstant.value() + "'" ); } @@ -116,25 +165,12 @@ public void generateCCode(@NotNull CBodyBuilder bodyBuilder) { throw bodyBuilder.invalidInsn( "Static load target '" + className + "." + staticName + - "' is unsupported; only @GlobalScope global constants, global enums, builtin constants, and engine class integer constants are allowed" + "' is unsupported; only @GlobalScope global constants, global enums, builtin constants," + + " builtin class enum values, engine class integer constants, and engine class enum values" + + " are allowed" ); } - private @NotNull ExtensionBuiltinClass.ConstantInfo findBuiltinConstant(@NotNull CBodyBuilder bodyBuilder, - @NotNull ExtensionBuiltinClass builtinClass, - @NotNull String staticName) { - var matchedConstant = builtinClass.constants().stream() - .filter(constant -> staticName.equals(constant.name())) - .findFirst() - .orElse(null); - if (matchedConstant == null) { - throw bodyBuilder.invalidInsn( - "Builtin constant '" + staticName + "' not found in class '" + builtinClass.name() + "'" - ); - } - return matchedConstant; - } - private @NotNull GdType parseBuiltinConstantType(@NotNull CBodyBuilder bodyBuilder, @NotNull ExtensionBuiltinClass.ConstantInfo constant, @NotNull String className, diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java index e919e800..e05191ca 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/FrontendCfgGraphBuilder.java @@ -1560,7 +1560,7 @@ yield new OpaqueExpressionRoute( ), List.of() ); - case CONSTANT -> null; + case CONSTANT, SINGLETON -> null; default -> throw new IllegalStateException( "Identifier writable-route publication is not supported for binding kind " + binding.kind() ); diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java index 63540216..c1d44a5a 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendBodyLoweringSession.java @@ -1015,6 +1015,17 @@ boolean isStaticPropertyBinding(@NotNull FrontendBinding binding) { ); } + @NotNull GdObjectType requireSingletonType(@NotNull FrontendBinding binding) { + var singletonType = classRegistry.findSingletonType(binding.symbolName()); + if (singletonType == null) { + throw new IllegalStateException( + "Published singleton binding '" + binding.symbolName() + + "' is missing registry-validated object metadata" + ); + } + return singletonType; + } + /// Allocates one body-local helper temp owned by writable-route lowering. /// /// Ordinary CFG value slots continue to use `cfgTempSlotId(...)` / `mergeSlotId(...)`. This diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendOpaqueExprInsnLoweringProcessors.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendOpaqueExprInsnLoweringProcessors.java index 17073a73..372875f7 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendOpaqueExprInsnLoweringProcessors.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendOpaqueExprInsnLoweringProcessors.java @@ -46,8 +46,8 @@ private FrontendOpaqueExprInsnLoweringProcessors() { /// Resolves a bare identifier leaf through the already-published binding table. /// /// This processor is allowed to choose only among binding-backed runtime load routes - /// (local/parameter/capture/property/self); it must not re-run any scope lookup or member - /// inference. + /// (local/parameter/capture/property/self/singleton); it must not re-run any scope lookup or + /// member inference. /// /// `FrontendTopBindingAnalyzer` publishes `FrontendBindingKind.SELF` only for explicit /// `SelfExpression`. If an `IdentifierExpression` arrives here with binding kind `SELF`, some @@ -86,6 +86,14 @@ private static final class FrontendIdentifierOpaqueExprInsnLoweringProcessor session.requireSelfSlot(); block.appendNonTerminatorInstruction(new LoadPropertyInsn(resultSlotId, binding.symbolName(), "self")); } + case SINGLETON -> { + session.requireSingletonType(binding); + block.appendNonTerminatorInstruction(new LoadStaticInsn( + resultSlotId, + "@GlobalScope", + binding.symbolName() + )); + } case CONSTANT -> { if (binding.declarationSite() instanceof ExtensionGlobalConstant globalConstant) { block.appendNonTerminatorInstruction(new LiteralIntInsn(resultSlotId, globalConstant.value())); diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBinding.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBinding.java index e656b57e..de56c785 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBinding.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBinding.java @@ -1,5 +1,7 @@ package gd.script.gdcc.frontend.sema; +import gd.script.gdcc.scope.ScopeLookupStatus; +import gd.script.gdcc.scope.ScopeValue; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -7,16 +9,48 @@ /// One resolved frontend binding fact attached to an AST use site. /// -/// The record stores symbol category plus declaration provenance. It intentionally does not encode -/// usage semantics such as read/write/call, so assignment left-hand sites and ordinary reads share -/// the same container shape. +/// The record stores symbol category plus declaration provenance. For ordinary value bindings it +/// also keeps the exact value resolved by top binding so later body phases do not re-run lexical +/// lookup and accidentally bypass declaration-order or self-reference visibility rules. +/// +/// @param symbolName source-facing symbol name attached to this use site +/// @param kind frontend binding category published for downstream semantic and lowering phases +/// @param declarationSite declaration provenance retained for diagnostics and tests +/// @param resolvedValue exact ordinary value selected by top binding; null for non-value bindings +/// @param valueAccessStatus allowed/blocked lookup status for `resolvedValue`; null iff it is null public record FrontendBinding( @NotNull String symbolName, @NotNull FrontendBindingKind kind, - @Nullable Object declarationSite + @Nullable Object declarationSite, + @Nullable ScopeValue resolvedValue, + @Nullable ScopeLookupStatus valueAccessStatus ) { + public FrontendBinding( + @NotNull String symbolName, + @NotNull FrontendBindingKind kind, + @Nullable Object declarationSite + ) { + this(symbolName, kind, declarationSite, null, null); + } + public FrontendBinding { Objects.requireNonNull(symbolName, "symbolName must not be null"); Objects.requireNonNull(kind, "kind must not be null"); + if ((resolvedValue == null) != (valueAccessStatus == null)) { + throw new IllegalArgumentException("resolved value and access status must be recorded together"); + } + if (valueAccessStatus == ScopeLookupStatus.NOT_FOUND) { + throw new IllegalArgumentException("resolved value access status must be found"); + } + } + + public @NotNull FrontendBinding withResolvedValue(@NotNull ScopeValue updatedValue) { + return new FrontendBinding( + symbolName, + kind, + declarationSite, + Objects.requireNonNull(updatedValue, "updatedValue must not be null"), + Objects.requireNonNull(valueAccessStatus, "valueAccessStatus must not be null") + ); } } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzer.java index 65bf0cc2..466de318 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzer.java @@ -17,6 +17,7 @@ import gd.script.gdcc.scope.ResolveRestriction; import gd.script.gdcc.scope.Scope; import gd.script.gdcc.scope.ScopeOwnerKind; +import gd.script.gdcc.scope.ScopeValue; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdVoidType; @@ -546,7 +547,34 @@ private void backfillInferredLocalType(@NotNull VariableDeclaration variableDecl } return; } - blockScope.resetLocalType(variableDeclaration.name().trim(), variableDeclaration, backfilledType); + var localName = variableDeclaration.name().trim(); + blockScope.resetLocalType(localName, variableDeclaration, backfilledType); + var updatedValue = blockScope.resolveValueHere(localName); + if (updatedValue != null) { + refreshPublishedLocalValues(variableDeclaration, updatedValue); + } + } + + /// Mirrors local type stabilization writeback. + /// + /// Downstream expression and receiver analysis consume `FrontendBinding`'s + /// published `resolvedValue` instead of re-running scope lookup. `BlockScope.resetLocalType` + /// replaces the immutable local `ScopeValue`, so this fallback would otherwise leave earlier + /// use-site bindings pointing at the pre-backfill `Variant` slot while the block scope holds + /// the narrowed type. Refreshing by declaration identity keeps the same top-binding choice and + /// only updates that choice's rewritten slot payload. + private void refreshPublishedLocalValues( + @NotNull VariableDeclaration variableDeclaration, + @NotNull ScopeValue updatedValue + ) { + for (var entry : analysisData.symbolBindings().entrySet()) { + var binding = entry.getValue(); + var resolvedValue = binding.resolvedValue(); + if (resolvedValue == null || resolvedValue.declaration() != variableDeclaration) { + continue; + } + entry.setValue(binding.withResolvedValue(updatedValue)); + } } private boolean sameType(@NotNull GdType first, @NotNull GdType second) { diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzer.java index 1edebf34..fde3ad7c 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzer.java @@ -46,6 +46,7 @@ import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.scope.ResolveRestriction; import gd.script.gdcc.scope.Scope; +import gd.script.gdcc.scope.ScopeValue; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVoidType; import org.jetbrains.annotations.NotNull; @@ -164,16 +165,18 @@ static void probeStabilizeLocalSlot( ); } - private static void stabilizeLocalSlot( + private static @Nullable ScopeValue stabilizeLocalSlot( @NotNull BlockScope blockScope, @NotNull VariableDeclaration variableDeclaration, @NotNull FrontendExpressionType initializerType ) { var stableType = stableLocalTypeOrNull(initializerType); if (stableType == null) { - return; + return null; } - blockScope.resetLocalType(variableDeclaration.name().trim(), variableDeclaration, stableType); + var localName = variableDeclaration.name().trim(); + blockScope.resetLocalType(localName, variableDeclaration, stableType); + return blockScope.resolveValueHere(localName); } private static @Nullable GdType stableLocalTypeOrNull( @@ -350,11 +353,30 @@ private void walk(@NotNull SourceFile sourceFile) { var initializerType = silentExpressionResolver.resolveExpressionType(initializer); probes.add(new ProbeEntry(variableDeclaration, initializer, initializerType)); if (writeBackStableSlots) { - stabilizeLocalSlot(blockScope, variableDeclaration, initializerType); + var updatedValue = stabilizeLocalSlot(blockScope, variableDeclaration, initializerType); + if (updatedValue != null) { + refreshPublishedLocalValues(variableDeclaration, updatedValue); + } } return FrontendASTTraversalDirective.SKIP_CHILDREN; } + /// Keep published use-site values aligned with the slot type rewrite while preserving the + /// original top-binding identity by declaration. + private void refreshPublishedLocalValues( + @NotNull VariableDeclaration variableDeclaration, + @NotNull ScopeValue updatedValue + ) { + for (var entry : symbolBindings.entrySet()) { + var binding = entry.getValue(); + var resolvedValue = binding.resolvedValue(); + if (resolvedValue == null || resolvedValue.declaration() != variableDeclaration) { + continue; + } + entry.setValue(binding.withResolvedValue(updatedValue)); + } + } + @Override public @NotNull FrontendASTTraversalDirective handleIfStatement(@NotNull IfStatement ifStatement) { if (supportedExecutableBlockDepth <= 0 || isNotPublished(ifStatement)) { diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSemanticAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSemanticAnalyzer.java index 3052bf77..dd32f6a0 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSemanticAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSemanticAnalyzer.java @@ -484,7 +484,7 @@ public FrontendSemanticAnalyzer( // Top-binding analysis classifies supported use-sites into stable symbol categories while // still keeping member/call resolution out of scope. Keeping it separate from variable // analysis preserves a clean hand-off between declaration inventory and use-site binding. - topBindingAnalyzer.analyze(analysisData, diagnosticManager); + topBindingAnalyzer.analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); // Local type stabilization updates only block-local `:=` slot types. It runs after use-site diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzer.java index 182e70c0..53a0d52a 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzer.java @@ -15,17 +15,23 @@ import gd.script.gdcc.frontend.sema.resolver.FrontendVisibleValueResolution; import gd.script.gdcc.frontend.sema.resolver.FrontendVisibleValueResolver; import gd.script.gdcc.frontend.sema.resolver.FrontendVisibleValueStatus; +import gd.script.gdcc.gdextension.ExtensionGdClass; import gd.script.gdcc.gdextension.ExtensionUtilityFunction; +import gd.script.gdcc.scope.ClassDef; +import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.scope.FunctionDef; import gd.script.gdcc.scope.ResolveRestriction; import gd.script.gdcc.scope.*; import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.type.GdObjectType; import dev.superice.gdparser.frontend.ast.ASTNodeHandler; import dev.superice.gdparser.frontend.ast.ASTWalker; import dev.superice.gdparser.frontend.ast.AssignmentExpression; import dev.superice.gdparser.frontend.ast.AttributeCallStep; import dev.superice.gdparser.frontend.ast.AssertStatement; import dev.superice.gdparser.frontend.ast.AttributeExpression; +import dev.superice.gdparser.frontend.ast.AttributePropertyStep; +import dev.superice.gdparser.frontend.ast.AttributeStep; import dev.superice.gdparser.frontend.ast.AttributeSubscriptStep; import dev.superice.gdparser.frontend.ast.Block; import dev.superice.gdparser.frontend.ast.CallExpression; @@ -57,6 +63,7 @@ import java.util.IdentityHashMap; import java.nio.file.Path; +import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -72,9 +79,11 @@ public class FrontendTopBindingAnalyzer { /// Runs top-binding analysis and refreshes `symbolBindings()` from scratch. public void analyze( + @NotNull ClassRegistry classRegistry, @NotNull FrontendAnalysisData analysisData, @NotNull DiagnosticManager diagnosticManager ) { + Objects.requireNonNull(classRegistry, "classRegistry must not be null"); Objects.requireNonNull(analysisData, "analysisData must not be null"); Objects.requireNonNull(diagnosticManager, "diagnosticManager must not be null"); @@ -100,7 +109,8 @@ public void analyze( scopesByAst, symbolBindings, diagnosticManager, - visibleValueResolver + visibleValueResolver, + classRegistry ).walk(sourceClassRelation.unit().ast()); } analysisData.updateSymbolBindings(symbolBindings); @@ -121,6 +131,7 @@ private static final class AstWalkerTopBindingBinder implements ASTNodeHandler { private final @NotNull FrontendAstSideTable symbolBindings; private final @NotNull DiagnosticManager diagnosticManager; private final @NotNull FrontendVisibleValueResolver visibleValueResolver; + private final @NotNull ClassRegistry classRegistry; private final @NotNull ASTWalker astWalker; private final @NotNull IdentityHashMap reportedUnsupportedRoots = new IdentityHashMap<>(); private int supportedExecutableBlockDepth; @@ -135,7 +146,8 @@ private AstWalkerTopBindingBinder( @NotNull FrontendAstSideTable scopesByAst, @NotNull FrontendAstSideTable symbolBindings, @NotNull DiagnosticManager diagnosticManager, - @NotNull FrontendVisibleValueResolver visibleValueResolver + @NotNull FrontendVisibleValueResolver visibleValueResolver, + @NotNull ClassRegistry classRegistry ) { this.sourcePath = Objects.requireNonNull(sourcePath, "sourcePath must not be null"); this.moduleSkeleton = Objects.requireNonNull(moduleSkeleton, "moduleSkeleton must not be null"); @@ -146,6 +158,7 @@ private AstWalkerTopBindingBinder( visibleValueResolver, "visibleValueResolver must not be null" ); + this.classRegistry = Objects.requireNonNull(classRegistry, "classRegistry must not be null"); astWalker = new ASTWalker(this); } @@ -484,7 +497,16 @@ private void walkAssignmentExpression(@NotNull AssignmentExpression assignmentEx private void walkAttributeExpression(@NotNull AttributeExpression attributeExpression) { // Only the outermost chain head is bound here, but arguments nested inside // attribute-call/subscript steps still belong to the executable expression tree. - walkChainHeadBaseExpression(attributeExpression.base()); + // + // Dual-role bias: when the base is a bare IdentifierExpression whose value namespace + // resolves to SINGLETON and whose type-meta namespace also resolves to ENGINE_CLASS, + // inspect the first suffix step to decide whether the head should publish TYPE_META + // (static/constructor route) or stay on the ordinary SINGLETON value path. This runs + // before walking the base so the ordinary identifier handler never publishes a + // SINGLETON binding that would then need to be overwritten. + if (!tryApplyDualRoleTypeMetaBias(attributeExpression)) { + walkChainHeadBaseExpression(attributeExpression.base()); + } for (var step : attributeExpression.steps()) { switch (step) { case AttributeCallStep attributeCallStep -> walkExpressionList(attributeCallStep.arguments()); @@ -706,9 +728,17 @@ private void publishValueResolution( @NotNull FrontendVisibleValueResolution resolution ) { switch (resolution.status()) { - case FOUND_ALLOWED -> publishScopeValueBinding(identifierExpression, resolution.visibleValue()); + case FOUND_ALLOWED -> publishScopeValueBinding( + identifierExpression, + resolution.visibleValue(), + ScopeLookupStatus.FOUND_ALLOWED + ); case FOUND_BLOCKED -> { - publishScopeValueBinding(identifierExpression, resolution.visibleValue()); + publishScopeValueBinding( + identifierExpression, + resolution.visibleValue(), + ScopeLookupStatus.FOUND_BLOCKED + ); reportBindingError( identifierExpression, "Binding '" + identifierExpression.name() + "' is not accessible in the current context" @@ -950,14 +980,20 @@ private void publishFunctionBinding( private void publishScopeValueBinding( @NotNull IdentifierExpression identifierExpression, - @Nullable ScopeValue scopeValue + @Nullable ScopeValue scopeValue, + @NotNull ScopeLookupStatus accessStatus ) { var resolvedValue = Objects.requireNonNull(scopeValue, "scopeValue must not be null"); + if (accessStatus == ScopeLookupStatus.NOT_FOUND) { + throw new IllegalArgumentException("scope value binding must be a found result"); + } publishBinding( identifierExpression, identifierExpression.name(), toBindingKind(resolvedValue.kind()), - resolvedValue.declaration() + resolvedValue.declaration(), + resolvedValue, + accessStatus ); } @@ -984,6 +1020,246 @@ private boolean shouldPreferGlobalEnumTypeMeta( && supportsTopLevelTypeMeta(typeMetaResult.requireValue()); } + /// Dual-role chain-head route bias entry point. + /// + /// When the `AttributeExpression` base is a bare `IdentifierExpression` whose value + /// namespace resolves to `SINGLETON` (via `resolveVisibleValue`, which honors + /// declaration-order filtering and self-reference sealing) and whose type-meta + /// namespace also resolves to `ENGINE_CLASS`, inspect the first suffix step to decide + /// whether the head should publish `TYPE_META` (for static constant / enum value / + /// static method / constructor `.new()` routes) or stay on the ordinary `SINGLETON` + /// value path (for instance method / property routes). + /// + /// This method MUST consume `resolveVisibleValue(...)` as the value-winner authority, + /// matching the contract of `bindTopLevelTypeMetaCandidate(...)`: if a local, parameter, + /// property, or other non-singleton value wins the visible-value resolution, the bias + /// must not override it — the caller falls through to the ordinary + /// `bindTopLevelTypeMetaCandidate(...)` flow which publishes the value winner and + /// reports shadowing diagnostics. Similarly, `FOUND_BLOCKED` and `DEFERRED_UNSUPPORTED` + /// value states are handled by the ordinary flow, not here. + /// + /// The decision is fail-closed: if the first suffix can be satisfied by both the + /// singleton instance namespace (instance method, instance property, or signal) and the + /// type-meta static namespace, the head keeps `SINGLETON` to avoid silently changing the + /// route based on registry traversal order. + /// + /// Returns `true` when a `TYPE_META` binding was published and the caller must skip the + /// ordinary base walk; returns `false` when the ordinary walk should proceed. + private boolean tryApplyDualRoleTypeMetaBias(@NotNull AttributeExpression attributeExpression) { + if (!(attributeExpression.base() instanceof IdentifierExpression identifierExpression)) { + return false; + } + if (attributeExpression.steps().isEmpty()) { + return false; + } + var currentScope = findCurrentScope(identifierExpression); + if (currentScope == null) { + return false; + } + var name = identifierExpression.name(); + + // Value-winner authority: resolveVisibleValue honors declaration-order filtering, + // self-reference sealing, and deferred boundaries. Only a FOUND_ALLOWED SINGLETON + // value winner is eligible for the dual-role bias. All other value states (local, + // parameter, property, blocked, deferred, not-found) must fall through to the + // ordinary bindTopLevelTypeMetaCandidate flow. + if (trySealPropertyInitializerValueBoundary(identifierExpression)) { + return false; + } + var valueResolution = resolveVisibleValue(identifierExpression); + if (valueResolution.status() != FrontendVisibleValueStatus.FOUND_ALLOWED) { + return false; + } + var visibleValue = valueResolution.visibleValue(); + if (visibleValue == null || visibleValue.kind() != ScopeValueKind.SINGLETON) { + return false; + } + var singletonType = classRegistry.findSingletonType(name); + if (singletonType == null) { + return false; + } + + // Type-meta namespace must also resolve the same name to ENGINE_CLASS (or GDCC_CLASS). + var typeMetaResult = moduleSkeleton.resolveSourceFacingTypeMeta( + currentScope, + name, + currentRestriction + ); + if (!typeMetaResult.isAllowed()) { + return false; + } + var typeMeta = typeMetaResult.requireValue(); + if (typeMeta.kind() != ScopeTypeMetaKind.ENGINE_CLASS + && typeMeta.kind() != ScopeTypeMetaKind.GDCC_CLASS) { + return false; + } + if (!supportsTopLevelTypeMeta(typeMeta)) { + return false; + } + + var firstStep = attributeExpression.steps().getFirst(); + var stepName = extractStepName(firstStep); + if (stepName == null) { + return false; + } + + // Constructor-like `.new()` route: prefer TYPE_META, but still respect fail-closed. + // If the singleton declared type has an instance method, instance property, or signal + // named "new", the suffix resolves in the singleton instance namespace, so the head + // must stay SINGLETON to avoid silently stealing an instance-member route. Only when + // "new" does NOT resolve as a singleton instance member do we switch to TYPE_META and + // let the downstream constructor route decide legality. + if (firstStep instanceof AttributeCallStep && stepName.equals("new")) { + if (!resolvesInSingletonInstanceNamespace(singletonType, stepName)) { + publishBinding( + identifierExpression, + name, + FrontendBindingKind.TYPE_META, + typeMeta.declaration() + ); + return true; + } + return false; + } + + // For property steps, check whether the suffix only resolves in the type-meta static + // namespace (engine class constant, class enum value, or static method reference). + // For call steps (non-`new`), check whether the suffix only resolves as a static method. + boolean inTypeMetaStatic = resolvesInTypeMetaStaticNamespace(typeMeta, stepName); + boolean inSingletonInstance = resolvesInSingletonInstanceNamespace(singletonType, stepName); + + // Fail-closed: only switch to TYPE_META when the suffix is NOT available as a + // singleton instance member (instance method, instance property, or signal). This + // prevents silently changing the route when both namespaces can satisfy the same + // suffix name. + if (inTypeMetaStatic && !inSingletonInstance) { + publishBinding( + identifierExpression, + name, + FrontendBindingKind.TYPE_META, + typeMeta.declaration() + ); + return true; + } + return false; + } + + /// Extracts the member name from the first attribute step, or `null` for unknown step types. + private @Nullable String extractStepName(@NotNull AttributeStep step) { + return switch (step) { + case AttributePropertyStep propertyStep -> propertyStep.name(); + case AttributeCallStep callStep -> callStep.name(); + case AttributeSubscriptStep subscriptStep -> subscriptStep.name(); + default -> null; + }; + } + + /// Checks whether `stepName` resolves in the type-meta static namespace. Engine class + /// constants and class enum values use the registry's inherited lookup, while static + /// methods keep the existing hierarchy walk shared with GDCC classes. + private boolean resolvesInTypeMetaStaticNamespace( + @NotNull ScopeTypeMeta typeMeta, + @NotNull String stepName + ) { + if (typeMeta.declaration() instanceof ExtensionGdClass engineClass) { + if (classRegistry.findEngineClassConstantInHierarchy(engineClass.getName(), stepName) != null) { + return true; + } + if (classRegistry.findEngineClassEnumValueInHierarchy(engineClass.getName(), stepName) != null) { + return true; + } + } else if (classRegistry.getClassDef( + typeMeta.instanceType() instanceof GdObjectType ot ? ot : new GdObjectType(typeMeta.canonicalName()) + ) instanceof ExtensionGdClass engineClass) { + if (classRegistry.findEngineClassConstantInHierarchy(engineClass.getName(), stepName) != null) { + return true; + } + if (classRegistry.findEngineClassEnumValueInHierarchy(engineClass.getName(), stepName) != null) { + return true; + } + } + return hasStaticMethodInHierarchy(typeMeta, stepName); + } + + /// Checks whether `stepName` resolves as a singleton instance member: instance method, + /// instance property, or signal (walking the class hierarchy of the singleton declared + /// type). Signal is included defensively so that when signal chain access enters the + /// supported grammar, the fail-closed rule already covers it instead of silently + /// switching the head to TYPE_META. + private boolean resolvesInSingletonInstanceNamespace( + @NotNull GdObjectType singletonType, + @NotNull String stepName + ) { + return hasInstanceMethodInHierarchy(singletonType, stepName) + || hasInstancePropertyInHierarchy(singletonType, stepName) + || hasSignalInHierarchy(singletonType, stepName); + } + + /// Walks the class hierarchy starting from `typeMeta` to check if a static method named + /// `stepName` exists. Covers both ENGINE_CLASS and GDCC_CLASS. + private boolean hasStaticMethodInHierarchy(@NotNull ScopeTypeMeta typeMeta, @NotNull String stepName) { + ClassDef current = classRegistry.resolveClassDefFromTypeMeta(typeMeta); + var visited = new HashSet(); + while (current != null && visited.add(current.getName())) { + var found = current.getFunctions().stream() + .anyMatch(fn -> fn.getName().equals(stepName) && fn.isStatic()); + if (found) { + return true; + } + current = classRegistry.resolveSuperclass(current); + } + return false; + } + + /// Walks the class hierarchy of the singleton declared type to check if an instance method + /// named `stepName` exists. + private boolean hasInstanceMethodInHierarchy(@NotNull GdObjectType singletonType, @NotNull String stepName) { + ClassDef current = classRegistry.getClassDef(singletonType); + var visited = new HashSet(); + while (current != null && visited.add(current.getName())) { + var found = current.getFunctions().stream() + .anyMatch(fn -> fn.getName().equals(stepName) && !fn.isStatic()); + if (found) { + return true; + } + current = classRegistry.resolveSuperclass(current); + } + return false; + } + + /// Walks the class hierarchy of the singleton declared type to check if an instance + /// property named `stepName` exists. + private boolean hasInstancePropertyInHierarchy(@NotNull GdObjectType singletonType, @NotNull String stepName) { + ClassDef current = classRegistry.getClassDef(singletonType); + var visited = new HashSet(); + while (current != null && visited.add(current.getName())) { + var found = current.getProperties().stream() + .anyMatch(prop -> prop.getName().equals(stepName) && !prop.isStatic()); + if (found) { + return true; + } + current = classRegistry.resolveSuperclass(current); + } + return false; + } + + /// Walks the class hierarchy of the singleton declared type to check if a signal named + /// `stepName` exists. Signal is checked defensively so the fail-closed rule covers it + /// even before signal chain access enters the supported grammar. + private boolean hasSignalInHierarchy(@NotNull GdObjectType singletonType, @NotNull String stepName) { + ClassDef current = classRegistry.getClassDef(singletonType); + var visited = new HashSet(); + while (current != null && visited.add(current.getName())) { + var found = current.getSignals().stream() + .anyMatch(signal -> signal.getName().equals(stepName)); + if (found) { + return true; + } + current = classRegistry.resolveSuperclass(current); + } + return false; + } + private @Nullable Scope findCurrentScope(@NotNull IdentifierExpression identifierExpression) { return scopesByAst.get(Objects.requireNonNull(identifierExpression, "identifierExpression must not be null")); } @@ -1068,13 +1344,26 @@ private void publishBinding( @NotNull String symbolName, @NotNull FrontendBindingKind kind, @Nullable Object declarationSite + ) { + publishBinding(useSite, symbolName, kind, declarationSite, null, null); + } + + private void publishBinding( + @NotNull Node useSite, + @NotNull String symbolName, + @NotNull FrontendBindingKind kind, + @Nullable Object declarationSite, + @Nullable ScopeValue resolvedValue, + @Nullable ScopeLookupStatus valueAccessStatus ) { symbolBindings.put( useSite, new FrontendBinding( Objects.requireNonNull(symbolName, "symbolName must not be null"), Objects.requireNonNull(kind, "kind must not be null"), - declarationSite + declarationSite, + resolvedValue, + valueAccessStatus ) ); } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupport.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupport.java index 3b30c34c..7923f9c7 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupport.java @@ -3,6 +3,7 @@ import gd.script.gdcc.frontend.scope.ClassScope; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import gd.script.gdcc.frontend.sema.FrontendAstSideTable; +import gd.script.gdcc.frontend.sema.FrontendBinding; import gd.script.gdcc.frontend.sema.FrontendReceiverKind; import gd.script.gdcc.scope.ResolveRestriction; import gd.script.gdcc.scope.Scope; @@ -33,7 +34,7 @@ /// - both `FrontendChainBindingAnalyzer` and `FrontendExprTypeAnalyzer` need the same rules for /// turning a chain head into `ReceiverState` /// - those rules are not the chain-reduction core itself; they are the glue that bridges already -/// published binding/scope facts into a stable receiver model +/// published binding facts into a stable receiver model /// - keeping this logic in one place reduces the risk that `self`, `TYPE_META`, literal heads, or /// blocked value bindings drift apart between the two analyzers /// @@ -89,7 +90,8 @@ public interface FallbackExpressionReceiverResolver { /// The constructor arguments intentionally mirror the minimal inputs that may change while an /// analyzer walks the AST: /// - `analysisData` gives access to already published binding facts - /// - `scopesByAst` and `currentRestriction` drive current-scope lookup + /// - `scopesByAst` identifies skipped subtrees; `currentRestriction` still applies to + /// type-meta and callable recovery paths /// - `staticContext` controls policies such as `self` becoming `BLOCKED` /// - the two callbacks let analyzers plug in their own nested-chain and fallback-expression /// strategies without duplicating the atomic head rules @@ -192,7 +194,7 @@ public FrontendChainHeadReceiverSupport( case TYPE_META -> resolveTypeMetaReceiver(identifier); case SELF -> resolveSelfReceiver(identifier); case PARAMETER, LOCAL_VAR, CAPTURE, PROPERTY, SIGNAL, CONSTANT, SINGLETON, GLOBAL_ENUM -> - resolveValueReceiver(identifier); + resolveValueReceiver(identifier, binding); case METHOD, STATIC_METHOD, UTILITY_FUNCTION -> resolveCallableReceiver(identifier); case UNKNOWN -> failedHeadReceiver( identifier, @@ -208,9 +210,9 @@ public FrontendChainHeadReceiverSupport( /// Resolves a published `TYPE_META` identifier into a type-meta receiver. /// /// This path is used for class-style chain heads such as `Worker.build()` or `Vector3.BACK`. - /// It re-checks the current lexical scope because the helper is consuming published facts, not - /// owning them: if the AST node is inside a skipped subtree we publish `UNSUPPORTED`; if the - /// previously published type-meta winner is no longer reachable, we publish `FAILED`. + /// It re-checks the current lexical scope because type-meta bindings do not yet carry typed + /// binding payloads. If the AST node is inside a skipped subtree we publish `UNSUPPORTED`; if the + /// previously published type-meta binding is no longer reachable, we publish `FAILED`. public @NotNull FrontendChainReductionHelper.ReceiverState resolveTypeMetaReceiver( @NotNull IdentifierExpression identifierExpression ) { @@ -244,11 +246,12 @@ public FrontendChainHeadReceiverSupport( /// Resolves a published ordinary value identifier into an instance receiver. /// - /// The result preserves the important distinction between: + /// The result consumes the exact `ScopeValue` resolved by top binding and preserves the important + /// distinction between: /// - `RESOLVED`: a usable value receiver exists - /// - `BLOCKED`: a winner exists, but the current restriction forbids its use here + /// - `BLOCKED`: a resolved value exists, but the current restriction forbids its use here /// - `UNSUPPORTED`: the subtree has no stable scope fact - /// - `FAILED`: the previously published winner can no longer be recovered from scope lookup + /// - `FAILED`: the published value binding is missing the required resolved value payload public @NotNull FrontendChainReductionHelper.ReceiverState resolveValueReceiver( @NotNull IdentifierExpression identifierExpression ) { @@ -263,24 +266,53 @@ public FrontendChainHeadReceiverSupport( "Value receiver '" + identifier.name() + "' is inside a skipped subtree" ); } - var valueResult = currentScope.resolveValue(identifier.name(), currentRestriction); - if (valueResult.isAllowed()) { - return FrontendChainReductionHelper.ReceiverState.resolvedInstance(valueResult.requireValue().type()); + var binding = analysisData.symbolBindings().get(identifier); + if (binding == null) { + return new FrontendChainReductionHelper.ReceiverState( + FrontendChainReductionHelper.Status.FAILED, + FrontendReceiverKind.UNKNOWN, + null, + null, + "No published value receiver binding fact is available for identifier '" + identifier.name() + "'" + ); } - if (valueResult.isBlocked()) { - var winner = valueResult.requireValue(); - return FrontendChainReductionHelper.ReceiverState.blockedFrom( - FrontendChainReductionHelper.ReceiverState.resolvedInstance(winner.type()), - "Binding '" + identifier.name() + "' is not accessible in the current context" + return resolveValueReceiver(identifier, binding); + } + + private @NotNull FrontendChainReductionHelper.ReceiverState resolveValueReceiver( + @NotNull IdentifierExpression identifierExpression, + @NotNull FrontendBinding binding + ) { + var identifier = Objects.requireNonNull(identifierExpression, "identifierExpression must not be null"); + var resolvedValue = Objects.requireNonNull(binding, "binding must not be null").resolvedValue(); + if (resolvedValue == null) { + return new FrontendChainReductionHelper.ReceiverState( + FrontendChainReductionHelper.Status.FAILED, + FrontendReceiverKind.UNKNOWN, + null, + null, + "Published value receiver '" + identifier.name() + + "' is missing its top-binding resolved value payload" ); } - return new FrontendChainReductionHelper.ReceiverState( - FrontendChainReductionHelper.Status.FAILED, - FrontendReceiverKind.UNKNOWN, - null, - null, - "Published value receiver '" + identifier.name() + "' is no longer visible" - ); + return switch (Objects.requireNonNull( + binding.valueAccessStatus(), + "valueAccessStatus must not be null when resolvedValue is present" + )) { + case FOUND_ALLOWED -> FrontendChainReductionHelper.ReceiverState.resolvedInstance(resolvedValue.type()); + case FOUND_BLOCKED -> FrontendChainReductionHelper.ReceiverState.blockedFrom( + FrontendChainReductionHelper.ReceiverState.resolvedInstance(resolvedValue.type()), + "Binding '" + identifier.name() + "' is not accessible in the current context" + ); + case NOT_FOUND -> new FrontendChainReductionHelper.ReceiverState( + FrontendChainReductionHelper.Status.FAILED, + FrontendReceiverKind.UNKNOWN, + null, + null, + "Published value receiver '" + identifier.name() + + "' carries an invalid not-found resolved value status" + ); + }; } /// Resolves a published bare callable symbol into a `Callable` value receiver. diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelper.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelper.java index 72dd103f..aa6590ea 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelper.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelper.java @@ -755,26 +755,36 @@ private static boolean shouldResolveFirstBlockedStepExactly( + "' is not backed by builtin class metadata"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.BUILTIN, receiverTypeMeta, detailReason); } - var constant = builtinClass.constants().stream() - .filter(candidate -> step.name().equals(candidate.name())) - .findFirst() - .orElse(null); - if (constant == null) { + var constantLookup = classRegistry.findBuiltinClassConstantInHierarchy(builtinClass.name(), step.name()); + if (constantLookup == null) { var methodReference = resolveStaticMethodReference(classRegistry, receiverTypeMeta, step.name()); if (methodReference != null) { return resolvedMethodReferenceTrace(stepIndex, step, incomingReceiver, methodReference); } - var detailReason = "Builtin constant '" + step.name() + "' not found in class '" + builtinClass.name() + "'"; + var enumValueLookup = classRegistry.findBuiltinClassEnumValueInHierarchy(builtinClass.name(), step.name()); + if (enumValueLookup != null) { + return resolvedStaticLoadTrace( + stepIndex, + step, + incomingReceiver, + ScopeOwnerKind.BUILTIN, + GdIntType.INT, + enumValueLookup.enumValue() + ); + } + var detailReason = "Builtin constant or enum value '" + step.name() + + "' not found in class '" + builtinClass.name() + "'"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.BUILTIN, receiverTypeMeta, detailReason); } + var constant = constantLookup.constant(); if (constant.type() == null || constant.type().isBlank()) { - var detailReason = "Builtin constant '" + step.name() + "' in class '" + builtinClass.name() + var detailReason = "Builtin constant '" + step.name() + "' in class '" + constantLookup.ownerClass().name() + "' has no declared type"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.BUILTIN, constant, detailReason); } var constantType = classRegistry.tryResolveDeclaredType(constant.type()); if (constantType == null) { - var detailReason = "Builtin constant '" + step.name() + "' in class '" + builtinClass.name() + var detailReason = "Builtin constant '" + step.name() + "' in class '" + constantLookup.ownerClass().name() + "' has unsupported declared type '" + constant.type() + "'"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.BUILTIN, constant, detailReason); } @@ -801,21 +811,31 @@ private static boolean shouldResolveFirstBlockedStepExactly( + "' is not backed by engine class metadata"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.ENGINE, receiverTypeMeta, detailReason); } - var constant = engineClass.constants().stream() - .filter(candidate -> step.name().equals(candidate.name())) - .findFirst() - .orElse(null); - if (constant == null) { + var constantLookup = classRegistry.findEngineClassConstantInHierarchy(engineClass.getName(), step.name()); + if (constantLookup == null) { var methodReference = resolveStaticMethodReference(classRegistry, receiverTypeMeta, step.name()); if (methodReference != null) { return resolvedMethodReferenceTrace(stepIndex, step, incomingReceiver, methodReference); } - var detailReason = "Engine class constant '" + step.name() + "' not found in class '" + engineClass.name() + "'"; + var enumValueLookup = classRegistry.findEngineClassEnumValueInHierarchy(engineClass.getName(), step.name()); + if (enumValueLookup != null) { + return resolvedStaticLoadTrace( + stepIndex, + step, + incomingReceiver, + ScopeOwnerKind.ENGINE, + GdIntType.INT, + enumValueLookup.enumValue() + ); + } + var detailReason = "Engine class constant or enum value '" + step.name() + + "' not found in class '" + engineClass.name() + "' or its superclasses"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.ENGINE, receiverTypeMeta, detailReason); } + var constant = constantLookup.constant(); var literal = constant.value() == null ? "" : constant.value().trim(); if (!INTEGER_LITERAL_PATTERN.matcher(literal).matches()) { - var detailReason = "Engine class constant '" + step.name() + "' in class '" + engineClass.name() + var detailReason = "Engine class constant '" + step.name() + "' in class '" + constantLookup.ownerClass().name() + "' is not an integer literal"; return failedStaticLoadTrace(stepIndex, step, incomingReceiver, ScopeOwnerKind.ENGINE, constant, detailReason); } @@ -2094,7 +2114,7 @@ yield new StepTrace( yield builtinClass == null ? null : resolveMethodReference(classRegistry, builtinClass, memberName, true); } case ENGINE_CLASS, GDCC_CLASS -> { - var classDef = resolveDeclaredClass(classRegistry, receiverTypeMeta); + var classDef = classRegistry.resolveClassDefFromTypeMeta(receiverTypeMeta); yield classDef == null ? null : resolveMethodReference(classRegistry, classDef, memberName, true); } }; @@ -2127,25 +2147,11 @@ yield new StepTrace( List.copyOf(ownerMethods) ); } - current = resolveMethodReferenceSuperclass(classRegistry, current); + current = classRegistry.resolveSuperclass(current); } return null; } - private static @Nullable ClassDef resolveMethodReferenceSuperclass( - @NotNull ClassRegistry classRegistry, - @NotNull ClassDef current - ) { - if (current.getSuperName().isBlank()) { - return null; - } - var builtinSuper = classRegistry.findBuiltinClass(current.getSuperName()); - if (builtinSuper != null) { - return builtinSuper; - } - return classRegistry.getClassDef(new GdObjectType(current.getSuperName())); - } - private static @Nullable ScopeOwnerKind resolveMethodOwnerKind( @NotNull ClassRegistry classRegistry, @NotNull ClassDef ownerClass @@ -2168,19 +2174,6 @@ yield new StepTrace( return null; } - private static @Nullable ClassDef resolveDeclaredClass( - @NotNull ClassRegistry classRegistry, - @NotNull ScopeTypeMeta receiverTypeMeta - ) { - if (receiverTypeMeta.declaration() instanceof ClassDef classDef) { - return classDef; - } - if (receiverTypeMeta.instanceType() instanceof GdObjectType objectType) { - return classRegistry.getClassDef(objectType); - } - return null; - } - private record MethodReferenceResolution( @NotNull String memberName, @NotNull FrontendBindingKind bindingKind, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendConstructorResolutionSupport.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendConstructorResolutionSupport.java index d7e2645a..16903304 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendConstructorResolutionSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendConstructorResolutionSupport.java @@ -3,13 +3,11 @@ import gd.script.gdcc.frontend.sema.FrontendCallResolutionStatus; import gd.script.gdcc.gdextension.ExtensionBuiltinClass; import gd.script.gdcc.gdextension.ExtensionGdClass; -import gd.script.gdcc.scope.ClassDef; import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.scope.FunctionDef; import gd.script.gdcc.scope.ParameterDef; import gd.script.gdcc.scope.ScopeOwnerKind; import gd.script.gdcc.scope.ScopeTypeMeta; -import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import org.jetbrains.annotations.NotNull; @@ -81,7 +79,7 @@ record Resolution( @NotNull ScopeTypeMeta receiverTypeMeta, @NotNull List argumentTypes ) { - if (!(resolveDeclaredClass(classRegistry, receiverTypeMeta) instanceof ExtensionGdClass engineClass)) { + if (!(classRegistry.resolveClassDefFromTypeMeta(receiverTypeMeta) instanceof ExtensionGdClass engineClass)) { return failed( receiverTypeMeta.declaration(), ScopeOwnerKind.ENGINE, @@ -110,7 +108,7 @@ record Resolution( @NotNull ScopeTypeMeta receiverTypeMeta, @NotNull List argumentTypes ) { - var classDef = resolveDeclaredClass(classRegistry, receiverTypeMeta); + var classDef = classRegistry.resolveClassDefFromTypeMeta(receiverTypeMeta); if (classDef == null) { return failed( receiverTypeMeta.declaration(), @@ -450,19 +448,6 @@ private static int firstMissingRequiredParameter( return joiner.toString(); } - private static @Nullable ClassDef resolveDeclaredClass( - @NotNull ClassRegistry classRegistry, - @NotNull ScopeTypeMeta receiverTypeMeta - ) { - if (receiverTypeMeta.declaration() instanceof ClassDef classDef) { - return classDef; - } - if (receiverTypeMeta.instanceType() instanceof GdObjectType objectType) { - return classRegistry.getClassDef(objectType); - } - return null; - } - private static @Nullable ExtensionBuiltinClass resolveBuiltinStaticOwner( @NotNull ClassRegistry classRegistry, @NotNull ScopeTypeMeta receiverTypeMeta diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java index 88829d15..04eb28c4 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java @@ -186,7 +186,7 @@ public FrontendExpressionSemanticSupport( return propagated(switch (binding.kind()) { case SELF -> resolveSelfExpressionType(identifierExpression).expressionType(); case PARAMETER, LOCAL_VAR, CAPTURE, PROPERTY, SIGNAL, CONSTANT, SINGLETON, GLOBAL_ENUM -> - resolveValueIdentifierExpressionType(identifierExpression); + resolveValueIdentifierExpressionType(identifierExpression, binding); case TYPE_META -> FrontendExpressionType.failed( "Type-meta identifier '" + identifierExpression.name() + "' cannot be consumed as an ordinary value; use a static route such as '" @@ -632,7 +632,8 @@ private boolean shouldContinueBlockedBareCallResolution( } private @NotNull FrontendExpressionType resolveValueIdentifierExpressionType( - @NotNull IdentifierExpression identifierExpression + @NotNull IdentifierExpression identifierExpression, + @NotNull FrontendBinding binding ) { var currentScope = scopesByAst.get(identifierExpression); if (currentScope == null) { @@ -640,19 +641,27 @@ private boolean shouldContinueBlockedBareCallResolution( "Identifier '" + identifierExpression.name() + "' is inside a skipped subtree" ); } - var valueResult = currentScope.resolveValue(identifierExpression.name(), currentRestriction()); - if (valueResult.isAllowed()) { - return FrontendExpressionType.resolved(valueResult.requireValue().type()); + var resolvedValue = binding.resolvedValue(); + if (resolvedValue == null) { + return FrontendExpressionType.failed( + "Published value binding '" + identifierExpression.name() + + "' is missing its top-binding resolved value payload" + ); } - if (valueResult.isBlocked()) { - return FrontendExpressionType.blocked( - valueResult.requireValue().type(), + return switch (Objects.requireNonNull( + binding.valueAccessStatus(), + "valueAccessStatus must not be null when resolvedValue is present" + )) { + case FOUND_ALLOWED -> FrontendExpressionType.resolved(resolvedValue.type()); + case FOUND_BLOCKED -> FrontendExpressionType.blocked( + resolvedValue.type(), "Binding '" + identifierExpression.name() + "' is not accessible in the current context" ); - } - return FrontendExpressionType.failed( - "Published value binding '" + identifierExpression.name() + "' is no longer visible" - ); + case NOT_FOUND -> FrontendExpressionType.failed( + "Published value binding '" + identifierExpression.name() + + "' carries an invalid not-found resolved value status" + ); + }; } private @NotNull FrontendExpressionType resolveCallableIdentifierExpressionType( diff --git a/src/main/java/gd/script/gdcc/scope/ClassRegistry.java b/src/main/java/gd/script/gdcc/scope/ClassRegistry.java index 865a59d8..bd2d82fe 100644 --- a/src/main/java/gd/script/gdcc/scope/ClassRegistry.java +++ b/src/main/java/gd/script/gdcc/scope/ClassRegistry.java @@ -30,6 +30,30 @@ /// returns `FOUND_BLOCKED` /// - legacy `findXxx(...)` helpers stay available for compatibility callers public final class ClassRegistry implements Scope { + public record EngineClassConstantLookup( + @NotNull ExtensionGdClass ownerClass, + @NotNull ExtensionGdClass.ConstantInfo constant + ) { + } + + public record EngineClassEnumValueLookup( + @NotNull ExtensionGdClass ownerClass, + @NotNull ExtensionEnumValue enumValue + ) { + } + + public record BuiltinClassConstantLookup( + @NotNull ExtensionBuiltinClass ownerClass, + @NotNull ExtensionBuiltinClass.ConstantInfo constant + ) { + } + + public record BuiltinClassEnumValueLookup( + @NotNull ExtensionBuiltinClass ownerClass, + @NotNull ExtensionEnumValue enumValue + ) { + } + /// Built-in type are language built-in types, they are not engine defined types. private final Map builtinByName = new HashMap<>(); /// Engine defined classes exposed to scripts via GDExtension API. Aka engine types. @@ -39,6 +63,8 @@ public final class ClassRegistry implements Scope { private final Map globalEnumByName = new HashMap<>(); private final Map globalConstantByName = new HashMap<>(); private final Map singletonByName = new HashMap<>(); + private final Map singletonTypeByName = new HashMap<>(); + private final Map invalidSingletonMetadataByName = new HashMap<>(); /// User-defined classes keyed strictly by canonical registration name. private final Map gdccClassByName = new HashMap<>(); /// Source-facing alias side table for canonical gdcc registrations whose source name differs. @@ -74,6 +100,62 @@ public ClassRegistry(@NotNull ExtensionAPI api) { for (var s : api.singletons()) { if (s != null && s.name() != null) singletonByName.put(s.name(), s); } + validateSingletonMetadata(); + } + + /// Registry-owned singleton metadata failure. + /// + /// Frontend diagnostics translate these facts later; scope lookup and backend codegen only need a + /// stable query surface that distinguishes valid singleton object receivers from bad metadata. + public record InvalidSingletonMetadata( + @NotNull String lookupName, + @Nullable String rawType, + @NotNull Reason reason + ) { + public InvalidSingletonMetadata { + Objects.requireNonNull(lookupName, "lookupName"); + Objects.requireNonNull(reason, "reason"); + } + + public enum Reason { + MISSING_TYPE, + UNRESOLVED_TYPE, + NON_OBJECT_TYPE + } + } + + private void validateSingletonMetadata() { + for (var entry : singletonByName.entrySet()) { + var lookupName = entry.getKey(); + var rawType = entry.getValue().type(); + if (rawType == null || rawType.isBlank()) { + invalidSingletonMetadataByName.put(lookupName, new InvalidSingletonMetadata( + lookupName, + rawType, + InvalidSingletonMetadata.Reason.MISSING_TYPE + )); + continue; + } + + var declaredType = tryResolveDeclaredType(rawType); + if (declaredType == null) { + invalidSingletonMetadataByName.put(lookupName, new InvalidSingletonMetadata( + lookupName, + rawType, + InvalidSingletonMetadata.Reason.UNRESOLVED_TYPE + )); + continue; + } + if (!(declaredType instanceof GdObjectType objectType)) { + invalidSingletonMetadataByName.put(lookupName, new InvalidSingletonMetadata( + lookupName, + rawType, + InvalidSingletonMetadata.Reason.NON_OBJECT_TYPE + )); + continue; + } + singletonTypeByName.put(lookupName, objectType); + } } /// Check whether a name refers to a builtin class from API. @@ -667,11 +749,17 @@ private static boolean isGodotIdentifierPart(int codePoint) { /// Return the singleton's object type for a singleton name. public @Nullable GdObjectType findSingletonType(@NotNull String name) { - var s = singletonByName.get(name); - if (s == null) return null; - var t = s.type(); - if (t == null) return null; - return new GdObjectType(t); + return singletonTypeByName.get(name); + } + + /// Return registry-owned invalid singleton metadata, if the API declared a singleton that cannot + /// become an object receiver. + public @Nullable InvalidSingletonMetadata findInvalidSingletonMetadata(@NotNull String name) { + return invalidSingletonMetadataByName.get(name); + } + + public @NotNull @UnmodifiableView Map invalidSingletonMetadata() { + return Collections.unmodifiableMap(invalidSingletonMetadataByName); } public @NotNull @UnmodifiableView Map getVirtualMethods(@NotNull String className) { @@ -838,4 +926,128 @@ private boolean checkObjectAssignable(@NotNull GdType from, @NotNull GdType to) public @NotNull @UnmodifiableView List getExtensionUtilityFunctionList() { return utilityByName.values().stream().toList(); } + + /// Resolves a [ClassDef] from type-meta metadata. If the meta carries a direct `declaration()` + /// that is already a `ClassDef`, returns it. Otherwise, attempts to find the `ClassDef` by + /// resolving the `instanceType()` via `getClassDef`. + public @Nullable ClassDef resolveClassDefFromTypeMeta(@NotNull ScopeTypeMeta typeMeta) { + if (typeMeta.declaration() instanceof ClassDef classDef) { + return classDef; + } + if (typeMeta.instanceType() instanceof GdObjectType objectType) { + return getClassDef(objectType); + } + return null; + } + + /// Resolves the immediate superclass of `current` from the registry. + /// Returns `null` when the super-name is blank or the class cannot be found. + public @Nullable ClassDef resolveSuperclass(@NotNull ClassDef current) { + var superName = current.getSuperName(); + if (superName.isBlank()) { + return null; + } + var builtinSuper = findBuiltinClass(superName); + if (builtinSuper != null) { + return builtinSuper; + } + return getClassDef(new GdObjectType(superName)); + } + + /// Checks whether the engine class identified by `className`, or one of its engine + /// superclasses, declares a constant whose name equals `constantName`. + public boolean hasEngineClassConstant(@NotNull String className, @NotNull String constantName) { + return findEngineClassConstantInHierarchy(className, constantName) != null; + } + + /// Looks up the constant named `constantName` in the queried engine class and then in its + /// superclasses. Direct class metadata wins over inherited metadata. + public @Nullable EngineClassConstantLookup findEngineClassConstantInHierarchy( + @NotNull String className, + @NotNull String constantName + ) { + var current = gdClassByName.get(className); + var visited = new HashSet(); + while (current != null && visited.add(current.getName())) { + var constant = current.constants().stream() + .filter(candidate -> constantName.equals(candidate.name())) + .findFirst() + .orElse(null); + if (constant != null) { + return new EngineClassConstantLookup(current, constant); + } + current = resolveSuperclass(current) instanceof ExtensionGdClass superClass ? superClass : null; + } + return null; + } + + /// Looks up the enum value named `valueName` inside the engine class named `className`. + /// Searches every enum on the direct class first, then walks engine superclasses. + public @Nullable ExtensionEnumValue findEngineClassEnumValue(@NotNull String className, @NotNull String valueName) { + var lookup = findEngineClassEnumValueInHierarchy(className, valueName); + return lookup == null ? null : lookup.enumValue(); + } + + /// Looks up an engine class enum value and carries the class that actually declared it. + public @Nullable EngineClassEnumValueLookup findEngineClassEnumValueInHierarchy( + @NotNull String className, + @NotNull String valueName + ) { + var engineClass = gdClassByName.get(className); + var visited = new HashSet(); + while (engineClass != null && visited.add(engineClass.getName())) { + var enumValue = engineClass.enums().stream() + .flatMap(e -> e.values().stream()) + .filter(v -> v.name().equals(valueName)) + .findFirst() + .orElse(null); + if (enumValue != null) { + return new EngineClassEnumValueLookup(engineClass, enumValue); + } + engineClass = resolveSuperclass(engineClass) instanceof ExtensionGdClass superClass ? superClass : null; + } + return null; + } + + /// Looks up the builtin constant named `constantName`. Builtin metadata currently has no + /// superclass edge in ExtensionAPI, so this shared helper intentionally remains direct-only. + public @Nullable BuiltinClassConstantLookup findBuiltinClassConstantInHierarchy( + @NotNull String className, + @NotNull String constantName + ) { + var builtinClass = builtinByName.get(className); + if (builtinClass == null) { + return null; + } + return builtinClass.constants().stream() + .filter(candidate -> constantName.equals(candidate.name())) + .findFirst() + .map(constant -> new BuiltinClassConstantLookup(builtinClass, constant)) + .orElse(null); + } + + /// Looks up the enum value named `valueName` declared inside the builtin class named `className`. + /// Searches every enum of the class. Returns `null` when the class or the enum value is not found. + public @Nullable ExtensionEnumValue findBuiltinClassEnumValue(@NotNull String className, @NotNull String valueName) { + var lookup = findBuiltinClassEnumValueInHierarchy(className, valueName); + return lookup == null ? null : lookup.enumValue(); + } + + /// Looks up a builtin enum value and carries the builtin class that declared it. This matches + /// the engine lookup shape even though builtin metadata is direct-only today. + public @Nullable BuiltinClassEnumValueLookup findBuiltinClassEnumValueInHierarchy( + @NotNull String className, + @NotNull String valueName + ) { + var builtinClass = builtinByName.get(className); + if (builtinClass == null) { + return null; + } + return builtinClass.enums().stream() + .flatMap(e -> e.values().stream()) + .filter(v -> v.name().equals(valueName)) + .findFirst() + .map(enumValue -> new BuiltinClassEnumValueLookup(builtinClass, enumValue)) + .orElse(null); + } } diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenEngineMethodUsageSessionTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenEngineMethodUsageSessionTest.java index 0239ecda..4a204a6a 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenEngineMethodUsageSessionTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CCodegenEngineMethodUsageSessionTest.java @@ -1,5 +1,6 @@ package gd.script.gdcc.backend.c.gen; +import gd.script.gdcc.backend.GeneratedFile; import gd.script.gdcc.backend.CodegenContext; import gd.script.gdcc.backend.ProjectInfo; import gd.script.gdcc.backend.c.gen.binding.EngineMethodSymbolKey; @@ -11,6 +12,7 @@ import gd.script.gdcc.gdextension.ExtensionBuiltinClass; import gd.script.gdcc.gdextension.ExtensionFunctionArgument; import gd.script.gdcc.gdextension.ExtensionGdClass; +import gd.script.gdcc.gdextension.ExtensionSingleton; import gd.script.gdcc.lir.LirBasicBlock; import gd.script.gdcc.lir.LirClassDef; import gd.script.gdcc.lir.LirFunctionDef; @@ -20,6 +22,7 @@ import gd.script.gdcc.lir.insn.BinaryOpInsn; import gd.script.gdcc.lir.insn.ConstructObjectInsn; import gd.script.gdcc.lir.insn.LoadPropertyInsn; +import gd.script.gdcc.lir.insn.LoadStaticInsn; import gd.script.gdcc.lir.insn.ReturnInsn; import gd.script.gdcc.lir.insn.StorePropertyInsn; import gd.script.gdcc.scope.ClassRegistry; @@ -41,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -254,6 +258,49 @@ void failedRenderShouldNotLeakEngineConstructorUsageIntoLaterSuccessfulRenders() assertEquals("Node", session.engineConstructors().getFirst().className()); } + @Test + @DisplayName("generate should filter fixed singleton wrappers and render only non-provided singleton wrappers") + void generateShouldFilterFixedSingletonWrappersAndRenderOnlyNonProvidedSingletonWrappers() { + var hostClass = newClass("SingletonUsageWorker", "RefCounted"); + + var loadEngine = newVoidFunction("load_engine"); + loadEngine.createAndAddVariable("engine", new GdObjectType("Engine")); + entry(loadEngine).appendInstruction(new LoadStaticInsn("engine", "@GlobalScope", "Engine")); + entry(loadEngine).setTerminator(new ReturnInsn(null)); + hostClass.addFunction(loadEngine); + + var loadClassDb = newVoidFunction("load_class_db"); + loadClassDb.createAndAddVariable("class_db", new GdObjectType("ClassDB")); + entry(loadClassDb).appendInstruction(new LoadStaticInsn("class_db", "@GlobalScope", "ClassDB")); + entry(loadClassDb).setTerminator(new ReturnInsn(null)); + hostClass.addFunction(loadClassDb); + + var loadGameSingleton = newVoidFunction("load_game_singleton"); + loadGameSingleton.createAndAddVariable("game", new GdObjectType("Node")); + entry(loadGameSingleton).appendInstruction(new LoadStaticInsn("game", "@GlobalScope", "GameSingleton")); + entry(loadGameSingleton).setTerminator(new ReturnInsn(null)); + hostClass.addFunction(loadGameSingleton); + + var module = new LirModule("singleton_usage_module", List.of(hostClass)); + var codegen = newCodegen(module, singletonUsageApi(), List.of(hostClass)); + + var files = codegen.generate(); + var entrySource = generatedFileText(files, "entry.c"); + var bindHeader = generatedFileText(files, "engine_method_binds.h"); + + assertTrue(entrySource.contains("godot_Engine_singleton()"), entrySource); + assertTrue(entrySource.contains("godot_ClassDB_singleton()"), entrySource); + assertTrue(entrySource.contains("godot_GameSingleton_singleton()"), entrySource); + assertFalse(bindHeader.contains("static inline godot_Engine * godot_Engine_singleton(void)"), bindHeader); + assertFalse(bindHeader.contains("static inline godot_ClassDB * godot_ClassDB_singleton(void)"), bindHeader); + assertTrue(bindHeader.contains("static inline godot_Node * godot_GameSingleton_singleton(void)"), bindHeader); + assertTrue(bindHeader.contains("godot_global_get_singleton(GD_STATIC_SN(u8\"GameSingleton\"))"), bindHeader); + assertTrue(bindHeader.contains("context.lookup_name = \"GameSingleton\";"), bindHeader); + assertTrue(bindHeader.contains("context.owner = \"@GlobalScope\";"), bindHeader); + assertTrue(bindHeader.contains("context.type = \"Node\";"), bindHeader); + assertFalse(bindHeader.contains("godot_Node_singleton"), bindHeader); + } + @Test @DisplayName("public generateFuncBody should stay deterministic and side-effect free") void publicGenerateFuncBodyShouldStayDeterministicAndSideEffectFree() { @@ -325,6 +372,17 @@ private static void assertSnapshot( assertIterableEquals(expected, actual); } + private static @NotNull String generatedFileText( + @NotNull List files, + @NotNull String filePath + ) { + return files.stream() + .filter(file -> file.filePath().equals(filePath)) + .findFirst() + .map(file -> new String(file.contentWriter())) + .orElseThrow(() -> new AssertionError("Missing generated file " + filePath)); + } + private static @NotNull CCodegen newCodegen( @NotNull LirModule module, @NotNull ExtensionAPI api, @@ -359,6 +417,28 @@ private static void assertSnapshot( ); } + private static @NotNull ExtensionAPI singletonUsageApi() { + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of( + singletonClass("Engine"), + singletonClass("ClassDB"), + nodeClass() + ), + List.of( + new ExtensionSingleton("Engine", "Engine"), + new ExtensionSingleton("ClassDB", "ClassDB"), + new ExtensionSingleton("GameSingleton", "Node") + ), + List.of() + ); + } + private static @NotNull LirClassDef newClass(@NotNull String name, @NotNull String superName) { return new LirClassDef(name, superName, false, false, Map.of(), List.of(), List.of(), List.of()); } @@ -464,6 +544,21 @@ private static void assertSnapshot( ); } + private static @NotNull ExtensionGdClass singletonClass(@NotNull String name) { + return new ExtensionGdClass( + name, + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + } + private static @NotNull ExtensionGdClass windowClassWithRawPropertyAccessors() { var getTitle = new ExtensionGdClass.ClassMethod( "get_title_override", diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/CLoadStaticInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/CLoadStaticInsnGenTest.java index 615cec38..90215bac 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/CLoadStaticInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/CLoadStaticInsnGenTest.java @@ -10,6 +10,7 @@ import gd.script.gdcc.gdextension.ExtensionGdClass; import gd.script.gdcc.gdextension.ExtensionGlobalConstant; import gd.script.gdcc.gdextension.ExtensionGlobalEnum; +import gd.script.gdcc.gdextension.ExtensionSingleton; import gd.script.gdcc.lir.LirBasicBlock; import gd.script.gdcc.lir.LirClassDef; import gd.script.gdcc.lir.LirFunctionDef; @@ -19,6 +20,8 @@ import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.type.GdFloatVectorType; import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdStringType; import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVoidType; import org.junit.jupiter.api.DisplayName; @@ -99,6 +102,21 @@ void shouldLoadGlobalScopeGlobalConstantValue() { assertFalse(body.contains("$out = 0;")); } + @Test + @DisplayName("load_static should load @GlobalScope singleton property as borrowed object receiver") + void shouldLoadGlobalScopeSingletonPropertyValue() { + var body = generateBody( + singletonFixtureApi(), + setupLoadStaticFunction( + new GdObjectType("Node"), + new LoadStaticInsn("out", "@GlobalScope", "GameSingleton") + ) + ); + + assertTrue(body.contains("godot_GameSingleton_singleton()")); + assertFalse(body.contains("godot_Node_singleton()")); + } + @Test @DisplayName("load_static should materialize builtin Vector3 constant") void shouldLoadBuiltinVector3Constant() throws IOException { @@ -133,6 +151,170 @@ void shouldLoadEngineClassIntegerConstant() throws IOException { assertTrue(body.contains("$out = " + constant.value().trim() + ";")); } + @Test + @DisplayName("load_static should load engine class enum values as integer literals") + void shouldLoadEngineClassEnumValue() throws IOException { + var api = ExtensionApiLoader.loadDefault(); + var inputClass = api.classes().stream() + .filter(clazz -> "Input".equals(clazz.name())) + .findFirst() + .orElseThrow(); + var enumValue = inputClass.enums().stream() + .flatMap(e -> e.values().stream()) + .filter(value -> "MOUSE_MODE_VISIBLE".equals(value.name())) + .findFirst() + .orElseThrow(); + + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "Input", "MOUSE_MODE_VISIBLE"))); + assertTrue(body.contains("$out = " + enumValue.value() + ";")); + } + + @Test + @DisplayName("load_static should load inherited engine class integer constants") + void shouldLoadInheritedEngineClassIntegerConstant() { + var api = inheritedEngineStaticFixtureApi(); + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "ChildInput", "PARENT_LIMIT"))); + assertTrue(body.contains("$out = 42;")); + assertFalse(body.contains("$out = 0;")); + } + + @Test + @DisplayName("load_static should load inherited engine class enum values") + void shouldLoadInheritedEngineClassEnumValue() { + var api = inheritedEngineStaticFixtureApi(); + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "ChildInput", "PARENT_MOUSE_MODE"))); + assertTrue(body.contains("$out = 7;")); + assertFalse(body.contains("$out = 0;")); + } + + @Test + @DisplayName("load_static should load builtin class enum values as integer literals") + void shouldLoadBuiltinClassEnumValue() throws IOException { + var api = ExtensionApiLoader.loadDefault(); + var vector3Class = api.builtinClasses().stream() + .filter(clazz -> "Vector3".equals(clazz.name())) + .findFirst() + .orElseThrow(); + var enumValue = vector3Class.enums().stream() + .flatMap(e -> e.values().stream()) + .filter(value -> "AXIS_X".equals(value.name())) + .findFirst() + .orElseThrow(); + + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "Vector3", "AXIS_X"))); + assertTrue(body.contains("$out = " + enumValue.value() + ";")); + } + + @Test + @DisplayName("load_static should reject missing engine class enum value") + void shouldRejectMissingEngineClassEnumValue() throws IOException { + var api = ExtensionApiLoader.loadDefault(); + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdIntType.INT, new LoadStaticInsn("out", "Input", "MOUSE_MODE_MISSING")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not found")); + assertTrue(ex.getMessage().contains("Input")); + } + + @Test + @DisplayName("load_static should reject missing builtin class enum value") + void shouldRejectMissingBuiltinClassEnumValue() throws IOException { + var api = ExtensionApiLoader.loadDefault(); + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdIntType.INT, new LoadStaticInsn("out", "Vector3", "AXIS_MISSING")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not found")); + assertTrue(ex.getMessage().contains("Vector3")); + } + + @Test + @DisplayName("load_static should reject engine class enum value with incompatible target type") + void shouldRejectEngineClassEnumValueIncompatibleTargetType() throws IOException { + var api = ExtensionApiLoader.loadDefault(); + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdStringType.STRING, new LoadStaticInsn("out", "Input", "MOUSE_MODE_VISIBLE")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not assignable")); + } + + @Test + @DisplayName("load_static should reject missing inherited engine static member") + void shouldRejectMissingInheritedEngineStaticMember() { + var api = inheritedEngineStaticFixtureApi(); + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdIntType.INT, new LoadStaticInsn("out", "ChildInput", "MISSING_STATIC")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not found")); + assertTrue(ex.getMessage().contains("ChildInput")); + } + + @Test + @DisplayName("load_static should reject inherited engine class enum value with incompatible target type") + void shouldRejectInheritedEngineClassEnumValueIncompatibleTargetType() { + var api = inheritedEngineStaticFixtureApi(); + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdStringType.STRING, new LoadStaticInsn("out", "ChildInput", "PARENT_MOUSE_MODE")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not assignable")); + } + + @Test + @DisplayName("load_static should preserve int64 engine class enum values") + void shouldLoadInt64EngineClassEnumValue() { + var engineClass = new ExtensionGdClass( + "WideFlags", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "WideFlag", true, List.of(new ExtensionEnumValue("WIDE_FLAG", 3_435_973_836_8L)) + )), + List.of(), + List.of(), + List.of(), + List.of() + ); + var api = new ExtensionAPI( + null, List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(engineClass), List.of(), List.of() + ); + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "WideFlags", "WIDE_FLAG"))); + assertTrue(body.contains("$out = 34359738368;")); + assertFalse(body.contains("$out = 0;")); + } + + @Test + @DisplayName("load_static should render negative engine class enum values") + void shouldLoadNegativeEngineClassEnumValue() { + var engineClass = new ExtensionGdClass( + "ResourceUID", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "InvalidId", false, List.of(new ExtensionEnumValue("INVALID_ID", -1L)) + )), + List.of(), + List.of(), + List.of(), + List.of() + ); + var api = new ExtensionAPI( + null, List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(engineClass), List.of(), List.of() + ); + var body = generateBody(api, setupLoadStaticFunction(GdIntType.INT, + new LoadStaticInsn("out", "ResourceUID", "INVALID_ID"))); + assertTrue(body.contains("$out = -1;")); + } + @Test @DisplayName("load_static should reject non-integer engine class constants") void shouldRejectNonIntegerEngineClassConstant() { @@ -165,6 +347,45 @@ void shouldRejectNonIntegerEngineClassConstant() { assertTrue(ex.getMessage().contains("not an integer literal")); } + @Test + @DisplayName("load_static should reject inherited non-integer engine class constants") + void shouldRejectInheritedNonIntegerEngineClassConstant() { + var parentClass = new ExtensionGdClass( + "BadStaticParent", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("NOT_INT", "3.14")) + ); + var childClass = new ExtensionGdClass( + "BadStaticChild", + false, + true, + "BadStaticParent", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var api = new ExtensionAPI( + null, List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(parentClass, childClass), List.of(), List.of() + ); + + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(api, + setupLoadStaticFunction(GdIntType.INT, new LoadStaticInsn("out", "BadStaticChild", "NOT_INT")))); + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not an integer literal")); + assertTrue(ex.getMessage().contains("BadStaticParent")); + } + @Test @DisplayName("load_static should reject incompatible target type") void shouldRejectIncompatibleBuiltinTargetType() throws IOException { @@ -175,6 +396,18 @@ void shouldRejectIncompatibleBuiltinTargetType() throws IOException { assertTrue(ex.getMessage().contains("not assignable")); } + @Test + @DisplayName("load_static should reject singleton property with incompatible target type") + void shouldRejectIncompatibleSingletonTargetType() { + var ex = assertThrows(InvalidInsnException.class, () -> generateBody( + singletonFixtureApi(), + setupLoadStaticFunction(GdIntType.INT, new LoadStaticInsn("out", "@GlobalScope", "GameSingleton")) + )); + + assertInstanceOf(InvalidInsnException.class, ex); + assertTrue(ex.getMessage().contains("not assignable from singleton type 'Node'")); + } + @Test @DisplayName("load_static should reject missing @GlobalScope global constant") void shouldRejectMissingGlobalScopeGlobalConstant() { @@ -234,6 +467,65 @@ private LirFunctionDef setupLoadStaticFunction(GdType resultType, LoadStaticInsn return func; } + private ExtensionAPI singletonFixtureApi() { + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass( + "Node", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + )), + List.of(new ExtensionSingleton("GameSingleton", "Node")), + List.of() + ); + } + + private ExtensionAPI inheritedEngineStaticFixtureApi() { + var parentClass = new ExtensionGdClass( + "BaseInput", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("PARENT_MOUSE_MODE", 7)) + )), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("PARENT_LIMIT", "42")) + ); + var childClass = new ExtensionGdClass( + "ChildInput", + false, + true, + "BaseInput", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + return new ExtensionAPI( + null, List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(parentClass, childClass), List.of(), List.of() + ); + } + private String generateBody(ExtensionAPI api, LirFunctionDef func) { var clazz = new LirClassDef("Worker", "RefCounted", false, false, Map.of(), List.of(), List.of(), List.of()); clazz.addFunction(func); diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBindingTemplateTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBindingTemplateTest.java index 57e24c0d..2c57e7a9 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBindingTemplateTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/binding/ModuleLocalGodotBindingTemplateTest.java @@ -36,6 +36,9 @@ void singletonWrapperShouldRenderOnlySingletonLookupHelperWithoutDesignatedIniti () -> assertTrue(header.contains("gdcc_binding_lookup_context context = { 0 };"), header), () -> assertTrue(header.contains("context.kind = \"module_singleton\";"), header), () -> assertTrue(header.contains("context.function_name = \"godot_SceneTree_singleton\";"), header), + () -> assertTrue(header.contains("context.lookup_name = \"SceneTree\";"), header), + () -> assertTrue(header.contains("context.owner = \"@GlobalScope\";"), header), + () -> assertTrue(header.contains("context.type = \"SceneTree\";"), header), () -> assertFalse(header.contains("gdcc_binding_lookup_fail(&(gdcc_binding_lookup_context){"), header), () -> assertFalse(header.contains("\n .kind = \"module_singleton\""), header), () -> assertFalse(header.contains("godot_classdb_get_method_bind("), header), @@ -43,6 +46,26 @@ void singletonWrapperShouldRenderOnlySingletonLookupHelperWithoutDesignatedIniti ); } + @Test + void singletonWrapperShouldKeepLookupNameSeparateFromReturnTypeName() throws Exception { + var header = render( + List.of(ModuleLocalGodotBinding.singleton("GameSingleton", "Node")), + List.of() + ); + + assertAll( + () -> assertTrue(header.contains("static inline godot_Node * godot_GameSingleton_singleton(void)"), header), + () -> assertTrue(header.contains("static godot_Node * gdcc_module_singleton_GameSingleton = NULL;"), header), + () -> assertTrue(header.contains("godot_global_get_singleton(GD_STATIC_SN(u8\"GameSingleton\"))"), header), + () -> assertTrue(header.contains("context.function_name = \"godot_GameSingleton_singleton\";"), header), + () -> assertTrue(header.contains("context.lookup_name = \"GameSingleton\";"), header), + () -> assertTrue(header.contains("context.owner = \"@GlobalScope\";"), header), + () -> assertTrue(header.contains("context.type = \"Node\";"), header), + () -> assertFalse(header.contains("godot_Node_singleton"), header), + () -> assertFalse(header.contains("godot_GameSingleton *"), header) + ); + } + @Test void classConstantWrapperShouldRenderOnlyLiteralHelper() throws Exception { var header = render( diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/binding/usage/GodotBindingUsageSessionTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/binding/usage/GodotBindingUsageSessionTest.java index 78cc000c..1d50f8ae 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/binding/usage/GodotBindingUsageSessionTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/binding/usage/GodotBindingUsageSessionTest.java @@ -67,6 +67,21 @@ void gdccHelperWrappersShouldBeRuntimeProvided() throws IOException { assertTrue(session.moduleLocalCFunctionNames().isEmpty()); } + @Test + void providedFixedSingletonsShouldBeAcceptedButNotCommitted() { + var session = new GodotBindingUsageSession(Set.of("godot_Engine_singleton", "godot_ClassDB_singleton")); + var buffer = session.newFunctionBuffer(); + + buffer.recordModuleLocalGodotBinding(ModuleLocalGodotBinding.singleton("Engine", "Engine")); + buffer.recordGodotCall("godot_Engine_singleton"); + buffer.recordModuleLocalGodotBinding(ModuleLocalGodotBinding.singleton("ClassDB", "ClassDB")); + buffer.recordGodotCall("godot_ClassDB_singleton"); + session.commit(buffer); + + assertTrue(session.moduleLocalBindings().isEmpty()); + assertTrue(session.moduleLocalCFunctionNames().isEmpty()); + } + @Test void moduleLocalBindingsShouldKeepFirstUseOrderAndMergeCompatibleConstants() { var session = new GodotBindingUsageSession(Set.of()); @@ -89,6 +104,25 @@ void moduleLocalBindingsShouldKeepFirstUseOrderAndMergeCompatibleConstants() { assertEquals(Set.of("godot_SceneTree_singleton", "godot_Probe_READY"), session.moduleLocalCFunctionNames()); } + @Test + void nonProvidedSingletonShouldCommitLookupAndReturnTypeSeparately() { + var session = new GodotBindingUsageSession(Set.of()); + var buffer = session.newFunctionBuffer(); + + buffer.recordModuleLocalGodotBinding(ModuleLocalGodotBinding.singleton("GameSingleton", "Node")); + buffer.recordGodotCall("godot_GameSingleton_singleton"); + session.commit(buffer); + + var singleton = (ModuleLocalGodotBinding.Singleton) session.moduleLocalBindings().getFirst(); + assertEquals("godot_GameSingleton_singleton", singleton.cFunctionName()); + assertEquals("@GlobalScope", singleton.symbol().owner()); + assertEquals("GameSingleton", singleton.lookupName()); + assertEquals("GameSingleton", singleton.symbol().name()); + assertEquals("Node", singleton.returnTypeName()); + assertEquals("godot_Node *", singleton.returnType()); + assertEquals(Set.of("godot_GameSingleton_singleton"), session.moduleLocalCFunctionNames()); + } + @Test void failedFunctionBufferShouldNotLeakIntoSessionWhenItIsNotCommitted() { var session = new GodotBindingUsageSession(Set.of()); @@ -126,6 +160,19 @@ void sameCNameForDifferentBindingsShouldFailFast() { assertTrue(failure.getMessage().contains("Godot binding C name conflict for 'godot_Probe_READY'")); } + @Test + void sameSingletonCNameWithDifferentReturnTypeShouldFailFast() { + var buffer = new GodotBindingUsageSession(Set.of()).newFunctionBuffer(); + + buffer.recordModuleLocalGodotBinding(ModuleLocalGodotBinding.singleton("GameSingleton", "Node")); + var failure = assertThrows( + IllegalStateException.class, + () -> buffer.recordModuleLocalGodotBinding(ModuleLocalGodotBinding.singleton("GameSingleton", "Object")) + ); + + assertTrue(failure.getMessage().contains("Godot binding C name conflict for 'godot_GameSingleton_singleton'")); + } + @Test void sameCanonicalConstantWithDifferentValueShouldFailFast() { var buffer = new GodotBindingUsageSession(Set.of()).newFunctionBuffer(); @@ -169,6 +216,7 @@ void cNameConflictAcrossCommittedBuffersShouldFailFast() { null, List.of() ), + className, className ); } diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java index 33ec1dc4..e2c58b0d 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBodyInsnPassTest.java @@ -504,6 +504,64 @@ func ping(value: int) -> int: ); } + @Test + void runFailsFastWhenPublishedSingletonBindingLosesRegistryMetadata() throws Exception { + var prepared = prepareContext( + "body_insn_singleton_missing_registry_metadata.gd", + """ + class_name BodyInsnSingletonMissingRegistryMetadata + extends RefCounted + + func ping(value: int) -> int: + return value + """, + Map.of( + "BodyInsnSingletonMissingRegistryMetadata", + "RuntimeBodyInsnSingletonMissingRegistryMetadata" + ), + true + ); + var pingContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnSingletonMissingRegistryMetadata", + "ping" + ); + var rootBlock = assertInstanceOf(dev.superice.gdparser.frontend.ast.Block.class, pingContext.loweringRoot()); + var returnStatement = assertInstanceOf(ReturnStatement.class, rootBlock.statements().getFirst()); + var value = assertInstanceOf(IdentifierExpression.class, returnStatement.value()); + var originalBinding = pingContext.analysisData().symbolBindings().get(value); + assertNotNull(originalBinding); + // Simulate a broken upstream publication so this test stays focused on the body-lowering guard. + pingContext.analysisData().symbolBindings().put( + value, + new FrontendBinding( + "MissingRegistrySingleton", + FrontendBindingKind.SINGLETON, + originalBinding.declarationSite() + ) + ); + + var exception = assertThrows( + IllegalStateException.class, + () -> new FrontendLoweringBodyInsnPass().run(prepared.context()) + ); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertTrue( + exception.getMessage().contains( + "Published singleton binding 'MissingRegistrySingleton'" + ), + exception.getMessage() + ), + () -> assertTrue( + exception.getMessage().contains("missing registry-validated object metadata"), + exception.getMessage() + ) + ); + } + @Test void runFailsFastWhenDirectSlotReceiverIdentifierPretendsToBeSelf() throws Exception { var prepared = prepareContext( @@ -2703,6 +2761,123 @@ func ping() -> void: ); } + @Test + void runLowersSingletonValueReceiverAsInstanceReceiverInExecutableBody() throws Exception { + var prepared = prepareContext( + "body_insn_singleton_receiver_call.gd", + """ + class_name BodyInsnSingletonReceiverCall + extends RefCounted + + func frames() -> int: + return Engine.get_frames_drawn() + + func pressed() -> bool: + return Input.is_action_pressed(&"ui_accept", true) + + func scale() -> void: + Engine.set_time_scale(1.0) + """, + Map.of("BodyInsnSingletonReceiverCall", "RuntimeBodyInsnSingletonReceiverCall"), + true + ); + var framesContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnSingletonReceiverCall", + "frames" + ); + var pressedContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnSingletonReceiverCall", + "pressed" + ); + var scaleContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnSingletonReceiverCall", + "scale" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var framesInstructions = allInstructions(framesContext.targetFunction()); + var framesReceiver = requireOnlyInstruction(framesContext.targetFunction(), LoadStaticInsn.class); + var framesCall = requireOnlyInstruction(framesContext.targetFunction(), CallMethodInsn.class); + var framesReturn = requireOnlyReturnInsn(framesContext.targetFunction()); + var pressedReceiver = requireOnlyInstruction(pressedContext.targetFunction(), LoadStaticInsn.class); + var pressedCall = requireOnlyInstruction(pressedContext.targetFunction(), CallMethodInsn.class); + var pressedName = requireOnlyInstruction(pressedContext.targetFunction(), LiteralStringNameInsn.class); + var pressedExactMatch = requireOnlyInstruction(pressedContext.targetFunction(), LiteralBoolInsn.class); + var pressedArgIds = pressedCall.args().stream() + .map(operand -> assertInstanceOf(LirInstruction.VariableOperand.class, operand).id()) + .toList(); + var scaleInstructions = allInstructions(scaleContext.targetFunction()); + var scaleReceiver = requireOnlyInstruction(scaleContext.targetFunction(), LoadStaticInsn.class); + var scaleCall = requireOnlyInstruction(scaleContext.targetFunction(), CallMethodInsn.class); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals("@GlobalScope", framesReceiver.className()), + () -> assertEquals("Engine", framesReceiver.staticName()), + () -> assertEquals("get_frames_drawn", framesCall.methodName()), + () -> assertEquals(framesReceiver.resultId(), framesCall.objectId()), + () -> assertEquals(framesCall.resultId(), framesReturn.returnValueId()), + () -> assertEquals(0, countInstructions(framesInstructions, CallGlobalInsn.class)), + () -> assertEquals("@GlobalScope", pressedReceiver.className()), + () -> assertEquals("Input", pressedReceiver.staticName()), + () -> assertEquals("is_action_pressed", pressedCall.methodName()), + () -> assertEquals(pressedReceiver.resultId(), pressedCall.objectId()), + () -> assertEquals(List.of(pressedName.resultId(), pressedExactMatch.resultId()), pressedArgIds), + () -> assertEquals("@GlobalScope", scaleReceiver.className()), + () -> assertEquals("Engine", scaleReceiver.staticName()), + () -> assertEquals("set_time_scale", scaleCall.methodName()), + () -> assertEquals(scaleReceiver.resultId(), scaleCall.objectId()), + () -> assertNull(scaleCall.resultId()), + () -> assertEquals(0, countInstructions(scaleInstructions, CallGlobalInsn.class)) + ); + } + + @Test + void runLowersSingletonReceiverBeforeLaterLocalShadowAsGlobalScopeLoad() throws Exception { + var prepared = prepareContext( + "body_insn_singleton_receiver_later_local.gd", + """ + class_name BodyInsnSingletonReceiverLaterLocal + extends RefCounted + + func frames() -> int: + var frames := Engine.get_frames_drawn() + var Engine: String = "" + return frames + """, + Map.of("BodyInsnSingletonReceiverLaterLocal", "RuntimeBodyInsnSingletonReceiverLaterLocal"), + true + ); + var framesContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.EXECUTABLE_BODY, + "RuntimeBodyInsnSingletonReceiverLaterLocal", + "frames" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var instructions = allInstructions(framesContext.targetFunction()); + var receiver = requireOnlyInstruction(framesContext.targetFunction(), LoadStaticInsn.class); + var call = requireOnlyInstruction(framesContext.targetFunction(), CallMethodInsn.class); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals("@GlobalScope", receiver.className()), + () -> assertEquals("Engine", receiver.staticName()), + () -> assertEquals("get_frames_drawn", call.methodName()), + () -> assertEquals(receiver.resultId(), call.objectId()), + () -> assertEquals(0, countInstructions(instructions, CallGlobalInsn.class)) + ); + } + @Test void runLowersDynamicInstanceCallsIntoCallMethodInsnWithVariantResultSlot() throws Exception { var prepared = prepareContext( @@ -5837,6 +6012,53 @@ func ping() -> float: ); } + @Test + void runLowersSingletonReceiverPropertyInitializerIntoExecutableInitFunction() throws Exception { + var prepared = prepareContext( + "body_insn_property_singleton_receiver_call.gd", + """ + class_name BodyInsnPropertySingletonReceiverCall + extends RefCounted + + var frames: int = Engine.get_frames_drawn() + + func ping() -> int: + return frames + """, + Map.of( + "BodyInsnPropertySingletonReceiverCall", + "RuntimeBodyInsnPropertySingletonReceiverCall" + ), + true + ); + var initContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.PROPERTY_INIT, + "RuntimeBodyInsnPropertySingletonReceiverCall", + "_field_init_frames" + ); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var initFunction = initContext.targetFunction(); + var instructions = allInstructions(initFunction); + var receiverLoad = requireOnlyInstruction(initFunction, LoadStaticInsn.class); + var methodCall = requireOnlyInstruction(initFunction, CallMethodInsn.class); + var returnInsn = requireOnlyReturnInsn(initFunction); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals("seq_0", initFunction.getEntryBlockId()), + () -> assertTrue(initFunction.getBasicBlockCount() > 0), + () -> assertEquals("@GlobalScope", receiverLoad.className()), + () -> assertEquals("Engine", receiverLoad.staticName()), + () -> assertEquals("get_frames_drawn", methodCall.methodName()), + () -> assertEquals(receiverLoad.resultId(), methodCall.objectId()), + () -> assertEquals(methodCall.resultId(), returnInsn.returnValueId()), + () -> assertEquals(0, countInstructions(instructions, CallGlobalInsn.class)) + ); + } + @Test void runFailsFastWhenPropertyInitializerCallFactIsMissingDuringBodyLowering() throws Exception { var prepared = prepareContext( diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBuildCfgPassTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBuildCfgPassTest.java index dc1f3b31..f7d15a69 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBuildCfgPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringBuildCfgPassTest.java @@ -198,6 +198,56 @@ func branchy(flag: bool) -> void: ); } + @Test + void runPublishesSingletonBackedPropertyInitCfgGraph() throws Exception { + var prepared = prepareContext( + "build_cfg_property_init_singleton_receiver.gd", + """ + class_name BuildCfgPropertyInitSingletonReceiver + extends RefCounted + + var frames: int = Engine.get_frames_drawn() + """, + Map.of( + "BuildCfgPropertyInitSingletonReceiver", + "RuntimeBuildCfgPropertyInitSingletonReceiver" + ) + ); + var propertyContext = requireContext( + prepared.context().requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.PROPERTY_INIT, + "RuntimeBuildCfgPropertyInitSingletonReceiver", + "_field_init_frames" + ); + + new FrontendLoweringBuildCfgPass().run(prepared.context()); + + var propertyGraph = propertyContext.requireFrontendCfgGraph(); + var propertyEntry = assertInstanceOf( + FrontendCfgGraph.SequenceNode.class, + propertyGraph.requireNode(propertyGraph.entryNodeId()) + ); + var propertyStop = assertInstanceOf( + FrontendCfgGraph.StopNode.class, + propertyGraph.requireNode(propertyEntry.nextId()) + ); + var singletonReceiver = assertInstanceOf(OpaqueExprValueItem.class, propertyEntry.items().getFirst()); + var methodCall = assertInstanceOf(CallItem.class, propertyEntry.items().getLast()); + + assertAll( + () -> assertFalse(prepared.diagnostics().hasErrors()), + () -> assertEquals(List.of("seq_0", "stop_0"), propertyGraph.nodeIds()), + () -> assertEquals(2, propertyEntry.items().size()), + () -> assertEquals(List.of(), singletonReceiver.operandValueIds()), + () -> assertEquals("get_frames_drawn", methodCall.callableName()), + () -> assertEquals(singletonReceiver.resultValueId(), methodCall.receiverValueIdOrNull()), + () -> assertEquals(List.of(singletonReceiver.resultValueId()), methodCall.operandValueIds()), + () -> assertEquals(methodCall.resultValueId(), propertyStop.returnValueIdOrNull()), + () -> assertEquals(0, propertyContext.targetFunction().getBasicBlockCount()), + () -> assertTrue(propertyContext.targetFunction().getEntryBlockId().isEmpty()) + ); + } + @Test void runFailsFastWhenPropertyInitializerExpressionFactIsMissing() throws Exception { var prepared = prepareContext( diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java index c0197b02..afba13b6 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java @@ -253,6 +253,69 @@ void runSkipsPropertyWithoutInitializerAndKeepsInitFuncUnset() throws Exception ); } + @Test + void runPublishesSingletonBackedPropertyInitContextAndKeepsShellOnly() throws Exception { + var diagnostics = new DiagnosticManager(); + var module = parseModule( + List.of(new SourceFixture( + "preparation_singleton_property_init.gd", + """ + class_name PreparationSingletonPropertyInit + extends RefCounted + + var frames: int = Engine.get_frames_drawn() + + func ping() -> int: + return frames + """ + )), + Map.of( + "PreparationSingletonPropertyInit", + "RuntimePreparationSingletonPropertyInit" + ) + ); + var context = new FrontendLoweringContext( + module, + new ClassRegistry(ExtensionApiLoader.loadDefault()), + diagnostics + ); + new FrontendLoweringAnalysisPass().run(context); + new FrontendLoweringClassSkeletonPass().run(context); + var sourceFile = module.units().getFirst().ast(); + var property = requireStatement( + sourceFile.statements(), + VariableDeclaration.class, + declaration -> declaration.name().equals("frames") + ); + + new FrontendLoweringFunctionPreparationPass().run(context); + + var lirModule = context.requireLirModule(); + var owningClass = requireClass(lirModule, "RuntimePreparationSingletonPropertyInit"); + var propertyDef = requireProperty(owningClass, "frames"); + var propertyContext = requireContext( + context.requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.PROPERTY_INIT, + "RuntimePreparationSingletonPropertyInit", + "_field_init_frames" + ); + + assertFalse(diagnostics.hasErrors()); + assertSame(property, propertyContext.sourceOwner()); + assertSame(property.value(), propertyContext.loweringRoot()); + assertInstanceOf(AttributeExpression.class, propertyContext.loweringRoot()); + assertSame(requireFunction(owningClass, "_field_init_frames"), propertyContext.targetFunction()); + assertTrue(propertyContext.targetFunction().isHidden()); + assertEquals("int", propertyContext.targetFunction().getReturnType().getTypeName()); + assertEquals(1, propertyContext.targetFunction().getParameterCount()); + assertEquals("self", propertyContext.targetFunction().getParameter(0).name()); + assertEquals("_field_init_frames", propertyDef.getInitFunc()); + for (var function : owningClass.getFunctions()) { + assertEquals(0, function.getBasicBlockCount(), function.getName()); + assertTrue(function.getEntryBlockId().isEmpty(), function.getName()); + } + } + @Test void functionLoweringContextCanRepresentFutureParameterDefaultInitWithoutShapeChanges() throws Exception { var analyzed = analyzeSharedModule( diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringPassManagerTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringPassManagerTest.java index 21a2a198..771b8cb9 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringPassManagerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringPassManagerTest.java @@ -20,6 +20,12 @@ import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; +import gd.script.gdcc.lir.LirFunctionDef; +import gd.script.gdcc.lir.LirInstruction; +import gd.script.gdcc.lir.insn.CallMethodInsn; +import gd.script.gdcc.lir.insn.CallGlobalInsn; +import gd.script.gdcc.lir.insn.LoadStaticInsn; +import gd.script.gdcc.lir.insn.ReturnInsn; import java.util.ArrayDeque; import java.lang.reflect.Method; @@ -217,6 +223,60 @@ func ping(flag: bool) -> void: assertTrue(xml.contains(" int: + return frames + """ + )), + Map.of( + "ManagerSingletonPropertyInit", + "RuntimeManagerSingletonPropertyInit" + ) + ); + + var context = manager.lowerToContext( + module, + new ClassRegistry(ExtensionApiLoader.loadDefault()), + diagnostics + ); + + var propertyContext = requireContext( + context.requireFunctionLoweringContexts(), + FunctionLoweringContext.Kind.PROPERTY_INIT, + "RuntimeManagerSingletonPropertyInit", + "_field_init_frames" + ); + var initFunction = propertyContext.targetFunction(); + var receiverLoad = requireOnlyInstruction(initFunction, LoadStaticInsn.class); + var methodCall = requireOnlyInstruction(initFunction, CallMethodInsn.class); + var returnInsn = requireOnlyInstruction(initFunction, ReturnInsn.class); + + assertAll( + () -> assertFalse(diagnostics.hasErrors()), + () -> assertTrue(propertyContext.hasFrontendCfgGraph()), + () -> assertTrue(initFunction.getBasicBlockCount() > 0), + () -> assertFalse(initFunction.getEntryBlockId().isEmpty()), + () -> assertEquals("@GlobalScope", receiverLoad.className()), + () -> assertEquals("Engine", receiverLoad.staticName()), + () -> assertEquals("get_frames_drawn", methodCall.methodName()), + () -> assertEquals(receiverLoad.resultId(), methodCall.objectId()), + () -> assertEquals(methodCall.resultId(), returnInsn.returnValueId()), + () -> assertEquals(0, countInstructions(initFunction, CallGlobalInsn.class)) + ); + } + @Test void lowerToContextPublishesFrontendCfgGraphForExecutableBodies() throws Exception { var diagnostics = new DiagnosticManager(); @@ -511,6 +571,35 @@ func ping(value): throw new AssertionError("Missing reachable branch for condition root " + conditionRoot.getClass().getSimpleName()); } + private static int countInstructions( + @NotNull LirFunctionDef function, + @NotNull Class instructionType + ) { + return (int) allInstructions(function).stream() + .filter(instructionType::isInstance) + .count(); + } + + private static @NotNull T requireOnlyInstruction( + @NotNull LirFunctionDef function, + @NotNull Class instructionType + ) { + var matches = allInstructions(function).stream() + .filter(instructionType::isInstance) + .map(instructionType::cast) + .toList(); + assertEquals(1, matches.size(), () -> "Expected exactly one " + instructionType.getSimpleName()); + return matches.getFirst(); + } + + private static @NotNull List allInstructions(@NotNull LirFunctionDef function) { + var instructions = new ArrayList(); + for (var block : function) { + instructions.addAll(block.getInstructions()); + } + return instructions; + } + private static @NotNull FunctionDeclaration requireFunctionDeclaration( @NotNull dev.superice.gdparser.frontend.ast.SourceFile sourceFile, @NotNull String functionName diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java index d6458311..9a82d33e 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java @@ -1577,6 +1577,7 @@ private static final class RecordingTopBindingAnalyzer extends FrontendTopBindin @Override public void analyze( + @NotNull ClassRegistry classRegistry, @NotNull FrontendAnalysisData analysisData, @NotNull DiagnosticManager diagnosticManager ) { @@ -1589,7 +1590,7 @@ public void analyze( var probeNode = new PassStatement(SYNTHETIC_RANGE); publishedSymbolBindings.put(probeNode, new FrontendBinding("__probe__", FrontendBindingKind.UNKNOWN, null)); - super.analyze(analysisData, diagnosticManager); + super.analyze(classRegistry, analysisData, diagnosticManager); stableSymbolBindingsReferencePreserved = publishedSymbolBindings == analysisData.symbolBindings(); symbolBindingsPublicationClearedProbeEntry = analysisData.symbolBindings().isEmpty() diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendChainBindingAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendChainBindingAnalyzerTest.java index 76af74c8..712d0c49 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendChainBindingAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendChainBindingAnalyzerTest.java @@ -16,10 +16,12 @@ import gd.script.gdcc.gdextension.ExtensionAPI; import gd.script.gdcc.gdextension.ExtensionApiLoader; import gd.script.gdcc.gdextension.ExtensionBuiltinClass; +import gd.script.gdcc.gdextension.ExtensionEnumValue; import gd.script.gdcc.gdextension.ExtensionFunctionArgument; import gd.script.gdcc.gdextension.ExtensionGdClass; import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.type.GdCallableType; +import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import dev.superice.gdparser.frontend.ast.AttributeExpression; import dev.superice.gdparser.frontend.ast.AttributeCallStep; @@ -56,13 +58,13 @@ void analyzePublishesResolvedMemberAndStaticCallFactsForSupportedRoutes() throws """ class_name ResolvedRoutes extends Node - + var payload: int = 1 - + class Worker: static func build(seed): return "" - + func ping(seed): self.payload Worker.build(seed) @@ -94,6 +96,167 @@ func ping(seed): assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.call_resolution").isEmpty()); } + @Test + void analyzeKeepsSingletonCallReceiverStableAgainstLaterLocalShadowing() throws Exception { + var analyzed = analyze( + "singleton_call_later_local_shadow.gd", + """ + class_name SingletonCallLaterLocalShadow + extends RefCounted + + func ping(): + Engine.get_frames_drawn() + var Engine: String = "" + """ + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var engineHead = findNode(pingFunction, IdentifierExpression.class, identifier -> identifier.name().equals("Engine")); + var framesStep = findNode(pingFunction, AttributeCallStep.class, step -> step.name().equals("get_frames_drawn")); + var binding = analyzed.analysisData().symbolBindings().get(engineHead); + var resolvedCall = analyzed.analysisData().resolvedCalls().get(framesStep); + + assertAll( + () -> assertNotNull(binding), + () -> assertEquals(FrontendBindingKind.SINGLETON, binding.kind()), + () -> assertNotNull(binding.resolvedValue()), + () -> assertEquals("Engine", binding.resolvedValue().type().getTypeName()), + () -> assertNotNull(resolvedCall), + () -> assertEquals(FrontendCallResolutionStatus.RESOLVED, resolvedCall.status()), + () -> assertEquals(FrontendCallResolutionKind.INSTANCE_METHOD, resolvedCall.callKind()), + () -> assertEquals(FrontendReceiverKind.INSTANCE, resolvedCall.receiverKind()), + () -> assertNotNull(resolvedCall.receiverType()), + () -> assertEquals("Engine", resolvedCall.receiverType().getTypeName()), + () -> assertNotNull(resolvedCall.returnType()), + () -> assertEquals("int", resolvedCall.returnType().getTypeName()) + ); + } + + // ------------------------------------------------------------------ + // Dual-role chain-head route bias downstream behavior + // ------------------------------------------------------------------ + + /// `Engine.get_frames_drawn()` must produce an INSTANCE-method resolved call with + /// SINGLETON head binding, confirming the dual-role bias does not steal instance calls. + @Test + void analyzeDualRoleSingletonInstanceCallStaysInstanceRoute() throws Exception { + var analyzed = analyze( + "dual_role_singleton_instance_route.gd", + """ + class_name DualRoleSingletonInstanceRoute + extends RefCounted + + func ping(): + Engine.get_frames_drawn() + """ + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var engineHead = findNode(pingFunction, IdentifierExpression.class, identifier -> identifier.name().equals("Engine")); + var framesStep = findNode(pingFunction, AttributeCallStep.class, step -> step.name().equals("get_frames_drawn")); + var binding = analyzed.analysisData().symbolBindings().get(engineHead); + var resolvedCall = analyzed.analysisData().resolvedCalls().get(framesStep); + + assertAll( + () -> assertNotNull(binding), + () -> assertEquals(FrontendBindingKind.SINGLETON, binding.kind()), + () -> assertNotNull(resolvedCall), + () -> assertEquals(FrontendCallResolutionStatus.RESOLVED, resolvedCall.status()), + () -> assertEquals(FrontendCallResolutionKind.INSTANCE_METHOD, resolvedCall.callKind()), + () -> assertEquals(FrontendReceiverKind.INSTANCE, resolvedCall.receiverKind()) + ); + } + + /// `IP.RESOLVER_MAX_QUERIES` must produce a TYPE_META head binding and a resolved static + /// load member, confirming the dual-role bias routes engine class constants through the + /// static-load path. + @Test + void analyzeDualRoleEngineClassConstantRoutesToTypeMetaStaticLoad() throws Exception { + var analyzed = analyze( + "dual_role_engine_constant_route.gd", + """ + class_name DualRoleEngineConstantRoute + extends RefCounted + + func ping(): + IP.RESOLVER_MAX_QUERIES + """ + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var ipHead = findNode(pingFunction, IdentifierExpression.class, identifier -> identifier.name().equals("IP")); + var constantStep = findNode(pingFunction, AttributePropertyStep.class, step -> step.name().equals("RESOLVER_MAX_QUERIES")); + var binding = analyzed.analysisData().symbolBindings().get(ipHead); + var resolvedMember = analyzed.analysisData().resolvedMembers().get(constantStep); + + assertAll( + () -> assertNotNull(binding), + () -> assertEquals(FrontendBindingKind.TYPE_META, binding.kind()), + () -> assertNotNull(resolvedMember), + () -> assertEquals(FrontendMemberResolutionStatus.RESOLVED, resolvedMember.status()), + () -> assertEquals(FrontendReceiverKind.TYPE_META, resolvedMember.receiverKind()) + ); + } + + /// `ResourceUID.path_to_uid("res://foo.gd")` must produce a TYPE_META head binding and a + /// resolved static method call, confirming the dual-role bias routes static methods through + /// the type-meta static-method path. + @Test + void analyzeDualRoleStaticMethodRoutesToTypeMetaStaticCall() throws Exception { + var analyzed = analyze( + "dual_role_static_method_route.gd", + """ + class_name DualRoleStaticMethodRoute + extends RefCounted + + func ping(): + ResourceUID.path_to_uid("res://foo.gd") + """ + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var uidHead = findNode(pingFunction, IdentifierExpression.class, identifier -> identifier.name().equals("ResourceUID")); + var callStep = findNode(pingFunction, AttributeCallStep.class, step -> step.name().equals("path_to_uid")); + var binding = analyzed.analysisData().symbolBindings().get(uidHead); + var resolvedCall = analyzed.analysisData().resolvedCalls().get(callStep); + + assertAll( + () -> assertNotNull(binding), + () -> assertEquals(FrontendBindingKind.TYPE_META, binding.kind()), + () -> assertNotNull(resolvedCall), + () -> assertEquals(FrontendCallResolutionStatus.RESOLVED, resolvedCall.status()), + () -> assertEquals(FrontendCallResolutionKind.STATIC_METHOD, resolvedCall.callKind()), + () -> assertEquals(FrontendReceiverKind.TYPE_META, resolvedCall.receiverKind()) + ); + } + + /// `Input.MOUSE_MODE_VISIBLE` must produce a TYPE_META head binding, confirming the + /// dual-role bias routes class enum values through the static-load path. Full enum-value + /// member resolution is a downstream chain-reduction concern; here we only assert that the + /// head binding switches to TYPE_META and does not materialize a singleton receiver. + @Test + void analyzeDualRoleClassEnumValueRoutesToTypeMetaStaticLoad() throws Exception { + var analyzed = analyze( + "dual_role_class_enum_route.gd", + """ + class_name DualRoleClassEnumRoute + extends RefCounted + + func ping(): + Input.MOUSE_MODE_VISIBLE + """ + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var inputHead = findNode(pingFunction, IdentifierExpression.class, identifier -> identifier.name().equals("Input")); + var binding = analyzed.analysisData().symbolBindings().get(inputHead); + + assertAll( + () -> assertNotNull(binding), + () -> assertEquals(FrontendBindingKind.TYPE_META, binding.kind()) + ); + } + @Test void analyzeAcceptsStableVariantSourcesForInstanceAndStaticCallsAfterZeroArgCustomConstruction() throws Exception { var analyzed = analyze( @@ -101,17 +264,17 @@ void analyzeAcceptsStableVariantSourcesForInstanceAndStaticCallsAfterZeroArgCust """ class_name VariantCallRoutes extends RefCounted - + class Worker: func _init(): pass - + static func build_count(value: int): return 1 - + func consume(value: int): return 1 - + func ping(seed): Worker.new().consume(seed.anything()) Worker.build_count(seed.anything()) @@ -150,14 +313,14 @@ void analyzeResolvesMappedTopLevelStaticCallAcrossSourceUnitsViaCallerSideRemap( var workerUnit = parserService.parseUnit(Path.of("tmp", "mapped_worker_chain.gd"), """ class_name MappedWorker extends RefCounted - + static func build(seed) -> String: return "" """, diagnostics); var consumerUnit = parserService.parseUnit(Path.of("tmp", "mapped_consumer_chain.gd"), """ class_name Consumer extends RefCounted - + func ping(seed): MappedWorker.build(seed) """, diagnostics); @@ -191,17 +354,17 @@ void analyzePublishesPropertyInitializerChainFactsWithoutOpeningClassConstInitia """ class_name PropertyInitializerRoutes extends RefCounted - + class Handle: func read() -> int: return 1 - + class Worker: var handle: Handle - + static func build() -> Worker: return null - + var ready_value := Worker.build().handle.read() const Alias = Worker.build() """ @@ -241,7 +404,7 @@ static func build() -> Worker: assertEquals(FrontendCallResolutionKind.INSTANCE_METHOD, resolvedRead.callKind()); assertEquals(FrontendReceiverKind.INSTANCE, resolvedRead.receiverKind()); - assertTrue(analyzed.analysisData().resolvedCalls().get(aliasBuildStep) == null); + assertNull(analyzed.analysisData().resolvedCalls().get(aliasBuildStep)); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.member_resolution").isEmpty()); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.call_resolution").isEmpty()); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_chain_route").isEmpty()); @@ -254,11 +417,11 @@ void analyzePublishesPropertyInitializerCallFailuresWithoutOpeningWholeClassBody """ class_name PropertyInitializerFailedCall extends RefCounted - + class Worker: func read() -> int: return 1 - + static var failed_call := Worker.read() """ ); @@ -288,14 +451,14 @@ void analyzeResolvesTypedLocalPropertyWritePathAfterStabilizationPhase() throws """ class_name TypedLocalPropertyWritePathBaseline extends RefCounted - + class Point: var next: Point = null var marker: int = -1 - + func make_point() -> Point: return Point.new() - + func write_path(point: Point) -> void: var tail := make_point() tail.next = point @@ -337,14 +500,14 @@ void analyzeResolvesTypedLocalPropertyReadPathAfterStabilizationPhase() throws E """ class_name TypedLocalPropertyReadPathBaseline extends RefCounted - + class Point: var next: Point = null var marker: int = -1 - + func make_point() -> Point: return Point.new() - + func read_path() -> bool: var point := make_point() return point.marker != -1 @@ -381,14 +544,14 @@ void analyzeResolvesTypedLocalAliasChainAfterStabilizationPhase() throws Excepti """ class_name TypedLocalAliasChainBaseline extends RefCounted - + class Point: var next: Point = null var marker: int = -1 - + func make_point() -> Point: return Point.new() - + func read_path() -> bool: var a := make_point() var b := a @@ -432,18 +595,18 @@ void analyzeResolvesComplexInitializerAliasAfterStabilizationPhase() throws Exce """ class_name TypedLocalComplexInitializerBaseline extends RefCounted - + class Point: var next: Point = null var marker: int = -1 - + class Box: var next: Point = Point.new() - + class Factory: func make_point(seed: int) -> Box: return Box.new() - + func read_path(factory: Factory, seed: int) -> bool: var p := factory.make_point(seed).next var q := p @@ -483,7 +646,7 @@ void analyzeKeepsTrueDynamicLocalAliasOpenAfterStabilizationPhase() throws Excep """ class_name TypedLocalDynamicFailClosedPipeline extends RefCounted - + func ping(dynamic_host): var point := dynamic_host.next return point.member @@ -530,7 +693,7 @@ void analyzeSuppressesDuplicateChainDiagnosticWhenPropertyInitializerHeadIsSeale """ class_name PropertyInitializerHeadOwnedByTopBinding extends RefCounted - + var payload: int = 1 var copy := self.payload """ @@ -543,7 +706,7 @@ void analyzeSuppressesDuplicateChainDiagnosticWhenPropertyInitializerHeadIsSeale ); var payloadStep = findNode(copyDeclaration.value(), AttributePropertyStep.class, step -> step.name().equals("payload")); - assertTrue(analyzed.analysisData().resolvedMembers().get(payloadStep) == null); + assertNull(analyzed.analysisData().resolvedMembers().get(payloadStep)); assertEquals(1, diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_binding_subtree").size()); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_binding_subtree") .getFirst() @@ -561,13 +724,13 @@ void analyzeSealsSameClassTypeMetaValueAndMethodRoutesInPropertyInitializerAsUns """ class_name PropertyInitializerSameClassTypeMetaCall extends RefCounted - + signal changed var payload: int = 1 - + func read() -> int: return 1 - + static var blocked_value := PropertyInitializerSameClassTypeMetaCall.payload static var blocked_signal := PropertyInitializerSameClassTypeMetaCall.changed static var blocked_call := PropertyInitializerSameClassTypeMetaCall.read() @@ -636,7 +799,7 @@ void analyzeSealsInheritedTypeMetaValueAndMethodRoutesInPropertyInitializerAsUns """ class_name PropertyInitializerInheritedTypeMetaCall extends PropertyInitializerBase - + static var blocked_value := PropertyInitializerBase.payload static var blocked_signal := PropertyInitializerBase.changed static var blocked_call := PropertyInitializerBase.read() @@ -717,16 +880,16 @@ void analyzeSealsSameClassInstanceSuffixRoutesInPropertyInitializerAsUnsupported """ class_name PropertyInitializerSameClassSuffixRoutes extends RefCounted - + signal changed var payload: int = 1 - + func read() -> int: return 1 - + static func build() -> PropertyInitializerSameClassSuffixRoutes: return null - + var blocked_value := build().payload var blocked_call := build().read() var blocked_signal := build().changed @@ -786,10 +949,10 @@ void analyzeSealsInheritedInstanceSuffixRoutesInPropertyInitializerAsUnsupported """ class_name PropertyInitializerInheritedSuffixRoutes extends PropertyInitializerBase - + static func build_base() -> PropertyInitializerBase: return null - + var blocked_value := build_base().payload var blocked_call := build_base().read() var blocked_signal := build_base().changed @@ -864,17 +1027,17 @@ void analyzePublishesEachNonHeadStepForResolvedStaticRouteChain() throws Excepti """ class_name ResolvedStaticRouteChain extends RefCounted - + class Handle: func start() -> int: return 1 - + class Worker: var handle: Handle = Handle.new() - + static func build(seed) -> Worker: return Worker.new() - + func ping(seed): Worker.build(seed).handle.start() """ @@ -924,11 +1087,11 @@ void analyzePublishesConstructorThenWarnsWhenInstanceSyntaxHitsStaticMethod() th """ class_name InstanceStaticMethod extends RefCounted - + class Worker: static func build(): return 1 - + func ping(): Worker.new().build() """ @@ -963,11 +1126,11 @@ void analyzePublishesBareBuiltinAndObjectNewConstructorsOnSharedCallSurface() th """ class_name ConstructorSurfaceRoutes extends RefCounted - + class Worker: func _init(): pass - + func ping(): Array() Vector3i(1, 2, 3) @@ -1011,7 +1174,7 @@ func ping(): () -> assertEquals(FrontendReceiverKind.TYPE_META, resolvedVector.receiverKind()), () -> assertEquals( List.of("int", "int", "int"), - resolvedVector.argumentTypes().stream().map(type -> type.getTypeName()).toList() + resolvedVector.argumentTypes().stream().map(GdType::getTypeName).toList() ), () -> assertNotNull(resolvedNode), () -> assertEquals(FrontendCallResolutionStatus.RESOLVED, resolvedNode.status()), @@ -1034,11 +1197,11 @@ void analyzeKeepsParameterizedGdccConstructorsFailClosed() throws Exception { """ class_name ParameterizedGdccConstructorRoute extends RefCounted - + class Worker: func _init(value: int): pass - + func ping(seed): Worker.new(seed) """ @@ -1064,7 +1227,7 @@ void analyzeTargetsSingleArgVariantBuiltinConstructorsWithoutRelaxingBareObjectR """ class_name VariantBuiltinConstructorRoute extends RefCounted - + func ping(plain: Array, seed: Variant): int(plain[0]) String(plain[0]) @@ -1195,7 +1358,7 @@ void analyzeKeepsGenericBuiltinConstructorRankingFailClosedForMultiArgumentVaria """ class_name AmbiguousMultiArgBuiltinConstructorRoute extends RefCounted - + func ping(first: Variant, second: Variant): String(first, second) """, @@ -1240,7 +1403,7 @@ void analyzePrefersMoreSpecificBareBuiltinConstructorOverVariantFallback() throw """ class_name SpecificBuiltinConstructorRoute extends RefCounted - + func ping(): String("seed") """, @@ -1284,7 +1447,7 @@ void analyzeKeepsNonInstantiableEngineConstructorFailureReasonPrecise() throws E """ class_name NonInstantiableEngineConstructorRoute extends RefCounted - + func ping(): Node.new() """, @@ -1317,10 +1480,10 @@ void analyzePublishesBinaryArgumentFactsWithoutCascadingSuffixMisses() throws Ex """ class_name DeferredSuffix extends RefCounted - + func build(value: int) -> String: return "" - + func ping(): self.build(1 + 2).length """ @@ -1355,10 +1518,10 @@ void analyzeKeepsDeferredArgumentBoundaryForRemainingUnsupportedExpressionKinds( """ class_name DeferredSuffixRemainingGap extends RefCounted - + func build(value: int) -> String: return "" - + func ping(flag): self.build(1 if flag else 2).length """ @@ -1394,10 +1557,10 @@ void analyzeUsesDynamicArgumentVariantToKeepOuterCallResolvable() throws Excepti """ class_name DynamicArgumentRoute extends RefCounted - + func consume(value) -> int: return 1 - + func ping(worker): self.consume(worker.ping()) """ @@ -1430,10 +1593,10 @@ void analyzeUsesTypedPlainSubscriptArgumentsToKeepOuterCallExact() throws Except """ class_name SubscriptArgumentRoute extends RefCounted - + func consume(value: int) -> int: return value - + func ping(items: Array[int]): self.consume(items[0]) """ @@ -1458,10 +1621,10 @@ void analyzeKeepsValueRequiredNestedAssignmentFailClosedForDynamicTargets() thro """ class_name DynamicAssignmentArgumentRoute extends RefCounted - + func consume(value: int) -> int: return value - + func ping(dynamic_value): self.consume(dynamic_value[0] = 1) """ @@ -1492,13 +1655,13 @@ void analyzeKeepsExactSuffixAfterAttributeSubscriptStep() throws Exception { """ class_name AttributeSubscriptSuffix extends RefCounted - + class Item: var payload: int = 1 - + class Holder: var items: Array[Item] = [] - + func ping(holder: Holder): holder.items[0].payload """ @@ -1528,10 +1691,10 @@ void analyzeReportsUnsupportedBoundaryForAttributeSubscriptKeyedBuiltinRoute() t """ class_name AttributeSubscriptKeyedUnsupported extends RefCounted - + class Holder: var text: String = "" - + func ping(holder: Holder): holder.text[0].length """, @@ -1557,14 +1720,14 @@ void analyzePublishesMethodReferenceMembersAsCallableValues() throws Exception { """ class_name MethodReferenceMembers extends RefCounted - + class Worker: static func build() -> int: return 1 - + func helper() -> int: return 1 - + func ping(): self.helper Worker.build @@ -1608,10 +1771,10 @@ void analyzeUsesCallableReceiverForBareFunctionHeadChains() throws Exception { """ class_name CallableHeadChain extends RefCounted - + func helper() -> int: return 1 - + func ping(): helper.call() """ @@ -1639,14 +1802,14 @@ void analyzeResolvesCallableBindAndCallableHeadVariants() throws Exception { """ class_name CallableBindAndHeadVariants extends RefCounted - + class Worker: static func build(value: int) -> int: return value - + func helper(value: int) -> int: return value - + func ping(items: Array[Callable], dict: Dictionary[String, Callable]): helper.bind(1) self.helper.bind(1) @@ -1726,14 +1889,14 @@ void analyzePropagatesBlockedBareCallArgumentsThroughChainPath() throws Exceptio """ class_name BlockedBareCallChainPath extends RefCounted - + class Target: func consume(value: int) -> int: return value - + func helper(value: int) -> int: return value - + static func ping_static(target: Target, value: int): target.consume(helper(value)) """ @@ -1762,16 +1925,16 @@ static func ping_static(target: Target, value: int): assertEquals(FrontendCallResolutionStatus.BLOCKED, publishedBlockedBareCall.status()); assertEquals(FrontendCallResolutionKind.INSTANCE_METHOD, publishedBlockedBareCall.callKind()); assertEquals(FrontendReceiverKind.INSTANCE, publishedBlockedBareCall.receiverKind()); - assertEquals(List.of("int"), publishedBlockedBareCall.argumentTypes().stream().map(type -> type.getTypeName()).toList()); + assertEquals(List.of("int"), publishedBlockedBareCall.argumentTypes().stream().map(GdType::getTypeName).toList()); assertEquals("int", publishedBlockedBareCall.returnType().getTypeName()); var outerChainType = analyzed.analysisData().expressionTypes().get(chainExpression); assertNotNull(outerChainType); assertEquals(FrontendExpressionTypeStatus.BLOCKED, outerChainType.status()); - assertTrue(outerChainType.publishedType() == null); + assertNull(outerChainType.publishedType()); assertTrue(outerChainType.detailReason().contains("Argument #1 is blocked")); - assertTrue(analyzed.analysisData().resolvedCalls().get(consumeStep) == null); + assertNull(analyzed.analysisData().resolvedCalls().get(consumeStep)); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.call_resolution").isEmpty()); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.deferred_chain_resolution").isEmpty()); assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_chain_route").isEmpty()); @@ -1784,13 +1947,13 @@ void analyzeResolvesBareCallArgumentDependenciesInsideChainCalls() throws Except """ class_name BareCallChainArgument extends RefCounted - + func helper() -> int: return 1 - + func consume(value: int) -> int: return value - + func ping(): self.consume(helper()) """ @@ -1831,7 +1994,7 @@ void analyzePublishesStaticLoadFactsForGlobalEnumBuiltinAndEngineConstants() thr """ class_name StaticLoadRoutes extends Node - + func ping(): Side.SIDE_LEFT Vector3.BACK @@ -1877,6 +2040,49 @@ func ping(): assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_chain_route").isEmpty()); } + @Test + void analyzePublishesInheritedEngineStaticLoadFacts() throws Exception { + var analyzed = analyze( + "inherited_engine_static_load_routes.gd", + """ + class_name InheritedEngineStaticLoadRoutes + extends RefCounted + + func ping(): + ChildInput.PARENT_LIMIT + ChildInput.PARENT_MOUSE_MODE + """, + new ClassRegistry(inheritedEngineStaticFixtureApi()) + ); + + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var constantStatement = assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().getFirst()); + var enumStatement = assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().get(1)); + var constantLoad = analyzed.analysisData().resolvedMembers().get( + findNode(constantStatement, AttributePropertyStep.class, step -> step.name().equals("PARENT_LIMIT")) + ); + var enumLoad = analyzed.analysisData().resolvedMembers().get( + findNode(enumStatement, AttributePropertyStep.class, step -> step.name().equals("PARENT_MOUSE_MODE")) + ); + + assertAll( + () -> assertNotNull(constantLoad), + () -> assertEquals(FrontendMemberResolutionStatus.RESOLVED, constantLoad.status()), + () -> assertEquals(FrontendBindingKind.CONSTANT, constantLoad.bindingKind()), + () -> assertEquals(FrontendReceiverKind.TYPE_META, constantLoad.receiverKind()), + () -> assertNotNull(constantLoad.resultType()), + () -> assertEquals("int", constantLoad.resultType().getTypeName()), + () -> assertNotNull(enumLoad), + () -> assertEquals(FrontendMemberResolutionStatus.RESOLVED, enumLoad.status()), + () -> assertEquals(FrontendBindingKind.CONSTANT, enumLoad.bindingKind()), + () -> assertEquals(FrontendReceiverKind.TYPE_META, enumLoad.receiverKind()), + () -> assertNotNull(enumLoad.resultType()), + () -> assertEquals("int", enumLoad.resultType().getTypeName()) + ); + assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.member_resolution").isEmpty()); + assertTrue(diagnosticsByCategory(analyzed.analysisData(), "sema.unsupported_chain_route").isEmpty()); + } + @Test void analyzePublishesBuiltinInstancePropertyFactsAndKeepsMissingMemberAsFailure() throws Exception { var analyzed = analyze( @@ -1884,7 +2090,7 @@ void analyzePublishesBuiltinInstancePropertyFactsAndKeepsMissingMemberAsFailure( """ class_name BuiltinInstancePropertyRoutes extends RefCounted - + func ping(vector: Vector3): vector.x Basis.IDENTITY.x @@ -1938,11 +2144,11 @@ void analyzeFailsTypeMetaCallToInstanceMethod() throws Exception { """ class_name FailedStaticCall extends RefCounted - + class Worker: func speak(): return 1 - + func ping(): Worker.speak() """ @@ -1970,10 +2176,10 @@ void analyzeSealsUnsupportedGdccStaticLoadAtBoundary() throws Exception { """ class_name UnsupportedStaticLoad extends RefCounted - + class Worker: pass - + func ping(): Worker.VALUE """ @@ -2003,7 +2209,7 @@ void analyzePublishesFailedHeadFailureRoutesAndDiagnostics() throws Exception { """ class_name HeadFailureRoutes extends RefCounted - + func ping(): missing.payload missing_call.speak() @@ -2055,7 +2261,7 @@ func ping(): @NotNull String source, @NotNull ClassRegistry registry, @NotNull Map topLevelCanonicalNameMap - ) throws Exception { + ) { var diagnostics = new DiagnosticManager(); var parserService = new GdScriptParserService(); var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnostics); @@ -2139,6 +2345,50 @@ func ping(): )); } + private static @NotNull ExtensionAPI inheritedEngineStaticFixtureApi() throws Exception { + var api = ExtensionApiLoader.loadDefault(); + var parentClass = new ExtensionGdClass( + "BaseInput", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("PARENT_MOUSE_MODE", 7)) + )), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("PARENT_LIMIT", "42")) + ); + var childClass = new ExtensionGdClass( + "ChildInput", + false, + true, + "BaseInput", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var patchedClasses = new ArrayList<>(api.classes()); + patchedClasses.add(parentClass); + patchedClasses.add(childClass); + return new ExtensionAPI( + api.header(), + api.builtinClassSizes(), + api.builtinClassMemberOffsets(), + api.globalEnums(), + api.utilityFunctions(), + api.builtinClasses(), + patchedClasses, + api.singletons(), + api.nativeStructures() + ); + } + /// Keep one synthetic two-arg ambiguity alive so the future single-arg Variant constructor shortcut /// stays tightly scoped and does not erase generic constructor-ranking failures. private static @NotNull ExtensionBuiltinClass withAmbiguousStringPairConstructors( diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzerTest.java index 7927dbad..ec26ff6b 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzerTest.java @@ -1225,7 +1225,7 @@ func ping(flag): analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnosticManager); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendChainBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzerTest.java index b2d989b9..7d6ab3f9 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendExprTypeAnalyzerTest.java @@ -46,6 +46,7 @@ import java.util.Map; import java.util.function.Predicate; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -146,6 +147,58 @@ func ping(seed): assertTrue(diagnosticsByCategory(analyzed, "sema.expression_resolution").isEmpty()); } + @Test + void analyzeKeepsSingletonResolvedValueForLaterLocalAndInitializerSelfReferenceTypes() throws Exception { + var analyzed = analyze( + "expr_type_singleton_resolved_value_stability.gd", + """ + class_name ExprTypeSingletonResolvedValueStability + extends RefCounted + + func ping(): + Engine + Engine.get_frames_drawn() + var Engine: String = Engine + """ + ); + + var pingFunction = findFunction(analyzed.ast(), "ping"); + var bareEngineStatement = assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().getFirst()); + var framesStatement = assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().get(1)); + var engineDeclaration = findVariable(pingFunction.body().statements(), "Engine"); + var bareEngine = assertInstanceOf(IdentifierExpression.class, bareEngineStatement.expression()); + var framesExpression = assertInstanceOf(AttributeExpression.class, framesStatement.expression()); + var initializerEngine = assertInstanceOf(IdentifierExpression.class, engineDeclaration.value()); + + var bareEngineBinding = analyzed.analysisData().symbolBindings().get(bareEngine); + var initializerEngineBinding = analyzed.analysisData().symbolBindings().get(initializerEngine); + assertAll( + () -> assertNotNull(bareEngineBinding), + () -> assertEquals(FrontendBindingKind.SINGLETON, bareEngineBinding.kind()), + () -> assertNotNull(bareEngineBinding.resolvedValue()), + () -> assertEquals("Engine", bareEngineBinding.resolvedValue().type().getTypeName()), + () -> assertNotNull(initializerEngineBinding), + () -> assertEquals(FrontendBindingKind.SINGLETON, initializerEngineBinding.kind()), + () -> assertNotNull(initializerEngineBinding.resolvedValue()), + () -> assertEquals("Engine", initializerEngineBinding.resolvedValue().type().getTypeName()) + ); + + var bareEngineType = analyzed.analysisData().expressionTypes().get(bareEngine); + var framesType = analyzed.analysisData().expressionTypes().get(framesExpression); + var initializerEngineType = analyzed.analysisData().expressionTypes().get(initializerEngine); + assertAll( + () -> assertNotNull(bareEngineType), + () -> assertEquals(FrontendExpressionTypeStatus.RESOLVED, bareEngineType.status()), + () -> assertEquals("Engine", bareEngineType.publishedType().getTypeName()), + () -> assertNotNull(framesType), + () -> assertEquals(FrontendExpressionTypeStatus.RESOLVED, framesType.status()), + () -> assertEquals("int", framesType.publishedType().getTypeName()), + () -> assertNotNull(initializerEngineType), + () -> assertEquals(FrontendExpressionTypeStatus.RESOLVED, initializerEngineType.status()), + () -> assertEquals("Engine", initializerEngineType.publishedType().getTypeName()) + ); + } + @Test void analyzePublishesSupportedPropertyInitializerExpressionTypesWithoutOpeningClassConstInitializers() throws Exception { var analyzed = analyze( @@ -2263,6 +2316,51 @@ func ping(worker): assertEquals(GdVariantType.VARIANT, dynamicUseType.publishedType()); } + @Test + void analyzeBackfillRefreshesPublishedLocalResolvedValuePayload() throws Exception { + var input = prepareInputBeforeExpressionTyping( + "expr_type_backfill_refreshes_binding_resolved_value.gd", + """ + class_name ExprTypeBackfillRefreshesBindingResolvedValue + extends RefCounted + + func ping(): + var value := 1 + value + """, + false + ); + var pingFunction = findFunction(input.unit().ast(), "ping"); + var bodyScope = assertInstanceOf(BlockScope.class, input.analysisData().scopesByAst().get(pingFunction.body())); + var valueDeclaration = findVariable(pingFunction.body().statements(), "value"); + var valueUse = assertInstanceOf( + IdentifierExpression.class, + assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().get(1)).expression() + ); + var staleResolvedValue = input.analysisData().symbolBindings().get(valueUse).resolvedValue(); + assertNotNull(staleResolvedValue); + assertEquals(GdVariantType.VARIANT, staleResolvedValue.type()); + + new FrontendExprTypeAnalyzer().analyze( + input.classRegistry(), + input.analysisData(), + input.diagnosticManager() + ); + + var refreshedBinding = input.analysisData().symbolBindings().get(valueUse); + var valueUseType = input.analysisData().expressionTypes().get(valueUse); + assertAll( + () -> assertNotNull(refreshedBinding), + () -> assertNotNull(refreshedBinding.resolvedValue()), + () -> assertSame(valueDeclaration, refreshedBinding.resolvedValue().declaration()), + () -> assertEquals("int", refreshedBinding.resolvedValue().type().getTypeName()), + () -> assertEquals("int", bodyScope.resolveValue("value").type().getTypeName()), + () -> assertNotNull(valueUseType), + () -> assertEquals(FrontendExpressionTypeStatus.RESOLVED, valueUseType.status()), + () -> assertEquals("int", valueUseType.publishedType().getTypeName()) + ); + } + @Test void analyzeLeavesPreStabilizedInferredLocalSlotTypesUntouched() throws Exception { var analyzed = analyze( @@ -2544,7 +2642,7 @@ func ping(): @NotNull String fileName, @NotNull String source, @NotNull ClassRegistry registry - ) throws Exception { + ) { return analyze(fileName, source, registry, Map.of()); } @@ -2568,6 +2666,14 @@ func ping(): private static @NotNull PreparedExpressionInput prepareInputBeforeExpressionTyping( @NotNull String fileName, @NotNull String source + ) throws Exception { + return prepareInputBeforeExpressionTyping(fileName, source, true); + } + + private static @NotNull PreparedExpressionInput prepareInputBeforeExpressionTyping( + @NotNull String fileName, + @NotNull String source, + boolean runLocalTypeStabilization ) throws Exception { var diagnostics = new DiagnosticManager(); var parserService = new GdScriptParserService(); @@ -2586,10 +2692,12 @@ func ping(): analysisData.updateDiagnostics(diagnostics.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnostics); analysisData.updateDiagnostics(diagnostics.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnostics); - analysisData.updateDiagnostics(diagnostics.snapshot()); - new FrontendLocalTypeStabilizationAnalyzer().analyze(classRegistry, analysisData, diagnostics); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnostics); analysisData.updateDiagnostics(diagnostics.snapshot()); + if (runLocalTypeStabilization) { + new FrontendLocalTypeStabilizationAnalyzer().analyze(classRegistry, analysisData, diagnostics); + analysisData.updateDiagnostics(diagnostics.snapshot()); + } new FrontendChainBindingAnalyzer().analyze(classRegistry, analysisData, diagnostics); analysisData.updateDiagnostics(diagnostics.snapshot()); return new PreparedExpressionInput(unit, classRegistry, analysisData, diagnostics); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzerTest.java index 406551a7..fb321839 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendLocalTypeStabilizationAnalyzerTest.java @@ -661,7 +661,7 @@ func ping(toggle, choice, items): analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnosticManager); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); return new PreparedProbeInput(unit, analysisData, diagnosticManager, classRegistry); } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzerTest.java index 399a9b34..d0c6fd65 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTopBindingAnalyzerTest.java @@ -14,11 +14,16 @@ import gd.script.gdcc.frontend.sema.FrontendClassSkeletonBuilder; import gd.script.gdcc.gdextension.ExtensionAPI; import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.gdextension.ExtensionEnumValue; +import gd.script.gdcc.gdextension.ExtensionGdClass; import gd.script.gdcc.gdextension.ExtensionGlobalConstant; +import gd.script.gdcc.gdextension.ExtensionSingleton; import gd.script.gdcc.lir.LirFunctionDef; import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.scope.ScopeLookupStatus; import gd.script.gdcc.scope.ScopeTypeMeta; import gd.script.gdcc.scope.ScopeTypeMetaKind; +import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdVariantType; import dev.superice.gdparser.frontend.ast.ForStatement; @@ -47,6 +52,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -60,7 +66,7 @@ void analyzeRejectsMissingModuleSkeletonBoundary() { var thrown = assertThrows( IllegalStateException.class, - () -> analyzer.analyze(analysisData, new DiagnosticManager()) + () -> analyzer.analyze(new ClassRegistry(ExtensionApiLoader.loadDefault()), analysisData, new DiagnosticManager()) ); assertTrue(thrown.getMessage().contains("moduleSkeleton")); @@ -81,7 +87,7 @@ func ping(): var thrown = assertThrows( IllegalStateException.class, - () -> analyzer.analyze(analysisData, preparedInput.diagnosticManager()) + () -> analyzer.analyze(preparedInput.classRegistry(), analysisData, preparedInput.diagnosticManager()) ); assertTrue(thrown.getMessage().contains("diagnostics")); @@ -101,7 +107,7 @@ func ping(): var thrown = assertThrows( IllegalStateException.class, - () -> analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()) + () -> analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()) ); assertTrue(thrown.getMessage().contains(preparedInput.unit().path().toString())); @@ -121,7 +127,7 @@ func ping(): var staleNode = preparedInput.unit().ast(); publishedSymbolBindings.put(staleNode, new FrontendBinding("__stale__", FrontendBindingKind.UNKNOWN, null)); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); assertSame(publishedSymbolBindings, preparedInput.analysisData().symbolBindings()); assertTrue(preparedInput.analysisData().symbolBindings().isEmpty()); @@ -161,7 +167,7 @@ func ping(value, arr, i): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var localDeclaration = findVariable(pingFunction.body().statements(), "local"); @@ -225,7 +231,7 @@ void analyzeBindsGlobalConstantsAsOrdinaryConstantValues() throws Exception { """ class_name GlobalConstantValue extends Node - + func ping(): print(GDCC_TEST_BIG_FLAG) """, @@ -233,7 +239,7 @@ func ping(): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var useSite = findIdentifierExpression(pingFunction.body(), "GDCC_TEST_BIG_FLAG"); @@ -246,6 +252,64 @@ func ping(): ); } + @Test + void analyzePublishesStableSingletonResolvedValueForLaterLocalShadowing() throws Exception { + var preparedInput = prepareBindingInput( + "singleton_later_local_resolved_value.gd", + """ + class_name SingletonLaterLocalResolvedValue + extends RefCounted + + func ping(): + Engine.get_frames_drawn() + var Engine: String = "" + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + var engineHead = findIdentifierExpression(pingFunction.body(), "Engine"); + var binding = assertBinding(preparedInput.analysisData(), engineHead, FrontendBindingKind.SINGLETON); + + assertAll( + () -> assertEquals(ScopeLookupStatus.FOUND_ALLOWED, binding.valueAccessStatus()), + () -> assertNotNull(binding.resolvedValue()), + () -> assertEquals(ScopeValueKind.SINGLETON, binding.resolvedValue().kind()), + () -> assertEquals("Engine", binding.resolvedValue().name()) + ); + } + + @Test + void analyzeKeepsInitializerSelfReferenceBoundToSingletonResolvedValue() throws Exception { + var preparedInput = prepareBindingInput( + "singleton_initializer_self_reference_resolved_value.gd", + """ + class_name SingletonInitializerSelfReferenceResolvedValue + extends RefCounted + + func ping(): + var Engine: String = Engine + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + var engineDeclaration = findVariable(pingFunction.body().statements(), "Engine"); + var initializerEngine = findIdentifierExpression(engineDeclaration.value(), "Engine"); + var binding = assertBinding(preparedInput.analysisData(), initializerEngine, FrontendBindingKind.SINGLETON); + + assertAll( + () -> assertEquals(ScopeLookupStatus.FOUND_ALLOWED, binding.valueAccessStatus()), + () -> assertNotNull(binding.resolvedValue()), + () -> assertEquals(ScopeValueKind.SINGLETON, binding.resolvedValue().kind()), + () -> assertEquals("Engine", binding.resolvedValue().name()) + ); + } + @Test void analyzeSealsSameClassNonStaticPropertyInitializerHeadsButKeepsAllowedStaticRoutes() throws Exception { var preparedInput = prepareBindingInput( @@ -281,7 +345,7 @@ func read() -> int: ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var sourceFile = preparedInput.unit().ast(); var mirrorDeclaration = findVariable(sourceFile.statements(), "mirror"); @@ -373,7 +437,7 @@ static func helper() -> int: ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var sourceFile = preparedInput.unit().ast(); var blockedValueDeclaration = findVariable(sourceFile.statements(), "blocked_value"); @@ -449,7 +513,7 @@ func ping(): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -494,7 +558,7 @@ func ping(): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -521,7 +585,7 @@ func ping(seed): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var initializer = findVariable(pingFunction.body().statements(), "Inner"); @@ -573,7 +637,7 @@ func ping(): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -615,7 +679,7 @@ func ping(): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBindingsForName( @@ -658,7 +722,7 @@ func ping(): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBindingsForName( @@ -701,7 +765,7 @@ static func ping(): ); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -725,7 +789,7 @@ func ping(): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -755,7 +819,7 @@ func ping(): classScope.defineFunction(createFunctionDef("mix", false)); classScope.defineFunction(createFunctionDef("mix", true)); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertNull(preparedInput.analysisData().symbolBindings().get(findIdentifierExpression(pingFunction.body(), "mix"))); @@ -789,7 +853,7 @@ func ping(): true )); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -820,7 +884,7 @@ func bind_unknown(): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var propertyFunction = findFunction(preparedInput.unit().ast(), "bind_property"); var propertyInitializer = findVariable(propertyFunction.body().statements(), "node"); @@ -861,7 +925,7 @@ func ping(player, i): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var playerUseSites = findNodes( @@ -933,7 +997,7 @@ func ping(player, arr, key, cond, value, obj, matrix, row, col): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var helperFunction = findFunction(preparedInput.unit().ast(), "helper"); @@ -1114,7 +1178,7 @@ func ping(value, matrix, row, col): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBindingsForName( @@ -1176,7 +1240,7 @@ func ping(seed = helper()): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); var defaultHelperUseSite = findIdentifierExpression( @@ -1252,7 +1316,7 @@ func ok(value): var okFunction = findFunction(preparedInput.unit().ast(), "ok"); preparedInput.analysisData().scopesByAst().remove(skippedFunction.body()); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); assertNull(preparedInput.analysisData().symbolBindings().get(findIdentifierExpression(skippedFunction.body(), "value"))); assertBinding( @@ -1283,7 +1347,7 @@ func ping(value): var ifStatement = findNode(pingFunction.body(), dev.superice.gdparser.frontend.ast.IfStatement.class, _ -> true); preparedInput.analysisData().scopesByAst().remove(ifStatement.body()); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var printedValues = findNodes( pingFunction.body(), @@ -1317,7 +1381,7 @@ static func ping(): """); var analyzer = new FrontendTopBindingAnalyzer(); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); assertBinding( @@ -1357,7 +1421,7 @@ func ping(value): var useSite = findIdentifierExpression(pingFunction.body(), "value"); preparedInput.analysisData().scopesByAst().remove(useSite); - analyzer.analyze(preparedInput.analysisData(), preparedInput.diagnosticManager()); + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); assertNull(preparedInput.analysisData().symbolBindings().get(useSite)); var unsupportedDiagnostics = preparedInput.diagnosticManager().snapshot().asList().stream() @@ -1368,6 +1432,765 @@ func ping(value): assertTrue(unsupportedDiagnostics.getFirst().message().contains("value")); } + // ------------------------------------------------------------------ + // Dual-role chain-head route bias tests + // ------------------------------------------------------------------ + + /// `Engine.get_frames_drawn()` is a singleton instance call: the head `Engine` must stay + /// bound as `SINGLETON` so the chain enters the instance-method route, not `TYPE_META`. + @Test + void analyzeKeepsSingletonInstanceCallHeadAsSingletonBinding() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_singleton_instance_call.gd", + """ + class_name DualRoleSingletonInstanceCall + extends RefCounted + + func ping(): + Engine.get_frames_drawn() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.SINGLETON + ); + } + + /// `Input.is_action_pressed(...)` is a singleton instance call with arguments: the head + /// `Input` must stay `SINGLETON`. + @Test + void analyzeKeepsSingletonInstanceCallWithArgumentsAsSingletonBinding() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_singleton_instance_call_args.gd", + """ + class_name DualRoleSingletonInstanceCallArgs + extends RefCounted + + func ping(): + Input.is_action_pressed(&"ui_accept") + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Input"), + FrontendBindingKind.SINGLETON + ); + } + + /// `Engine.new()` is a constructor-like route on a dual-role name: the head `Engine` must + /// be published as `TYPE_META` so the chain enters the constructor primary route. Whether + /// the constructor is legal for a singleton is a downstream route concern, not a head-bias + /// concern. + @Test + void analyzePublishesTypeMetaForDualRoleConstructorNewRoute() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_constructor_new.gd", + """ + class_name DualRoleConstructorNew + extends RefCounted + + func ping(): + Engine.new() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.TYPE_META + ); + } + + /// `.new()` fail-closed: when the singleton's declared type has an instance method named + /// `new`, the suffix resolves in the singleton instance namespace. The head must stay + /// `SINGLETON`, not be blindly switched to `TYPE_META`. This uses a custom fixture where + /// the dual-role name's engine class defines `new` as an instance method. + @Test + void analyzeDualRoleConstructorNewFailClosedWhenSingletonHasInstanceNew() throws Exception { + var api = createDualRoleWithInstanceNewFixtureApi(); + var preparedInput = prepareBindingInput( + "dual_role_constructor_new_fail_closed.gd", + """ + class_name DualRoleConstructorNewFailClosed + extends RefCounted + + func ping(): + DualWithInstanceNew.new() + """, + new ClassRegistry(api) + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "DualWithInstanceNew"), + FrontendBindingKind.SINGLETON + ); + } + + /// Fail-closed for signal: when the singleton's declared type has a signal with the same + /// name as a type-meta static member (e.g. an engine class constant), the suffix resolves + /// in the singleton instance namespace via signal. The head must stay `SINGLETON`, not be + /// switched to `TYPE_META`. This uses a custom fixture where the dual-role name's engine + /// class defines both a signal `shared_name` and a constant `shared_name`. + @Test + void analyzeDualRoleFailClosedWhenSingletonHasSignalAndTypeMetaHasStaticMember() throws Exception { + var api = createDualRoleWithSignalAndConstantFixtureApi(); + var preparedInput = prepareBindingInput( + "dual_role_signal_fail_closed.gd", + """ + class_name DualRoleSignalFailClosed + extends RefCounted + + func ping(): + DualWithSignal.shared_name + """, + new ClassRegistry(api) + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "DualWithSignal"), + FrontendBindingKind.SINGLETON + ); + } + + /// `IP.RESOLVER_MAX_QUERIES` is an engine class constant on a dual-role singleton: the + /// suffix only resolves in the type-meta static namespace, so the head `IP` must be + /// published as `TYPE_META`. + @Test + void analyzePublishesTypeMetaForDualRoleEngineClassConstantAccess() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_engine_constant.gd", + """ + class_name DualRoleEngineConstant + extends RefCounted + + func ping(): + IP.RESOLVER_MAX_QUERIES + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "IP"), + FrontendBindingKind.TYPE_META + ); + } + + /// `Input.MOUSE_MODE_VISIBLE` is a class enum value on a dual-role singleton: the suffix + /// only resolves in the type-meta static namespace (class enum), so the head `Input` must + /// be published as `TYPE_META`. + @Test + void analyzePublishesTypeMetaForDualRoleClassEnumValueAccess() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_class_enum_value.gd", + """ + class_name DualRoleClassEnumValue + extends RefCounted + + func ping(): + Input.MOUSE_MODE_VISIBLE + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Input"), + FrontendBindingKind.TYPE_META + ); + } + + /// Inherited static members must participate in the dual-role bias just like direct static + /// members: the singleton's class is `ChildInput`, but both suffixes are declared on `BaseInput`. + @Test + void analyzePublishesTypeMetaForDualRoleInheritedEngineStaticMembers() throws Exception { + var api = createDualRoleInheritedStaticFixtureApi(false); + var preparedInput = prepareBindingInput( + "dual_role_inherited_static_members.gd", + """ + class_name DualRoleInheritedStaticMembers + extends RefCounted + + func ping(): + ChildInput.PARENT_LIMIT + ChildInput.PARENT_MOUSE_MODE + """, + new ClassRegistry(api) + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBindingsForName( + preparedInput.analysisData(), + pingFunction.body(), + "ChildInput", + FrontendBindingKind.TYPE_META, + 2 + ); + } + + /// Fail-closed still applies when both namespaces are satisfied only through inherited members. + @Test + void analyzeKeepsSingletonWhenInheritedInstanceMemberConflictsWithInheritedStaticMember() throws Exception { + var api = createDualRoleInheritedStaticFixtureApi(true); + var preparedInput = prepareBindingInput( + "dual_role_inherited_static_fail_closed.gd", + """ + class_name DualRoleInheritedStaticFailClosed + extends RefCounted + + func ping(): + ChildInput.PARENT_LIMIT + """, + new ClassRegistry(api) + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "ChildInput"), + FrontendBindingKind.SINGLETON + ); + } + + /// `ResourceUID.uid_to_path(...)` is a static method on a dual-role singleton: the suffix + /// only resolves in the type-meta static namespace (static method), so the head `ResourceUID` + /// must be published as `TYPE_META`. + @Test + void analyzePublishesTypeMetaForDualRoleStaticMethodCall() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_static_method_call.gd", + """ + class_name DualRoleStaticMethodCall + extends RefCounted + + func ping(): + ResourceUID.uid_to_path() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "ResourceUID"), + FrontendBindingKind.TYPE_META + ); + } + + /// Mixed usage: bare `Input`, `Input.is_action_pressed(...)`, and `Input.MOUSE_MODE_VISIBLE` + /// in the same function. Each use-site binding must be independent: bare `Input` stays + /// `SINGLETON`, instance call head stays `SINGLETON`, and static constant head switches to + /// `TYPE_META`. + @Test + void analyzeKeepsDualRoleUseSitesIndependentInSameFunction() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_mixed_use_sites.gd", + """ + class_name DualRoleMixedUseSites + extends RefCounted + + func ping(): + var bare = Input + Input.is_action_pressed(&"ui_accept") + Input.MOUSE_MODE_VISIBLE + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + var inputUseSites = findNodes( + pingFunction.body(), + IdentifierExpression.class, + identifier -> identifier.name().equals("Input") + ); + // Three use-sites: bare value, instance call head, static constant head. + assertEquals(3, inputUseSites.size()); + // Bare `Input` (inside VariableDeclaration initializer) stays SINGLETON. + var bareDecl = findVariable(pingFunction.body().statements(), "bare"); + var bareInput = findIdentifierExpression(bareDecl.value(), "Input"); + assertBinding(preparedInput.analysisData(), bareInput, FrontendBindingKind.SINGLETON); + // The other two use-sites: one is SINGLETON (instance call), one is TYPE_META (constant). + var nonBareSites = inputUseSites.stream() + .filter(site -> site != bareInput) + .toList(); + var kinds = nonBareSites.stream() + .map(site -> preparedInput.analysisData().symbolBindings().get(site).kind()) + .toList(); + assertTrue(kinds.contains(FrontendBindingKind.SINGLETON), "instance call head must stay SINGLETON"); + assertTrue(kinds.contains(FrontendBindingKind.TYPE_META), "static constant head must be TYPE_META"); + } + + /// Fail-closed: when the first suffix resolves in BOTH the singleton instance namespace and + /// the type-meta static namespace, the head must keep `SINGLETON`. This uses a custom fixture + /// where the dual-role name `AmbiguousDual` is both a singleton (type `AmbiguousDual`) and an + /// engine class. The engine class defines `shared_member` as BOTH a static method and an + /// instance method, so the suffix resolves in both namespaces. + @Test + void analyzeFailClosedKeepsSingletonWhenSuffixResolvesInBothNamespaces() throws Exception { + var api = createDualRoleAmbiguousFixtureApi(); + var preparedInput = prepareBindingInput( + "dual_role_ambiguous_suffix.gd", + """ + class_name DualRoleAmbiguousSuffix + extends RefCounted + + func ping(): + AmbiguousDual.shared_member + """, + new ClassRegistry(api) + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "AmbiguousDual"), + FrontendBindingKind.SINGLETON + ); + } + + /// Non-dual-role engine class static access (e.g. `Node.NOTIFICATION_ENTER_TREE`) must not + /// be affected by the dual-role bias: `Node` is not a singleton, so it follows the existing + /// `TYPE_META` route as before. + @Test + void analyzeKeepsNonDualRoleEngineClassStaticAsTypeMeta() throws Exception { + var preparedInput = prepareBindingInput( + "non_dual_role_engine_static.gd", + """ + class_name NonDualRoleEngineStatic + extends Node + + func ping(): + Node.NOTIFICATION_ENTER_TREE + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Node"), + FrontendBindingKind.TYPE_META + ); + } + + /// Property initializer: dual-role static constant access in a property initializer must + /// also publish `TYPE_META` at the head, consistent with executable body behavior. + @Test + void analyzePublishesTypeMetaForDualRoleConstantInPropertyInitializer() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_property_init_constant.gd", + """ + class_name DualRolePropertyInitConstant + extends RefCounted + + var queries: int = IP.RESOLVER_MAX_QUERIES + + func ping() -> int: + return queries + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var sourceFile = preparedInput.unit().ast(); + var ipHead = findIdentifierExpression(sourceFile, "IP"); + assertBinding(preparedInput.analysisData(), ipHead, FrontendBindingKind.TYPE_META); + } + + /// Prior-declared local shadows a dual-role singleton: the value winner must be `LOCAL_VAR`, + /// not `SINGLETON` or `TYPE_META`. This anchors the contract that the dual-role bias consumes + /// `resolveVisibleValue(...)` as the value-winner authority — a local that wins visible-value + /// resolution must not be overridden by the bias. + @Test + void analyzeDualRoleBiasRespectsPriorLocalShadowingSingletonValue() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_prior_local_shadows_singleton.gd", + """ + class_name DualRolePriorLocalShadowsSingleton + extends RefCounted + + func ping(): + var Engine: String = "local" + Engine.get_frames_drawn() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + // The chain-head `Engine` (in `Engine.get_frames_drawn()`) must be LOCAL_VAR, not + // SINGLETON or TYPE_META, because the prior local wins visible-value resolution. + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.LOCAL_VAR + ); + } + + /// Prior-declared local shadows a dual-role singleton with a static-constant-only suffix: + /// even though `IP.RESOLVER_MAX_QUERIES` would normally trigger the TYPE_META bias, the + /// prior local wins visible-value resolution, so the head must stay `LOCAL_VAR`. The bias + /// must not bypass the value-winner authority. + @Test + void analyzeDualRoleBiasDoesNotOverridePriorLocalForStaticConstantSuffix() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_prior_local_shadows_static_constant.gd", + """ + class_name DualRolePriorLocalShadowsStaticConstant + extends RefCounted + + func ping(): + var IP: String = "local" + IP.RESOLVER_MAX_QUERIES + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "IP"), + FrontendBindingKind.LOCAL_VAR + ); + } + + /// Prior-declared local shadows a dual-role singleton with a constructor `.new()` suffix: + /// even though `.new()` would normally trigger the TYPE_META bias, the prior local wins + /// visible-value resolution, so the head must stay `LOCAL_VAR`. + @Test + void analyzeDualRoleBiasDoesNotOverridePriorLocalForConstructorNewSuffix() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_prior_local_shadows_constructor_new.gd", + """ + class_name DualRolePriorLocalShadowsConstructorNew + extends RefCounted + + func ping(): + var Engine: String = "local" + Engine.new() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.LOCAL_VAR + ); + } + + /// Parameter shadows a dual-role singleton: the value winner must be `PARAMETER`, not + /// `SINGLETON` or `TYPE_META`. This confirms the bias respects parameter-level shadowing + /// in addition to local-level shadowing. + @Test + void analyzeDualRoleBiasRespectsParameterShadowingSingletonValue() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_parameter_shadows_singleton.gd", + """ + class_name DualRoleParameterShadowsSingleton + extends RefCounted + + func ping(Engine): + Engine.get_frames_drawn() + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.PARAMETER + ); + } + + /// Later-declared local does NOT shadow a dual-role singleton: `resolveVisibleValue` filters + /// later locals, so the value winner is still `SINGLETON`. The dual-role bias then applies + /// normally — `Engine.get_frames_drawn()` stays `SINGLETON` (instance call), and + /// `IP.RESOLVER_MAX_QUERIES` switches to `TYPE_META`. + @Test + void analyzeDualRoleBiasStillAppliesWhenLaterLocalDoesNotShadowSingleton() throws Exception { + var preparedInput = prepareBindingInput( + "dual_role_later_local_no_shadow.gd", + """ + class_name DualRoleLaterLocalNoShadow + extends RefCounted + + func ping(): + Engine.get_frames_drawn() + IP.RESOLVER_MAX_QUERIES + var Engine: String = "later" + var IP: String = "later" + """ + ); + var analyzer = new FrontendTopBindingAnalyzer(); + + analyzer.analyze(preparedInput.classRegistry(), preparedInput.analysisData(), preparedInput.diagnosticManager()); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + // Instance call head stays SINGLETON (later local filtered by visible-value resolver). + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "Engine"), + FrontendBindingKind.SINGLETON + ); + // Static constant head switches to TYPE_META (later local filtered). + assertBinding( + preparedInput.analysisData(), + findIdentifierExpression(pingFunction.body(), "IP"), + FrontendBindingKind.TYPE_META + ); + } + + /// Custom fixture: a dual-role name `DualWithInstanceNew` that is both a singleton (type + /// `DualWithInstanceNew`) and an engine class. The engine class defines `new` as an instance + /// method, so `.new()` resolves in the singleton instance namespace, exercising the + /// `.new()` fail-closed rule. + private static @NotNull ExtensionAPI createDualRoleWithInstanceNewFixtureApi() throws Exception { + var defaultApi = ExtensionApiLoader.loadDefault(); + var dualClass = new ExtensionGdClass( + "DualWithInstanceNew", + false, + true, + "Object", + "core", + List.of(), + List.of(new ExtensionGdClass.ClassMethod( + "new", + false, + false, + false, + false, + 0L, + List.of(), + null, + List.of() + )), + List.of(), + List.of(), + List.of() + ); + var customClasses = new java.util.ArrayList<>(defaultApi.classes()); + customClasses.add(dualClass); + var customSingletons = new java.util.ArrayList<>(defaultApi.singletons()); + customSingletons.add(new ExtensionSingleton("DualWithInstanceNew", "DualWithInstanceNew")); + return new ExtensionAPI( + defaultApi.header(), + defaultApi.builtinClassSizes(), + defaultApi.builtinClassMemberOffsets(), + defaultApi.globalConstants(), + defaultApi.globalEnums(), + defaultApi.utilityFunctions(), + defaultApi.builtinClasses(), + customClasses, + customSingletons, + defaultApi.nativeStructures() + ); + } + + /// Custom fixture: a dual-role name `DualWithSignal` that is both a singleton (type + /// `DualWithSignal`) and an engine class. The engine class defines `shared_name` as BOTH + /// a signal and an integer constant, so the suffix resolves in both the singleton instance + /// namespace (via signal) and the type-meta static namespace (via constant), exercising the + /// signal fail-closed rule. + private static @NotNull ExtensionAPI createDualRoleWithSignalAndConstantFixtureApi() throws Exception { + var defaultApi = ExtensionApiLoader.loadDefault(); + var dualClass = new ExtensionGdClass( + "DualWithSignal", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(new ExtensionGdClass.SignalInfo("shared_name", List.of())), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("shared_name", "42")) + ); + var customClasses = new java.util.ArrayList<>(defaultApi.classes()); + customClasses.add(dualClass); + var customSingletons = new java.util.ArrayList<>(defaultApi.singletons()); + customSingletons.add(new ExtensionSingleton("DualWithSignal", "DualWithSignal")); + return new ExtensionAPI( + defaultApi.header(), + defaultApi.builtinClassSizes(), + defaultApi.builtinClassMemberOffsets(), + defaultApi.globalConstants(), + defaultApi.globalEnums(), + defaultApi.utilityFunctions(), + defaultApi.builtinClasses(), + customClasses, + customSingletons, + defaultApi.nativeStructures() + ); + } + + /// Custom fixture: a dual-role name `AmbiguousDual` that is both a singleton (type + /// `AmbiguousDual`) and an engine class. The engine class defines `shared_member` as BOTH + /// a static method and an instance method, so the suffix resolves in both the singleton + /// instance namespace and the type-meta static namespace, exercising the fail-closed rule. + private static @NotNull ExtensionAPI createDualRoleAmbiguousFixtureApi() throws Exception { + var defaultApi = ExtensionApiLoader.loadDefault(); + var ambiguousClass = new ExtensionGdClass( + "AmbiguousDual", + false, + true, + "Object", + "core", + List.of(), + List.of( + // static method: resolves in type-meta static namespace + new ExtensionGdClass.ClassMethod( + "shared_member", + false, + false, + true, + false, + 0L, + List.of(), + null, + List.of() + ), + // instance method: resolves in singleton instance namespace + new ExtensionGdClass.ClassMethod( + "shared_member", + false, + false, + false, + false, + 0L, + List.of(), + null, + List.of() + ) + ), + List.of(), + List.of(), + List.of() + ); + var customClasses = new java.util.ArrayList<>(defaultApi.classes()); + customClasses.add(ambiguousClass); + var customSingletons = new java.util.ArrayList<>(defaultApi.singletons()); + customSingletons.add(new ExtensionSingleton("AmbiguousDual", "AmbiguousDual")); + return new ExtensionAPI( + defaultApi.header(), + defaultApi.builtinClassSizes(), + defaultApi.builtinClassMemberOffsets(), + defaultApi.globalConstants(), + defaultApi.globalEnums(), + defaultApi.utilityFunctions(), + defaultApi.builtinClasses(), + customClasses, + customSingletons, + defaultApi.nativeStructures() + ); + } + + private static @NotNull ExtensionAPI createDualRoleInheritedStaticFixtureApi(boolean inheritedInstanceConflict) + throws IOException { + var defaultApi = ExtensionApiLoader.loadDefault(); + var parentClass = new ExtensionGdClass( + "BaseInput", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("PARENT_MOUSE_MODE", 7)) + )), + List.of(), + inheritedInstanceConflict + ? List.of(new ExtensionGdClass.SignalInfo("PARENT_LIMIT", List.of())) + : List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("PARENT_LIMIT", "42")) + ); + var childClass = new ExtensionGdClass( + "ChildInput", + false, + true, + "BaseInput", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var customClasses = new java.util.ArrayList<>(defaultApi.classes()); + customClasses.add(parentClass); + customClasses.add(childClass); + var customSingletons = new java.util.ArrayList<>(defaultApi.singletons()); + customSingletons.add(new ExtensionSingleton("ChildInput", "ChildInput")); + return new ExtensionAPI( + defaultApi.header(), + defaultApi.builtinClassSizes(), + defaultApi.builtinClassMemberOffsets(), + defaultApi.globalConstants(), + defaultApi.globalEnums(), + defaultApi.utilityFunctions(), + defaultApi.builtinClasses(), + customClasses, + customSingletons, + defaultApi.nativeStructures() + ); + } + private static @NotNull List bindingDiagnostics(@NotNull DiagnosticManager diagnosticManager) { return diagnosticManager.snapshot().asList().stream() .filter(diagnostic -> diagnostic.category().equals("sema.binding")) @@ -1444,7 +2267,7 @@ private static void assertBindingsForName( @NotNull String fileName, @NotNull String source, @NotNull ClassRegistry classRegistry - ) throws Exception { + ) { return prepareBindingInput(fileName, source, classRegistry, Map.of()); } @@ -1453,7 +2276,7 @@ private static void assertBindingsForName( @NotNull String source, @NotNull ClassRegistry classRegistry, @NotNull Map topLevelCanonicalNameMap - ) throws Exception { + ) { var parserService = new GdScriptParserService(); var diagnosticManager = new DiagnosticManager(); var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnosticManager); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzerTest.java index 416d074b..ef9f7e3a 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzerTest.java @@ -1262,7 +1262,7 @@ private static void assertEvent( analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnosticManager); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendChainBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVarTypePostAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVarTypePostAnalyzerTest.java index b9a6d814..f5343ac3 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVarTypePostAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVarTypePostAnalyzerTest.java @@ -403,7 +403,7 @@ func ping(): analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnosticManager); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendLocalTypeStabilizationAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVirtualOverrideAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVirtualOverrideAnalyzerTest.java index 82a3a342..f6d40ec2 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVirtualOverrideAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVirtualOverrideAnalyzerTest.java @@ -282,7 +282,7 @@ func _process(delta) -> void: analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendVariableAnalyzer().analyze(analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); - new FrontendTopBindingAnalyzer().analyze(analysisData, diagnosticManager); + new FrontendTopBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); new FrontendChainBindingAnalyzer().analyze(classRegistry, analysisData, diagnosticManager); analysisData.updateDiagnostics(diagnosticManager.snapshot()); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupportTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupportTest.java index e18a6772..490b6350 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupportTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainHeadReceiverSupportTest.java @@ -16,12 +16,16 @@ import gd.script.gdcc.lir.LirFunctionDef; import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.scope.ResolveRestriction; +import gd.script.gdcc.scope.ScopeLookupStatus; import gd.script.gdcc.scope.ScopeTypeMeta; import gd.script.gdcc.scope.ScopeTypeMetaKind; +import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdCallableType; import gd.script.gdcc.type.GdIntType; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdStringType; +import gd.script.gdcc.type.GdType; import dev.superice.gdparser.frontend.ast.AttributeExpression; import dev.superice.gdparser.frontend.ast.AttributePropertyStep; import dev.superice.gdparser.frontend.ast.CallExpression; @@ -54,7 +58,10 @@ void resolveHeadReceiverShouldResolveValueTypeMetaAndLiteralHeads() throws Excep context.bodyScope().defineLocal("worker", new GdObjectType("Worker"), workerValue); context.classScope().defineTypeMeta(innerTypeMeta("Hero__sub__Worker", "Worker")); - context.analysisData().symbolBindings().put(workerValue, new FrontendBinding("worker", FrontendBindingKind.LOCAL_VAR, workerValue)); + context.analysisData().symbolBindings().put( + workerValue, + valueBinding("worker", FrontendBindingKind.LOCAL_VAR, new GdObjectType("Worker"), ScopeValueKind.LOCAL, workerValue) + ); context.analysisData().symbolBindings().put(workerType, new FrontendBinding("Worker", FrontendBindingKind.TYPE_META, null)); context.analysisData().scopesByAst().put(workerValue, context.bodyScope()); context.analysisData().scopesByAst().put(workerType, context.bodyScope()); @@ -152,7 +159,7 @@ void resolveHeadReceiverShouldFailInsteadOfReturningNullForMissingOrUnknownBindi } @Test - void resolveHeadReceiverShouldFailWhenPublishedBindingCannotBeRecoveredFromCurrentScope() throws Exception { + void resolveHeadReceiverShouldFailWhenPublishedValueBindingLacksResolvedValuePayload() throws Exception { var context = newTestContext(); var missingValue = identifier("worker"); var missingTypeMeta = identifier("Worker"); @@ -167,13 +174,38 @@ void resolveHeadReceiverShouldFailWhenPublishedBindingCannotBeRecoveredFromCurre assertNotNull(valueReceiver); assertEquals(FrontendChainReductionHelper.Status.FAILED, valueReceiver.status()); - assertTrue(valueReceiver.detailReason().contains("no longer visible")); + assertTrue(valueReceiver.detailReason().contains("missing its top-binding resolved value payload")); assertNotNull(typeReceiver); assertEquals(FrontendChainReductionHelper.Status.FAILED, typeReceiver.status()); assertTrue(typeReceiver.detailReason().contains("no longer visible")); } + @Test + void resolveValueReceiverShouldUsePublishedResolvedValueDespiteCurrentScopeShadow() throws Exception { + var context = newTestContext(); + var engine = identifier("Engine"); + context.bodyScope().defineLocal("Engine", GdStringType.STRING, engine); + context.analysisData().symbolBindings().put( + engine, + valueBinding( + "Engine", + FrontendBindingKind.SINGLETON, + new GdObjectType("Engine"), + ScopeValueKind.SINGLETON, + "singleton:Engine" + ) + ); + context.analysisData().scopesByAst().put(engine, context.bodyScope()); + + var receiver = newSupport(context, ResolveRestriction.unrestricted(), false).resolveHeadReceiver(engine); + + assertNotNull(receiver); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, receiver.status()); + assertEquals(FrontendReceiverKind.INSTANCE, receiver.receiverKind()); + assertEquals(new GdObjectType("Engine"), receiver.receiverType()); + } + @Test void resolveHeadReceiverShouldDelegateNestedAttributeAndFallbackCallbacks() throws Exception { var context = newTestContext(); @@ -320,6 +352,22 @@ void resolveHeadReceiverShouldPreserveBlockedAndFailedCallableReceivers() throws return new IdentifierExpression(name, TINY); } + private static @NotNull FrontendBinding valueBinding( + @NotNull String name, + @NotNull FrontendBindingKind bindingKind, + @NotNull GdType type, + @NotNull ScopeValueKind valueKind, + Object declaration + ) { + return new FrontendBinding( + name, + bindingKind, + declaration, + new ScopeValue(name, type, valueKind, declaration, false, true, false), + ScopeLookupStatus.FOUND_ALLOWED + ); + } + private static @NotNull ScopeTypeMeta innerTypeMeta( @NotNull String canonicalName, @NotNull String sourceName diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionFacadeTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionFacadeTest.java index e0f21846..2836ff77 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionFacadeTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionFacadeTest.java @@ -13,6 +13,9 @@ import gd.script.gdcc.lir.LirPropertyDef; import gd.script.gdcc.scope.ClassRegistry; import gd.script.gdcc.scope.ResolveRestriction; +import gd.script.gdcc.scope.ScopeLookupStatus; +import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdObjectType; import gd.script.gdcc.type.GdStringType; import dev.superice.gdparser.frontend.ast.AttributeExpression; @@ -30,7 +33,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -116,7 +118,21 @@ private static void defineWorkerLocal( context.bodyScope().defineLocal("worker", new GdObjectType("Worker"), workerIdentifier); context.analysisData().symbolBindings().put( workerIdentifier, - new FrontendBinding("worker", FrontendBindingKind.LOCAL_VAR, workerIdentifier) + new FrontendBinding( + "worker", + FrontendBindingKind.LOCAL_VAR, + workerIdentifier, + new ScopeValue( + "worker", + new GdObjectType("Worker"), + ScopeValueKind.LOCAL, + workerIdentifier, + false, + true, + false + ), + ScopeLookupStatus.FOUND_ALLOWED + ) ); context.analysisData().scopesByAst().put(workerIdentifier, context.bodyScope()); } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelperTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelperTest.java index 6bd9bc5a..0254fce3 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelperTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendChainReductionHelperTest.java @@ -852,6 +852,373 @@ void reduceResolvesGlobalEnumBuiltinAndEngineStaticLoads() { assertEquals("int", engineResultType.getTypeName()); } + @Test + void reduceResolvesEngineAndBuiltinClassEnumValues() { + var engineClass = new ExtensionGdClass( + "Input", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("MOUSE_MODE_VISIBLE", 0)) + )), + List.of(), + List.of(), + List.of(), + List.of() + ); + var builtinClass = new ExtensionBuiltinClass( + "Vector3", + false, + List.of(), + List.of(), + List.of(new ExtensionBuiltinClass.ClassEnum( + "Axis", false, List.of(new ExtensionEnumValue("AXIS_X", 0)) + )), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(builtinClass), List.of(engineClass), List.of(), List.of()); + + var engineResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("Input"), property("MOUSE_MODE_VISIBLE")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("Input", new GdObjectType("Input"), ScopeTypeMetaKind.ENGINE_CLASS, engineClass, false) + ), + registry, + noExpressionTypes() + )); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, engineResult.stepTraces().getFirst().status()); + var engineMember = engineResult.stepTraces().getFirst().suggestedMember(); + assertNotNull(engineMember); + var engineResultType = engineMember.resultType(); + assertNotNull(engineResultType); + assertEquals("int", engineResultType.getTypeName()); + + var builtinResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("Vector3"), property("AXIS_X")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("Vector3", GdFloatVectorType.VECTOR3, ScopeTypeMetaKind.BUILTIN, builtinClass, false) + ), + registry, + noExpressionTypes() + )); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, builtinResult.stepTraces().getFirst().status()); + var builtinMember = builtinResult.stepTraces().getFirst().suggestedMember(); + assertNotNull(builtinMember); + var builtinResultType = builtinMember.resultType(); + assertNotNull(builtinResultType); + assertEquals("int", builtinResultType.getTypeName()); + } + + @Test + void reduceResolvesInheritedEngineClassConstantAndEnumValue() { + var parentClass = new ExtensionGdClass( + "BaseInput", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("PARENT_MOUSE_MODE", 7)) + )), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("PARENT_LIMIT", "42")) + ); + var childClass = new ExtensionGdClass( + "ChildInput", + false, + true, + "BaseInput", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(), List.of(parentClass, childClass), List.of(), List.of()); + var childMeta = typeMeta("ChildInput", new GdObjectType("ChildInput"), ScopeTypeMetaKind.ENGINE_CLASS, childClass, false); + + var constantResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("ChildInput"), property("PARENT_LIMIT")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta(childMeta), + registry, + noExpressionTypes() + )); + var constantTrace = constantResult.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, constantTrace.status()); + var constantMember = constantTrace.suggestedMember(); + assertNotNull(constantMember); + assertEquals(FrontendBindingKind.CONSTANT, constantMember.bindingKind()); + assertEquals(FrontendReceiverKind.TYPE_META, constantMember.receiverKind()); + assertSame(GdIntType.INT, constantMember.resultType()); + + var enumResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("ChildInput"), property("PARENT_MOUSE_MODE")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta(childMeta), + registry, + noExpressionTypes() + )); + var enumTrace = enumResult.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, enumTrace.status()); + var enumMember = enumTrace.suggestedMember(); + assertNotNull(enumMember); + assertEquals(FrontendBindingKind.CONSTANT, enumMember.bindingKind()); + assertEquals(FrontendReceiverKind.TYPE_META, enumMember.receiverKind()); + assertSame(GdIntType.INT, enumMember.resultType()); + } + + @Test + void reduceDirectEngineClassStaticMemberWinsOverInheritedStaticMember() { + var parentClass = new ExtensionGdClass( + "StaticParent", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "Mode", false, List.of(new ExtensionEnumValue("SHARED_ENUM", 1)) + )), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("SHARED_CONST", "11")) + ); + var childClass = new ExtensionGdClass( + "StaticChild", + false, + true, + "StaticParent", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "Mode", false, List.of(new ExtensionEnumValue("SHARED_ENUM", 2)) + )), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("SHARED_CONST", "22")) + ); + var registry = newRegistry(List.of(), List.of(parentClass, childClass), List.of(), List.of()); + + var constantLookup = registry.findEngineClassConstantInHierarchy("StaticChild", "SHARED_CONST"); + var enumLookup = registry.findEngineClassEnumValueInHierarchy("StaticChild", "SHARED_ENUM"); + + assertAll( + () -> assertNotNull(constantLookup), + () -> assertEquals("StaticChild", constantLookup.ownerClass().getName()), + () -> assertEquals("22", constantLookup.constant().value()), + () -> assertNotNull(enumLookup), + () -> assertEquals("StaticChild", enumLookup.ownerClass().getName()), + () -> assertEquals(2, enumLookup.enumValue().value()) + ); + } + + @Test + void reduceFailsWhenInheritedEngineStaticMemberMissingAcrossHierarchy() { + var parentClass = new ExtensionGdClass( + "StaticRoot", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var childClass = new ExtensionGdClass( + "StaticLeaf", + false, + true, + "StaticRoot", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(), List.of(parentClass, childClass), List.of(), List.of()); + + var result = FrontendChainReductionHelper.reduce(request( + chain(identifier("StaticLeaf"), property("MISSING_STATIC")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("StaticLeaf", new GdObjectType("StaticLeaf"), ScopeTypeMetaKind.ENGINE_CLASS, childClass, false) + ), + registry, + noExpressionTypes() + )); + + var trace = result.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.FAILED, trace.status()); + assertNotNull(trace.detailReason()); + assertTrue(trace.detailReason().contains("MISSING_STATIC")); + assertTrue(trace.detailReason().contains("StaticLeaf")); + } + + @Test + void reduceRejectsInheritedEngineClassNonIntegerConstant() { + var parentClass = new ExtensionGdClass( + "StaticBase", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass.ConstantInfo("BAD", "3.14")) + ); + var childClass = new ExtensionGdClass( + "StaticDerived", + false, + true, + "StaticBase", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(), List.of(parentClass, childClass), List.of(), List.of()); + + var result = FrontendChainReductionHelper.reduce(request( + chain(identifier("StaticDerived"), property("BAD")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("StaticDerived", new GdObjectType("StaticDerived"), ScopeTypeMetaKind.ENGINE_CLASS, childClass, false) + ), + registry, + noExpressionTypes() + )); + + var trace = result.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.FAILED, trace.status()); + assertNotNull(trace.detailReason()); + assertTrue(trace.detailReason().contains("BAD")); + assertTrue(trace.detailReason().contains("StaticBase")); + assertTrue(trace.detailReason().contains("not an integer literal")); + } + + @Test + void reduceFailsWhenEngineOrBuiltinClassEnumValueNotFound() { + var engineClass = new ExtensionGdClass( + "Input", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "MouseMode", false, List.of(new ExtensionEnumValue("MOUSE_MODE_VISIBLE", 0)) + )), + List.of(), + List.of(), + List.of(), + List.of() + ); + var builtinClass = new ExtensionBuiltinClass( + "Vector3", + false, + List.of(), + List.of(), + List.of(new ExtensionBuiltinClass.ClassEnum( + "Axis", false, List.of(new ExtensionEnumValue("AXIS_X", 0)) + )), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(builtinClass), List.of(engineClass), List.of(), List.of()); + + var engineResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("Input"), property("MOUSE_MODE_MISSING")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("Input", new GdObjectType("Input"), ScopeTypeMetaKind.ENGINE_CLASS, engineClass, false) + ), + registry, + noExpressionTypes() + )); + var engineTrace = engineResult.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.FAILED, engineTrace.status()); + assertNotNull(engineTrace.detailReason()); + assertTrue(engineTrace.detailReason().contains("MOUSE_MODE_MISSING")); + assertTrue(engineTrace.detailReason().contains("Input")); + + var builtinResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("Vector3"), property("AXIS_MISSING")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("Vector3", GdFloatVectorType.VECTOR3, ScopeTypeMetaKind.BUILTIN, builtinClass, false) + ), + registry, + noExpressionTypes() + )); + var builtinTrace = builtinResult.stepTraces().getFirst(); + assertEquals(FrontendChainReductionHelper.Status.FAILED, builtinTrace.status()); + assertNotNull(builtinTrace.detailReason()); + assertTrue(builtinTrace.detailReason().contains("AXIS_MISSING")); + assertTrue(builtinTrace.detailReason().contains("Vector3")); + } + + @Test + void reduceResolvesNegativeAndInt64ClassEnumValues() { + var engineClass = new ExtensionGdClass( + "ResourceUID", + false, + true, + "Object", + "core", + List.of(new ExtensionGdClass.ClassEnum( + "InvalidId", false, List.of(new ExtensionEnumValue("INVALID_ID", -1L)) + )), + List.of(), + List.of(), + List.of(), + List.of() + ); + var builtinClass = new ExtensionBuiltinClass( + "WideFlags", + false, + List.of(), + List.of(), + List.of(new ExtensionBuiltinClass.ClassEnum( + "WideFlag", true, List.of(new ExtensionEnumValue("WIDE_FLAG", 3_435_973_836_8L)) + )), + List.of(), + List.of(), + List.of() + ); + var registry = newRegistry(List.of(builtinClass), List.of(engineClass), List.of(), List.of()); + + var engineResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("ResourceUID"), property("INVALID_ID")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("ResourceUID", new GdObjectType("ResourceUID"), ScopeTypeMetaKind.ENGINE_CLASS, engineClass, false) + ), + registry, + noExpressionTypes() + )); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, engineResult.stepTraces().getFirst().status()); + assertSame(GdIntType.INT, engineResult.stepTraces().getFirst().suggestedMember().resultType()); + + var builtinResult = FrontendChainReductionHelper.reduce(request( + chain(identifier("WideFlags"), property("WIDE_FLAG")), + FrontendChainReductionHelper.ReceiverState.resolvedTypeMeta( + typeMeta("WideFlags", GdIntType.INT, ScopeTypeMetaKind.BUILTIN, builtinClass, false) + ), + registry, + noExpressionTypes() + )); + assertEquals(FrontendChainReductionHelper.Status.RESOLVED, builtinResult.stepTraces().getFirst().status()); + assertSame(GdIntType.INT, builtinResult.stepTraces().getFirst().suggestedMember().resultType()); + } + @Test void reduceIsStableForRepeatedRuns() { var worker = newClass("Worker"); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupportTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupportTest.java index 45f46da0..1a192272 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupportTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupportTest.java @@ -128,6 +128,36 @@ func ping(seed): assertTrue(workerResult.expressionType().detailReason().contains("static route")); } + @Test + void resolveIdentifierExpressionTypeFailsWhenValueBindingMissesResolvedValuePayload() throws Exception { + var analyzed = analyze( + "expression_semantic_support_missing_resolved_value.gd", + """ + class_name ExpressionSemanticSupportMissingResolvedValue + extends RefCounted + + func ping(seed): + seed + """ + ); + var support = createSupport(analyzed, ResolveRestriction.instanceContext(), false); + var pingFunction = findFunction(analyzed.ast(), "ping"); + var seed = assertInstanceOf( + IdentifierExpression.class, + assertInstanceOf(ExpressionStatement.class, pingFunction.body().statements().getFirst()).expression() + ); + analyzed.analysisData().symbolBindings().put( + seed, + new FrontendBinding("seed", FrontendBindingKind.PARAMETER, seed) + ); + + var seedResult = support.resolveIdentifierExpressionType(seed); + + assertFalse(seedResult.rootOwnsOutcome()); + assertEquals(FrontendExpressionTypeStatus.FAILED, seedResult.expressionType().status()); + assertTrue(seedResult.expressionType().detailReason().contains("missing its top-binding resolved value payload")); + } + @Test void resolveCallExpressionTypeDistinguishesResolvedBlockedAndUnsupportedCalls() throws Exception { var analyzed = analyze( diff --git a/src/test/java/gd/script/gdcc/lir/parser/SimpleLirBlockInsnParserTest.java b/src/test/java/gd/script/gdcc/lir/parser/SimpleLirBlockInsnParserTest.java index e599659f..6720e947 100644 --- a/src/test/java/gd/script/gdcc/lir/parser/SimpleLirBlockInsnParserTest.java +++ b/src/test/java/gd/script/gdcc/lir/parser/SimpleLirBlockInsnParserTest.java @@ -222,6 +222,26 @@ public void parse_callIntrinsicVectorInstructionRoundTripsThroughSerializer() th ); } + @Test + public void parse_loadStaticGlobalScopeSingletonSurfaceRoundTripsThroughSerializer() throws Exception { + var input = "$engine = load_static \"@GlobalScope\" \"Engine\";\n"; + var parsed = parse(input); + var serializer = new SimpleLirBlockInsnSerializer(); + var out = new StringWriter(); + + serializer.serialize(parsed, out); + var reparsed = parse(out.toString()); + var loadStatic = assertInstanceOf(LoadStaticInsn.class, reparsed.getFirst()); + + assertAll( + () -> assertEquals(input, out.toString()), + () -> assertEquals(1, reparsed.size()), + () -> assertEquals("engine", loadStatic.resultId()), + () -> assertEquals("@GlobalScope", loadStatic.className()), + () -> assertEquals("Engine", loadStatic.staticName()) + ); + } + @Test public void parse_callIntrinsicRequiresQuotedIntrinsicName() { var line = "$f = call_intrinsic c_int_to_float $i;"; diff --git a/src/test/java/gd/script/gdcc/scope/ClassRegistryTest.java b/src/test/java/gd/script/gdcc/scope/ClassRegistryTest.java index fd363ddd..46f94dc4 100644 --- a/src/test/java/gd/script/gdcc/scope/ClassRegistryTest.java +++ b/src/test/java/gd/script/gdcc/scope/ClassRegistryTest.java @@ -6,6 +6,7 @@ import gd.script.gdcc.gdextension.ExtensionBuiltinClass; import gd.script.gdcc.gdextension.ExtensionFunctionArgument; import gd.script.gdcc.gdextension.ExtensionGdClass; +import gd.script.gdcc.gdextension.ExtensionSingleton; import gd.script.gdcc.gdextension.ExtensionUtilityFunction; import gd.script.gdcc.lir.LirClassDef; import gd.script.gdcc.lir.LirFunctionDef; @@ -24,6 +25,7 @@ import gd.script.gdcc.type.GdType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdVoidType; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -163,6 +165,60 @@ void findTypeDoesNotReturnForSingletonEnumOrFunction() throws IOException { } } + @Test + void singletonMetadataShouldResolveLookupNameToDeclaredObjectType() { + var registry = new ClassRegistry(singletonValidationFixtureApi(List.of( + new ExtensionSingleton("GameSingleton", "Node") + ))); + + var singletonType = registry.findSingletonType("GameSingleton"); + var singletonValue = registry.resolveValue("GameSingleton"); + + assertAll( + () -> assertNotNull(singletonType), + () -> assertEquals("Node", singletonType.getTypeName()), + () -> assertNotNull(singletonValue), + () -> assertEquals(ScopeValueKind.SINGLETON, singletonValue.kind()), + () -> assertEquals("Node", singletonValue.type().getTypeName()), + () -> assertNull(registry.findType("GameSingleton")), + () -> assertNull(registry.resolveTypeMeta("GameSingleton")), + () -> assertTrue(registry.invalidSingletonMetadata().isEmpty()) + ); + } + + @Test + void invalidSingletonMetadataShouldNotPublishSingletonValue() { + var registry = new ClassRegistry(singletonValidationFixtureApi(List.of( + new ExtensionSingleton("MissingTypeSingleton", null), + new ExtensionSingleton("BlankTypeSingleton", " "), + new ExtensionSingleton("UnknownTypeSingleton", "FutureNode"), + new ExtensionSingleton("BuiltinTypeSingleton", "int") + ))); + + assertAll( + () -> assertInvalidSingleton( + registry, + "MissingTypeSingleton", + ClassRegistry.InvalidSingletonMetadata.Reason.MISSING_TYPE + ), + () -> assertInvalidSingleton( + registry, + "BlankTypeSingleton", + ClassRegistry.InvalidSingletonMetadata.Reason.MISSING_TYPE + ), + () -> assertInvalidSingleton( + registry, + "UnknownTypeSingleton", + ClassRegistry.InvalidSingletonMetadata.Reason.UNRESOLVED_TYPE + ), + () -> assertInvalidSingleton( + registry, + "BuiltinTypeSingleton", + ClassRegistry.InvalidSingletonMetadata.Reason.NON_OBJECT_TYPE + ) + ); + } + @Test void findTypeShouldReuseStrictResolverBeforeCompatibilityFallback() throws IOException { var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); @@ -176,6 +232,46 @@ void findTypeShouldReuseStrictResolverBeforeCompatibilityFallback() throws IOExc assertEquals(new GdArrayType(new GdObjectType("FutureEnemy")), registry.findType("Array[FutureEnemy]")); } + private static void assertInvalidSingleton( + @NotNull ClassRegistry registry, + @NotNull String lookupName, + @NotNull ClassRegistry.InvalidSingletonMetadata.Reason reason + ) { + var invalidMetadata = registry.findInvalidSingletonMetadata(lookupName); + assertAll( + () -> assertNull(registry.findSingletonType(lookupName)), + () -> assertNull(registry.resolveValue(lookupName)), + () -> assertNotNull(invalidMetadata), + () -> assertEquals(reason, invalidMetadata.reason()) + ); + } + + private static @NotNull ExtensionAPI singletonValidationFixtureApi(@NotNull List singletons) { + return new ExtensionAPI( + null, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(new ExtensionGdClass( + "Node", + false, + true, + "Object", + "core", + List.of(), + List.of(), + List.of(), + List.of(), + List.of() + )), + singletons, + List.of() + ); + } + @Test void findTypeShouldExplainBuiltinMappingGapWithoutImplyingBuiltinTypesAreUnsupported() { var registry = new ClassRegistry(new ExtensionAPI( diff --git a/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java b/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java index 40ff04d7..04d6517d 100644 --- a/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java +++ b/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java @@ -70,6 +70,8 @@ public class GdScriptUnitTestCompileRunnerTest { "runtime/array_void_return_helper_size.gd", "runtime/array_void_return_push_back_size.gd", "runtime/comment_statement_control_flow_surface.gd", + "runtime/dual_role_singleton_mixed_use_sites.gd", + "runtime/dual_role_singleton_static_constant.gd", "runtime/dynamic_call.gd", "runtime/dynamic_member_variant_named_access.gd", "runtime/dynamic_member_variant_named_access_missing.gd", @@ -82,9 +84,12 @@ public class GdScriptUnitTestCompileRunnerTest { "runtime/engine_node_refcounted_workflow.gd", "runtime/engine_option_button_default_args.gd", "runtime/engine_scene_tree_call_group_flags_exact_vararg.gd", + "runtime/inherited_engine_static_constant.gd", "runtime/int_to_float_engine_class.gd", "runtime/int_to_float_inbound_dynamic_call.gd", "runtime/rect2i_to_rect2_call_guard.gd", + "runtime/singleton_receiver_binding_drift.gd", + "runtime/singleton_receiver_calls.gd", "runtime/string_literal_escape_unicode_surface.gd", "runtime/string_literal_internal_surface.gd", "runtime/string_literal_utf8_offset_surface.gd", diff --git a/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_mixed_use_sites.gd b/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_mixed_use_sites.gd new file mode 100644 index 00000000..1a985830 --- /dev/null +++ b/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_mixed_use_sites.gd @@ -0,0 +1,25 @@ +class_name DualRoleSingletonMixedUseSitesSmoke +extends Node + +func mixed_routes_in_one_body() -> int: + # Two dual-role names in the same function body, exercising both Step 9 routes. + # Input.is_action_pressed(...) stays SINGLETON (instance call via CallMethodInsn); + # IP.RESOLVER_MAX_QUERIES switches to TYPE_META (engine class constant static load). + # If the dual-role bias polluted the scope, one expression would fail to lower. + var pressed: bool = Input.is_action_pressed(&"ui_accept", true) + var queries: int = IP.RESOLVER_MAX_QUERIES + if queries != 256: + return -1 + # pressed is environment-dependent; reference it so the SINGLETON instance + # call use-site stays live alongside the TYPE_META static load use-site. + if pressed: + return queries + return queries + +func read_input_pressed_state() -> bool: + # Singleton instance call on a dual-role name: SINGLETON head + CallMethodInsn. + return Input.is_action_pressed(&"ui_accept", true) + +func read_ip_resolver_max_queries() -> int: + # Static load on a dual-role name: TYPE_META head (Step 9 dual-role bias). + return IP.RESOLVER_MAX_QUERIES diff --git a/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_static_constant.gd b/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_static_constant.gd new file mode 100644 index 00000000..51876934 --- /dev/null +++ b/src/test/test_suite/unit_test/script/runtime/dual_role_singleton_static_constant.gd @@ -0,0 +1,27 @@ +class_name DualRoleSingletonStaticConstantSmoke +extends Node + +# Property initializer exercising the dual-role TYPE_META static load route. +# IP is a dual-role name (singleton in value namespace, engine class in type-meta +# namespace). RESOLVER_MAX_QUERIES only resolves in the type-meta static namespace, +# so the chain head must publish TYPE_META per Step 9 dual-role bias. +var startup_resolver_queries: int = IP.RESOLVER_MAX_QUERIES + +func read_resolver_max_queries() -> int: + # Dual-role TYPE_META static load in executable body. + return IP.RESOLVER_MAX_QUERIES + +func read_resolver_invalid_id() -> int: + # Another IP class constant on the same dual-role singleton. + return IP.RESOLVER_INVALID_ID + +func read_resource_uid_invalid_id() -> int: + # ResourceUID is also a dual-role singleton; INVALID_ID is a class constant. + return ResourceUID.INVALID_ID + +func read_display_server_main_window_id() -> int: + # DisplayServer is dual-role; MAIN_WINDOW_ID is a class constant with value 0. + return DisplayServer.MAIN_WINDOW_ID + +func read_startup_resolver_queries() -> int: + return startup_resolver_queries diff --git a/src/test/test_suite/unit_test/script/runtime/inherited_engine_static_constant.gd b/src/test/test_suite/unit_test/script/runtime/inherited_engine_static_constant.gd new file mode 100644 index 00000000..048504b4 --- /dev/null +++ b/src/test/test_suite/unit_test/script/runtime/inherited_engine_static_constant.gd @@ -0,0 +1,13 @@ +class_name InheritedEngineStaticConstantSmoke +extends Node + +# Property initializer exercising inherited engine class static load. +# NOTIFICATION_ENTER_TREE is declared on Node, accessed via Node2D (Node2D -> CanvasItem -> Node). +var enter_tree_notification: int = Node2D.NOTIFICATION_ENTER_TREE + +func read_enter_tree_notification() -> int: + # Inherited engine class constant in executable body. + return Node2D.NOTIFICATION_ENTER_TREE + +func read_enter_tree_notification_property() -> int: + return enter_tree_notification diff --git a/src/test/test_suite/unit_test/script/runtime/singleton_receiver_binding_drift.gd b/src/test/test_suite/unit_test/script/runtime/singleton_receiver_binding_drift.gd new file mode 100644 index 00000000..e1784497 --- /dev/null +++ b/src/test/test_suite/unit_test/script/runtime/singleton_receiver_binding_drift.gd @@ -0,0 +1,14 @@ +class_name SingletonReceiverBindingDriftRuntime +extends Node + +func frames_before_later_local() -> int: + var frames := Engine.get_frames_drawn() + # This local intentionally shadows the singleton after the use-site. + var Engine := "local Engine" + if Engine != "local Engine": + return -1 + return frames + +func frames_from_self_named_initializer() -> int: + var Engine := Engine.get_frames_drawn() + return Engine diff --git a/src/test/test_suite/unit_test/script/runtime/singleton_receiver_calls.gd b/src/test/test_suite/unit_test/script/runtime/singleton_receiver_calls.gd new file mode 100644 index 00000000..d5cadfd2 --- /dev/null +++ b/src/test/test_suite/unit_test/script/runtime/singleton_receiver_calls.gd @@ -0,0 +1,18 @@ +class_name SingletonReceiverCallsSmoke +extends Node + +var startup_frames: int = Engine.get_frames_drawn() + +func read_startup_frames() -> int: + return startup_frames + +func read_frames_drawn() -> int: + return Engine.get_frames_drawn() + +func reset_engine_time_scale() -> float: + Engine.set_time_scale(1.0) + return Engine.get_time_scale() + +func read_ui_accept_pressed_state() -> bool: + # The pressed state is environment-dependent; the stable contract is the typed singleton call. + return Input.is_action_pressed(&"ui_accept", true) diff --git a/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_mixed_use_sites.gd b/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_mixed_use_sites.gd new file mode 100644 index 00000000..47c54855 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_mixed_use_sites.gd @@ -0,0 +1,24 @@ +# gdcc-test: output_not_contains=engine method call failed: Input.is_action_pressed +# gdcc-test: output_not_contains=engine method call failed: IP.RESOLVER_MAX_QUERIES +extends Node + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + if int(target.call("mixed_routes_in_one_body")) != 256: + push_error("Dual-role mixed use-sites polluted each other in the same body.") + return + + var pressed_state = target.call("read_input_pressed_state") + if typeof(pressed_state) != TYPE_BOOL: + push_error("Dual-role Input.is_action_pressed returned non-bool: %d" % [typeof(pressed_state)]) + return + + if int(target.call("read_ip_resolver_max_queries")) != 256: + push_error("Dual-role IP.RESOLVER_MAX_QUERIES static load returned wrong value.") + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_static_constant.gd b/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_static_constant.gd new file mode 100644 index 00000000..3f5f9b2f --- /dev/null +++ b/src/test/test_suite/unit_test/validation/runtime/dual_role_singleton_static_constant.gd @@ -0,0 +1,33 @@ +# gdcc-test: output_not_contains=engine method call failed: IP.RESOLVER_MAX_QUERIES +# gdcc-test: output_not_contains=engine method call failed: IP.RESOLVER_INVALID_ID +# gdcc-test: output_not_contains=engine method call failed: ResourceUID.INVALID_ID +# gdcc-test: output_not_contains=engine method call failed: DisplayServer.MAIN_WINDOW_ID +extends Node + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + if int(target.call("read_resolver_max_queries")) != 256: + push_error("Dual-role IP.RESOLVER_MAX_QUERIES static load returned wrong value.") + return + + if int(target.call("read_resolver_invalid_id")) != -1: + push_error("Dual-role IP.RESOLVER_INVALID_ID static load returned wrong value.") + return + + if int(target.call("read_resource_uid_invalid_id")) != -1: + push_error("Dual-role ResourceUID.INVALID_ID static load returned wrong value.") + return + + if int(target.call("read_display_server_main_window_id")) != 0: + push_error("Dual-role DisplayServer.MAIN_WINDOW_ID static load returned wrong value.") + return + + if int(target.call("read_startup_resolver_queries")) != 256: + push_error("Dual-role property initializer IP.RESOLVER_MAX_QUERIES returned wrong value.") + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/runtime/inherited_engine_static_constant.gd b/src/test/test_suite/unit_test/validation/runtime/inherited_engine_static_constant.gd new file mode 100644 index 00000000..dfc84e43 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/runtime/inherited_engine_static_constant.gd @@ -0,0 +1,19 @@ +# gdcc-test: output_not_contains=engine method call failed: Node2D.NOTIFICATION_ENTER_TREE +extends Node + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + var expected = Node2D.NOTIFICATION_ENTER_TREE + if int(target.call("read_enter_tree_notification")) != int(expected): + push_error("Inherited Node2D.NOTIFICATION_ENTER_TREE static load returned wrong value.") + return + + if int(target.call("read_enter_tree_notification_property")) != int(expected): + push_error("Inherited Node2D.NOTIFICATION_ENTER_TREE property initializer returned wrong value.") + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_binding_drift.gd b/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_binding_drift.gd new file mode 100644 index 00000000..ef968850 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_binding_drift.gd @@ -0,0 +1,20 @@ +# gdcc-test: output_not_contains=engine method call failed: Engine.get_frames_drawn +extends Node + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + var later_local = int(target.call("frames_before_later_local")) + if later_local < 0: + push_error("Singleton receiver drifted to the later local shadow.") + return + + var self_initializer = int(target.call("frames_from_self_named_initializer")) + if self_initializer < 0: + push_error("Singleton receiver drifted to the self-referential local initializer.") + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_calls.gd b/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_calls.gd new file mode 100644 index 00000000..bc293936 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/runtime/singleton_receiver_calls.gd @@ -0,0 +1,29 @@ +# gdcc-test: output_not_contains=engine method call failed: Engine.get_frames_drawn +# gdcc-test: output_not_contains=engine method call failed: Engine.set_time_scale +# gdcc-test: output_not_contains=engine method call failed: Input.is_action_pressed +extends Node + +func _ready() -> void: + var target = get_parent().get_node_or_null("__UNIT_TEST_TARGET_NODE_NAME__") + if target == null: + push_error("Target node missing.") + return + + if int(target.call("read_startup_frames")) < 0: + push_error("Singleton receiver property initializer returned a negative frame count.") + return + + if int(target.call("read_frames_drawn")) < 0: + push_error("Singleton receiver method call returned a negative frame count.") + return + + if abs(float(target.call("reset_engine_time_scale")) - 1.0) > 0.0001: + push_error("Singleton receiver void call did not restore Engine time scale.") + return + + var pressed_state = target.call("read_ui_accept_pressed_state") + if typeof(pressed_state) != TYPE_BOOL: + push_error("Singleton receiver argument call returned non-bool state: %d" % [typeof(pressed_state)]) + return + + print("__UNIT_TEST_PASS_MARKER__")