From ec653d8d16f87c2213ff4c9f07b090044f18dd8d Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Mon, 17 Aug 2026 12:09:39 +0800 Subject: [PATCH 1/9] docs(frontend): add lambda implementation plan --- .../frontend/frontend_lambda_plan.md | 1103 +++++++++++++++++ 1 file changed, 1103 insertions(+) create mode 100644 doc/module_impl/frontend/frontend_lambda_plan.md diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md new file mode 100644 index 00000000..c15026c9 --- /dev/null +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -0,0 +1,1103 @@ +# Frontend Lambda 实施计划 + +> 本文档是 GDScript `func(...)` / `LambdaExpression` 从当前结构性 deferred / unsupported +> 边界,转正到 frontend shared semantic、type-check、compile gate、CFG、body lowering +> 与 C backend 的**分阶段实施计划**。 +> 落地完成后,应将架构与冻结合同吸收为长期事实源,并**删除**本计划中的进度流水账。 +> 本文档不改变 Godot 运行时语义,也不提前开放 `match`、parameter default、 +> block-local `const` 或 `await`。 + +## 文档状态 + +- 状态:计划稿(尚未实施)。当前生产代码对 lambda 全链路 fail-closed; + scope 双层图、`CallableScope.defineCapture`、LIR `construct_lambda` / + `is_lambda` / `` 与 C 模板 capture struct 已预留但未接线。 +- 更新时间:2026-08-15 +- 适用范围: + - `src/main/java/gd/script/gdcc/frontend/sema/**` + - `src/main/java/gd/script/gdcc/frontend/scope/**` + - `src/main/java/gd/script/gdcc/frontend/lowering/**` + - `src/main/java/gd/script/gdcc/lir/**` + - `src/main/java/gd/script/gdcc/backend/c/gen/**` + - `src/main/c/codegen/include_451/gdcc/**` + - `src/main/c/codegen/template_451/**` + - `src/test/java/gd/script/gdcc/frontend/**` + - `src/test/java/gd/script/gdcc/lir/**` + - `src/test/java/gd/script/gdcc/backend/**` +- 关联文档: + - `doc/module_impl/common_rules.md` + - `doc/module_impl/frontend/frontend_rules.md` + - `doc/module_impl/frontend/diagnostic_manager.md` + - `doc/module_impl/frontend/scope_architecture_refactor_plan.md` + - `doc/module_impl/frontend/scope_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_variable_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md` + - `doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md` + - `doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_lowering_plan.md` + - `doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md` + - `doc/module_impl/frontend/frontend_complex_writable_target_implementation.md` + - `doc/module_impl/frontend/frontend_signal_support.md` + - `doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md`(转正顺序真源) + - `doc/module_impl/frontend/frontend_for_range_loop_implementation.md`(三层支持面 / compile-gate 解封条件的已落地参照;该文档本身已不再含阶段流水账) + - `doc/gdcc_low_ir.md`(`construct_lambda`、`is_lambda`、``) + - `doc/gdcc_runtime_lib.md` + - `doc/gdcc_c_backend.md` + - `doc/gdcc_ownership_lifecycle_spec.md` + - `doc/module_impl/frontend/frontend_gdcompiler_type_implementation.md` + - `doc/test_error/test_suite_engine_integration_known_limits.md` +- 明确非目标: + - 不在本计划转正 `match`、parameter default、block-local `const`、`await` + - 不在本计划实现 Godot `OPCODE_CREATE_SELF_LAMBDA` / `GDScriptLambdaSelfCallable` + 的独立 opcode;`self` 走既有 `construct_lambda` capture, + `capturesSelf` 时 custom Callable 的 `object_id` 绑 enclosing instance + (见 §3.5) + - 不把 `CAPTURE` 纳入 direct-slot alias publication(见 §3.6) + - 不复用 `construct_callable` / `construct_standalone_callable` 承载 lambda + (`frontend_signal_support.md` 已禁止) + - 不把 `GdCompilerType` 写入 lambda `` 或 lambda 参数/返回 ABI + - 不在 property initializer / parameter default / skipped subtree 中开放 lambda + - 不把 lambda 注册进 ClassDB / `_class_bind_methods` + - 不实现 `Callable.bind` / `unbind` 新 lowering + - 不更改 Godot 对普通局部/参数的 copy-on-capture 语义 + +--- + +## 1. 背景与当前起点 + +### 1.1 源码形态 + +GDScript lambda 是表达式级匿名函数: + +```gdscript +var cb := func(offset: int) -> int: + return seed + offset +sig.connect(func(): + pinged.emit()) +``` + +Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 +`parameters()` / `returnType()` / `body()`。它不是 `FunctionDeclaration`, +没有源码级函数名,也不进入 class member namespace。 + +### 1.2 已落地、可复用的骨架 + +| 层 | 已有事实 | 文件 | +|----|----------|------| +| Scope 图 | `LambdaExpression` → `CallableScope(LAMBDA_EXPRESSION)` + `BlockScope(LAMBDA_BODY)` | `FrontendScopeAnalyzer.handleLambdaExpression` | +| Capture API | `CallableScope.defineCapture(...)`、`ScopeValueKind.CAPTURE`、`CaptureDef` | `CallableScope.java`、`CaptureDef.java` | +| Binding kind | `FrontendBindingKind.CAPTURE` 已存在,但生产路径几乎不产出 | `FrontendBindingKind.java` | +| Loop | lambda 是 callable boundary,重置 loop depth | `FrontendLoopControlFlowAnalyzer` | +| LIR insn | `ConstructLambdaInsn` / `CONSTRUCT_LAMBDA` / text+DOM parse | `lir/insn/ConstructLambdaInsn.java` | +| LIR function | `LirFunctionDef.isLambda`、`addCapture`(非 lambda 会抛) | `LirFunctionDef.java` | +| ABI 护栏 | `LirPublicAbiValidator` 拒绝 compiler-only capture type | `LirPublicAbiValidator.java` | +| C 模板 | `lambdaCaptureName`、`entry.h.ftl` capture typedef、`func.ftl` `_capture` 参数 | `template_451/*` | +| Custom Callable 先例 | `gdcc_new_standalone_callable` + `godot_callable_custom_create2` | `gdcc_callable.h` | + +### 1.3 当前 fail-closed 封口点 + +这些封口必须按阶段显式翻面,禁止“某一个 analyzer 单独偷开”: + +| 封口 | 位置 | 当前行为 | +|------|------|----------| +| 结构策略 | `FrontendBodySemanticSupportPolicy` | `LAMBDA_BODY` / `LAMBDA_EXPRESSION` → `LAMBDA_SUBTREE(false, false)` | +| Variable inventory | `FrontendVariableAnalyzer` | `sema.unsupported_variable_inventory_subtree`;不 bind param/local/capture | +| Interface | `FrontendInterfacePhase.handleLambdaExpression` | `SKIP_CHILDREN`;body 不进 suite entry / declaration index | +| Suite owner | `FrontendSuiteResolver.callableBody/callableParameters` | 只认 Function/Constructor;lambda 返回 `null` / empty | +| Visible value | `FrontendVisibleValueResolver` | AST 边 + current-scope backstop → `DEFERRED_UNSUPPORTED` / `LAMBDA_SUBTREE` | +| Top / chain binding | `FrontendBodyOwnerProcedures` | `sema.unsupported_binding_subtree` / `sema.unsupported_chain_route`;`walkRootBounded` 剪枝 | +| Expr typing | `FrontendExpressionSemanticSupport.resolveLambdaExpressionType` | `UNSUPPORTED`:"Lambda expression typing is not supported..." | +| Type-check | `FrontendTypeCheckAnalyzer.handleLambdaExpression` | `SKIP_CHILDREN` | +| Compile surface | `FrontendCompileCheckAnalyzer.walkExpression` | 不进入 lambda;依赖上游 unsupported | +| Inspection | `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` | 把 `LambdaExpression` 硬编码为 deferred 祖先 | +| Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef` | +| Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | 不扫描表达式里的 lambda | +| CFG alias | `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` | `CAPTURE` fail-fast | +| C codegen | `ConstructInsnGen` | 无 `CONSTRUCT_LAMBDA` case | +| DOM serialize | `DomLirSerializer` | 写空 ``,丢失 capture 描述 | + +### 1.4 Godot 参考(语义对齐,不复制 VM) + +Godot 4 实现(`godotengine/godot`): + +- Analyzer:`modules/gdscript/gdscript_analyzer.cpp` 在解析 identifier 时, + 沿 `source_lambda -> parent_function` 链把外层 local/parameter 记入 + `LambdaNode.captures` / `captures_indices`;中间层 nested lambda 一并记录。 +- Parser 节点:`LambdaNode.use_self`、`captures`。 +- Codegen:`write_lambda(..., p_use_self)` → `OPCODE_CREATE_LAMBDA` 或 + `OPCODE_CREATE_SELF_LAMBDA`。 +- Runtime:`GDScriptLambdaCallable`(`function->call(nullptr, ...)`, + `get_object()` 返回 **script**)与 `GDScriptLambdaSelfCallable` + (`function->call(instance, ...)`,`get_object()` 返回 **instance**)。 +- Capture 在构造时拷进 `Vector`;调用时插在用户实参前面。 + 对象 capture 若已被释放则替换为 `null`。 + +GDCC 已冻结的 LIR 是 `construct_lambda "" $cap...` + userdata 结构体 + +`free_func` 析构,**没有**独立 self-lambda opcode。本计划按该 LIR 落地, +Godot self-lambda 的 object-id / disconnect-by-object 差异记入 §3.5 与 §10。 + +--- + +## 2. 目标、职责边界与转正顺序 + +### 2.1 目标 + +1. 在 supported executable body(函数 / 构造函数,以及其内部已支持 block)中, + `LambdaExpression` 成为正式表达式:结果类型为 `GdCallableType`,可赋给 + `Callable` 槽、作为 `Signal.connect` / `Callable.call` 实参。 +2. lambda 拥有完整 lexical inventory:parameter、ordinary local、capture。 +3. lambda body 无条件进入 shared semantic(禁止 typed-dependent body gate)。 +4. 外层 lowering 发射 `construct_lambda`;合成 hidden `LirFunctionDef` + (`is_lambda=true`)承接 body CFG / LIR。 +5. C backend 生成 capture 结构体、custom Callable、`free_func` 与 lambda 函数体。 +6. compile-only gate 仅在 lowering + backend + 文档 + 测试全部闭环后解封。 + +### 2.2 职责拆分 + +| 阶段 | 组件 | 职责 | +|------|------|------| +| Scope | `FrontendScopeAnalyzer` | 已建双层图;本计划不改图形状 | +| Inventory | `FrontendVariableAnalyzer` | bind lambda param/local;推导并 `defineCapture` | +| Capture plan | 新增 `FrontendLambdaCapturePlan` | 冻结 capture 名、类型、来源 binding、是否含 `self` | +| Interface | `FrontendInterfacePhase` | 把 `LambdaExpression` 记为 callable owner;body 进 suite entry | +| Suite | `FrontendSuiteResolver` | 把 lambda 当 nested callable owner 解析 | +| Expr typing | `FrontendExpressionSemanticSupport` | 发布 `RESOLVED(GdCallableType)` | +| Type-check | `FrontendTypeCheckAnalyzer` | 消费 Callable 事实;遍历 lambda body | +| Loop | `FrontendLoopControlFlowAnalyzer` | 保持 callable boundary(不断开) | +| Skeleton / pre-pass | `FrontendLoweringFunctionPreparationPass` | 合成 hidden lambda `LirFunctionDef` + context | +| CFG | `FrontendCfgGraphBuilder` | 新增 lambda value item;capture 读数;`CAPTURE` 仍非 alias root | +| Body lowering | 新增 processor | 发射 `ConstructLambdaInsn` | +| Backend | `ConstructInsnGen` + `gdcc_callable.h` + 模板 | custom Callable + capture copy/free | +| Compile gate | `FrontendCompileCheckAnalyzer` | 阶段 I 才把 lambda 纳入 compile surface | + +### 2.3 强制转正顺序 + +复制 `frontend_resolution_pipeline_implementation.md` §2 与 for-in 模板: + +``` +scope graph(已完成) + → 完整 lexical inventory(param / local / capture 名) + → declaration index / typed baseline / suite entry + → SuiteResolver body entry(无条件) + → typed resolution(capture 类型取外层绑定的声明处类型,含 `:=` 推断) + → type-check + → 合成 LirFunctionDef + CFG + construct_lambda + → C backend + → compile gate 解封 +``` + +任一阶段不得靠 `PENDING` / typed readiness 决定是否进入 lambda body。 +compile gate 的 readiness 是 lowering 边界,不是 semantic body entry gate。 + +### 2.4 MVP 支持面 + +正式支持: + +- 出现在 function / constructor executable body(含 `if` / `while` / `for` body、 + 普通 block)中的 `LambdaExpression`。Interface / Suite 只在 + `supportedBodyDepth > 0` 时把 lambda 记为 callable owner;property + initializer、class-level 表达式、parameter default 中的 lambda 不得 + `recordCallable` +- 嵌套 lambda(内层 capture 必须同时写入中间层,见 §3.4) +- 显式参数类型、显式/缺省返回类型(缺省按既有 `resolveTypeOrVariant → Variant`) +- 无 capture、捕获外层 `ScopeValueKind.PARAMETER` / `LOCAL`(含 ordinary + `var` 与 for 迭代器)/ 外层 `CAPTURE` +- 捕获 enclosing instance 的 `self`(作为普通 capture 名,见 §3.5) +- lambda 值赋给 `var` / `Callable` 参数 / `Signal.connect` / `Callable.call` + +继续 fail-closed: + +- property initializer、parameter default、class-level 表达式中的 lambda +- lambda 自己的 parameter default +- lambda body 内的 `match`、block-local `const`、`await` +- `CAPTURE` 作为 direct-slot alias root +- 把 class member / global / utility / type-meta 收成 capture +- 静态函数里使用 `self` 或 instance member(沿用既有 `ResolveRestriction`) + +--- + +## 3. 冻结合同(实施前必须遵守) + +### 3.1 结构策略翻转 + +`FrontendBodySemanticSupportPolicy` 必须改为: + +- `forBlockScopeKind(LAMBDA_BODY)` → `EXECUTABLE_BODY` +- `forCallableScopeKind(LAMBDA_EXPRESSION)` → `EXECUTABLE_BODY` + +该翻转必须落在 **阶段 B**(与 inventory 同提交),不能拖到阶段 C。 +原因:`FrontendVariableAnalyzer.bindLocal` 经 +`FrontendExecutableInventorySupport.canPublishCallableLocalValueInventory(kind)` +读取 `publishesLexicalInventory()`;`LAMBDA_BODY` 在翻转前会把 body local +报成 `sema.variable_binding` “expected supported executable BlockScope”。 + +阶段 B 时 `FrontendInterfacePhase.handleLambdaExpression` 仍 `SKIP_CHILDREN`, +`FrontendSuiteResolver` 尚无 `LambdaExpression` 分支,因此 +`entersSuiteResolver=true` 没有活消费者。阶段 C 再打开 interface / suite / +resolver。不得把 policy 拆成两个 boolean 分两次翻。 + +`FrontendVisibleValueDomain.LAMBDA_SUBTREE` 枚举值保留,但生产路径从阶段 B +policy 翻转起不再映射到它。`MATCH_SUBTREE` / `PARAMETER_DEFAULT` / +`BLOCK_LOCAL_CONST_SUBTREE` 不得被这次改动打开。 + +### 3.2 Lambda 是 nested callable owner,不是 for 那种 header-only statement + +`ForStatement` 的 body 是**同一** enclosing callable 的 child suite。 +`LambdaExpression` 是**新的** callable: + +1. `FrontendInterfacePhase.handleLambdaExpression` 仅当 `supportedBodyDepth > 0` + 时调用 `recordCallable(lambda, parameters, body)`。这与 + `handleVariableDeclaration` 在 depth=0 时走 property-init 而不 bind local + 的守卫同构。depth=0(property initializer / class-level 表达式)必须 + `SKIP_CHILDREN` 且不进 `callableOwners`。 +2. `FrontendSuiteResolver.callableBody/callableParameters/restrictionForCallable/isStaticCallable` + 增加 `LambdaExpression` 分支。 +3. **解析时机**:不要把 lambda 仅丢进顶层 `callableOwners` 循环、在外层函数 + **之后**才解析。外层语句的 expr typing 会先碰到 `LambdaExpression`。 + 正确顺序: + - Interface 对 supported-body 内的 lambda 先无条件发布 inventory / + baseline / suite entry。 + - 外层 **非 silent** 的 expr-type owner 路径在发布 `GdCallableType` + **之前**,若该 lambda 已 `recordCallable` 且尚未解析,则调用 + `FrontendSuiteResolver.resolveCallableOwner(lambda)` + (独立 `FrontendCallableExportBatch`)。这是 nested resolve 的 + **唯一**生产触发点。 + - 局部稳定化的 **silent resolver** 必须把 `LambdaExpression` + initializer 列入 fail-closed(`var cb := func(): ...` 的 slot + 保持 inventory `Variant`,不写 side table、不发诊断、不触发 + nested resolve)。`:=` 推断为 `GdCallableType` 若要发生,只能由 + 非 silent 路径在 nested resolve 完成、表达式类型已发布后,按 + `frontend_local_type_stabilization` 既有单调写回决定;不得为了 + 稳定化去解析 lambda body。 + - 未 `recordCallable` 的 lambda(property-init / default / skipped) + **不得**触发 nested resolve;保持 deferred / unsupported。 + - 用 AST-identity side table 标记已解析,避免顶层循环二次解析。 +4. lambda 的 static/instance restriction **继承 enclosing callable**: + - enclosing `FunctionDeclaration.isStatic()` → `ResolveRestriction.staticContext()` + - 否则 `instanceContext()` + - 嵌套 lambda 继续继承最外层 enclosing 非 lambda callable。 + +### 3.3 表达式类型 + +`resolveLambdaExpressionType` 成功时发布: + +- `FrontendExpressionType.resolved(new GdCallableType())` +- 不在 MVP 填 `arguments` / 特化 `returnType`(与 method-as-value / + `construct_callable` 的 unparameterized `Callable` 对齐) +- 不稳定依赖(参数类型解析失败等)按既有 `propagated(...)` 传递,不得伪装成 + `UNSUPPORTED` + +仅 **已 `recordCallable`** 的 lambda 节点不再发 +`sema.unsupported_expression_route` / `sema.unsupported_binding_subtree` / +`sema.unsupported_chain_route`。未记录的 lambda(property-init 等)必须 +继续由 `FrontendBodyOwnerProcedures` 按位置发 unsupported,避免 +`resolveLambdaExpressionType` 对无 suite entry 的 body 触发 +`FrontendBodyStructuralCompleteness` fail-fast。 + +### 3.4 Capture 推导 + +新增 `FrontendLambdaCapturePlanner`(或等价 support),在 variable inventory +阶段按**名字**推导。capture **类型**取外层绑定的**声明处类型** +(显式标注,或 `:=` 在该声明上的推断/稳定化结果),不是 inventory +baseline,也不是声明之后的断言或精化。 + +**可捕获**(scope lookup 命中的是 `ScopeValueKind`,不是 +`FrontendBindingKind`): + +- 最近 enclosing supported callable 中的 `PARAMETER` +- 最近 enclosing supported executable block 中的 `LOCAL` + (ordinary `var` 与 for 迭代器都是 `LOCAL`) +- 外层 lambda 已发布的 `CAPTURE`(嵌套传递) +- enclosing instance callable 的 `self`(§3.5) + +**不可捕获**(按普通词法继续解析,不进 ``): + +- 当前 lambda 自己的 parameter / local +- class property / signal / method / 内层 class type-meta +- global / singleton / utility / enum +- 尚未转正域里的名字(match pattern、block-local const、parameter default) + +**算法(对齐 Godot innermost-first,但不依赖 Godot AST)**: + +1. 先 bind 当前 lambda 的 parameters,再 bind body ordinary locals。 +2. 扫描 lambda body 中所有 `IdentifierExpression`(含嵌套表达式;跳过 + 声明站点本身,即 `Parameter.name` / `VariableDeclaration.name`)。 +3. **从该 identifier 自己的 scope 向上**做 value lookup(innermost-first), + **禁止**一律从 lambda `CallableScope.parent` 查找。 +4. 仅当第一次命中落在**当前 lambda callable 边界之外**,且 + `ScopeValueKind` 为 `PARAMETER` / `LOCAL` / `CAPTURE` 时,才记为 + 当前 lambda 的 capture。当前 lambda 自己的 parameter / local 命中 + → 不捕获。 +5. 若该名沿 parent lambda 链仍可见,中间层也 `defineCapture`(嵌套传递)。 +6. 同一名字只记一次;顺序按**首次出现的源码顺序**冻结,供 LIR operand 顺序使用。 +7. capture 与 parameter 冲突时 `ensureCallableValueSlotAvailable` 仍 fail-fast; + 生产路径必须在 define 前做 diagnostic + skip,不要让用户源码打到 + `IllegalArgumentException`。 +8. planner 按 **post-order** 运行:先完成嵌套 lambda 的 param/local bind 与 + 其自身 capture plan,再扫描外层。外层扫描依赖内层 scope 已就位; + 否则内层尚未 bind 的 param/local 会被误判成外层 capture(H2 同类缺陷)。 +9. 中间层传递:当且仅当 source binding 位于中间层 lambda 边界之外,且 + 中间层无同名 param/local 遮蔽时,中间层 `defineCapture` 同名条目 + (所有传递层共享同一个 `sourceDeclaration`,声明处类型同源)。 + 中间层遮蔽时传递在该层终止,内层改为捕获中间层自己的 param/local。 + +反例(必须作为阶段 B negative path): + +```gdscript +func f(): + var x = 1 + var cb := func(): + var x = 2 + return x # 指 lambda local,不得捕获外层 x +``` + +**类型(已冻结:声明处类型,含 `:=` 推断;后续断言不影响)**: + +Capture 类型等于外层被捕获绑定在其**声明语句完成时**的 source-facing +类型。阶段 B 只登记名字与 `sourceDeclaration`,`defineCapture` 先写 +`Variant` 占位。真实类型在 **该 lambda 自己的 nested suite resolution +开始、其 body 语句尚未处理时** 填充,写入后立即冻结。 + +填充必须同时更新三处,禁止只写 plan 不写 scope: + +- `CaptureEntry.type` +- LIR ``(lowering 时消费 plan) +- 该 lambda `CallableScope` 上的 `CAPTURE` 绑定:新增 + `resetCaptureType`(镜像 `BlockScope.resetLocalType` 的单调 + `Variant → exact` 守卫与 declaration 身份校验)。body type-check + 经 `effectiveScopeValue` 读 CAPTURE 时**不会**套 overlay,因此 + scope 绑定必须与 `CaptureEntry.type` 同源。 + +**读取路径**(禁止读物理 scope slot,禁止回落 `slotTypes()` / +`VAR_TYPE_POST`): + +1. 填充时点发生在外层语句 mid-suite。`BlockScope.resetLocalType` + 只在外层 callable 的 `exportBatch.applyTo(analysisData)` 时执行, + 此刻物理 slot 仍是 inventory `Variant`。 +2. 必须走外层当前 typed environment 上的 **declaration-anchored** + 查询:按 `sourceDeclaration` + owning `BlockScope` 对象身份匹配 + 已 flush 的 `LOCAL_TYPE_STABILIZATION` / `FOR_ITERATION_RESOLUTION` + update(`FrontendTypedLexicalEnvironment.localSlotType` 的 overlay + 段)。这不是“求值点最新 effective 类型”,而是该声明自己的 + 已提交稳定化事实。 +3. 不得复用 `localSlotType` 在无 update 时回落到 `slotType(astNode)` + (那会读到 `VAR_TYPE_POST` / 函数摘要)。无 matching update 时 + 取声明类型或 inventory baseline。 +4. 只有上述两个 stage 能写 slot update(`FrontendAnalysisData` + 强校验)且写回单调 `Variant → exact`,因此“该声明的最新 + update”≡“该声明自己的稳定化结果”。日后若新增会写 slot 的 + 断言 stage,会先撞该校验——这就是“声明之后的断言不改 capture” + 的执行点。 + +| 外层绑定 | capture 类型 | +|----------|----------------| +| 显式标注 `PARAMETER` / `var x: T` / `var x: T = ...` | 声明类型 `T` | +| 未标注 `PARAMETER` | `Variant` | +| `var x := 1`(`:=` 推断) | 该声明的局部稳定化结果,此处为 `int` | +| `var x := expr` 且 initializer 未能稳定 | 保持 `Variant` | +| `var x = 1`(无标注、非 `:=`) | `Variant`(不因 initializer 是 `1` 而推断 `int`) | +| for 迭代器(`ScopeValueKind.LOCAL`,声明身份是 `ForStatement`) | `FOR_ITERATION_RESOLUTION` 在进入 for body 前已提交的精化结果 | +| 外层 lambda 的 `CAPTURE` | 该外层 capture 已冻结的声明处类型 | +| `self`(§3.5) | enclosing class 的 `GdObjectType` | + +`:=` 的 `int` 来自**那条声明**已 flush 的稳定化 update,不是物理 +scope 写回,也不是函数末尾摘要。阶段 B inventory 仍会先给推断 +局部写 `Variant` baseline;若在 nested resolve 入口读物理 slot, +`var x := 1` 会错成 `Variant`。 + +capture 合法性以外层声明在 **lambda 语句处按 declaration-order +可见**为前提。`var cb := func(): return x` 后接 `var x := 1` 时, +planner 可能仍登记名字,但该条目不得进入 lowering;use-site +走既有 `DECLARATION_AFTER_USE_SITE`。不可见条目不得填进 +``。 + +明确禁止: + +- 用声明**之后**的赋值、类型断言、后续 overlay、`VAR_TYPE_POST` 或 + 函数末尾 slot 类型回写 `CAPTURE` / `FrontendLambdaPlan` / ``。 + 即使后来断言 `x` 为其他具体类型,capture 仍是声明处类型。 +- 对 `var x = 1` 因看到 initializer `1` 而把 capture 写成 `int`。 +- 只更新 `CaptureEntry.type` 却让 `CallableScope` 的 CAPTURE 停在 + `Variant` 占位。 +- 引入 compiler-only 类型。`LirPublicAbiValidator` 继续拒绝 + compiler-only capture。 + +例: + +```gdscript +var a := 1 # 声明处稳定为 int → capture int +var b = 1 # 声明处 Variant → capture Variant +var cb := func(): + return a + b # a:int 副本,b:Variant 副本 +# 此后即使断言 b 为 int,或再给 a 做其它精化,都不改 capture ABI +``` + +lambda body 的 type-check / lowering 只消费这份声明处类型。外层槽后续 +变化只影响外层语句。 + +**Godot 语义**:capture 是**构造时拷贝**。lambda 内对 capture 名赋值只改 +lambda 自己的副本,不写回外层 local。对象/容器元素的可变性来自共享对象身份, +不是 live-slot alias。 + +### 3.5 `self`:用 capture,不新增 opcode + +Godot 用 `use_self` + `GDScriptLambdaSelfCallable` 把 Callable 绑到 instance。 +GDCC LIR 只有 `construct_lambda` + capture 列表。 + +MVP 冻结: + +- 若 lambda body 出现显式 `SelfExpression`,或 instance restriction 下的 + implicit instance member / instance method 调用需要 receiver,则把 + enclosing executable 的 `self` 收为名为 `self` 的 capture。 +- 该 capture 的类型是 enclosing class 的 `GdObjectType`(与函数 `self` 参数一致)。 +- lowering 把外层 `self` slot 作为 `construct_lambda` 的一个 capture operand。 +- 合成 lambda `LirFunctionDef` **不**再注入第二份 `ensureExecutableSelfParameter`; + `self` 只出现在 `` / `_capture->self`。 +- lambda `LirFunctionDef` 一律 `setStatic(true)`(§3.7),避免 + `declareSelfSlotIfNeeded` 造 stray `self`。仅当 `capturesSelf` 时把 + `self` 放进 ``。`Kind.LAMBDA_BODY` 仍必须新增(CFG/body + pass 的 sourceOwner 门闩),但 self 槽策略以 `setStatic(true)` 为准, + 不要两套并行。 +- CFG / body lowering 的 self capture **operand** 不得伪造 + `IdentifierExpression + SELF`(该路径会 fail-fast)。必须用专用 + descriptor(例如 `SELF_SLOT`)直接读 enclosing executable 的 `self` 槽。 +- 静态 enclosing callable 中使用 `self`:沿用既有 restriction diagnostic, + 不合成非法 capture。 + +**`object_id`(已确认,对齐 Godot self-lambda)**: + +- `capturesSelf == true`:`GDExtensionCallableCustomInfo2.object_id` 必须填 + enclosing `self` 在 **`construct_lambda` 求值点** 的 + `GDObjectInstanceID`(fat pointer 已缓存的 `instance_id`,或 + `gdcc_object_id_from_raw` 仅当 raw 已保证 live)。 + 这样 `Callable.get_object()` / Signal 连接把这颗 Callable 绑到实例: + 实例 `free` / `queue_free` 后连接自动失效,后续 `emit` 不得再进 + `call_func`。 +- `capturesSelf == false`:`object_id = 0`。GDCC 没有与 Godot + `GDScript` 资源对等的 script 对象可绑;不得把 enclosing instance + 或 class singleton 偷偷填进去。 +- **不得**改 `construct_standalone_callable` / `gdcc_new_standalone_callable` + 的 `object_id = 0`,也不得改 `construct_callable` 的 receiver 合同。 +- `is_valid_func`:`object_id != 0` 时必须提供;对象已从 ObjectDB + 消失则返回 false。不得只靠 `call_func` 里解引用悬空 `self` 来“发现” + 失效。 +- `self` capture 字段仍按 ownership 合同 copy/release;`object_id` + 只负责 Godot 侧身份 / disconnect,不替代 capture 里的对象指针。 + +### 3.6 `CAPTURE` 与 writable / alias + +- `CallableScope.defineCapture` 现有 `writable=true`:允许 lambda **内部** + 对 capture 名赋值(写副本)。 +- `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` 对 `CAPTURE` 继续 + fail-fast。本计划**不**把 capture 当 live-slot alias。 +- `FrontendOpaqueExprInsnLoweringProcessors` 已把 `CAPTURE` 当符号名解析: + lambda **body** 内读 capture 名,按该函数的 local / capture 变量降低。 +- 外层函数里,被 capture 的 local 仍按普通 `LOCAL_VAR` 读写;构造 lambda 时 + 读一次快照。 + +### 3.7 合成函数身份 + +每个通过 compile surface 的 `LambdaExpression` 对应恰好一个 hidden +`LirFunctionDef`: + +- 名字:`_lambda_`,`k` 在 owning `LirClassDef` 内从 0 递增,稳定且与 + AST 遍历顺序一致(先声明顺序,再源码出现顺序)。 +- `setLambda(true)`、`setHidden(true)`、`setStatic(true)`。 + `setStatic(true)` 是为了让 `FrontendBodyLoweringSession.declareSelfSlotIfNeeded` + 不给 lambda 造 stray `self` 变量;`isLambda` 已跳过 ClassDB bind, + 无绑定副作用。`self` 只作为 §3.5 capture 存在。 + **禁止**再写 `setStatic(false)`。 +- 参数表 = lambda 源码参数(含类型);**不含** `self` 参数。 +- `` = `FrontendLambdaCapturePlan` 冻结列表。 +- 返回类型 = `FrontendDeclaredTypeSupport.resolveTypeOrVariant(lambda.returnType())`。 +- 不进入 `ClassScope.defineFunction` / 不进 ClassDB bind。 +- 命名不得与用户函数或 `_field_init_` / `_field_getter_` / `_field_setter_` 冲突; + `_lambda_` 前缀视为 compiler-owned(若用户声明同名前缀,skeleton 发 + `sema.class_skeleton` 并跳过用户声明,与 `_field_*` 合同平行)。 + +Side table(建议挂在 `FrontendAnalysisData`): + +``` +IdentityHashMap +``` + +`FrontendLambdaPlan` 至少包含: + +- `LambdaExpression` AST 身份 +- 合成名 `_lambda_` +- 有序列表 `captures: List` +- `capturesSelf: boolean` +- enclosing callable AST +- owning class canonical name + +### 3.8 LIR / C ABI + +沿用 `doc/gdcc_low_ir.md`: + +``` +$result = construct_lambda "" $capture1 $capture2 ... +``` + +- 无 capture:userdata = `NULL`,`free_func` 仍可 no-op。 +- 有 capture:拷入 heap 上的 `${Class}_Capture_${func}`, + `callable_userdata` 指向该块;`free_func` 按字段析构后 `godot_mem_free`。 +- `object_id` 按 §3.5:`capturesSelf` → enclosing `self.instance_id`, + 否则 `0`。该字段在 `godot_callable_custom_create2` 时写入,之后不改。 +- `call_func` 把 userdata 解成 `_capture`,再调 + `${Class}_${lambdaName}($arg..., _capture)`。进入 `call_func` 前若 + `object_id != 0` 且对象已死,应被 Godot / `is_valid_func` 挡掉, + 不得解引用 `_capture->self`。 +- 用户可见 arity = lambda 源码参数个数(**不含** capture)。 +- `DomLirSerializer` 必须写出真实 ``,不得再写空节点。 +- `func.ftl` 现有 bug:`<#list func.captureList as capture>` 对 **每个** + capture 元素都发射一次 `_capture` 形参。`captureCount == 1` 碰巧正确; + `captureCount > 1` 会得到多个无逗号分隔的 `_capture` 形参。必须改成 + `captureCount > 0` 时只发射一次。 +- lambda 函数 prologue:把 `_capture->name` 拷入 `LirFunctionDef` 为该 + capture 登记的 local `$name`(`addCapture` 已 `variables.put(name, ...)`)。 +- `CCodegen.generateFunctionPrepareBlock` 会给每个非参数 variable + 发射默认构造(`String` → `""`,`Array` → 新数组等)。capture local + 必须从该自动初始化中排除,再由 prologue 从 `_capture->name` 拷入; + 否则 destroyable 类型会泄漏默认构造值。问题在 `CCodegen.java`, + 不是 `entry.c.ftl` 的“未初始化 local”。 + +### 3.9 诊断 + +| 场景 | category | 策略 | +|------|----------|------| +| 仍未转正的位置出现 lambda(property init 等) | 保持 `sema.unsupported_binding_subtree` 或 inventory 对应 category | diagnostic + skip | +| lambda 内 parameter default | 与普通函数相同的 deferred default 诊断 | 不打开 default 语义 | +| capture / parameter 同名冲突 | `sema.variable_binding` 既有 duplicate/shadow 类 | diagnostic + skip define | +| 静态上下文捕获 `self` | 既有 restriction / `sema.type_check` 或 lookup | 不新增平行语义 | +| compile 时 lambda 缺 plan / 缺合成函数 | `sema.compile_check` 或 lowering fail-fast | 缺 published fact 不得重绑 | +| 用户函数名以 `_lambda_` 开头 | `sema.class_skeleton` | 跳过该 member | + +普通源码错误走 `DiagnosticManager` + skip subtree,不得当异常控制流。 +缺失/冲突的 **published** fact 在 lowering 侧 fail-fast。 + +--- + +## 4. 分阶段实施步骤 + +每阶段必须可运行、可回归、可单独提交。未到阶段 I 前, +`analyzeForCompile` 对 lambda 的 compile surface 仍可保持跳过或 +“有 lambda 则 blocker”,但 **shared `analyze()`** 从阶段 C 起必须进入 body。 + +### 阶段 A — 数据面与合同测试(不翻支持面) + +**目标**:先把 plan / side table / 命名 / 模板缺陷修掉,生产封口不变。 + +**实施内容**: + +- 新增 `FrontendLambdaPlan` / `FrontendLambdaCapturePlan` / `CaptureEntry`。 +- `FrontendAnalysisData` 增加 lambda plan map 的存取 API。 +- 单测 `ScopeCaptureShapeTest` 风格的 planner 纯函数测试可先用手工 scope 图。 +- 修复 `func.ftl`:`_capture` 只发射一次(纯模板修复,不改变语义封口)。 +- `DomLirSerializer` 写出真实 ``(可先用手工 LIR fixture 测)。 + +**验收细则**: + +- happy path:手工构造 `is_lambda` + 两个 capture 的 `LirFunctionDef`, + DOM 往返后 `getCaptureList()` 完整。 +- happy path:`func.ftl` 对 `captureCount > 1` 只出现一个 `_capture` 形参 + (不是“两条路径重复”,而是 list 按元素各发射一次)。 +- negative path:非 lambda `addCapture` 仍抛;compiler-only capture 仍被 + `LirPublicAbiValidator` 拒绝。 +- 现有 `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` + **仍红字不变**(本阶段不翻 inventory)。 + +### 阶段 B — Policy 翻转 + Variable inventory 解封(仍不进 SuiteResolver) + +**目标**:lambda param / local / capture **名字**进入 scope;去掉 +`sema.unsupported_variable_inventory_subtree`。 + +**实施内容**: + +- **先做 §3.1 policy 翻转**(`LAMBDA_BODY` / `LAMBDA_EXPRESSION` → + `EXECUTABLE_BODY`),否则 `bindLocal` 会因 + `canPublishCallableLocalValueInventory(LAMBDA_BODY)==false` 发 + `sema.variable_binding`。同步更新 + `FrontendBodySemanticSupportPolicyTest` 的 lambda 行。 +- `FrontendVariableAnalyzer.handleLambdaExpression` 改为 + `bindCallableParameters(lambda, params, body)`,不再 SKIP。 +- `UnsupportedVariableBoundaryReporter.handleLambdaExpression` 停止对 + **supported executable 内**的 lambda 报 inventory 错。 +- 引入 planner:在 bind param/local 之后按 §3.4 innermost-first + `defineCapture`。本阶段只登记名字 + `sourceDeclaration`, + `defineCapture` 的类型用 `Variant` 占位;真实类型按 §3.4 在 + nested resolve 入口填充(阶段 C/D)。 +- **本阶段不改** `FrontendInterfacePhase.handleLambdaExpression`(仍 + `SKIP_CHILDREN`),因此 `entersSuiteResolver=true` 无消费者。 +- property-init / parameter-default / skipped subtree 内的 lambda: + variable analyzer 的 boundary reporter **只扫描 callable body**, + 不会走进 property initializer。property-init lambda 的 unsupported + 仍由 `FrontendBodyOwnerProcedures` 的 binding/chain 诊断负责 + (本阶段保持这些诊断)。不得误以为 Phase B 会给 property-init + 再发一条 inventory 错。 + +**验收细则**: + +- happy path:`FrontendVariableAnalyzerTest` 原 + `analyzeWarnsForDeferredLambdaSubtreesWhileBindingOuterLocal` **翻转**: + `lambdaScope.resolveValue("item")` 为 `PARAMETER`; + `lambdaBodyScope.resolveValue("lambda_local")` 为 local; + 外层被引用的 `seed` 在 lambda `CallableScope` 上为 `CAPTURE`。 +- happy path:嵌套 lambda 引用最外层 local 时,中间 lambda 也能 + `resolveValueHere` 到 `CAPTURE`。 +- negative path:lambda local 遮蔽外层同名 local 时 **不得** capture + (`var x = 1; func(): var x = 2; return x`)。 +- negative path:嵌套 lambda 的 param/local 遮蔽外层名时,中间层不得 + 被错误插入该 capture。 +- negative path:`match` / block-local const 的 inventory 错仍在。 +- negative path:property initializer 里的 lambda 仍发 + `sema.unsupported_binding_subtree`(owner 是 BodyOwnerProcedures, + 不是 variable inventory),不得 bind、不得 `recordCallable`。 +- negative path:lambda 参数与将捕获的同名冲突 → 一条 + `sema.variable_binding`,不抛异常。 + +### 阶段 C — Policy / resolver / interface / suite 解封 + +**目标**:lambda body 成为 supported executable suite。shared `analyze()` +进入 body 并发布普通表达式事实。 + +**实施内容**: + +- Policy 已在阶段 B 翻转;本阶段只打开 resolver / interface / suite。 +- `FrontendVisibleValueResolver` 去掉 lambda AST 边与 + `LAMBDA_EXPRESSION` / `LAMBDA_BODY` current-scope 的 deferred 封口。 +- `FrontendInterfacePhase.handleLambdaExpression`:仅 + `supportedBodyDepth > 0` 时 `recordCallable(...)`;depth=0 仍 + `SKIP_CHILDREN`。 +- `FrontendSuiteResolver` 四个 helper 增加 `LambdaExpression`。 + `resolveCallableOwner` 对 lambda 必须在走 body 语句之前按 §3.4 + 填充 capture 类型(declaration-anchored overlay + `resetCaptureType`)。 + 该填充不是 body 进入条件。 +- `FrontendBodyOwnerProcedures`:对**已 record** 的 lambda,把 + unsupported binding/chain 诊断换成 nested `resolveCallableOwner` + 触发。`walkRootBounded` 对 `LambdaExpression` 的剪枝**必须保留**, + 外层 owner 不得再 walk 进其 children 当普通表达式树。 + 对**未 record** 的 lambda,继续发 unsupported binding/chain。 +- `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` + 中把 `LambdaExpression` 从硬编码 deferred 祖先列表移除(否则 + inspection 仍把已转正 lambda 标成 deferred)。 +- 同步检查 `smallestContainingCallable`:若它只认 + `FunctionDeclaration` / `ConstructorDeclaration`,lambda body 内诊断 + 会回退到 class/file 锚点。阶段 C 应让它识别 `LambdaExpression`, + 或在文档中明确接受该回退。 +- `FrontendBodySemanticSupportPolicyTest` 应已在阶段 B 翻转;本阶段 + 补 resolver / interface 测试。 + +**验收细则**: + +- happy path:`FrontendInterfacePhaseTest` / + `FrontendSuiteResolverTest` 中 `lambda.body()` **进入** + `suiteEntryRoots()` 与 `bodyDeclarationIndex()`。 +- happy path:`FrontendVisibleValueResolverTest.resolveSealsLambdaBody*` + **翻转**为可解析外层 capture / 当前 param / 当前 local。 +- happy path:lambda body 内 `return seed` 对 `seed` 发布 + `symbolBindings` + `CAPTURE`。 +- negative path:`match` / const / parameter default 仍 `DEFERRED_UNSUPPORTED`。 +- negative path:合成 `LAMBDA_BODY` scope 不再被 current-scope backstop 误封 + (原 `resolveRejectsSyntheticLambdaBodyCurrentScope*` 翻转或改为 + “无 AST 时仍按 EXECUTABLE_BODY 解析”)。 + +本阶段 **仍不必**让 `analyzeForCompile` 放行。 + +### 阶段 D — 表达式类型与 Callable 值 + +**目标**:lambda 表达式发布稳定 `RESOLVED(GdCallableType)`。 + +**实施内容**: + +- `resolveLambdaExpressionType` 按 §3.2 / §3.3 先 nested-resolve body, + 再 `resolved(new GdCallableType())`。 +- `FrontendTypeCheckAnalyzer.handleLambdaExpression` 改为 walk body + (或依赖 suite 已走完、此处只消费外层赋值兼容)。 +- 赋值 `var cb: Callable = func(): pass` 走既有 ordinary boundary。 +- `var x: int = func(): pass` 发既有 `sema.type_check` 不兼容。 + +**验收细则**: + +- happy path:`FrontendTypeCheckAnalyzerTest` / + `FrontendBodyOwnerProceduresExprTypeTest` 中 + `var deferred_value := func(...): ...` 的 initializer 从 `UNSUPPORTED` + 变为 `RESOLVED(Callable)`。silent 稳定化不得解析该 initializer; + 该 local 是否随后精化为 `GdCallableType` 只由 nested resolve 完成后 + 的非 silent 写回决定,缺写回则保持 `Variant`。 +- happy path:`sig.connect(func(): pass)` 在 shared analyze 下不再出现 + `unsupported_binding_subtree`(compile_check 仍可暂缺或仍 blocker)。 +- negative path:类型不兼容赋值仍只由 type-check 拥有,不重复 inventory 错。 +- negative path:lambda 内仍未支持的 `match` 继续上游 owner 报错, + 不把整个 lambda 打回 `UNSUPPORTED`。 + +### 阶段 E — 合成 hidden `LirFunctionDef` + +**目标**:每个 compile-bound lambda 在 lowering 前拥有完整 shell 函数。 + +**实施内容**: + +- 在 `FrontendLoweringFunctionPreparationPass`(或紧前的专用 pass)扫描 + 已发布 `FrontendLambdaPlan` 的 `LambdaExpression`。 +- `owningClass.addFunction` 合成 `_lambda_`,`setLambda` / `setHidden` / + `setStatic(true)`(§3.7),`addCapture`,`fill` 参数与返回类型。 +- 为每个 lambda 建 `FunctionLoweringContext`。**必须新增** + `Kind.LAMBDA_BODY`(或等价),不得复用 `Kind.EXECUTABLE_BODY`: + `FrontendLoweringBuildCfgPass.publishStraightLineExecutableGraph` + 要求 `sourceOwner` 是 `FunctionDeclaration` / `ConstructorDeclaration`, + `LambdaExpression` 会直接 `IllegalStateException`。 +- 同步给 `FrontendLoweringBuildCfgPass` 增加接受 + `sourceOwner instanceof LambdaExpression` 且 `loweringRoot instanceof Block` + 的分支。 +- `FrontendLoweringBodyInsnPass` 的 Kind switch **必须**把 `LAMBDA_BODY` + 并入与 `EXECUTABLE_BODY` 相同的分支(跑 `FrontendBodyLoweringSession`)。 + 该 switch 无 `default`:漏加会静默跳过,留下 shell-only 函数再被 + `CCodegen.generateFunctionPrepareBlock` 接上 `__prepare__`。 +- 不得把 lambda 塞进 `PROPERTY_INIT`。 +- **不要**对 lambda 调 `ensureExecutableSelfParameter`;self 只走 + §3.5 capture。 +- `CGenHelper` / bind methods 已跳过 `isLambda()`,保持。 + +**验收细则**: + +- happy path:一个含两层嵌套 lambda 的函数,class 上有两个 hidden + `is_lambda` 函数,capture 列表与 plan 一致。 +- happy path:无 capture 的 lambda 无 `` 条目,且 + `getCaptureCount()==0`。 +- negative path:用户手写 `func _lambda_0():` → `sema.class_skeleton`, + 不覆盖合成函数。 +- negative path:缺 `FrontendLambdaPlan` 的 lambda 进入该 pass → fail-fast, + 不静默跳过。 + +### 阶段 F — CFG + `construct_lambda` lowering + +**目标**:外层 body 把 lambda 值 lower 成 `ConstructLambdaInsn`; +lambda body 走既有 CFG / body insn pass。 + +**实施内容**: + +- 新增 CFG item(建议名 `LambdaLoadItem` / `LambdaConstructItem`): + 持有合成名、capture 的 source value id 列表、result value id。 +- `FrontendCfgGraphBuilder` 在表达式为 `LambdaExpression` 且存在 plan 时 + 建该 item。普通 capture operand 从 enclosing + `FrontendBindingKind.LOCAL_VAR` / `PARAMETER` / 外层 `CAPTURE` + slot 读取;`self` capture 必须用 `SELF_SLOT` + descriptor,禁止伪造 `IdentifierExpression + SELF`。 +- `FrontendSequenceItemInsnLoweringProcessors` 发射 + `ConstructLambdaInsn(result, name, captures...)`。 +- lambda **body** 的 CFG 以合成 `LirFunctionDef` 为 target,复用 + `buildExecutableBody`。 +- body 内 `IdentifierExpression + CAPTURE`:按该 lambda 函数的变量名读, + **仍不得**走 `DirectSlotAliasValueItem`。 +- 外层 `CAPTURE` 出现在非 lambda body(不应发生)继续 fail-fast。 + +**验收细则**: + +- happy path:`var cb := func(x: int): return x` 外层 LIR 含 + `construct_lambda "_lambda_0"`,无 capture operand;`_lambda_0` + 有参数 `x` 与 return。 +- happy path:捕获外层 `seed` 时 insn 为 + `construct_lambda "_lambda_0" $seed`;`_lambda_0` 的 + `` 类型等于该外层绑定的**声明处类型**(§3.4), + 不是外层函数末尾 slot 类型。 +- happy path:使用 `self.foo` 的 lambda 带 `self` capture operand。 +- negative path:`FrontendCfgGraphBuilderTest` 对 **外层** + `CAPTURE` alias 的 fail-fast **保留**。 +- negative path:plan 与 CFG capture 数量不一致 → fail-fast。 + +### 阶段 G — C runtime 与 codegen + +**目标**:`construct_lambda` 可编译、可调用、可释放。 + +**实施内容**: + +- 新增 `gdcc_new_lambda_callable(...)`(或 per-lambda 生成的薄包装), + 复用 `godot_callable_custom_create2`: + - `call_func` / `free_func` / `hash` / `equal`(引用相等即可,对齐 Godot) + - `get_argument_count` = 源码参数个数 + - `object_id`:`capturesSelf` 时为 enclosing `self.instance_id`,否则 `0` + (§3.5)。helper 必须接收该 ID,不得在 C 里对可能已死的 raw 调 + `godot_object_get_instance_id`。 + - `is_valid_func`:`object_id != 0` 时按 ObjectDB 存活返回; + `object_id == 0` 时可与 standalone 一样只检查 userdata / 函数指针。 +- `ConstructInsnGen` 注册 `CONSTRUCT_LAMBDA`: + 分配 capture 结构体、逐字段 copy、调用 helper。 +- 从 `CCodegen.generateFunctionPrepareBlock` 排除 capture locals + (`func.getCapture(name) != null`),避免默认构造后再 copy 造成 + destroyable 泄漏。 +- 在 lambda 函数入口(`__prepare__` 之后或专用 prologue insn)把 + `_capture->name` 拷入对应 `$name`(destroyable 用既有 copy helper)。 +- `free_func`:对每个 destroyable 字段 `destruct`,再 free userdata。 +- 无 capture:userdata `NULL`,`free_func` no-op。 +- 对象 capture 的生命周期遵循 `gdcc_ownership_lifecycle_spec.md` 的 + copy/destruct;不必在 MVP 复刻 Godot “已释放则变 null” 的调试打印, + 但不得 double-free。 +- 同步 `doc/gdcc_runtime_lib.md`(新 helper)与 `doc/gdcc_low_ir.md` + (`_capture` 尾参 + prologue-copy 约定;当前只写了 userdata)。 + +**验收细则**: + +- happy path:targeted backend 测试(可手工 LIR)生成的 C 含 capture + typedef、`construct_lambda` 调用、`free_func`。 +- happy path:Zig 可用时 e2e:lambda 返回常量、`Callable.call` 得到预期值。 +- happy path:捕获 `int` / `String` / `Array`;String/Array 在 Callable + 释放后无泄漏(ASan/现有 destroy 约定)。 +- happy path:`capturesSelf` 的 lambda 生成的 C 里 + `GDExtensionCallableCustomInfo2.object_id` 来自 `self` 的 cached + `instance_id`,不是字面 `0`。 +- happy path(Zig + `GODOT_BIN`):`sig.connect(func(): self.flag = true)` + 后 `free`/`queue_free` 该实例,再 `sig.emit()` 不得改到已释放对象, + 也不得崩溃;连接应按 Godot 随 `object_id` 失效。 +- happy path:不捕获 `self` 的 lambda 仍 `object_id = 0`。 +- negative path:未知 lambda 名 / capture 数与函数定义不一致 → + codegen fail-fast,不生成半残 C。 + +### 阶段 H — 回归锚点补齐(仍可选择暂不解封 gate) + +**目标**:focused tests 覆盖 happy / negative;test_suite 仍可暂避 lambda, +直到阶段 I。 + +**建议测试类**(新建或扩展): + +- `FrontendLambdaInventoryTest` +- `FrontendLambdaCapturePlannerTest` +- `FrontendLambdaSuiteResolverTest` +- `FrontendLambdaExpressionTypeTest` +- `FrontendLambdaLoweringTest` / 扩 `FrontendLoweringBodyInsnPassTest` +- `ConstructLambdaInsnGenTest` +- 扩 `FrontendCompileCheckAnalyzerTest`(阶段 I 翻转) +- 扩 `FrontendLoopControlFlowAnalyzerTest`(保持 boundary) + +**验收细则**: + +- 每条新恢复规则同时有 happy + negative(category、坏 subtree 跳过、 + 同模块其他 subtree 继续)。 +- `FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary` + **保持**:lambda 内 `break` 仍非法。 +- `FrontendLambdaCapturePlannerTest` / 对应 suite 测试必须锚定 §3.4 + 类型表:`var x := 1` → capture `int`;`var x = 1` → capture + `Variant`(不得因 initializer 推断);显式标注 / 参数 → 声明类型; + 未标注参数 → `Variant`;外层 `CAPTURE` 传递同源类型;`self` → + enclosing `GdObjectType`;for 迭代器 → `FOR_ITERATION_RESOLUTION` + 已提交结果。负例:lambda 之后对 source 的断言不改变已冻结 + capture 类型;`CallableScope` 的 CAPTURE 绑定与 `CaptureEntry.type` + 同源。 + +### 阶段 I — Compile gate 解封与文档吸收 + +**目标**:满足 `frontend_compile_check_analyzer_implementation.md` §7 五条件。 + +**实施内容**: + +- `FrontendCompileCheckAnalyzer` 将 supported executable 内的 + `LambdaExpression` 纳入 compile surface,递归扫描其 body facts。 +- 缺 `FrontendLambdaPlan` / 缺合成函数 / 缺 `construct_lambda` lowering + 合同 → `sema.compile_check` blocker 或 fail-fast,不得静默放行。 +- 更新: + - `frontend_rules.md` §MVP(删“lambda 仍 deferred”) + - `frontend_signal_support.md` 拒绝列表(其 lambda / capture 句)与 + compile-gate 句;**新增** `construct_lambda` 的 `object_id` 按 §3.5 + (`capturesSelf` 绑 `self.instance_id`,否则 `0`)。该文档现有 + “`object_id` 填 0”写的是 `construct_standalone_callable`,**不得** + 改那一句。`construct_callable` 合同也不动 + - `frontend_lowering_plan.md` §7 将 lambda 移出 post-MVP + - `frontend_variable_analyzer_implementation.md` / + `frontend_visible_value_resolver_implementation.md` / + `frontend_resolution_pipeline_implementation.md` / + `frontend_top_binding_analyzer_implementation.md` / + `scope_architecture_refactor_plan.md` 中对应 deferred 句 + - `doc/gdcc_runtime_lib.md`、`doc/gdcc_low_ir.md`(若阶段 G 已改 ABI 文本) + - `doc/test_error/test_suite_engine_integration_known_limits.md` §1 + - `doc/benchmark.md` 若仍写 “avoid lambda” +- 本计划合同吸收为 `frontend_lambda_implementation.md` 事实源, + 删除阶段流水账。 + +**验收细则**: + +- happy path:`analyzeForCompileBlocksDirectLambdaConnectArgument` **翻转** + 为 compile 成功、无 `unsupported_binding_subtree`、无多余 `compile_check`。 +- happy path:`analyzeForCompileReleasesBuiltinAndStaticMethodReferences` + 中的 `lambda_cb` 不再贡献 unsupported binding。 +- negative path:property-init / parameter-default 中的 lambda 仍不进 + compile surface。 +- negative path:lambda body 内 `match` 仍使 compile 失败,且诊断 owner + 不是误挂的 `sema.compile_check` 重复包一层。 +- Zig + `GODOT_BIN` 可用时:`sig.connect(func(): ...)` e2e 跑通。 + +--- + +## 5. 测试规则与建议命令 + +- 只跑相关测试类/方法,使用 `--no-daemon --info --console=plain`。 +- 优先: + +```text +pwsh -ExecutionPolicy Bypass -File script/run-gradle-targeted-tests.ps1 ` + -Tests FrontendLambdaInventoryTest,FrontendLambdaCapturePlannerTest,FrontendBodySemanticSupportPolicyTest +``` + +阶段 C 之后追加: + +```text +FrontendVisibleValueResolverTest,FrontendInterfacePhaseTest,FrontendSuiteResolverTest,FrontendVariableAnalyzerTest +``` + +阶段 D–F 之后追加: + +```text +FrontendTypeCheckAnalyzerTest,FrontendSemanticAnalyzerFrameworkTest,FrontendCfgGraphBuilderTest,FrontendLoweringBodyInsnPassTest +``` + +阶段 G–I 之后追加: + +```text +FrontendCompileCheckAnalyzerTest,ConstructLambdaInsnGenTest,DomLirParserTest,LirPublicAbiValidatorTest +``` + +- e2e / test_suite 仅在阶段 I 且 Zig 可用时加正向锚点;negative 留在 + frontend focused tests。 + +--- + +## 6. 必须翻转 vs 必须保持的现有测试 + +### 6.1 阶段推进后应翻转 + +| 测试 | 当前断言 | 翻转后 | +|------|----------|--------| +| `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` | inventory error + 空 scope | bind param/local/capture | +| `FrontendSemanticAnalyzerFrameworkTest.analyzePublishesTopBindings...` 中 lambda 段 | 无 binding + unsupported_* | `CAPTURE`/`PARAMETER` + `GdCallableType` | +| `FrontendInterfacePhaseTest.publishesForInventoryWhileUnsupported...` 中 lambda | body 不在 suite entry | body 在 suite entry | +| `FrontendSuiteResolverTest.forBodyResolvesWhileUnsupported...` 中 lambda | 非 supported block | supported + owner events | +| `FrontendVisibleValueResolverTest.resolveSealsLambdaBodyAsDeferredUnsupported` | `LAMBDA_SUBTREE` | 可解析 | +| `FrontendVisibleValueResolverTest.resolveRejectsSyntheticLambdaBody...` | current-scope 封口 | 不再因 kind 封口 | +| `FrontendBodySemanticSupportPolicyTest` lambda 行 | `LAMBDA_SUBTREE` | **阶段 B** 即翻成 `EXECUTABLE_BODY` | +| `FrontendTypeCheckAnalyzerTest` lambda initializer | `UNSUPPORTED` | `RESOLVED(Callable)` 或类型不兼容 | +| `FrontendBodyOwnerProceduresExprTypeTest` inferred lambda local | `UNSUPPORTED` | `Callable` | +| `FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` | unsupported binding,无 compile_check | 阶段 I 后 compile-ready | +| `FrontendCompileCheckAnalyzerTest.analyzeForCompileReleasesBuiltinAndStaticMethodReferences` 的 lambda_cb | unsupported binding | 无该 error | + +### 6.2 必须保持 + +| 测试 | 原因 | +|------|------| +| 全部 `FrontendScopeAnalyzerTest` 的 lambda 图测试 | 图形状不改 | +| `ScopeCaptureShapeTest` | 纯 scope API | +| `FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary` | 仍是 callable boundary | +| `FrontendCfgGraphBuilderTest.buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot` | alias 仍不开放 | +| `LirPublicAbiValidatorTest` / `DomLirParserTest` compiler-only capture | ABI 护栏 | +| for / match / const 的 fail-closed 锚点 | 不得误开 | +| compile-surface skip:parameter default / match 内嵌套的 lambda | MVP 外 | + +--- + +## 7. 长期不变量 + +1. lambda body 一旦转正,inventory 与 suite entry **无条件**发布;typed fact + 只用于外层/体内普通 slot 精化、`:=` 声明处稳定化,以及 compile readiness。 + capture 类型取外层绑定的声明处类型(含 `:=` 推断),声明之后的断言 + 或精化不得改写。capture 类型填充永远不构成 body 进入条件。 +2. `construct_callable` / `construct_standalone_callable` 永不承载 lambda。 +3. `CAPTURE` 不是 alias root;capture 是构造时拷贝。 +4. compiler-only 类型不得出现在 lambda 参数、返回、``、 + `expressionTypes()`。 +5. hidden lambda 函数不进 ClassDB;`is_lambda` 与 `is_hidden` 同时为真。 +6. 缺 published `FrontendLambdaPlan` 时 lowering fail-fast,禁止现场重推导。 +7. 诊断:一处根因一条 diagnostic;downstream 不得重复包。 +8. `match` / parameter default / block-local `const` / `await` 不得借这次 + 改动进入支持面。 +9. 修改本合同时必须同步 `frontend_rules.md`、compile-check 文档、 + `gdcc_low_ir.md`(若改 insn)、`gdcc_runtime_lib.md`(若改 helper)。 + +--- + +## 8. 完成定义 + +全部满足才算本计划完成: + +1. 阶段 A–I 的验收细则均有 targeted 测试锚定。 +2. `analyzeForCompile` 对 MVP 支持面内的 lambda 不再因“是 lambda”而失败。 +3. 至少一条 e2e(Zig 可用时):构造 Callable、`.call()`、以及 + `Signal.connect(func(): ...)`。 +4. `func.ftl` 多 capture 只生成一个 `_capture` 参数;prologue 初始化全部 + capture local。 +5. DOM LIR 往返保留 ``。 +6. 文档:`frontend_rules.md` MVP 句、known_limits §1、lowering plan §7、 + signal 拒绝列表已更新;本计划吸收为事实源或明确标注“已落地”。 +7. `match` / default / const 回归测试仍 fail-closed。 + +--- + +## 9. 风险与未定点(不阻塞按上列 MVP 开工) + +1. **`object_id` 来源**:已在 §3.5 冻结。实施时必须从 fat pointer 缓存 + 的 `instance_id` 取值;对可能已死的 raw 调 `godot_object_get_instance_id` + 会崩(`object_value_fat_pointer_implementation.md`)。 +2. **capture 类型**:已在 §3.4 冻结为外层绑定的**声明处类型** + (`var x := 1` → `int`,`var x = 1` → `Variant`)。填充时点是 + 该 lambda nested resolve 入口;读取路径是外层 typed environment + 上按 `sourceDeclaration` 匹配的已 flush 稳定化 update,并同步 + `resetCaptureType`。不得读物理 scope slot,不得回落 + `VAR_TYPE_POST` / 函数末尾 overlay。 +3. **嵌套 suite export**:lambda 的 `FrontendCallableExportBatch` 不得写进 + 外层函数的 statement overlay。必须独立 apply。 +4. **表达式中的 lambda 与 Interface 遍历顺序**:必须测 + `return func(): ...`、`foo(func(): ...)`、容器字面量里的 lambda + (容器字面量已 compile-ready,lambda 元素要走 nested resolve)。 + Interface 默认 `CONTINUE` 已能走进这些表达式,无需新 walker;只需 + `supportedBodyDepth` 守卫。 +5. **capture 与 `__prepare__`**:阶段 G 必须从 + `CCodegen.generateFunctionPrepareBlock` 排除 capture local,再 copy。 +6. **参数默认值**:lambda 的 default 与普通函数同一边界,不在本计划打开。 +7. Godot 把 capture 作为**前缀实参**喂给 `GDScriptFunction::call`;GDCC 把 + capture 放进 userdata 结构体。这是 ABI 差异,不是语义差异;测试应对齐 + 用户可见 arity 与 capture 值,而不是对齐 Godot 调用约定。 + +--- + +## 10. 与 Godot 的有意识差异 + +| 点 | Godot | GDCC MVP | +|----|-------|----------| +| self-lambda 身份 | 独立 `GDScriptLambdaSelfCallable`,`get_object()=instance` | 仍用 `construct_lambda` + `self` capture;`object_id = self.instance_id` | +| 非 self-lambda 身份 | `GDScriptLambdaCallable.get_object()=script` | `object_id = 0`(无 GDScript 资源可绑) | +| capture 传递 | 调用时插到实参前的 `Variant*` | userdata 结构体 + 函数尾部 `_capture` | +| lambda 名 | 可有 debug 名 | `_lambda_` hidden | +| 相等 | 引用相等 | 引用相等 | +| 已释放的 Object capture | debug 下替换 null | 遵循 GDCC ownership;不强制复刻打印 | + +`capturesSelf` 的 `Signal.connect(func(): self.foo())` **必须**按 Godot +随实例释放自动断开。不捕获 `self` 的 lambda 不得假装绑到 instance 或 script。 + +--- + +## 11. 参考事实源 + +### 11.1 本仓库 + +- `src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java` +- `src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java` +- `src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java` +- `src/main/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonBuilder.java` +- `src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java` +- `src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java` +- `src/main/java/gd/script/gdcc/lir/LirFunctionDef.java` +- `src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java` +- `src/main/c/codegen/include_451/gdcc/gdcc_callable.h` +- `src/main/c/codegen/template_451/func.ftl` +- `src/main/c/codegen/template_451/entry.h.ftl` +- `src/main/c/codegen/template_451/entry.c.ftl` + +### 11.2 Godot + +- `modules/gdscript/gdscript_parser.h`(`LambdaNode`) +- `modules/gdscript/gdscript_analyzer.cpp`(capture 沿 parent lambda 链复制) +- `modules/gdscript/gdscript_compiler.cpp`(`write_lambda`) +- `modules/gdscript/gdscript_byte_codegen.cpp`(`OPCODE_CREATE_*_LAMBDA`) +- `modules/gdscript/gdscript_lambda_callable.h/.cpp` +- `modules/gdscript/gdscript_vm.cpp`(`OPCODE_CREATE_LAMBDA` / `OPCODE_CREATE_SELF_LAMBDA`) From 3beae81fdd70779314bf4d90bcbc5c2247c66e78 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Mon, 17 Aug 2026 16:04:03 +0800 Subject: [PATCH 2/9] feat(frontend): implement lambda capture planning pipeline - Track ordered lambda captures with self and nested-scope handling - Publish validated lambda plans through frontend analysis data - Serialize capture metadata into LIR and generate compatible C function headers - Add coverage for capture planning, side-table stability, serialization, and templates - Document lambda implementation and diagnostic requirements --- .../frontend/diagnostic_manager.md | 4 + .../frontend/frontend_lambda_plan.md | 104 ++++- src/main/c/codegen/template_451/func.ftl | 4 +- .../frontend/sema/FrontendAnalysisData.java | 26 +- .../sema/FrontendLambdaCapturePlan.java | 76 ++++ .../sema/FrontendLambdaCapturePlanner.java | 202 ++++++++++ .../frontend/sema/FrontendLambdaPlan.java | 60 +++ .../frontend/sema/LambdaCaptureEntry.java | 60 +++ .../sema/patch/FrontendOwnerPatch.java | 5 + .../patch/FrontendPublishedFactTypeGuard.java | 18 + .../gd/script/gdcc/lir/LirFunctionDef.java | 6 +- .../gdcc/lir/parser/DomLirSerializer.java | 10 +- .../c/gen/FuncHeaderCaptureTemplateTest.java | 110 ++++++ .../sema/FrontendAnalysisDataTest.java | 1 + .../FrontendLambdaCapturePlannerTest.java | 366 ++++++++++++++++++ .../sema/FrontendLambdaPlanSideTableTest.java | 113 ++++++ .../gdcc/lir/parser/DomLirSerializerTest.java | 60 ++- 17 files changed, 1199 insertions(+), 26 deletions(-) create mode 100644 src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlan.java create mode 100644 src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java create mode 100644 src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java create mode 100644 src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java create mode 100644 src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java create mode 100644 src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java create mode 100644 src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index 97fe791c..e06b9770 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -130,6 +130,10 @@ frontend 当前已经冻结的诊断承载方式如下: - `resolvedMembers` - `resolvedCalls` - `slotTypes` +- `forIterationPlans` +- `typeTestTargets` +- `containerLiteralPlans` +- `lambdaPlans`(阶段 A 数据面已接线;生产 inventory 仍 fail-closed) 其中: diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index c15026c9..c45db642 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,10 +9,10 @@ ## 文档状态 -- 状态:计划稿(尚未实施)。当前生产代码对 lambda 全链路 fail-closed; - scope 双层图、`CallableScope.defineCapture`、LIR `construct_lambda` / - `is_lambda` / `` 与 C 模板 capture struct 已预留但未接线。 -- 更新时间:2026-08-15 +- 状态:阶段 A 已落地;B–I 尚未实施。当前生产代码对 lambda 全链路仍 fail-closed; + 数据面(`FrontendLambdaPlan` / side table / planner 纯函数)、`func.ftl` + 单 `_capture` 形参与 `DomLirSerializer` 真实 `` 已接线。 +- 更新时间:2026-08-17(阶段 A 落地;已冻结 capture 类型首次 publish 与独立 stage) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -158,8 +158,8 @@ Godot self-lambda 的 object-id / disconnect-by-object 差异记入 §3.5 与 § | 阶段 | 组件 | 职责 | |------|------|------| | Scope | `FrontendScopeAnalyzer` | 已建双层图;本计划不改图形状 | -| Inventory | `FrontendVariableAnalyzer` | bind lambda param/local;推导并 `defineCapture` | -| Capture plan | 新增 `FrontendLambdaCapturePlan` | 冻结 capture 名、类型、来源 binding、是否含 `self` | +| Inventory | `FrontendVariableAnalyzer` | bind lambda param/local;推导并 `defineCapture`(`Variant` 占位)。**不**写 `lambdaPlans()` | +| Capture plan | 阶段 C 独立 owner | 在 nested resolve 入口填声明处类型后**第一次**发布 `FrontendLambdaPlan` | | Interface | `FrontendInterfacePhase` | 把 `LambdaExpression` 记为 callable owner;body 进 suite entry | | Suite | `FrontendSuiteResolver` | 把 lambda 当 nested callable owner 解析 | | Expr typing | `FrontendExpressionSemanticSupport` | 发布 `RESOLVED(GdCallableType)` | @@ -359,15 +359,36 @@ Capture 类型等于外层被捕获绑定在其**声明语句完成时**的 sour `Variant` 占位。真实类型在 **该 lambda 自己的 nested suite resolution 开始、其 body 语句尚未处理时** 填充,写入后立即冻结。 +**`lambdaPlans()` 首次发布(已冻结,2026-08-17)**: + +- 阶段 B **不得**把 `Variant` 占位写入 `FrontendAnalysisData.lambdaPlans()`。 + inventory 只通过 `CallableScope.defineCapture` 暴露名字;inspection / + 阶段 B 测试读 scope 上的 `CAPTURE` 绑定,不读 side table。 +- 阶段 C 在 nested resolve 入口填完声明处类型后,**第一次** publish + 完整 `FrontendLambdaPlan`(`LambdaCaptureEntry.type` 已是声明处类型, + `capturesSelf` 已按 §3.5 与 leading `self` 对齐)。 +- `applyPatch` / `mergeSideTable` 保持 first-wins。禁止对同一 + `LambdaExpression` 先发占位再 `withType` 走 patch 覆盖。 + `LambdaCaptureEntry.withType` 只用于构造最终 plan 的本地组装,不是 + side-table 回写 API。 +- 阶段 C 必须新增**独立** semantic stage + `OverlayFacts.lambdaPlans` + 槽 + 专用 `FrontendOwnerPatch`(建议名 `LAMBDA_RESOLUTION` / + `FrontendLambdaResolutionPatch`)。**不得**把 plan 折进 + `FrontendExprTypePatch`。`FrontendPatchTransaction.order` 必须给 + 该 stage 编号;位置在 inventory 之后、且必须能在外层 EXPR_TYPE + 发布 `GdCallableType` **之前**随 nested resolve export。 +- lowering 只消费已发布的完整 plan;缺 plan 仍 fail-fast,禁止 + 现场从 scope 重推导。 + 填充必须同时更新三处,禁止只写 plan 不写 scope: -- `CaptureEntry.type` -- LIR ``(lowering 时消费 plan) +- `LambdaCaptureEntry.type`(随第一次 publish 写入 `lambdaPlans()`) +- LIR ``(lowering 时消费 **已发布** plan,不再回读 scope) - 该 lambda `CallableScope` 上的 `CAPTURE` 绑定:新增 `resetCaptureType`(镜像 `BlockScope.resetLocalType` 的单调 `Variant → exact` 守卫与 declaration 身份校验)。body type-check 经 `effectiveScopeValue` 读 CAPTURE 时**不会**套 overlay,因此 - scope 绑定必须与 `CaptureEntry.type` 同源。 + scope 绑定必须与 `LambdaCaptureEntry.type` 同源。 **读取路径**(禁止读物理 scope slot,禁止回落 `slotTypes()` / `VAR_TYPE_POST`): @@ -418,7 +439,7 @@ planner 可能仍登记名字,但该条目不得进入 lowering;use-site 函数末尾 slot 类型回写 `CAPTURE` / `FrontendLambdaPlan` / ``。 即使后来断言 `x` 为其他具体类型,capture 仍是声明处类型。 - 对 `var x = 1` 因看到 initializer `1` 而把 capture 写成 `int`。 -- 只更新 `CaptureEntry.type` 却让 `CallableScope` 的 CAPTURE 停在 +- 只更新 `LambdaCaptureEntry.type` 却让 `CallableScope` 的 CAPTURE 停在 `Variant` 占位。 - 引入 compiler-only 类型。`LirPublicAbiValidator` 继续拒绝 compiler-only capture。 @@ -450,6 +471,9 @@ MVP 冻结: - 若 lambda body 出现显式 `SelfExpression`,或 instance restriction 下的 implicit instance member / instance method 调用需要 receiver,则把 enclosing executable 的 `self` 收为名为 `self` 的 capture。 +- `capturesSelf == true` **当且仅当** capture 列表的**第一项**名为 `self`。 + 其余 capture 仍按首次出现的源码顺序排在其后。不得把 `self` 插在中间, + 也不得在没有 leading `self` 时把 flag 设为 true。 - 该 capture 的类型是 enclosing class 的 `GdObjectType`(与函数 `self` 参数一致)。 - lowering 把外层 `self` slot 作为 `construct_lambda` 的一个 capture operand。 - 合成 lambda `LirFunctionDef` **不**再注入第二份 `ensureExecutableSelfParameter`; @@ -526,7 +550,7 @@ IdentityHashMap - `LambdaExpression` AST 身份 - 合成名 `_lambda_` -- 有序列表 `captures: List` +- 有序列表 `captures: List` - `capturesSelf: boolean` - enclosing callable AST - owning class canonical name @@ -588,14 +612,40 @@ $result = construct_lambda "" $capture1 $capture2 ... **目标**:先把 plan / side table / 命名 / 模板缺陷修掉,生产封口不变。 +**状态**:已落地(2026-08-17)。 + **实施内容**: -- 新增 `FrontendLambdaPlan` / `FrontendLambdaCapturePlan` / `CaptureEntry`。 +- 新增 `FrontendLambdaPlan` / `FrontendLambdaCapturePlan` / `LambdaCaptureEntry`。 - `FrontendAnalysisData` 增加 lambda plan map 的存取 API。 - 单测 `ScopeCaptureShapeTest` 风格的 planner 纯函数测试可先用手工 scope 图。 - 修复 `func.ftl`:`_capture` 只发射一次(纯模板修复,不改变语义封口)。 - `DomLirSerializer` 写出真实 ``(可先用手工 LIR fixture 测)。 +**落地记录**: + +- 数据类:`LambdaCaptureEntry`、`FrontendLambdaCapturePlan`、`FrontendLambdaPlan` + (`frontend/sema`)。`LambdaCaptureEntry.withType` 只用于本地组装最终 plan, + 不是 side-table 回写。`capturesSelf` 当且仅当列表第一项名为 `self`。 +- Side table:`FrontendAnalysisData.lambdaPlans()` / + `updateLambdaPlans(...)`,keyed by `LambdaExpression` identity。 + 现有 owner patch 通过 `FrontendOwnerPatch.lambdaPlans()` 默认空表接入 + `applyPatch`;阶段 A 不新增 semantic stage,也不把 plan 折进 + `FrontendExprTypePatch`。完整 plan 的第一次生产发布在阶段 C。 +- Planner:`FrontendLambdaCapturePlanner` 纯函数,输入手工 scope 图 + + 有序 `IdentifierUse`;不写 scope / side table / 诊断。嵌套传递通过 + `ParentLambda(callable, body)` 从父 lambda **body** 向上查找,避免 + 漏掉中间层 local 遮蔽。 +- `func.ftl`:`captureCount > 0` 时只发射一次 `_capture`。 +- `DomLirSerializer`:lambda 按 `getCaptureList()` 写出 + ``;`LirFunctionDef.captures` 改为 `LinkedHashMap` + 以冻结插入序。 +- 测试:`FrontendLambdaCapturePlannerTest`、 + `FrontendLambdaPlanSideTableTest`、`FuncHeaderCaptureTemplateTest`、 + `DomLirSerializerTest` 往返 / ABI / 非 lambda `addCapture`。 + `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` + 保持 fail-closed 红字断言。 + **验收细则**: - happy path:手工构造 `is_lambda` + 两个 capture 的 `LirFunctionDef`, @@ -625,8 +675,10 @@ $result = construct_lambda "" $capture1 $capture2 ... **supported executable 内**的 lambda 报 inventory 错。 - 引入 planner:在 bind param/local 之后按 §3.4 innermost-first `defineCapture`。本阶段只登记名字 + `sourceDeclaration`, - `defineCapture` 的类型用 `Variant` 占位;真实类型按 §3.4 在 - nested resolve 入口填充(阶段 C/D)。 + `defineCapture` 的类型用 `Variant` 占位。**禁止** + `updateLambdaPlans` / overlay 发布占位 plan。真实类型与 + `FrontendLambdaPlan` 的第一次 publish 按 §3.4 留到阶段 C + nested resolve 入口。 - **本阶段不改** `FrontendInterfacePhase.handleLambdaExpression`(仍 `SKIP_CHILDREN`),因此 `entersSuiteResolver=true` 无消费者。 - property-init / parameter-default / skipped subtree 内的 lambda: @@ -655,6 +707,8 @@ $result = construct_lambda "" $capture1 $capture2 ... 不是 variable inventory),不得 bind、不得 `recordCallable`。 - negative path:lambda 参数与将捕获的同名冲突 → 一条 `sema.variable_binding`,不抛异常。 +- negative path:本阶段结束时 `lambdaPlans()` 对任何 lambda 仍为空; + capture 名只出现在 `CallableScope` 的 `CAPTURE` 绑定上。 ### 阶段 C — Policy / resolver / interface / suite 解封 @@ -671,8 +725,15 @@ $result = construct_lambda "" $capture1 $capture2 ... `SKIP_CHILDREN`。 - `FrontendSuiteResolver` 四个 helper 增加 `LambdaExpression`。 `resolveCallableOwner` 对 lambda 必须在走 body 语句之前按 §3.4 - 填充 capture 类型(declaration-anchored overlay + `resetCaptureType`)。 - 该填充不是 body 进入条件。 + 填充 capture 类型(declaration-anchored overlay + `resetCaptureType`), + 并经独立 `LAMBDA_RESOLUTION` owner 把完整 `FrontendLambdaPlan` + **第一次**写入 overlay → suite export → `lambdaPlans()`。 + 该填充 / publish 不是 body 进入条件。 +- 新增 `FrontendSemanticStage.LAMBDA_RESOLUTION`(或等价名)、 + `FrontendLambdaResolutionPatch`、`OverlayFacts.lambdaPlans`。 + `toOwnerPatches` / `mergeFrom` / `hasFacts` / `clear` / + `FrontendPublishedFactTypeGuard.checkOwnerPatch` 必须接线。 + 不得复用 `FrontendExprTypePatch` 或改 `mergeSideTable` 覆盖语义。 - `FrontendBodyOwnerProcedures`:对**已 record** 的 lambda,把 unsupported binding/chain 诊断换成 nested `resolveCallableOwner` 触发。`walkRootBounded` 对 `LambdaExpression` 的剪枝**必须保留**, @@ -697,6 +758,12 @@ $result = construct_lambda "" $capture1 $capture2 ... **翻转**为可解析外层 capture / 当前 param / 当前 local。 - happy path:lambda body 内 `return seed` 对 `seed` 发布 `symbolBindings` + `CAPTURE`。 +- happy path:nested resolve 完成后 `lambdaPlans()[lambda]` 存在, + capture 类型是声明处类型(`var a := 1` → `int`,`var b = 1` → + `Variant`),且与 `CallableScope` 上对应 `CAPTURE` 同源。 + 阶段 B 结束时同一 key **仍不存在**。 +- negative path:对已发布 plan 再 patch 不同 payload 必须冲突; + 不得靠 `withType` + `applyPatch` 覆盖。 - negative path:`match` / const / parameter default 仍 `DEFERRED_UNSUPPORTED`。 - negative path:合成 `LAMBDA_BODY` scope 不再被 current-scope backstop 误封 (原 `resolveRejectsSyntheticLambdaBodyCurrentScope*` 翻转或改为 @@ -880,7 +947,7 @@ lambda body 走既有 CFG / body insn pass。 未标注参数 → `Variant`;外层 `CAPTURE` 传递同源类型;`self` → enclosing `GdObjectType`;for 迭代器 → `FOR_ITERATION_RESOLUTION` 已提交结果。负例:lambda 之后对 source 的断言不改变已冻结 - capture 类型;`CallableScope` 的 CAPTURE 绑定与 `CaptureEntry.type` + capture 类型;`CallableScope` 的 CAPTURE 绑定与 `LambdaCaptureEntry.type` 同源。 ### 阶段 I — Compile gate 解封与文档吸收 @@ -1039,6 +1106,9 @@ FrontendCompileCheckAnalyzerTest,ConstructLambdaInsnGenTest,DomLirParserTest,Lir 上按 `sourceDeclaration` 匹配的已 flush 稳定化 update,并同步 `resetCaptureType`。不得读物理 scope slot,不得回落 `VAR_TYPE_POST` / 函数末尾 overlay。 + **首次 publish**:阶段 B 不写 `lambdaPlans()`;阶段 C 填完后 + 第一次发布完整 plan;独立 `LAMBDA_RESOLUTION` stage + overlay + 槽 + patch;禁止 first-wins merge 上的 `withType` 回填。 3. **嵌套 suite export**:lambda 的 `FrontendCallableExportBatch` 不得写进 外层函数的 statement overlay。必须独立 apply。 4. **表达式中的 lambda 与 Interface 遍历顺序**:必须测 diff --git a/src/main/c/codegen/template_451/func.ftl b/src/main/c/codegen/template_451/func.ftl index 3fe66567..48dab17b 100644 --- a/src/main/c/codegen/template_451/func.ftl +++ b/src/main/c/codegen/template_451/func.ftl @@ -8,7 +8,7 @@ ${helper.renderGdTypeInC(func.returnType)} ${classDef.name}_${func.name}( <#list func.parameters as param> ${helper.renderGdTypeRefInC(param.type)} $${param.name}<#if param_has_next || func.captureCount gt 0>, - <#list func.captureList as capture> + <#if func.captureCount gt 0> <@lambdaCaptureName classDef func/>* _capture - + ) \ No newline at end of file diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java index faf3186c..44a95432 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java @@ -55,6 +55,9 @@ public final class FrontendAnalysisData { /// Published container-literal construction plans keyed by `ArrayExpression` / `DictionaryExpression`. /// Freezes element boundary decisions and duplicate-key issues for type-check / CFG / lowering. private final @NotNull FrontendAstSideTable containerLiteralPlans; + /// Published lambda identity/capture plans keyed by `LambdaExpression`. Phase A only stores the + /// carrier; production inventory still stays fail-closed until Phase B. + private final @NotNull FrontendAstSideTable lambdaPlans; private FrontendAnalysisData( @NotNull FrontendAstSideTable> annotationsByAst, @@ -67,7 +70,8 @@ private FrontendAnalysisData( @NotNull FrontendAstSideTable slotTypes, @NotNull FrontendAstSideTable forIterationPlans, @NotNull FrontendAstSideTable typeTestTargets, - @NotNull FrontendAstSideTable containerLiteralPlans + @NotNull FrontendAstSideTable containerLiteralPlans, + @NotNull FrontendAstSideTable lambdaPlans ) { this.annotationsByAst = Objects.requireNonNull(annotationsByAst, "annotationsByAst must not be null"); this.skippedSubtreeRoots = Objects.requireNonNull( @@ -92,6 +96,7 @@ private FrontendAnalysisData( containerLiteralPlans, "containerLiteralPlans must not be null" ); + this.lambdaPlans = Objects.requireNonNull(lambdaPlans, "lambdaPlans must not be null"); } /// Creates an empty analysis data carrier with the full side-table topology already present. @@ -107,6 +112,7 @@ private FrontendAnalysisData( new FrontendAstSideTable<>(), new FrontendAstSideTable<>(), new FrontendAstSideTable<>(), + new FrontendAstSideTable<>(), new FrontendAstSideTable<>() ); } @@ -175,6 +181,11 @@ public void updateContainerLiteralPlans( replaceSideTableContents(this.containerLiteralPlans, containerLiteralPlans, "containerLiteralPlans"); } + public void updateLambdaPlans(@NotNull FrontendAstSideTable lambdaPlans) { + FrontendPublishedFactTypeGuard.checkLambdaPlans(lambdaPlans); + replaceSideTableContents(this.lambdaPlans, lambdaPlans, "lambdaPlans"); + } + /// Applies one single-owner patch without replacing any stable side-table reference. /// /// Conflict checks and local-slot validation are scoped to this patch. Repeated calls, including @@ -192,6 +203,7 @@ public void applyPatch(@NotNull FrontendOwnerPatch patch) { checkedPatch.forIterationPlans(), checkedPatch.typeTestTargets(), checkedPatch.containerLiteralPlans(), + checkedPatch.lambdaPlans(), checkedPatch.localSlotTypeUpdates() ); } @@ -206,6 +218,7 @@ private void applyPatchFields( @NotNull FrontendAstSideTable patchForIterationPlans, @NotNull FrontendAstSideTable patchTypeTestTargets, @NotNull FrontendAstSideTable patchContainerLiteralPlans, + @NotNull FrontendAstSideTable patchLambdaPlans, @NotNull List localSlotTypeUpdates ) { var validatedLocalSlotUpdates = validateLocalSlotTypeUpdates(stage, localSlotTypeUpdates); @@ -247,6 +260,12 @@ private void applyPatchFields( "containerLiteralPlans", FrontendContainerLiteralPlan::samePlan ); + checkPatchConflicts( + lambdaPlans, + patchLambdaPlans, + "lambdaPlans", + FrontendLambdaPlan::samePlan + ); mergeSideTable(symbolBindings, patchSymbolBindings); mergeSideTable(resolvedMembers, patchResolvedMembers); @@ -256,6 +275,7 @@ private void applyPatchFields( mergeSideTable(forIterationPlans, patchForIterationPlans); mergeSideTable(typeTestTargets, patchTypeTestTargets); mergeSideTable(containerLiteralPlans, patchContainerLiteralPlans); + mergeSideTable(lambdaPlans, patchLambdaPlans); for (var validatedUpdate : validatedLocalSlotUpdates) { applyLocalSlotTypeUpdate(validatedUpdate); } @@ -316,6 +336,10 @@ private void applyPatchFields( return containerLiteralPlans; } + public @NotNull FrontendAstSideTable lambdaPlans() { + return lambdaPlans; + } + /// Refreshes published local bindings after a verified local-slot rewrite. /// /// This helper intentionally updates only the `resolvedValue` payload. The binding kind, source diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlan.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlan.java new file mode 100644 index 00000000..391f9017 --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlan.java @@ -0,0 +1,76 @@ +package gd.script.gdcc.frontend.sema; + +import org.jetbrains.annotations.NotNull; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/// Ordered capture list frozen for one `LambdaExpression`. +/// +/// Ordinary captures keep first-use source order. When this lambda captures enclosing `self`, +/// that entry is always first and `capturesSelf` is true; remaining entries stay first-use order. +/// Lowering uses the flag for `object_id` and reads `_capture->self` from the same first slot. +/// +/// @param captures unique-by-name capture entries; if `capturesSelf`, index 0 is `self` +/// @param capturesSelf true iff the list starts with the enclosing-instance `self` capture +public record FrontendLambdaCapturePlan( + @NotNull List captures, + boolean capturesSelf +) { + public static final @NotNull String SELF_CAPTURE_NAME = "self"; + + public FrontendLambdaCapturePlan { + captures = List.copyOf(Objects.requireNonNull(captures, "captures must not be null")); + var seen = new HashSet(); + for (var capture : captures) { + Objects.requireNonNull(capture, "captures must not contain null"); + if (!seen.add(capture.name())) { + throw new IllegalArgumentException("duplicate capture name '" + capture.name() + "'"); + } + } + var startsWithSelf = !captures.isEmpty() && SELF_CAPTURE_NAME.equals(captures.getFirst().name()); + if (capturesSelf != startsWithSelf) { + throw new IllegalArgumentException( + "capturesSelf must match a leading '" + SELF_CAPTURE_NAME + "' capture" + ); + } + for (var i = 1; i < captures.size(); i++) { + if (SELF_CAPTURE_NAME.equals(captures.get(i).name())) { + throw new IllegalArgumentException( + "'" + SELF_CAPTURE_NAME + "' capture must be the first entry" + ); + } + } + } + + /// Derives `capturesSelf` from whether the ordered list starts with `self`. + public static @NotNull FrontendLambdaCapturePlan of(@NotNull List captures) { + var copied = List.copyOf(Objects.requireNonNull(captures, "captures must not be null")); + return new FrontendLambdaCapturePlan(copied, startsWithSelfCapture(copied)); + } + + static boolean startsWithSelfCapture(@NotNull List captures) { + return !captures.isEmpty() && SELF_CAPTURE_NAME.equals(captures.getFirst().name()); + } + + public static boolean samePlan( + @NotNull FrontendLambdaCapturePlan first, + @NotNull FrontendLambdaCapturePlan second + ) { + Objects.requireNonNull(first, "first must not be null"); + Objects.requireNonNull(second, "second must not be null"); + if (first.capturesSelf() != second.capturesSelf()) { + return false; + } + if (first.captures().size() != second.captures().size()) { + return false; + } + for (var i = 0; i < first.captures().size(); i++) { + if (!LambdaCaptureEntry.sameEntry(first.captures().get(i), second.captures().get(i))) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java new file mode 100644 index 00000000..4e3753f7 --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java @@ -0,0 +1,202 @@ +package gd.script.gdcc.frontend.sema; + +import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.scope.ResolveRestriction; +import gd.script.gdcc.scope.Scope; +import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; + +/// Pure capture derivation over an already-built scope graph. +/// +/// The planner only inspects identifier uses and existing `ScopeValue` bindings. It never writes +/// scopes, never publishes side tables, and never emits diagnostics. Production inventory (Phase B) +/// will call `defineCapture` from this result; Phase A tests feed hand-built graphs. +/// +/// Algorithm (innermost-first, aligned with Godot, independent of Godot AST): +/// 1. Look up each use from **that identifier's own scope**, not from the lambda `CallableScope.parent`. +/// 2. Capture only when the first hit is outside this lambda's callable boundary and the kind is +/// `PARAMETER` / `LOCAL` / `CAPTURE`. +/// 3. Self-shadowing (this lambda's own parameter / local) is not a capture. +/// 4. Class / global / utility / type-meta hits stay ordinary lexical lookups. +/// 5. First source appearance freezes order. +/// 6. Nested transfer: when the source binding lives outside a parent lambda, that parent also +/// records the same name / `sourceDeclaration` unless it already shadows the name. +public final class FrontendLambdaCapturePlanner { + private FrontendLambdaCapturePlanner() { + } + + /// One identifier use inside a lambda body, already bound to the scope that owns the use site. + /// + /// @param name identifier text + /// @param startingScope scope of the use site; lookup walks from here, not from the lambda parent + public record IdentifierUse(@NotNull String name, @NotNull Scope startingScope) { + public IdentifierUse { + Objects.requireNonNull(name, "name must not be null"); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + Objects.requireNonNull(startingScope, "startingScope must not be null"); + } + } + + /// Derives the capture list for one lambda from ordered identifier uses. + /// + /// `identifierUses` must already be source-order and must omit declaration sites + /// (`Parameter.name` / `VariableDeclaration.name`). Nested lambdas are planned first by the + /// caller (post-order); this method only sees the current lambda. + public static @NotNull FrontendLambdaCapturePlan planCaptures( + @NotNull CallableScope lambdaScope, + @NotNull List identifierUses + ) { + Objects.requireNonNull(lambdaScope, "lambdaScope must not be null"); + Objects.requireNonNull(identifierUses, "identifierUses must not be null"); + var capturesByName = new LinkedHashMap(); + for (var use : identifierUses) { + Objects.requireNonNull(use, "identifierUses must not contain null"); + if (capturesByName.containsKey(use.name())) { + continue; + } + var capture = captureFromUse(lambdaScope, use); + if (capture != null) { + capturesByName.put(use.name(), capture); + } + } + return FrontendLambdaCapturePlan.of(List.copyOf(capturesByName.values())); + } + + /// Copies an inner capture onto a parent lambda when the source lives outside the parent and + /// the parent does not already shadow the name with its own parameter / local / capture. + /// + /// `parentBodyScope` must be a scope inside the parent lambda (typically `LAMBDA_BODY`) so + /// parent locals are visible. Looking up from the parent `CallableScope` alone would walk + /// *outward* and miss those locals. + public static @Nullable LambdaCaptureEntry transferredCapture( + @NotNull CallableScope parentLambdaScope, + @NotNull Scope parentBodyScope, + @NotNull LambdaCaptureEntry innerCapture + ) { + Objects.requireNonNull(parentLambdaScope, "parentLambdaScope must not be null"); + Objects.requireNonNull(parentBodyScope, "parentBodyScope must not be null"); + Objects.requireNonNull(innerCapture, "innerCapture must not be null"); + var source = firstValueHit(parentBodyScope, innerCapture.name()); + if (source == null || !isCapturableKind(source.kind())) { + return null; + } + if (isOwnedByCallable(parentLambdaScope, parentBodyScope, source)) { + return null; + } + return new LambdaCaptureEntry(innerCapture.name(), source.type(), source.kind(), source.declaration()); + } + + /// Walks the parent-lambda chain and returns the transfer entries each intermediate lambda must + /// record. The list is innermost-parent first; a shadowing parent stops the chain. + public static @NotNull List transferredCapturesAlongParents( + @NotNull List parentsInnermostFirst, + @NotNull LambdaCaptureEntry innerCapture + ) { + Objects.requireNonNull(parentsInnermostFirst, "parentsInnermostFirst must not be null"); + Objects.requireNonNull(innerCapture, "innerCapture must not be null"); + var transferred = new ArrayList(); + for (var parent : parentsInnermostFirst) { + Objects.requireNonNull(parent, "parentsInnermostFirst must not contain null"); + var next = transferredCapture(parent.callableScope(), parent.bodyScope(), innerCapture); + if (next == null) { + break; + } + transferred.add(next); + } + return List.copyOf(transferred); + } + + /// Parent lambda view used for nested capture transfer: callable boundary plus a body scope + /// that can see that lambda's own locals. + public record ParentLambda(@NotNull CallableScope callableScope, @NotNull Scope bodyScope) { + public ParentLambda { + Objects.requireNonNull(callableScope, "callableScope must not be null"); + Objects.requireNonNull(bodyScope, "bodyScope must not be null"); + } + } + + /// Resolves `name` from the use site and reports whether the first capturable hit is outside + /// `lambdaScope`. Used by tests that assert self-shadowing without building a full plan. + public static boolean isCaptureFromUse(@NotNull CallableScope lambdaScope, @NotNull IdentifierUse use) { + return captureFromUse(lambdaScope, use) != null; + } + + /// Type recorded for a capture: the declaration-site type already stored on the resolved + /// `ScopeValue`. Phase A does not read physical slot overlays; later phases replace the + /// inventory `Variant` placeholder through `LambdaCaptureEntry.withType`. + public static @NotNull GdType captureTypeOf(@NotNull ScopeValue sourceBinding) { + return Objects.requireNonNull(sourceBinding, "sourceBinding must not be null").type(); + } + + private static @Nullable LambdaCaptureEntry captureFromUse( + @NotNull CallableScope lambdaScope, + @NotNull IdentifierUse use + ) { + var hit = firstValueHit(use.startingScope(), use.name()); + if (hit == null || !isCapturableKind(hit.kind())) { + return null; + } + if (isOwnedByCallable(lambdaScope, use.startingScope(), hit)) { + return null; + } + return new LambdaCaptureEntry(use.name(), hit.type(), hit.kind(), hit.declaration()); + } + + private static boolean isCapturableKind(@NotNull ScopeValueKind kind) { + return kind == ScopeValueKind.PARAMETER + || kind == ScopeValueKind.LOCAL + || kind == ScopeValueKind.CAPTURE; + } + + /// Ownership for a use site: walk from the use scope toward the root. The first scope that + /// publishes `hit` owns it. Bindings introduced at `lambdaScope` or in blocks nested inside it + /// (before another callable) belong to this lambda. + private static boolean isOwnedByCallable( + @NotNull CallableScope lambdaScope, + @NotNull Scope startingScope, + @NotNull ScopeValue hit + ) { + var current = startingScope; + while (current != null) { + var here = current.resolveValueHere(hit.name(), ResolveRestriction.unrestricted()) + .allowedValueOrNull(); + if (here == hit) { + return current == lambdaScope || isNestedUnder(current, lambdaScope); + } + if (current == lambdaScope) { + return false; + } + current = current.getParentScope(); + } + return false; + } + + private static boolean isNestedUnder(@NotNull Scope inner, @NotNull CallableScope lambdaScope) { + var current = inner.getParentScope(); + while (current != null) { + if (current == lambdaScope) { + return true; + } + if (current instanceof CallableScope) { + return false; + } + current = current.getParentScope(); + } + return false; + } + + private static @Nullable ScopeValue firstValueHit(@NotNull Scope start, @NotNull String name) { + var result = start.resolveValue(name, ResolveRestriction.unrestricted()); + return result.isFound() ? result.requireValue() : null; + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java new file mode 100644 index 00000000..01667899 --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java @@ -0,0 +1,60 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Node; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +/// Frozen semantic identity of one `LambdaExpression`. +/// +/// Keyed in `FrontendAnalysisData.lambdaPlans()` by the lambda AST node. Phase A only publishes the +/// carrier; synthetic `_lambda_` numbering and production inventory still belong to later phases. +/// +/// @param lambda AST identity of the lambda expression +/// @param syntheticName compiler-owned function name, conventionally `_lambda_` +/// @param capturePlan ordered captures and `capturesSelf` +/// @param enclosingCallable nearest non-lambda callable AST (`FunctionDeclaration` / +/// `ConstructorDeclaration`); identity, not a reconstructed node +/// @param owningClassCanonicalName canonical name of the owning `LirClassDef` +public record FrontendLambdaPlan( + @NotNull LambdaExpression lambda, + @NotNull String syntheticName, + @NotNull FrontendLambdaCapturePlan capturePlan, + @NotNull Node enclosingCallable, + @NotNull String owningClassCanonicalName +) { + public FrontendLambdaPlan { + Objects.requireNonNull(lambda, "lambda must not be null"); + Objects.requireNonNull(syntheticName, "syntheticName must not be null"); + if (syntheticName.isBlank()) { + throw new IllegalArgumentException("syntheticName must not be blank"); + } + Objects.requireNonNull(capturePlan, "capturePlan must not be null"); + Objects.requireNonNull(enclosingCallable, "enclosingCallable must not be null"); + Objects.requireNonNull(owningClassCanonicalName, "owningClassCanonicalName must not be null"); + if (owningClassCanonicalName.isBlank()) { + throw new IllegalArgumentException("owningClassCanonicalName must not be blank"); + } + } + + public @NotNull List captures() { + return capturePlan.captures(); + } + + public boolean capturesSelf() { + return capturePlan.capturesSelf(); + } + + /// Logical equivalence for idempotent merge. The side table is already keyed by `lambda` + /// identity, so this compares payload only. `enclosingCallable` is identity-equal. + public static boolean samePlan(@NotNull FrontendLambdaPlan first, @NotNull FrontendLambdaPlan second) { + Objects.requireNonNull(first, "first must not be null"); + Objects.requireNonNull(second, "second must not be null"); + return first.syntheticName().equals(second.syntheticName()) + && FrontendLambdaCapturePlan.samePlan(first.capturePlan(), second.capturePlan()) + && first.enclosingCallable() == second.enclosingCallable() + && first.owningClassCanonicalName().equals(second.owningClassCanonicalName()); + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java b/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java new file mode 100644 index 00000000..fdff7012 --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java @@ -0,0 +1,60 @@ +package gd.script.gdcc.frontend.sema; + +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/// One frozen lambda capture: source-facing name, declaration-site type, and the outer binding that +/// produced it. +/// +/// Phase A only stores the payload. Later phases fill `type` from a `Variant` inventory placeholder +/// to the declaration-site type via [`withType`](#withType); they must also call +/// `CallableScope.resetCaptureType` so the scope binding stays in sync. +/// +/// @param name source identifier copied into the lambda (`self` uses this same shape) +/// @param type declaration-site type of the captured binding; never compiler-only +/// @param sourceKind `PARAMETER` / `LOCAL` / `CAPTURE` of the first hit outside this lambda +/// @param sourceDeclaration declaration identity of that outer binding (`VariableDeclaration`, +/// `Parameter`, `ForStatement`, or a later `self` descriptor) +public record LambdaCaptureEntry( + @NotNull String name, + @NotNull GdType type, + @NotNull ScopeValueKind sourceKind, + @Nullable Object sourceDeclaration +) { + public LambdaCaptureEntry { + Objects.requireNonNull(name, "name must not be null"); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(sourceKind, "sourceKind must not be null"); + if (sourceKind != ScopeValueKind.PARAMETER + && sourceKind != ScopeValueKind.LOCAL + && sourceKind != ScopeValueKind.CAPTURE) { + throw new IllegalArgumentException( + "sourceKind must be PARAMETER, LOCAL, or CAPTURE, got " + sourceKind + ); + } + } + + /// Returns a copy with a replacement type. Used when nested resolve fills the declaration-site + /// type; name, kind, and declaration identity stay frozen. + public @NotNull LambdaCaptureEntry withType(@NotNull GdType newType) { + return new LambdaCaptureEntry(name, Objects.requireNonNull(newType, "newType must not be null"), sourceKind, sourceDeclaration); + } + + /// Logical equivalence for idempotent side-table merge. Types compare by class+name so a + /// republished equivalent instance is not a conflict; `sourceDeclaration` is identity. + public static boolean sameEntry(@NotNull LambdaCaptureEntry first, @NotNull LambdaCaptureEntry second) { + Objects.requireNonNull(first, "first must not be null"); + Objects.requireNonNull(second, "second must not be null"); + return first.name().equals(second.name()) + && FrontendAnalysisData.sameType(first.type(), second.type()) + && first.sourceKind() == second.sourceKind() + && first.sourceDeclaration() == second.sourceDeclaration(); + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java index 10dc82c8..bf6df274 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java @@ -5,6 +5,7 @@ import gd.script.gdcc.frontend.sema.FrontendContainerLiteralPlan; import gd.script.gdcc.frontend.sema.FrontendExpressionType; import gd.script.gdcc.frontend.sema.FrontendForIterationPlan; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.FrontendResolvedCall; import gd.script.gdcc.frontend.sema.FrontendResolvedMember; import gd.script.gdcc.frontend.sema.FrontendSemanticStage; @@ -59,6 +60,10 @@ public sealed interface FrontendOwnerPatch permits return FrontendPatchTables.emptySideTable(); } + default @NotNull FrontendAstSideTable lambdaPlans() { + return FrontendPatchTables.emptySideTable(); + } + default @NotNull List localSlotTypeUpdates() { return List.of(); } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPublishedFactTypeGuard.java b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPublishedFactTypeGuard.java index c86eea9b..6da451ce 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPublishedFactTypeGuard.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPublishedFactTypeGuard.java @@ -6,6 +6,7 @@ import gd.script.gdcc.frontend.sema.FrontendContainerLiteralPlan; import gd.script.gdcc.frontend.sema.FrontendExpressionType; import gd.script.gdcc.frontend.sema.FrontendForIterationPlan; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.FrontendResolvedCall; import gd.script.gdcc.frontend.sema.FrontendResolvedMember; import gd.script.gdcc.frontend.sema.FrontendTypeTestTarget; @@ -32,6 +33,7 @@ public static void checkOwnerPatch(@NotNull FrontendOwnerPatch patch) { checkForIterationPlans(patch.forIterationPlans()); checkTypeTestTargets(patch.typeTestTargets()); checkContainerLiteralPlans(patch.containerLiteralPlans()); + checkLambdaPlans(patch.lambdaPlans()); checkLocalSlotTypeUpdates(patch.localSlotTypeUpdates()); } @@ -134,6 +136,22 @@ public static void checkContainerLiteralPlans( } } + public static void checkLambdaPlans(@NotNull FrontendAstSideTable plans) { + for (var plan : plans.values()) { + checkLambdaPlan(plan); + } + } + + /// Lambda plans are source-facing ABI facts: every capture type must stay ordinary. + public static void checkLambdaPlan(@NotNull FrontendLambdaPlan plan) { + for (var capture : plan.captures()) { + checkNoCompilerOnlyLeak( + capture.type(), + "lambdaPlans() capture type for '" + capture.name() + "'" + ); + } + } + /// Container plans are source-facing: result and every operand type must stay ordinary. public static void checkContainerLiteralPlan(@NotNull FrontendContainerLiteralPlan plan) { checkNoCompilerOnlyLeak(plan.resultType(), "containerLiteralPlans() result type"); diff --git a/src/main/java/gd/script/gdcc/lir/LirFunctionDef.java b/src/main/java/gd/script/gdcc/lir/LirFunctionDef.java index 3631f07e..51362c28 100644 --- a/src/main/java/gd/script/gdcc/lir/LirFunctionDef.java +++ b/src/main/java/gd/script/gdcc/lir/LirFunctionDef.java @@ -52,7 +52,7 @@ public LirFunctionDef( this.isHidden = isHidden; this.annotations = new HashMap<>(Objects.requireNonNull(annotations)); this.parameters = new ArrayList<>(Objects.requireNonNull(parameters)); - this.captures = new HashMap<>(Objects.requireNonNull(captures)); + this.captures = new LinkedHashMap<>(Objects.requireNonNull(captures)); this.returnType = Objects.requireNonNull(returnType); this.variables = new HashMap<>(Objects.requireNonNull(variables)); this.basicBlocks = new LinkedHashMap<>(basicBlocks); @@ -85,7 +85,7 @@ public LirFunctionDef( this.isHidden = isHidden; this.annotations = new HashMap<>(Objects.requireNonNull(annotations)); this.parameters = new ArrayList<>(Objects.requireNonNull(parameters)); - this.captures = new HashMap<>(Objects.requireNonNull(captures)); + this.captures = new LinkedHashMap<>(Objects.requireNonNull(captures)); this.returnType = Objects.requireNonNull(returnType); this.variables = new HashMap<>(Objects.requireNonNull(variables)); this.basicBlocks = new LinkedHashMap<>(basicBlocks); @@ -101,7 +101,7 @@ public LirFunctionDef(@NotNull String name) { this.isHidden = false; this.annotations = new HashMap<>(); this.parameters = new ArrayList<>(); - this.captures = new HashMap<>(); + this.captures = new LinkedHashMap<>(); this.variables = new HashMap<>(); this.basicBlocks = new LinkedHashMap<>(); this.entryBlockId = ""; diff --git a/src/main/java/gd/script/gdcc/lir/parser/DomLirSerializer.java b/src/main/java/gd/script/gdcc/lir/parser/DomLirSerializer.java index 597dffe2..591f82a0 100644 --- a/src/main/java/gd/script/gdcc/lir/parser/DomLirSerializer.java +++ b/src/main/java/gd/script/gdcc/lir/parser/DomLirSerializer.java @@ -117,8 +117,16 @@ public void serialize(@NotNull LirModule module, @NotNull Writer out) throws Exc } fEl.appendChild(paramsEl); - // captures - we do not serialize capture bodies here; write empty container for forward-compat + // captures: lambda functions emit real in insertion order Element capsEl = doc.createElement("captures"); + if (fn.isLambda()) { + for (var capture : fn.getCaptureList()) { + Element cEl = doc.createElement("capture"); + cEl.setAttribute("name", capture.getName()); + cEl.setAttribute("type", renderTypeText(capture.getType(), LirTypeUseSite.FUNCTION_CAPTURE)); + capsEl.appendChild(cEl); + } + } fEl.appendChild(capsEl); // return type diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java new file mode 100644 index 00000000..ff9408d4 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java @@ -0,0 +1,110 @@ +package gd.script.gdcc.backend.c.gen; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.ProjectInfo; +import gd.script.gdcc.backend.TemplateLoader; +import gd.script.gdcc.enums.GodotVersion; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirBasicBlock; +import gd.script.gdcc.lir.LirCaptureDef; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.lir.LirFunctionDef; +import gd.script.gdcc.lir.LirModule; +import gd.script.gdcc.lir.LirParameterDef; +import gd.script.gdcc.lir.insn.ReturnInsn; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdVoidType; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Anchors the Phase A `func.ftl` contract: a lambda with more than one capture still emits +/// exactly one `_capture` parameter. The capture struct itself still lists every field. +class FuncHeaderCaptureTemplateTest { + @Test + void lambdaWithMultipleCapturesEmitsSingleCaptureParameter() throws Exception { + var header = renderHeader(lambdaWithCaptures("seed", "offset")); + + assertTrue(header.contains("typedef struct Hero_Capture__lambda_0"), header); + assertTrue(header.contains("godot_int seed;"), header); + assertTrue(header.contains("godot_int offset;"), header); + assertEquals(1, countOccurrences(header, "_capture"), header); + assertTrue( + Pattern.compile("Hero__lambda_0\\([\\s\\S]*?\\*\\s*_capture[\\s\\S]*?\\);") + .matcher(header) + .find(), + header + ); + } + + @Test + void lambdaWithOneCaptureStillEmitsSingleCaptureParameter() throws Exception { + var header = renderHeader(lambdaWithCaptures("seed")); + + assertEquals(1, countOccurrences(header, "_capture"), header); + assertTrue(header.contains("godot_int seed;"), header); + } + + @Test + void nonLambdaDoesNotEmitCaptureParameter() throws Exception { + var ordinary = new LirFunctionDef("run", "entry"); + ordinary.setReturnType(GdVoidType.VOID); + ordinary.addParameter(new LirParameterDef("value", GdIntType.INT, null, ordinary)); + ordinary.addBasicBlock(new LirBasicBlock("entry", List.of(new ReturnInsn(null)))); + + var header = renderHeader(ordinary); + + assertTrue(header.contains("Hero_run("), header); + assertEquals(0, countOccurrences(header, "_capture"), header); + } + + private static String renderHeader(LirFunctionDef function) throws Exception { + var classDef = new LirClassDef("Hero", "RefCounted"); + classDef.addFunction(function); + var module = new LirModule("capture_template_test", List.of(classDef)); + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + registry.addGdccClass(classDef); + var helper = new CGenHelper( + new CodegenContext(new ProjectInfo("TestProject", GodotVersion.V451, Path.of(".")) { + }, registry), + List.of(classDef) + ); + return TemplateLoader.renderFromClasspath( + "template_451/entry.h.ftl", + Map.of("module", module, "helper", helper) + ); + } + + private static LirFunctionDef lambdaWithCaptures(String... names) { + var lambda = new LirFunctionDef("_lambda_0", "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(GdVoidType.VOID); + for (var name : names) { + lambda.addCapture(new LirCaptureDef(name, GdIntType.INT, lambda)); + } + lambda.addBasicBlock(new LirBasicBlock("entry", List.of(new ReturnInsn(null)))); + return lambda; + } + + private static int countOccurrences(String text, String needle) { + var count = 0; + var from = 0; + while (true) { + var index = text.indexOf(needle, from); + if (index < 0) { + return count; + } + count++; + from = index + needle.length(); + } + } +} diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java index d8fa889c..10c70306 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java @@ -79,6 +79,7 @@ void bootstrapCreatesAllSideTablesBeforeAnyPhaseBoundaryIsPublished() { assertTrue(analysisData.slotTypes().isEmpty()); assertTrue(analysisData.typeTestTargets().isEmpty()); assertTrue(analysisData.containerLiteralPlans().isEmpty()); + assertTrue(analysisData.lambdaPlans().isEmpty()); assertThrows(IllegalStateException.class, analysisData::moduleSkeleton); assertThrows(IllegalStateException.class, analysisData::diagnostics); } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java new file mode 100644 index 00000000..56c06392 --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java @@ -0,0 +1,366 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.Block; +import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.PassStatement; +import dev.superice.gdparser.frontend.ast.Point; +import dev.superice.gdparser.frontend.ast.Range; +import gd.script.gdcc.frontend.scope.BlockScope; +import gd.script.gdcc.frontend.scope.BlockScopeKind; +import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.frontend.scope.CallableScopeKind; +import gd.script.gdcc.frontend.scope.ClassScope; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdStringType; +import gd.script.gdcc.type.GdVariantType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Pure-function capture planner tests over hand-built scope graphs. +/// +/// These tests freeze §3.4 without opening production inventory: self-shadowing is not captured, +/// class properties stay lexical, first-use order is preserved, and nested transfer stops at a +/// shadowing parent. +class FrontendLambdaCapturePlannerTest { + private static final Range RANGE = new Range(0, 1, new Point(0, 0), new Point(0, 1)); + + @Test + void planCapturesOuterParameterAndLocalInFirstUseOrder() throws Exception { + var graph = outerFunctionGraph(); + var seedDecl = new Object(); + var offsetDecl = new Object(); + graph.functionScope.defineParameter("offset", GdIntType.INT, offsetDecl); + graph.functionBody.defineLocal("seed", GdIntType.INT, seedDecl); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of( + use("seed", graph.lambdaBody), + use("offset", graph.lambdaBody), + use("seed", graph.lambdaBody) + ) + ); + + assertEquals(2, plan.captures().size()); + assertEquals("seed", plan.captures().getFirst().name()); + assertSame(GdIntType.INT, plan.captures().getFirst().type()); + assertEquals(ScopeValueKind.LOCAL, plan.captures().getFirst().sourceKind()); + assertSame(seedDecl, plan.captures().getFirst().sourceDeclaration()); + assertEquals("offset", plan.captures().getLast().name()); + assertEquals(ScopeValueKind.PARAMETER, plan.captures().getLast().sourceKind()); + assertSame(offsetDecl, plan.captures().getLast().sourceDeclaration()); + assertFalse(plan.capturesSelf()); + } + + @Test + void planDoesNotCaptureLambdaOwnParameterOrLocal() throws Exception { + var graph = outerFunctionGraph(); + graph.functionBody.defineLocal("x", GdIntType.INT, new Object()); + var lambdaLocal = new Object(); + graph.lambdaScope.defineParameter("offset", GdIntType.INT, new Object()); + graph.lambdaBody.defineLocal("x", GdIntType.INT, lambdaLocal); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("offset", graph.lambdaBody), use("x", graph.lambdaBody)) + ); + + assertTrue(plan.captures().isEmpty()); + assertFalse(FrontendLambdaCapturePlanner.isCaptureFromUse( + graph.lambdaScope, + use("x", graph.lambdaBody) + )); + } + + @Test + void planDoesNotCaptureWhenNestedBlockShadowsOuterName() throws Exception { + var graph = outerFunctionGraph(); + var outerX = new Object(); + graph.functionBody.defineLocal("x", GdIntType.INT, outerX); + var ifBody = new BlockScope(graph.lambdaBody, BlockScopeKind.IF_BODY); + var innerX = new Object(); + ifBody.defineLocal("x", GdStringType.STRING, innerX); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("x", ifBody)) + ); + + assertTrue(plan.captures().isEmpty()); + assertFalse(FrontendLambdaCapturePlanner.isCaptureFromUse(graph.lambdaScope, use("x", ifBody))); + } + + @Test + void planCapturesOuterLocalWhenNestedBlockDoesNotShadow() throws Exception { + var graph = outerFunctionGraph(); + var outerX = new Object(); + graph.functionBody.defineLocal("x", GdIntType.INT, outerX); + var ifBody = new BlockScope(graph.lambdaBody, BlockScopeKind.IF_BODY); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("x", ifBody)) + ); + + assertEquals(1, plan.captures().size()); + assertSame(outerX, plan.captures().getFirst().sourceDeclaration()); + assertTrue(FrontendLambdaCapturePlanner.isCaptureFromUse(graph.lambdaScope, use("x", ifBody))); + } + + @Test + void planDoesNotCaptureClassPropertyOrUnknownName() throws Exception { + var graph = outerFunctionGraphWithProperty("health"); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("health", graph.lambdaBody), use("missing", graph.lambdaBody)) + ); + + assertTrue(plan.captures().isEmpty()); + } + + @Test + void planDoesNotCaptureBlockLocalConstant() throws Exception { + var graph = outerFunctionGraph(); + graph.functionBody.defineConstant("c", GdIntType.INT, new Object()); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("c", graph.lambdaBody)) + ); + + assertTrue(plan.captures().isEmpty()); + } + + @Test + void unannotatedLocalKeepsDeclarationSiteVariant() throws Exception { + var graph = outerFunctionGraph(); + var decl = new Object(); + graph.functionBody.defineLocal("b", GdVariantType.VARIANT, decl); + + var plan = FrontendLambdaCapturePlanner.planCaptures( + graph.lambdaScope, + List.of(use("b", graph.lambdaBody)) + ); + + assertEquals(1, plan.captures().size()); + assertSame(GdVariantType.VARIANT, plan.captures().getFirst().type()); + assertSame(decl, plan.captures().getFirst().sourceDeclaration()); + } + + @Test + void nestedTransferStopsWhenParentShadowsTheName() throws Exception { + var graph = nestedLambdaGraph(); + var outerX = new Object(); + graph.functionBody.defineLocal("x", GdIntType.INT, outerX); + var midX = new Object(); + graph.midLambdaBody.defineLocal("x", GdStringType.STRING, midX); + + var innerPlan = FrontendLambdaCapturePlanner.planCaptures( + graph.innerLambdaScope, + List.of(use("x", graph.innerLambdaBody)) + ); + assertEquals(1, innerPlan.captures().size()); + assertEquals(ScopeValueKind.LOCAL, innerPlan.captures().getFirst().sourceKind()); + assertSame(midX, innerPlan.captures().getFirst().sourceDeclaration()); + + assertNull(FrontendLambdaCapturePlanner.transferredCapture( + graph.midLambdaScope, + graph.midLambdaBody, + innerPlan.captures().getFirst() + )); + assertTrue(FrontendLambdaCapturePlanner.transferredCapturesAlongParents( + List.of(new FrontendLambdaCapturePlanner.ParentLambda(graph.midLambdaScope, graph.midLambdaBody)), + innerPlan.captures().getFirst() + ).isEmpty()); + } + + @Test + void nestedTransferCopiesOuterBindingOntoUnshadowedParents() throws Exception { + var graph = nestedLambdaGraph(); + var outerX = new Object(); + graph.functionBody.defineLocal("x", GdIntType.INT, outerX); + + var innerPlan = FrontendLambdaCapturePlanner.planCaptures( + graph.innerLambdaScope, + List.of(use("x", graph.innerLambdaBody)) + ); + assertSame(outerX, innerPlan.captures().getFirst().sourceDeclaration()); + + var transferred = FrontendLambdaCapturePlanner.transferredCapturesAlongParents( + List.of(new FrontendLambdaCapturePlanner.ParentLambda(graph.midLambdaScope, graph.midLambdaBody)), + innerPlan.captures().getFirst() + ); + assertEquals(1, transferred.size()); + assertEquals("x", transferred.getFirst().name()); + assertSame(outerX, transferred.getFirst().sourceDeclaration()); + assertSame(GdIntType.INT, transferred.getFirst().type()); + } + + @Test + void capturePlanRequiresLeadingSelfWhenCapturesSelf() { + var self = new LambdaCaptureEntry("self", GdIntType.INT, ScopeValueKind.LOCAL, new Object()); + var seed = new LambdaCaptureEntry("seed", GdIntType.INT, ScopeValueKind.LOCAL, new Object()); + + var withSelf = FrontendLambdaCapturePlan.of(List.of(self, seed)); + assertTrue(withSelf.capturesSelf()); + assertEquals("self", withSelf.captures().getFirst().name()); + assertFalse(FrontendLambdaCapturePlan.of(List.of(seed)).capturesSelf()); + + assertThrows( + IllegalArgumentException.class, + () -> new FrontendLambdaCapturePlan(List.of(seed), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> new FrontendLambdaCapturePlan(List.of(self, seed), false) + ); + assertThrows( + IllegalArgumentException.class, + () -> new FrontendLambdaCapturePlan(List.of(), true) + ); + assertThrows( + IllegalArgumentException.class, + () -> new FrontendLambdaCapturePlan(List.of(seed, self), false) + ); + } + + @Test + void lambdaCaptureEntryRejectsNonCapturableKindAndBlankName() { + assertThrows( + IllegalArgumentException.class, + () -> new LambdaCaptureEntry("x", GdIntType.INT, ScopeValueKind.PROPERTY, new Object()) + ); + assertThrows( + IllegalArgumentException.class, + () -> new LambdaCaptureEntry(" ", GdIntType.INT, ScopeValueKind.LOCAL, new Object()) + ); + } + + @Test + void lambdaPlanSamePlanIgnoresAstIdentityAndComparesPayload() { + var lambda = emptyLambda(); + var enclosing = emptyFunction("run"); + var capture = new LambdaCaptureEntry("seed", GdIntType.INT, ScopeValueKind.LOCAL, new Object()); + var first = new FrontendLambdaPlan( + lambda, + "_lambda_0", + new FrontendLambdaCapturePlan(List.of(capture), false), + enclosing, + "Hero" + ); + var second = new FrontendLambdaPlan( + lambda, + "_lambda_0", + new FrontendLambdaCapturePlan(List.of(capture.withType(GdIntType.INT)), false), + enclosing, + "Hero" + ); + assertTrue(FrontendLambdaPlan.samePlan(first, second)); + + var renamed = new FrontendLambdaPlan( + lambda, + "_lambda_1", + first.capturePlan(), + enclosing, + "Hero" + ); + assertFalse(FrontendLambdaPlan.samePlan(first, renamed)); + } + + private static @NotNull FrontendLambdaCapturePlanner.IdentifierUse use( + @NotNull String name, + @NotNull BlockScope scope + ) { + return new FrontendLambdaCapturePlanner.IdentifierUse(name, scope); + } + + private static @NotNull LambdaExpression emptyLambda() { + return new LambdaExpression(null, List.of(), null, new Block(List.of(new PassStatement(RANGE)), RANGE), RANGE); + } + + private static @NotNull FunctionDeclaration emptyFunction(@NotNull String name) { + return new FunctionDeclaration( + name, + List.of(), + null, + false, + new Block(List.of(new PassStatement(RANGE)), RANGE), + RANGE + ); + } + + private static @NotNull OuterGraph outerFunctionGraphWithProperty(@NotNull String propertyName) throws Exception { + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + var ownerClass = new LirClassDef("Hero", "RefCounted"); + ownerClass.addProperty(new gd.script.gdcc.lir.LirPropertyDef(propertyName, GdIntType.INT)); + registry.addGdccClass(ownerClass); + return finishOuterGraph(registry, ownerClass); + } + + private static @NotNull OuterGraph outerFunctionGraph() throws Exception { + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + var ownerClass = new LirClassDef("Hero", "RefCounted"); + registry.addGdccClass(ownerClass); + return finishOuterGraph(registry, ownerClass); + } + + private static @NotNull OuterGraph finishOuterGraph( + @NotNull ClassRegistry registry, + @NotNull LirClassDef ownerClass + ) { + var classScope = new ClassScope(registry, registry, ownerClass); + var functionScope = new CallableScope(classScope, CallableScopeKind.FUNCTION_DECLARATION); + var functionBody = new BlockScope(functionScope, BlockScopeKind.FUNCTION_BODY); + var lambdaScope = new CallableScope(functionBody, CallableScopeKind.LAMBDA_EXPRESSION); + var lambdaBody = new BlockScope(lambdaScope, BlockScopeKind.LAMBDA_BODY); + return new OuterGraph(ownerClass, functionScope, functionBody, lambdaScope, lambdaBody); + } + + private static @NotNull NestedGraph nestedLambdaGraph() throws Exception { + var outer = outerFunctionGraph(); + var midLambdaScope = new CallableScope(outer.functionBody, CallableScopeKind.LAMBDA_EXPRESSION); + var midLambdaBody = new BlockScope(midLambdaScope, BlockScopeKind.LAMBDA_BODY); + var innerLambdaScope = new CallableScope(midLambdaBody, CallableScopeKind.LAMBDA_EXPRESSION); + var innerLambdaBody = new BlockScope(innerLambdaScope, BlockScopeKind.LAMBDA_BODY); + return new NestedGraph( + outer.functionBody, + midLambdaScope, + midLambdaBody, + innerLambdaScope, + innerLambdaBody + ); + } + + private record OuterGraph( + @NotNull LirClassDef ownerClass, + @NotNull CallableScope functionScope, + @NotNull BlockScope functionBody, + @NotNull CallableScope lambdaScope, + @NotNull BlockScope lambdaBody + ) { + } + + private record NestedGraph( + @NotNull BlockScope functionBody, + @NotNull CallableScope midLambdaScope, + @NotNull BlockScope midLambdaBody, + @NotNull CallableScope innerLambdaScope, + @NotNull BlockScope innerLambdaBody + ) { + } +} diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java new file mode 100644 index 00000000..c7e4375d --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java @@ -0,0 +1,113 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.Block; +import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.PassStatement; +import dev.superice.gdparser.frontend.ast.Point; +import dev.superice.gdparser.frontend.ast.Range; +import gd.script.gdcc.exception.FrontendAnalysisPatchException; +import gd.script.gdcc.frontend.sema.patch.FrontendTopBindingPatch; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdccForRangeIterType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase A data-plane tests: lambda plans are a stable side table, compiler-only capture types +/// stay rejected, and existing owner patches do not wipe or republish the table. +class FrontendLambdaPlanSideTableTest { + private static final Range RANGE = new Range(0, 1, new Point(0, 0), new Point(0, 1)); + + @Test + void updateLambdaPlansPublishesWithoutReplacingStableSideTableReference() { + var analysisData = FrontendAnalysisData.bootstrap(); + var stable = analysisData.lambdaPlans(); + var lambda = emptyLambda(); + var plan = samplePlan(lambda); + var plans = new FrontendAstSideTable(); + plans.put(lambda, plan); + + analysisData.updateLambdaPlans(plans); + + assertSame(stable, analysisData.lambdaPlans()); + assertSame(plan, analysisData.lambdaPlans().get(lambda)); + } + + @Test + void updateLambdaPlansRejectsCompilerOnlyCaptureTypes() { + var analysisData = FrontendAnalysisData.bootstrap(); + var lambda = emptyLambda(); + var plans = new FrontendAstSideTable(); + plans.put(lambda, new FrontendLambdaPlan( + lambda, + "_lambda_0", + new FrontendLambdaCapturePlan( + List.of(new LambdaCaptureEntry( + "iter", + GdccForRangeIterType.FOR_RANGE_ITER, + ScopeValueKind.LOCAL, + new Object() + )), + false + ), + emptyFunction(), + "Hero" + )); + + var exception = assertThrows( + FrontendAnalysisPatchException.class, + () -> analysisData.updateLambdaPlans(plans) + ); + assertTrue(exception.getMessage().contains("compiler-only type"), exception.getMessage()); + assertTrue(analysisData.lambdaPlans().isEmpty()); + } + + @Test + void applyPatchFromExistingOwnerDoesNotClearPublishedLambdaPlans() { + var analysisData = FrontendAnalysisData.bootstrap(); + var lambda = emptyLambda(); + var plan = samplePlan(lambda); + var plans = new FrontendAstSideTable(); + plans.put(lambda, plan); + analysisData.updateLambdaPlans(plans); + + analysisData.applyPatch(new FrontendTopBindingPatch(new FrontendAstSideTable<>())); + + assertSame(plan, analysisData.lambdaPlans().get(lambda)); + } + + private static @NotNull FrontendLambdaPlan samplePlan(@NotNull LambdaExpression lambda) { + return new FrontendLambdaPlan( + lambda, + "_lambda_0", + new FrontendLambdaCapturePlan( + List.of(new LambdaCaptureEntry("seed", GdIntType.INT, ScopeValueKind.LOCAL, new Object())), + false + ), + emptyFunction(), + "Hero" + ); + } + + private static @NotNull LambdaExpression emptyLambda() { + return new LambdaExpression(null, List.of(), null, new Block(List.of(new PassStatement(RANGE)), RANGE), RANGE); + } + + private static @NotNull FunctionDeclaration emptyFunction() { + return new FunctionDeclaration( + "run", + List.of(), + null, + false, + new Block(List.of(new PassStatement(RANGE)), RANGE), + RANGE + ); + } +} diff --git a/src/test/java/gd/script/gdcc/lir/parser/DomLirSerializerTest.java b/src/test/java/gd/script/gdcc/lir/parser/DomLirSerializerTest.java index 7907cf5e..75ca72ec 100644 --- a/src/test/java/gd/script/gdcc/lir/parser/DomLirSerializerTest.java +++ b/src/test/java/gd/script/gdcc/lir/parser/DomLirSerializerTest.java @@ -3,8 +3,6 @@ import gd.script.gdcc.enums.LifecycleProvenance; import gd.script.gdcc.lir.*; import gd.script.gdcc.lir.insn.*; -import gd.script.gdcc.lir.*; -import gd.script.gdcc.lir.insn.*; import gd.script.gdcc.type.GdFloatType; import gd.script.gdcc.type.GdccForArrayIterType; import gd.script.gdcc.type.GdccForDictionaryIterType; @@ -149,4 +147,62 @@ public void serialize_module_rejectsCompilerOnlyTypeOnFunctionReturnSurface() { assertTrue(exception.getMessage().contains("compiler-only type leaked into function return"), exception.getMessage()); } + + @Test + public void serialize_module_writesOrderedLambdaCapturesAndRoundTrips() throws Exception { + var fn = new LirFunctionDef("_lambda_0", "entry"); + fn.setLambda(true); + fn.setHidden(true); + fn.setStatic(true); + fn.setReturnType(new GdFloatType()); + fn.addCapture(new LirCaptureDef("seed", new GdFloatType(), fn)); + fn.addCapture(new LirCaptureDef("offset", new GdObjectType("Node"), fn)); + fn.addBasicBlock(new LirBasicBlock("entry", List.of(new ReturnInsn(null)))); + + var cls = new LirClassDef("Hero", "Node", false, false, Map.of(), List.of(), List.of(), List.of(fn)); + var module = new LirModule("m", List.of(cls)); + var xml = new DomLirSerializer().serializeToString(module); + + assertTrue(xml.contains("is_lambda=\"true\""), xml); + assertTrue(xml.contains("") + || xml.contains(""), xml); + assertTrue(xml.contains("") + || xml.contains(""), xml); + assertTrue(xml.indexOf("name=\"seed\"") < xml.indexOf("name=\"offset\""), xml); + + var parsed = new DomLirParser(new gd.script.gdcc.scope.ClassRegistry( + gd.script.gdcc.gdextension.ExtensionApiLoader.loadDefault() + )).parse(xml); + var parsedFn = parsed.getClassDefs().getFirst().getFunctions().getFirst(); + assertEquals(2, parsedFn.getCaptureCount()); + var captures = parsedFn.getCaptureList(); + assertEquals("seed", captures.getFirst().getName()); + assertEquals("float", captures.getFirst().getType().getTypeName()); + assertEquals("offset", captures.getLast().getName()); + assertEquals("Node", captures.getLast().getType().getTypeName()); + } + + @Test + public void serialize_module_rejectsCompilerOnlyLambdaCapture() { + var fn = new LirFunctionDef("_lambda_0", "entry"); + fn.setLambda(true); + fn.addCapture(new LirCaptureDef("iter", GdccForRangeIterType.FOR_RANGE_ITER, fn)); + fn.addBasicBlock(new LirBasicBlock("entry", List.of(new ReturnInsn(null)))); + + var cls = new LirClassDef("Hero", "Node", false, false, Map.of(), List.of(), List.of(), List.of(fn)); + var exception = assertThrows( + IllegalArgumentException.class, + () -> new DomLirSerializer().serializeToString(new LirModule("m", List.of(cls))) + ); + assertTrue(exception.getMessage().contains("compiler-only type leaked into function capture"), exception.getMessage()); + } + + @Test + public void addCaptureOnNonLambdaStillThrows() { + var fn = new LirFunctionDef("run"); + assertThrows( + IllegalStateException.class, + () -> fn.addCapture(new LirCaptureDef("seed", new GdFloatType(), fn)) + ); + } } From 4b08776b3de25203e3b3e488967fc3adf90c3d73 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Mon, 17 Aug 2026 19:18:44 +0800 Subject: [PATCH 3/9] feat(frontend): enable lambda variable inventory binding - Bind lambda parameters, locals, and captures with placeholder types - Plan nested capture propagation and instance self captures - Preserve fail-closed handling for unsupported match and const subtrees - Add diagnostics, tests, and implementation documentation --- .../frontend/diagnostic_manager.md | 7 +- .../frontend/frontend_lambda_plan.md | 13 +- ...ontend_variable_analyzer_implementation.md | 38 +- .../FrontendBodySemanticSupportPolicy.java | 24 +- .../analyzer/FrontendVariableAnalyzer.java | 399 +++++++++++- .../binding/GodotBuiltinGeneratorTest.java | 9 +- ...FrontendBodySemanticSupportPolicyTest.java | 19 +- .../sema/FrontendVariableAnalyzerTest.java | 569 +++++++++++++++++- .../FrontendVisibleValueResolverTest.java | 14 +- 9 files changed, 993 insertions(+), 99 deletions(-) diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index e06b9770..500dc400 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -133,7 +133,7 @@ frontend 当前已经冻结的诊断承载方式如下: - `forIterationPlans` - `typeTestTargets` - `containerLiteralPlans` -- `lambdaPlans`(阶段 A 数据面已接线;生产 inventory 仍 fail-closed) +- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 起 lambda inventory 绑定进 scope,但 plan 仍不发布,首次发布在阶段 C nested resolve 入口) 其中: @@ -215,12 +215,13 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 其中 body/binding phase 新增 category 的语义固定为: - `sema.variable_binding` - - variable analyzer 对 duplicate parameter、duplicate local、same-callable local shadowing、scope kind mismatch 发出的恢复性 source error + - variable analyzer 对 duplicate parameter、duplicate local、same-callable local shadowing、capture 与既有 callable slot 冲突(如 lambda 参数名为 `self` 时又需捕获 `self`)、scope kind mismatch 发出的恢复性 source error - duplicate / shadowing local 的消息现在必须包含:当前声明位置、冲突声明位置、callable / block 归属、source path - `sema.unsupported_parameter_default_value` - variable analyzer 对 parameter default value 当前尚未接线时发出的 feature-boundary error - `sema.unsupported_variable_inventory_subtree` - - variable analyzer 对 lambda / `match` / block-local `const` inventory 边界发出的 feature-boundary error + - variable analyzer 对 `match` / block-local `const` inventory 边界发出的 feature-boundary error + - lambda 已在阶段 B 移出该边界:supported executable body 内的 lambda 会绑定 param / local / capture,不再发此诊断 - `sema.variable_slot_publication` - var-type-post analyzer 对 supported callable-local `var` 因 earlier duplicate/shadowing reject 而无法发布 `slotTypes()` 时发出的 warning - warning message 若能在当前 callable 边界内找到幸存的 accepted local / parameter / capture,必须带出该幸存绑定的语义类别与声明位置 diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index c45db642..0b5ac2c4 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,10 +9,10 @@ ## 文档状态 -- 状态:阶段 A 已落地;B–I 尚未实施。当前生产代码对 lambda 全链路仍 fail-closed; - 数据面(`FrontendLambdaPlan` / side table / planner 纯函数)、`func.ftl` - 单 `_capture` 形参与 `DomLirSerializer` 真实 `` 已接线。 -- 更新时间:2026-08-17(阶段 A 落地;已冻结 capture 类型首次 publish 与独立 stage) +- 状态:阶段 A、B 已落地;C–I 尚未实施。supported executable body 内的 lambda 已完成 + policy 翻转与 inventory 绑定(param / local / capture 名 + `Variant` 占位), + `lambdaPlans()` 仍不发布;resolver / interface / suite / lowering / backend 链路仍 fail-closed。 +- 更新时间:2026-08-17(阶段 B 落地:policy 翻转 + variable inventory 解封 + self capture) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -659,6 +659,11 @@ $result = construct_lambda "" $capture1 $capture2 ... ### 阶段 B — Policy 翻转 + Variable inventory 解封(仍不进 SuiteResolver) +> 状态:已落地(2026-08-17)。policy 已翻转;`bindLambdaInventory` 按 post-order 绑定 +> param / local / capture(`Variant` 占位);reporter 停止对 supported executable 内 +> lambda 报 inventory 错;`self` capture(显式 / 隐式 instance member / static 抑制) +> 已接线;`lambdaPlans()` 保持为空。 + **目标**:lambda param / local / capture **名字**进入 scope;去掉 `sema.unsupported_variable_inventory_subtree`。 diff --git a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md index 1330f957..9a3f6bb5 100644 --- a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md @@ -5,7 +5,7 @@ ## 文档状态 - 性质:长期事实源 -- 最后校对:2026-07-20(阶段 L:for iterator/body inventory 已转正,结构支持面不再依赖 typed gate) +- 最后校对:2026-08-17(lambda 阶段 B:supported executable body 内的 lambda parameter / local / capture inventory 已转正,capture 类型仍为 `Variant` 占位) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/sema/analyzer/**` @@ -24,7 +24,8 @@ - 不在这里实现完整 frontend binder / body - 不在这里写入 `symbolBindings()` 或表达式类型 side-table - 不在这里实现成员解析、调用解析或 use-site declaration-order 可见性 - - 不在这里接入 lambda / `match` / block-local `const` 的完整 inventory + - 不在这里接入 `match` / block-local `const` 的完整 inventory + - 不在这里填充 lambda capture 的声明处类型(`Variant` 占位的替换发生在 nested suite resolution 入口) - 不改变 shared `Scope` lookup 协议 --- @@ -59,12 +60,12 @@ - 把 `FunctionDeclaration` / `ConstructorDeclaration` 参数写入对应 `CallableScope` - 把 supported executable subtree 中的普通局部 `var` 写入对应 `BlockScope` - 为每个 `ForStatement` 在其 `FOR_BODY` scope 发布 iterator binding,并遍历 body 发布 ordinary local inventory +- 为 supported executable body 内的 `LambdaExpression` 绑定完整 inventory:parameter -> lambda `CallableScope`、body local -> `LAMBDA_BODY` scope、capture 名(`Variant` 占位类型 + `sourceDeclaration`)-> lambda `CallableScope` - 对当前明确不支持的 variable-inventory 来源发出显式 error,避免静默跳过 - 对 duplicate / shadowing / target scope kind mismatch 发布恢复性 diagnostic,并保持其他 subtree 继续处理 `FrontendVariableAnalyzer` 明确不负责: -- lambda parameter / local / capture inventory - `match` pattern binding 与 section local inventory - block-local `const` binding - 参数默认值表达式的类型/绑定分析 @@ -85,6 +86,9 @@ - supported nested executable block 中的普通局部 `var` -> 对应 `BlockScope` - `ForStatement` iterator -> 对应 `FOR_BODY` scope,declaration identity 为 owning `ForStatement` - for body 中的普通局部 `var` -> 对应 `FOR_BODY` scope +- lambda parameter -> lambda `CallableScope` +- lambda body 中的普通局部 `var` -> `LAMBDA_BODY` scope +- lambda capture 名 -> lambda `CallableScope`(`Variant` 占位类型,`sourceDeclaration` 指向外层绑定声明;`self` capture 的 declaration 为 enclosing 非 lambda callable 节点) 当前支持写入 ordinary local `var` 的 `BlockScopeKind` 为: @@ -96,14 +100,12 @@ - `ELSE_BODY` - `WHILE_BODY` - `FOR_BODY` +- `LAMBDA_BODY` ### 2.2 当前不写入的绑定 以下 binding 仍明确保持 deferred: -- lambda parameter -- lambda local -- lambda capture - `match` pattern binding - `match` section local - block-local `const` @@ -112,6 +114,7 @@ - class property / class const 不属于本 analyzer 的 callable-local inventory - block-local `const` 属于本 analyzer 已识别但当前明确 deferred 的输入 +- lambda capture 的名字与 `sourceDeclaration` 已写入 scope,但其声明处类型不在本阶段填充;scope 上的 `CAPTURE` 绑定保持 `Variant` 占位,且 `FrontendAnalysisData.lambdaPlans()` 在本 analyzer 完成后仍为空 ### 2.3 与 `FrontendAnalysisData` 的关系 @@ -192,9 +195,24 @@ 需要特别注意: - binder 主遍历不会把 arbitrary expression subtree 当作 local-binding 域 -- lambda / `match` subtree 不会进入 binding walk;`ForStatement` 使用专用 iterator/body inventory path +- lambda inventory 由 boundary reporter 在扫描 callable body 时发现并回调 `bindLambdaInventory` 完成(binder 主遍历不下钻表达式,因此 return / initializer 内的 lambda 也能到达);`match` subtree 不进入 binding walk;`ForStatement` 使用专用 iterator/body inventory path +- 同一 callable 内的 local binding 先于 boundary reporter 扫描执行,保证 lambda capture 推导时外层 local 已就位 - unsupported feature-boundary error 由独立的 boundary reporter 扫描 callable body 后补发 +### 4.4 lambda inventory 管线 + +`bindLambdaInventory` 对单个 lambda 的处理顺序固定为: + +1. bind 当前 lambda 的 parameters +2. 走 `walkSupportedExecutableBlock` bind body ordinary locals +3. boundary reporter 扫描 body:补发 `match` / block-local `const` 边界 error,并递归 bind 嵌套 lambda(post-order,嵌套 lambda 的 capture plan 先于外层完成) +4. 用 `LambdaCaptureSourceScanner` 按源码顺序收集 body 内的 capture 相关事件:bare `IdentifierExpression` 使用点(绑定到 use-site scope)、直接嵌套 lambda、显式 `SelfExpression`;跳过嵌套 lambda / `match` / block-local `const` subtree +5. 经 `FrontendLambdaCapturePlanner` 纯函数推导 own-use capture,再按事件顺序合并嵌套 lambda 的 transferred capture(同名首次出现者胜;中间层有同名 param/local 遮蔽时传递终止) +6. `self` 处理:显式 `SelfExpression`、隐式 instance member(bare identifier 命中 enclosing class 的 PROPERTY / SIGNAL / method overload)、或任一嵌套 lambda 已捕获 `self` 时,生成名为 `self` 的 leading capture;static enclosing callable 不合成 `self` capture +7. 通过守卫式 `defineCapture` 写入 scope:同名 CAPTURE 且同 sourceDeclaration 幂等跳过;与既有 param / capture 冲突发 `sema.variable_binding` 并跳过,绝不把用户源码打到 `IllegalArgumentException` + +本阶段写入的 capture 一律使用 `Variant` 占位类型;`FrontendAnalysisData.lambdaPlans()` 不发布任何占位 plan。 + ### 4.3 executable-context 判定 `supportedExecutableBlockDepth` 当前是稳定实现细节,其语义为: @@ -228,7 +246,6 @@ - `sema.unsupported_parameter_default_value` - 参数默认值当前被忽略 - `sema.unsupported_variable_inventory_subtree` - - lambda subtree 当前不支持 - `match` subtree 当前不支持 - block-local `const` 当前不支持 @@ -241,6 +258,7 @@ - duplicate parameter - duplicate local in the same block - same-callable local shadowing parameter / outer local / future capture +- lambda capture 与既有 callable slot(parameter / capture)冲突 - supported declaration 的 target scope kind mismatch 恢复规则固定为: @@ -318,7 +336,7 @@ For iterator 与 parameter、外层 local 或 body local 冲突时仍发布 `sem 后续最直接的增量工作包括: -- lambda parameter / local / capture inventory +- lambda capture 声明处类型填充与 `lambdaPlans()` 首次发布(nested suite resolution 入口) - for iteration planning 与 iterator slot refinement - `match` pattern binding 与 section inventory - block-local `const` inventory @@ -335,6 +353,8 @@ For iterator 与 parameter、外层 local 或 body local 冲突时仍发布 `sem - `FrontendVariableAnalyzerTest` - parameter/local 正向写入 - for iterator、body local、nested for 与冲突恢复 binding + - lambda parameter / local / capture 正向写入、嵌套 lambda capture 传递、`self` capture(显式 / 隐式 / static 抑制) + - lambda local / 中间层遮蔽负向路径、lambda 内 `match` / block-local `const` 边界、property-initializer lambda 不绑定、capture 与 `self` 参数冲突恢复 - unsupported feature-boundary error - duplicate / shadowing / kind mismatch 负向路径 - duplicate / shadowing local 详细 diagnostic message 锚点 diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java index fc4348c1..10a301a7 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java @@ -103,9 +103,12 @@ public boolean isSupportedSuiteBodyRoot() { /// Maps a block scope kind to its explicit structural body policy. /// - /// Function, constructor, ordinary block, conditional, loop, and `FOR_BODY` scopes are executable bodies. - /// Lambda and match-section scopes remain in their feature-specific deferred domains. The exhaustive switch is - /// intentional: a newly added block scope kind must make a compile-time-visible support choice here. + /// Function, constructor, ordinary block, conditional, loop, `FOR_BODY`, and `LAMBDA_BODY` scopes are + /// executable bodies. Match-section scopes remain in their feature-specific deferred domain. The exhaustive + /// switch is intentional: a newly added block scope kind must make a compile-time-visible support choice here. + /// + /// `LAMBDA_BODY` publishes lexical inventory since lambda Phase B (parameter/local/capture binding); its + /// suite-resolver entry stays unconsumed until `FrontendInterfacePhase` stops skipping lambda expressions. /// /// @param kind the block scope kind whose structural support is being queried /// @return the policy that controls inventory publication, suite entry, and visible-value domain @@ -119,17 +122,19 @@ public boolean isSupportedSuiteBodyRoot() { ELIF_BODY, ELSE_BODY, WHILE_BODY, - FOR_BODY -> EXECUTABLE_BODY; - case LAMBDA_BODY -> LAMBDA_SUBTREE; + FOR_BODY, + LAMBDA_BODY -> EXECUTABLE_BODY; case MATCH_SECTION_BODY -> MATCH_SUBTREE; }; } /// Maps a callable scope kind to its explicit structural body policy. /// - /// Function and constructor callables own executable bodies. Lambda callables remain deferred until their - /// parameter, capture, and body inventory are implemented as one feature-owned surface. As with block scopes, - /// the exhaustive switch prevents new callable kinds from receiving support by default. + /// Function, constructor, and lambda callables own executable bodies. As with block scopes, the exhaustive + /// switch prevents new callable kinds from receiving support by default. + /// + /// `LAMBDA_EXPRESSION` publishes lexical inventory since lambda Phase B; the resolver's lambda AST-edge and + /// callable-scope seals remain the fail-closed backstop until a later phase opens suite resolution. /// /// @param kind the callable scope kind whose structural support is being queried /// @return the policy for the callable's body/inventory boundary @@ -138,8 +143,7 @@ public boolean isSupportedSuiteBodyRoot() { @NotNull CallableScopeKind kind ) { return switch (Objects.requireNonNull(kind, "kind")) { - case FUNCTION_DECLARATION, CONSTRUCTOR_DECLARATION -> EXECUTABLE_BODY; - case LAMBDA_EXPRESSION -> LAMBDA_SUBTREE; + case FUNCTION_DECLARATION, CONSTRUCTOR_DECLARATION, LAMBDA_EXPRESSION -> EXECUTABLE_BODY; }; } } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java index 5ec75e8e..22152516 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java @@ -4,13 +4,21 @@ import gd.script.gdcc.frontend.diagnostic.FrontendRange; import gd.script.gdcc.frontend.scope.BlockScope; import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.frontend.scope.CallableScopeKind; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import gd.script.gdcc.frontend.sema.FrontendAstSideTable; import gd.script.gdcc.frontend.sema.FrontendDeclaredTypeSupport; import gd.script.gdcc.frontend.sema.FrontendExecutableInventorySupport; +import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlan; +import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlanner; import gd.script.gdcc.frontend.sema.FrontendModuleSkeleton; +import gd.script.gdcc.frontend.sema.LambdaCaptureEntry; +import gd.script.gdcc.scope.ResolveRestriction; import gd.script.gdcc.scope.Scope; import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdVariantType; import dev.superice.gdparser.frontend.ast.ASTNodeHandler; import dev.superice.gdparser.frontend.ast.ASTWalker; import dev.superice.gdparser.frontend.ast.Block; @@ -21,11 +29,13 @@ import dev.superice.gdparser.frontend.ast.ForStatement; import dev.superice.gdparser.frontend.ast.FrontendASTTraversalDirective; import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.IdentifierExpression; import dev.superice.gdparser.frontend.ast.IfStatement; import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.MatchStatement; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.Parameter; +import dev.superice.gdparser.frontend.ast.SelfExpression; import dev.superice.gdparser.frontend.ast.SourceFile; import dev.superice.gdparser.frontend.ast.Statement; import dev.superice.gdparser.frontend.ast.VariableDeclaration; @@ -34,8 +44,14 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; /// Frontend parameter/local inventory analyzer. /// @@ -43,7 +59,9 @@ /// - require skeleton, diagnostics, and top-level source scopes to be published first /// - write function/constructor parameters into `CallableScope` /// - write supported ordinary locals into `BlockScope` -/// - keep lambda / `match` / block-local `const` inventory outside the current support boundary +/// - bind lambda parameters/locals and derive `CAPTURE` bindings (names plus `sourceDeclaration`, +/// `Variant` placeholder types) for lambdas inside supported executable bodies +/// - keep `match` / block-local `const` inventory outside the current support boundary /// - emit explicit recovery diagnostics instead of letting unsupported inventory sources fail silently public class FrontendVariableAnalyzer { private static final @NotNull String VARIABLE_BINDING_CATEGORY = "sema.variable_binding"; @@ -95,7 +113,10 @@ public void analyze( /// - only source/class statement lists and supported executable blocks are descended into /// - function/constructor parameters are bound at the callable boundary /// - ordinary locals are bound only while the walker is inside a supported executable block - /// - lambda / `match` subtrees are pruned explicitly + /// - lambda inventories (parameters, locals, captures) are bound through + /// [`bindLambdaInventory`](#bindLambdaInventory), usually reached via the boundary reporter + /// because this walk does not descend into arbitrary expression children + /// - `match` subtrees are pruned explicitly /// - arbitrary expression children stay outside the binding walk private static final class AstWalkerVariableBinder implements ASTNodeHandler { private final @NotNull Path sourcePath; @@ -105,6 +126,20 @@ private static final class AstWalkerVariableBinder implements ASTNodeHandler { private final @NotNull ASTWalker astWalker; private final @NotNull UnsupportedVariableBoundaryReporter unsupportedBoundaryReporter; private @Nullable Node currentCallableOwner; + /// Nearest enclosing non-lambda callable (function/constructor) whose `self` a lambda may capture. + /// + /// Tracked separately from `currentCallableOwner` because nested lambdas replace the callable owner + /// while the `self` source stays the outermost instance callable of the chain. + private @Nullable Node currentSelfSourceCallable; + /// Completed per-lambda capture plans, keyed by AST identity, so an enclosing lambda can transfer + /// nested captures upward. Never leaves this analyzer run: Phase B must not + /// publish placeholder plans into `FrontendAnalysisData.lambdaPlans()`. + private final @NotNull IdentityHashMap lambdaCapturePlans = + new IdentityHashMap<>(); + /// Idempotency guard: both the declaration walk and the boundary reporter can reach the same lambda, + /// but parameters/captures must be bound exactly once. + private final @NotNull Set processedLambdas = + Collections.newSetFromMap(new IdentityHashMap<>()); /// Counts how many supported executable-block boundaries the walker is currently inside. /// /// The counter acts as a narrow capability flag rather than a generic nesting metric: @@ -129,7 +164,11 @@ private AstWalkerVariableBinder( this.scopesByAst = Objects.requireNonNull(scopesByAst, "scopesByAst"); this.diagnosticManager = Objects.requireNonNull(diagnosticManager, "diagnosticManager"); this.astWalker = new ASTWalker(this); - unsupportedBoundaryReporter = new UnsupportedVariableBoundaryReporter(sourcePath, diagnosticManager); + unsupportedBoundaryReporter = new UnsupportedVariableBoundaryReporter( + sourcePath, + diagnosticManager, + this::bindLambdaInventory + ); } private void walk(@NotNull SourceFile sourceFile) { @@ -247,9 +286,10 @@ private void walk(@NotNull SourceFile sourceFile) { @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { - // Unsupported feature-boundary diagnostics are emitted by - // `UnsupportedVariableBoundaryReporter`, which can scan expression subtrees without - // changing this binder's declaration-only walk. + // Lambda inventory (parameters, locals, captures) is part of the supported executable + // surface since Phase B. In practice the boundary reporter routes lambdas here because + // this binder never descends into expression children; binding stays idempotent either way. + bindLambdaInventory(lambdaExpression); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -279,7 +319,13 @@ private void bindCallableParameters( return; } var previousCallableOwner = currentCallableOwner; + var previousSelfSourceCallable = currentSelfSourceCallable; currentCallableOwner = callableOwner; + // Only a named function/constructor can be the `self` source for lambda captures; + // a nested lambda keeps the enclosing non-lambda callable as its self source. + if (!(callableOwner instanceof LambdaExpression)) { + currentSelfSourceCallable = callableOwner; + } try { for (var parameter : parameters) { bindParameter(parameter); @@ -287,10 +333,215 @@ private void bindCallableParameters( if (isNotPublished(body)) { return; } - unsupportedBoundaryReporter.report(body); + // Locals must be bound before the boundary scan: the scan binds nested lambdas + // whose capture planning resolves names against this callable's published locals. walkSupportedExecutableBlock(body); + unsupportedBoundaryReporter.report(body); } finally { currentCallableOwner = previousCallableOwner; + currentSelfSourceCallable = previousSelfSourceCallable; + } + } + + /// Binds the complete inventory of one lambda inside a supported executable body: + /// parameters, body locals, nested-lambda inventories (via the boundary reporter), and + /// finally this lambda's own `CAPTURE` bindings. + /// + /// Capture planning is deliberately post-order (plan §3.4 rule 8): every nested lambda has + /// finished binding and planning before this lambda scans its own uses, so nested + /// parameter/local bindings can never be misread as captures of this lambda. + private void bindLambdaInventory(@NotNull LambdaExpression lambdaExpression) { + if (processedLambdas.contains(lambdaExpression) || isNotPublished(lambdaExpression)) { + return; + } + processedLambdas.add(lambdaExpression); + if (!(scopesByAst.get(lambdaExpression) instanceof CallableScope lambdaScope) + || lambdaScope.kind() != CallableScopeKind.LAMBDA_EXPRESSION + || !(scopesByAst.get(lambdaExpression.body()) instanceof BlockScope lambdaBodyScope)) { + reportBindingError(lambdaExpression, "Lambda expression has no published lambda scope inventory"); + return; + } + bindCallableParameters(lambdaExpression, lambdaExpression.parameters(), lambdaExpression.body()); + var capturePlan = planLambdaCaptures(lambdaExpression, lambdaScope, lambdaBodyScope); + lambdaCapturePlans.put(lambdaExpression, capturePlan); + for (var capture : capturePlan.captures()) { + defineCaptureGuarded(lambdaScope, capture, lambdaExpression); + } + } + + /// Derives this lambda's ordered capture list from its own identifier uses plus captures + /// transferred from directly nested lambdas, keeping first-appearance source order (plan + /// §3.4 rule 6). A leading `self` entry is prepended when the body needs the enclosing + /// instance (§3.5). + private @NotNull FrontendLambdaCapturePlan planLambdaCaptures( + @NotNull LambdaExpression lambdaExpression, + @NotNull CallableScope lambdaScope, + @NotNull BlockScope lambdaBodyScope + ) { + var scanner = new LambdaCaptureSourceScanner(scopesByAst); + scanner.scan(lambdaExpression.body()); + var capturesByName = new LinkedHashMap(); + var needsSelf = scanner.usesExplicitSelf; + for (var event : scanner.events) { + switch (event) { + case LambdaCaptureSourceScanner.IdentifierEvent identifierEvent -> { + var use = identifierEvent.use(); + if (capturesByName.containsKey(use.name())) { + continue; + } + var ownCapture = captureForOwnUse(lambdaScope, use); + if (ownCapture != null) { + capturesByName.put(ownCapture.name(), ownCapture); + } + } + case LambdaCaptureSourceScanner.NestedLambdaEvent nestedLambdaEvent -> { + var childPlan = lambdaCapturePlans.get(nestedLambdaEvent.lambda()); + if (childPlan == null) { + // The nested lambda failed to bind; its own diagnostics already exist. + continue; + } + if (childPlan.capturesSelf()) { + needsSelf = true; + } + for (var childCapture : childPlan.captures()) { + if (FrontendLambdaCapturePlan.SELF_CAPTURE_NAME.equals(childCapture.name())) { + // `self` is never merged by name; it is rebuilt as the leading entry. + continue; + } + if (capturesByName.containsKey(childCapture.name())) { + continue; + } + // Transfer only when the source lives outside this lambda and this + // lambda does not shadow the name (planner returns null otherwise). + var transferred = FrontendLambdaCapturePlanner.transferredCapture( + lambdaScope, + lambdaBodyScope, + childCapture + ); + if (transferred != null) { + capturesByName.put(transferred.name(), transferred); + } + } + } + } + } + if (!needsSelf && usesImplicitInstanceMember(scanner.events, capturesByName.keySet())) { + needsSelf = true; + } + var entries = new ArrayList(); + var selfCapture = needsSelf ? buildSelfCaptureEntry(lambdaScope) : null; + if (selfCapture != null) { + entries.add(selfCapture); + } + entries.addAll(capturesByName.values()); + return FrontendLambdaCapturePlan.of(entries); + } + + /// Plans a capture for one bare identifier use of this lambda, reusing the pure planner + /// with a singleton use list so all capture decisions share one code path. + private @Nullable LambdaCaptureEntry captureForOwnUse( + @NotNull CallableScope lambdaScope, + @NotNull FrontendLambdaCapturePlanner.IdentifierUse use + ) { + var plan = FrontendLambdaCapturePlanner.planCaptures(lambdaScope, List.of(use)); + return plan.captures().isEmpty() ? null : plan.captures().getFirst(); + } + + /// Implicit `self` is needed when a bare identifier that was not captured resolves to an + /// INSTANCE member (non-static property/signal value, or a non-static method overload) — + /// such uses need the instance receiver at runtime (plan §3.5). Static members and global + /// utility functions (`print`, `abs`, ...) never need a receiver and must not synthesize + /// a `self` capture. + private boolean usesImplicitInstanceMember( + @NotNull List events, + @NotNull Set capturedNames + ) { + for (var event : events) { + if (!(event instanceof LambdaCaptureSourceScanner.IdentifierEvent(var use))) { + continue; + } + if (capturedNames.contains(use.name())) { + continue; + } + var valueResult = use.startingScope().resolveValue(use.name(), ResolveRestriction.unrestricted()); + if (valueResult.isFound()) { + var value = valueResult.requireValue(); + var kind = value.kind(); + if ((kind == ScopeValueKind.PROPERTY || kind == ScopeValueKind.SIGNAL) + && !value.staticMember()) { + return true; + } + continue; + } + var functionsResult = use.startingScope().resolveFunctions(use.name(), ResolveRestriction.unrestricted()); + if (functionsResult.isAllowed() + && functionsResult.requireValue().stream().anyMatch(function -> !function.isStatic())) { + return true; + } + } + return false; + } + + /// Builds the leading `self` capture entry, or `null` when `self` is unavailable here: + /// the enclosing non-lambda callable is static (the restriction diagnostic stays with the + /// body-typing phases, plan §3.5) or no owning class can be determined. + private @Nullable LambdaCaptureEntry buildSelfCaptureEntry(@NotNull CallableScope lambdaScope) { + var selfSourceCallable = currentSelfSourceCallable; + var instanceSelfSource = switch (selfSourceCallable) { + case FunctionDeclaration functionDeclaration when !functionDeclaration.isStatic() -> + functionDeclaration; + case ConstructorDeclaration constructorDeclaration -> constructorDeclaration; + case null, default -> null; + }; + if (instanceSelfSource == null) { + return null; + } + var owningClass = lambdaScope.owningClassOrNull(); + if (owningClass == null) { + return null; + } + // `self` behaves like the enclosing callable's implicit parameter; all transfer layers + // share this same declaration identity and the enclosing class object type (§3.5). + return new LambdaCaptureEntry( + FrontendLambdaCapturePlan.SELF_CAPTURE_NAME, + new GdObjectType(owningClass.getName()), + ScopeValueKind.PARAMETER, + instanceSelfSource + ); + } + + /// Registers one capture on the lambda scope without letting user source reach + /// `CallableScope`'s fail-fast duplicate guard (plan §3.4 rule 7). + /// + /// Phase B registers the capture NAME with a `Variant` placeholder type plus the source + /// declaration identity; the declaration-site type replaces the placeholder during nested + /// suite resolution in a later phase. + private void defineCaptureGuarded( + @NotNull CallableScope lambdaScope, + @NotNull LambdaCaptureEntry capture, + @NotNull LambdaExpression lambdaExpression + ) { + var existingBinding = lambdaScope.resolveValueHere(capture.name()); + if (existingBinding != null) { + if (existingBinding.kind() == ScopeValueKind.CAPTURE + && existingBinding.declaration() == capture.sourceDeclaration()) { + // Idempotent re-bind of the same capture (defensive; planning dedupes by name). + return; + } + reportBindingError( + lambdaExpression, + "Capture '" + capture.name() + "' conflicts with existing " + + describeShadowingTarget(existingBinding) + " in the same lambda" + ); + return; + } + try { + lambdaScope.defineCapture(capture.name(), GdVariantType.VARIANT, capture.sourceDeclaration()); + } catch (IllegalArgumentException exception) { + reportBindingError( + lambdaExpression, + "Duplicate capture '" + capture.name() + "' in the same lambda" + ); } } @@ -511,6 +762,7 @@ private void reportLocalConflict( return switch (currentCallableOwner) { case FunctionDeclaration functionDeclaration -> "function '" + functionDeclaration.name().trim() + "'"; case ConstructorDeclaration _ -> "constructor '_init'"; + case LambdaExpression _ -> "lambda expression"; case null -> "callable"; default -> currentCallableOwner.getClass().getSimpleName(); }; @@ -598,17 +850,26 @@ private boolean isNotPublished(@Nullable Node astNode) { /// This reporter exists because the binder itself is declaration-directed and therefore skips /// arbitrary expression children. Without a separate scan, lambdas nested inside expressions /// such as local initializers or return values would remain completely silent. + /// + /// Since lambda Phase B, lambdas found inside supported executable bodies are no longer + /// reported; they are handed to the binder's lambda-inventory pipeline instead. The remaining + /// reported boundaries are `match` subtrees. Block-local `const` subtrees stay sealed here as + /// well: the binder already reports the declaration itself, and names inside such a subtree + /// belong to a not-yet-supported domain. private static final class UnsupportedVariableBoundaryReporter implements ASTNodeHandler { private final @NotNull Path sourcePath; private final @NotNull DiagnosticManager diagnosticManager; + private final @NotNull Consumer lambdaInventoryBinder; private final @NotNull ASTWalker astWalker; private UnsupportedVariableBoundaryReporter( @NotNull Path sourcePath, - @NotNull DiagnosticManager diagnosticManager + @NotNull DiagnosticManager diagnosticManager, + @NotNull Consumer lambdaInventoryBinder ) { this.sourcePath = Objects.requireNonNull(sourcePath, "sourcePath"); this.diagnosticManager = Objects.requireNonNull(diagnosticManager, "diagnosticManager"); + this.lambdaInventoryBinder = Objects.requireNonNull(lambdaInventoryBinder, "lambdaInventoryBinder"); astWalker = new ASTWalker(this); } @@ -642,31 +903,123 @@ private void report(@NotNull Block callableBody) { @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { - reportUnsupportedBoundary( - lambdaExpression, - "Variable analysis does not support lambda subtrees yet; parameters, default values, locals, " - + "and captures inside this lambda are not bound yet" - ); + // Lambdas inside supported executable bodies are bound, not reported. The binder walks + // the lambda body itself, so this scan must not descend into it a second time. + lambdaInventoryBinder.accept(lambdaExpression); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @Override - public @NotNull FrontendASTTraversalDirective handleMatchStatement(@NotNull MatchStatement matchStatement) { - reportUnsupportedBoundary( - matchStatement, - "Variable analysis does not support `match` subtrees yet; pattern bindings and locals " - + "declared inside match sections are not bound yet" - ); - return FrontendASTTraversalDirective.SKIP_CHILDREN; + public @NotNull FrontendASTTraversalDirective handleVariableDeclaration( + @NotNull VariableDeclaration variableDeclaration + ) { + // Block-local `const` inventory stays unsupported; the binder reports the declaration + // and nothing inside its initializer subtree may bind inventory here. + if (variableDeclaration.kind() == DeclarationKind.CONST) { + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + return FrontendASTTraversalDirective.CONTINUE; } - private void reportUnsupportedBoundary(@NotNull Node node, @NotNull String message) { + @Override + public @NotNull FrontendASTTraversalDirective handleMatchStatement(@NotNull MatchStatement matchStatement) { diagnosticManager.error( UNSUPPORTED_VARIABLE_INVENTORY_SUBTREE_CATEGORY, - message, + "Variable analysis does not support `match` subtrees yet; pattern bindings and locals " + + "declared inside match sections are not bound yet", sourcePath, - FrontendRange.fromAstRange(node.range()) + FrontendRange.fromAstRange(matchStatement.range()) ); + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + + } + + /// Collects capture-relevant events of one lambda body in source order. + /// + /// Two event kinds feed capture planning: + /// - bare identifier uses, each pre-bound to the scope that owns the use site + /// - direct nested lambdas, whose completed plans may transfer captures upward + /// + /// The scanner skips nested lambda bodies (bound and planned on their own), `match` subtrees, + /// and block-local `const` subtrees: names inside not-yet-supported domains must never become + /// captures (plan §3.4 "不可捕获"). Explicit `self` expressions are flagged separately because + /// `self` is not an identifier use. + private static final class LambdaCaptureSourceScanner implements ASTNodeHandler { + /// One source-ordered capture-relevant event inside a lambda body. + private sealed interface LambdaCaptureEvent { + } + + /// Bare identifier use that may resolve to a capturable outer binding. + private record IdentifierEvent( + @NotNull FrontendLambdaCapturePlanner.IdentifierUse use + ) implements LambdaCaptureEvent { + } + + /// Direct nested lambda whose own capture plan may transfer entries to this lambda. + private record NestedLambdaEvent(@NotNull LambdaExpression lambda) implements LambdaCaptureEvent { + } + + private final @NotNull FrontendAstSideTable scopesByAst; + private final @NotNull ASTWalker astWalker; + private final @NotNull List events = new ArrayList<>(); + private boolean usesExplicitSelf; + + private LambdaCaptureSourceScanner(@NotNull FrontendAstSideTable scopesByAst) { + this.scopesByAst = Objects.requireNonNull(scopesByAst, "scopesByAst"); + astWalker = new ASTWalker(this); + } + + private void scan(@NotNull Block lambdaBody) { + astWalker.walk(lambdaBody); + } + + @Override + public @NotNull FrontendASTTraversalDirective handleNode(@NotNull Node node) { + return FrontendASTTraversalDirective.CONTINUE; + } + + @Override + public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { + events.add(new NestedLambdaEvent(lambdaExpression)); + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + + @Override + public @NotNull FrontendASTTraversalDirective handleMatchStatement(@NotNull MatchStatement matchStatement) { + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + + @Override + public @NotNull FrontendASTTraversalDirective handleVariableDeclaration( + @NotNull VariableDeclaration variableDeclaration + ) { + // The declaration name is a plain String, not an IdentifierExpression, so declaration + // sites never leak into uses; only the initializer of a supported `var` is scanned. + if (variableDeclaration.kind() == DeclarationKind.CONST) { + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + return FrontendASTTraversalDirective.CONTINUE; + } + + @Override + public @NotNull FrontendASTTraversalDirective handleIdentifierExpression( + @NotNull IdentifierExpression identifierExpression + ) { + var useSiteScope = scopesByAst.get(identifierExpression); + if (useSiteScope != null) { + events.add(new IdentifierEvent(new FrontendLambdaCapturePlanner.IdentifierUse( + identifierExpression.name().trim(), + useSiteScope + ))); + } + return FrontendASTTraversalDirective.CONTINUE; + } + + @Override + public @NotNull FrontendASTTraversalDirective handleSelfExpression(@NotNull SelfExpression selfExpression) { + usesExplicitSelf = true; + return FrontendASTTraversalDirective.CONTINUE; } } } diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/binding/GodotBuiltinGeneratorTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/binding/GodotBuiltinGeneratorTest.java index 1aa3a3e8..34e24ad1 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/binding/GodotBuiltinGeneratorTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/binding/GodotBuiltinGeneratorTest.java @@ -235,11 +235,12 @@ void renderBuiltinVarargMethodsShouldUseDynamicArgvWithoutZeroLengthVla() { void renderBuiltinSupportShouldKeepCheckedInVarargWrappersInSync() throws IOException { var api = ExtensionApiLoader.loadDefault(); var files = GodotBuiltinGenerator.renderBuiltinSupport(api); - var header = files.get("godot_builtin.h"); - var source = files.get("godot_builtin.c"); + var header = files.get("godot_builtin.h").replace("\r\n", "\n"); + var source = files.get("godot_builtin.c").replace("\r\n", "\n"); var checkedInRoot = Path.of("src/main/c/codegen/include_451/godot"); - var checkedInHeader = Files.readString(checkedInRoot.resolve("godot_builtin.h")); - var checkedInSource = Files.readString(checkedInRoot.resolve("godot_builtin.c")); + // Checked-in files may use CRLF depending on git autocrlf; normalize before comparing. + var checkedInHeader = Files.readString(checkedInRoot.resolve("godot_builtin.h")).replace("\r\n", "\n"); + var checkedInSource = Files.readString(checkedInRoot.resolve("godot_builtin.c")).replace("\r\n", "\n"); var varargSymbols = GodotBuiltinGenerator.collectSymbols(api).stream() .filter(GodotBindingSymbol::vararg) .toList(); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java index ca037b4a..118786a1 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java @@ -23,7 +23,9 @@ void executableBlockKindsPublishInventoryAndEnterSuiteResolver() { BlockScopeKind.ELIF_BODY, BlockScopeKind.ELSE_BODY, BlockScopeKind.WHILE_BODY, - BlockScopeKind.FOR_BODY + BlockScopeKind.FOR_BODY, + // Lambda Phase B: lambda bodies publish lexical inventory (param/local/capture binding). + BlockScopeKind.LAMBDA_BODY ); for (var kind : BlockScopeKind.values()) { @@ -38,18 +40,11 @@ void executableBlockKindsPublishInventoryAndEnterSuiteResolver() { @Test void unsupportedScopesKeepPreciseStructuralDomains() { - assertEquals( - FrontendBodySemanticSupportPolicy.LAMBDA_SUBTREE, - FrontendBodySemanticSupportPolicy.forBlockScopeKind(BlockScopeKind.LAMBDA_BODY) - ); + // Lambda scopes left this deferred set in Phase B; only `match` keeps a block-scope deferred domain. assertEquals( FrontendBodySemanticSupportPolicy.MATCH_SUBTREE, FrontendBodySemanticSupportPolicy.forBlockScopeKind(BlockScopeKind.MATCH_SECTION_BODY) ); - assertEquals( - FrontendBodySemanticSupportPolicy.LAMBDA_SUBTREE, - FrontendBodySemanticSupportPolicy.forCallableScopeKind(CallableScopeKind.LAMBDA_EXPRESSION) - ); var deferredPolicies = Set.of( FrontendBodySemanticSupportPolicy.LAMBDA_SUBTREE, @@ -84,7 +79,8 @@ void bodyEntrySemanticEntryAgreesWithFirstCertificateGateForAllBlockScopeKinds() BlockScopeKind.ELIF_BODY, BlockScopeKind.ELSE_BODY, BlockScopeKind.WHILE_BODY, - BlockScopeKind.FOR_BODY + BlockScopeKind.FOR_BODY, + BlockScopeKind.LAMBDA_BODY ); for (var kind : BlockScopeKind.values()) { @@ -97,7 +93,8 @@ void bodyEntrySemanticEntryAgreesWithFirstCertificateGateForAllBlockScopeKinds() void bodyEntrySemanticEntryAgreesWithFirstCertificateGateForAllCallableScopeKinds() { var supportedCallableKinds = EnumSet.of( CallableScopeKind.FUNCTION_DECLARATION, - CallableScopeKind.CONSTRUCTOR_DECLARATION + CallableScopeKind.CONSTRUCTOR_DECLARATION, + CallableScopeKind.LAMBDA_EXPRESSION ); for (var kind : CallableScopeKind.values()) { diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java index 5fea8031..bc4e7257 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java @@ -290,15 +290,15 @@ func ping(value, alias = value): } @Test - void analyzeWarnsForDeferredLambdaSubtreesWhileBindingOuterLocal() throws Exception { - var phaseInput = publishedPhaseInput("phase4_lambda_deferred.gd", """ - class_name LambdaVariableDeferred + void analyzeBindsLambdaParametersLocalsAndCapturesOuterValues() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_inventory.gd", """ + class_name LambdaVariableBound extends Node func ping(seed: int): - var builder := func(item: int, fallback = item): - var lambda_local := fallback - return lambda_local + var builder := func(item: int): + var lambda_local := item + return lambda_local + seed return seed """); var sourceFile = phaseInput.unit().ast(); @@ -316,29 +316,41 @@ func ping(seed: int): var builderLambda = assertInstanceOf(LambdaExpression.class, builderDeclaration.value()); var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(builderLambda)); var lambdaBodyScope = assertInstanceOf(BlockScope.class, phaseInput.analysisData().scopesByAst().get(builderLambda.body())); + var lambdaLocalDeclaration = findStatement( + builderLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("lambda_local") + ); var diagnosticsBefore = phaseInput.diagnostics().snapshot(); new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + // Lambda inventory is fully bound since Phase B, so no boundary diagnostic may fire. var diagnosticsAfter = phaseInput.diagnostics().snapshot(); - var newDiagnostics = newDiagnostics(diagnosticsBefore, diagnosticsAfter); - var error = newDiagnostics.getFirst(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + + var itemBinding = lambdaScope.resolveValue("item"); + assertNotNull(itemBinding); + assertEquals(ScopeValueKind.PARAMETER, itemBinding.kind()); + assertEquals(GdIntType.INT, itemBinding.type()); + assertSame(builderLambda.parameters().getFirst(), itemBinding.declaration()); + + var lambdaLocalBinding = lambdaBodyScope.resolveValue("lambda_local"); + assertNotNull(lambdaLocalBinding); + assertEquals(ScopeValueKind.LOCAL, lambdaLocalBinding.kind()); + assertSame(lambdaLocalDeclaration, lambdaLocalBinding.declaration()); + + // Phase B registers the capture name with a Variant placeholder type; the declaration-site + // type replaces the placeholder during nested suite resolution in a later phase. + var seedCapture = lambdaScope.resolveValueHere("seed"); + assertNotNull(seedCapture); + assertEquals(ScopeValueKind.CAPTURE, seedCapture.kind()); + assertEquals(GdVariantType.VARIANT, seedCapture.type()); + assertSame(pingFunction.parameters().getFirst(), seedCapture.declaration()); - assertEquals(1, newDiagnostics.size()); - assertEquals(FrontendDiagnosticSeverity.ERROR, error.severity()); - assertEquals("sema.unsupported_variable_inventory_subtree", error.category()); - assertTrue(error.message().contains("does not support lambda subtrees")); - assertTrue(error.message().contains("parameters, default values, locals, and captures")); - assertEquals(FrontendDiagnostic.sourcePathText(phaseInput.unit().path()), error.sourcePath()); - assertEquals(FrontendRange.fromAstRange(builderLambda.range()), error.range()); - assertNotNull(assertInstanceOf( - CallableScope.class, - phaseInput.analysisData().scopesByAst().get(pingFunction) - ).resolveValue("seed")); assertNotNull(pingBodyScope.resolveValue("builder")); - assertNull(lambdaScope.resolveValue("item")); - assertNull(lambdaScope.resolveValue("fallback")); - assertNull(lambdaBodyScope.resolveValue("lambda_local")); + // Placeholder captures live only on the scope; no plan may be published in Phase B. + assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); } @Test @@ -427,7 +439,7 @@ func ping(value: int): } @Test - void analyzeWarnsForDeferredLambdaSubtreesInsideReturnExpressions() throws Exception { + void analyzeBindsLambdaInsideReturnExpression() throws Exception { var phaseInput = publishedPhaseInput("phase4_lambda_return_expression.gd", """ class_name LambdaReturnExpression extends Node @@ -444,20 +456,521 @@ func ping(seed: int): ); var returnStatement = assertInstanceOf(ReturnStatement.class, pingFunction.body().statements().getFirst()); var returnedLambda = assertInstanceOf(LambdaExpression.class, returnStatement.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(returnedLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // Lambdas nested in arbitrary expressions are reached through the boundary reporter and + // must be bound exactly like statement-initializers, without any boundary diagnostic. + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + var seedCapture = lambdaScope.resolveValueHere("seed"); + assertNotNull(seedCapture); + assertEquals(ScopeValueKind.CAPTURE, seedCapture.kind()); + assertSame(pingFunction.parameters().getFirst(), seedCapture.declaration()); + } + + @Test + void analyzeTransfersNestedLambdaCapturesThroughIntermediateLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_nested_capture.gd", """ + class_name NestedLambdaCapture + extends Node + + func ping(seed: int): + var outer := func(): + var mid := func(): + return seed + return mid + return outer + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var outerDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("outer") + ); + var outerLambda = assertInstanceOf(LambdaExpression.class, outerDeclaration.value()); + var midDeclaration = findStatement( + outerLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("mid") + ); + var midLambda = assertInstanceOf(LambdaExpression.class, midDeclaration.value()); + var outerLambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(outerLambda)); + var midLambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(midLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + + // The innermost lambda captures the outer parameter directly. + var midCapture = midLambdaScope.resolveValueHere("seed"); + assertNotNull(midCapture); + assertEquals(ScopeValueKind.CAPTURE, midCapture.kind()); + assertSame(pingFunction.parameters().getFirst(), midCapture.declaration()); + + // The intermediate lambda must re-export the same capture (nested transfer, plan §3.4 + // rule 9): same name, same source declaration identity. + var outerCapture = outerLambdaScope.resolveValueHere("seed"); + assertNotNull(outerCapture); + assertEquals(ScopeValueKind.CAPTURE, outerCapture.kind()); + assertEquals(GdVariantType.VARIANT, outerCapture.type()); + assertSame(pingFunction.parameters().getFirst(), outerCapture.declaration()); + } + + @Test + void analyzeDoesNotCaptureWhenLambdaLocalShadowsOuterLocal() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_local_shadow.gd", """ + class_name LambdaLocalShadow + extends Node + + func ping(): + var x = 1 + var cb := func(): + var x = 2 + return x + return x + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var outerXDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("x") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var innerXDeclaration = findStatement( + cbLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("x") + ); + var pingBodyScope = assertInstanceOf(BlockScope.class, phaseInput.analysisData().scopesByAst().get(pingFunction.body())); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var lambdaBodyScope = assertInstanceOf(BlockScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda.body())); var diagnosticsBefore = phaseInput.diagnostics().snapshot(); new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + // The shadowing local lives behind the lambda callable boundary, so it is legal and the + // outer `x` must NOT be captured (plan §3.4 self-shadowing rule). + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + assertNull(lambdaScope.resolveValueHere("x")); + var innerXBinding = lambdaBodyScope.resolveValueHere("x"); + assertNotNull(innerXBinding); + assertEquals(ScopeValueKind.LOCAL, innerXBinding.kind()); + assertSame(innerXDeclaration, innerXBinding.declaration()); + var outerXBinding = pingBodyScope.resolveValueHere("x"); + assertNotNull(outerXBinding); + assertSame(outerXDeclaration, outerXBinding.declaration()); + } + + @Test + void analyzeStopsCaptureTransferAtShadowingIntermediateLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_transfer_shadow.gd", """ + class_name LambdaTransferShadow + extends Node + + func ping(seed: int): + var outer := func(): + var seed = 10 + var mid := func(): + return seed + return mid + return outer + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var outerDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("outer") + ); + var outerLambda = assertInstanceOf(LambdaExpression.class, outerDeclaration.value()); + var middleSeedDeclaration = findStatement( + outerLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("seed") + ); + var midDeclaration = findStatement( + outerLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("mid") + ); + var midLambda = assertInstanceOf(LambdaExpression.class, midDeclaration.value()); + var outerLambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(outerLambda)); + var midLambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(midLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + + // The inner lambda captures the INTERMEDIATE lambda's local, not the outer parameter. + var midCapture = midLambdaScope.resolveValueHere("seed"); + assertNotNull(midCapture); + assertEquals(ScopeValueKind.CAPTURE, midCapture.kind()); + assertSame(middleSeedDeclaration, midCapture.declaration()); + + // The intermediate lambda shadows the name with its own local, so the transfer chain + // terminates here: no capture may be inserted for the outer parameter (plan §3.4 rule 9). + assertNull(outerLambdaScope.resolveValueHere("seed")); + } + + @Test + void analyzeStillReportsMatchAndBlockLocalConstInsideLambdaBody() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_deferred_boundaries.gd", """ + class_name LambdaDeferredBoundaries + extends Node + + func ping(seed: int): + var cb := func(item: int): + match item: + 1: + pass + const k = seed + return item + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var matchStatement = findStatement(cbLambda.body().statements(), MatchStatement.class, _ -> true); + var constDeclaration = findStatement( + cbLambda.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("k") + ); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var lambdaBodyScope = assertInstanceOf(BlockScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda.body())); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // `match` and block-local `const` stay deferred inside lambda bodies as well. var diagnosticsAfter = phaseInput.diagnostics().snapshot(); var newDiagnostics = newDiagnostics(diagnosticsBefore, diagnosticsAfter); - var error = newDiagnostics.getFirst(); + var matchError = findDiagnostic(newDiagnostics, FrontendRange.fromAstRange(matchStatement.range())); + var constError = findDiagnostic(newDiagnostics, FrontendRange.fromAstRange(constDeclaration.range())); + assertEquals(2, newDiagnostics.size()); + assertEquals("sema.unsupported_variable_inventory_subtree", matchError.category()); + assertTrue(matchError.message().contains("does not support `match` subtrees")); + assertEquals("sema.unsupported_variable_inventory_subtree", constError.category()); + assertTrue(constError.message().contains("does not support block-local `const` declarations")); + + // Supported lambda inventory is still bound around the deferred fragments. + var itemBinding = lambdaScope.resolveValue("item"); + assertNotNull(itemBinding); + assertEquals(ScopeValueKind.PARAMETER, itemBinding.kind()); + + // Names inside the deferred `const` subtree must not leak into captures or locals. + assertNull(lambdaScope.resolveValueHere("seed")); + assertNull(lambdaScope.resolveValueHere("k")); + assertNull(lambdaBodyScope.resolveValueHere("k")); + } + + @Test + void analyzeLeavesPropertyInitializerLambdaUnbound() throws Exception { + var phaseInput = publishedPhaseInput("phase4_property_initializer_lambda.gd", """ + class_name PropertyInitializerLambda + extends Node + + var cb = func(item): + return item + + func ping(seed: int): + return seed + """); + var propertyDeclaration = findStatement( + phaseInput.unit().ast().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var propertyLambda = assertInstanceOf(LambdaExpression.class, propertyDeclaration.value()); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // The variable analyzer only inventories supported callable bodies. A property-initializer + // lambda is outside that reach: no inventory diagnostic (the property-initializer boundary + // diagnostic belongs to the binding/chain phases) and no binding may appear on its scope. + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + var lambdaScope = assertInstanceOf( + CallableScope.class, + phaseInput.analysisData().scopesByAst().get(propertyLambda) + ); + assertNull(lambdaScope.resolveValueHere("item")); + assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); + } + + @Test + void analyzeCapturesSelfForExplicitSelfUseInsideLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_self_capture.gd", """ + class_name LambdaSelfCapture + extends Node + + var hp: int = 0 + + func ping(): + var cb := func(): + return self.hp + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + + // §3.5: an explicit `self` use captures the enclosing instance under the name `self`, + // sourced at the enclosing callable. The scope binding keeps the Phase B Variant + // placeholder like every capture; the enclosing class object type is filled with all + // declaration-site capture types during nested suite resolution. + var selfCapture = lambdaScope.resolveValueHere("self"); + assertNotNull(selfCapture); + assertEquals(ScopeValueKind.CAPTURE, selfCapture.kind()); + assertEquals(GdVariantType.VARIANT, selfCapture.type()); + assertSame(pingFunction, selfCapture.declaration()); + assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); + } + + @Test + void analyzeCapturesSelfForImplicitInstanceMemberUseInsideLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_implicit_self.gd", """ + class_name LambdaImplicitSelf + extends Node + + var hp: int = 0 + + func ping(): + var cb := func(): + return hp + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + + // A bare instance-property read needs the instance receiver: `hp` itself is not + // capturable (class member), so the lambda captures `self` instead (§3.5). + assertNull(lambdaScope.resolveValueHere("hp")); + var selfCapture = lambdaScope.resolveValueHere("self"); + assertNotNull(selfCapture); + assertEquals(ScopeValueKind.CAPTURE, selfCapture.kind()); + assertSame(pingFunction, selfCapture.declaration()); + } + + @Test + void analyzeDoesNotCaptureSelfInsideStaticFunction() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_static_self.gd", """ + class_name LambdaStaticSelf + extends Node + + var hp: int = 0 + + static func ping(): + var cb := func(): + return self.hp + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // §3.5: a static enclosing callable never synthesizes a `self` capture; the restriction + // diagnostic for the illegal `self` use stays with the body-typing phases. + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + assertNull(lambdaScope.resolveValueHere("self")); + } + + @Test + void analyzeDoesNotCaptureSelfForUtilityFunctionCallInsideLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_utility_call.gd", """ + class_name LambdaUtilityCall + extends Node + + func ping(): + var cb := func(): + print("hi") + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // Global utility functions resolve without a receiver; they must not synthesize `self`. + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + assertNull(lambdaScope.resolveValueHere("self")); + assertNull(lambdaScope.resolveValueHere("print")); + } + + @Test + void analyzeDoesNotCaptureSelfForStaticMembersInsideLambda() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_static_members.gd", """ + class_name LambdaStaticMembers + extends Node + + static var counter: int = 0 + + static func helper() -> int: + return 1 + + func ping(): + var cb := func(): + counter += helper() + return counter + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + // Static property reads and static method calls need no instance receiver (§3.5), so no + // `self` capture may appear even though the names belong to the enclosing class. + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); + assertNull(lambdaScope.resolveValueHere("self")); + assertNull(lambdaScope.resolveValueHere("counter")); + assertNull(lambdaScope.resolveValueHere("helper")); + } + + @Test + void analyzeReportsCaptureConflictForSelfNamedParameterWithoutThrowing() throws Exception { + var phaseInput = publishedPhaseInput("phase4_lambda_self_param_conflict.gd", """ + class_name LambdaSelfParamConflict + extends Node + + var hp: int = 0 + + func ping(): + var cb := func(self): + return self.hp + return cb + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var cbDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var cbLambda = assertInstanceOf(LambdaExpression.class, cbDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(cbLambda)); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + // Plan §3.4 rule 7: a capture that collides with an existing parameter slot must produce + // exactly one binding diagnostic and never reach CallableScope's fail-fast duplicate guard. + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + var diagnosticsAfter = phaseInput.diagnostics().snapshot(); + var newDiagnostics = newDiagnostics(diagnosticsBefore, diagnosticsAfter); assertEquals(1, newDiagnostics.size()); + var error = newDiagnostics.getFirst(); assertEquals(FrontendDiagnosticSeverity.ERROR, error.severity()); - assertEquals("sema.unsupported_variable_inventory_subtree", error.category()); - assertTrue(error.message().contains("does not support lambda subtrees")); - assertEquals(FrontendDiagnostic.sourcePathText(phaseInput.unit().path()), error.sourcePath()); - assertEquals(FrontendRange.fromAstRange(returnedLambda.range()), error.range()); + assertEquals("sema.variable_binding", error.category()); + assertTrue(error.message().contains("Capture 'self' conflicts with existing parameter 'self'")); + assertEquals(FrontendRange.fromAstRange(cbLambda.range()), error.range()); + + // The parameter binding wins; no capture may overwrite it. + var selfBinding = lambdaScope.resolveValueHere("self"); + assertNotNull(selfBinding); + assertEquals(ScopeValueKind.PARAMETER, selfBinding.kind()); } @Test diff --git a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java index d0230013..b4b146df 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java @@ -622,7 +622,10 @@ func ping(value): } @Test - void resolveRejectsSyntheticLambdaBodyCurrentScopeEvenWithoutLambdaAstBoundary() throws Exception { + void resolveAllowsSyntheticLambdaBodyCurrentScopeWithoutLambdaAstBoundary() throws Exception { + // Lambda Phase B flipped the LAMBDA_BODY policy to publish lexical inventory, so the + // current-scope backstop no longer seals a synthetic LAMBDA_BODY scope. Without a real + // lambda AST boundary the request resolves as an ordinary EXECUTABLE_BODY lookup. var analyzedInput = analyzedInput("synthetic_lambda_scope.gd", """ class_name SyntheticLambdaScope extends Node @@ -641,12 +644,9 @@ func ping(value): FrontendVisibleValueDomain.EXECUTABLE_BODY )); - assertEquals(FrontendVisibleValueStatus.DEFERRED_UNSUPPORTED, result.status()); - assertEquals(FrontendVisibleValueDomain.LAMBDA_SUBTREE, result.deferredBoundary().domain()); - assertEquals( - FrontendVisibleValueDeferredReason.UNSUPPORTED_DOMAIN, - result.deferredBoundary().reason() - ); + assertEquals(FrontendVisibleValueStatus.FOUND_ALLOWED, result.status()); + assertNotNull(result.visibleValue()); + assertEquals(ScopeValueKind.PARAMETER, result.visibleValue().kind()); } @Test From b31b35eef6517d85fc7cb16d51918f8e7fd896e0 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Mon, 17 Aug 2026 22:26:49 +0800 Subject: [PATCH 4/9] feat(frontend): complete lambda suite resolution and lexical binding - Resolve lambda-local values, captures, and nested suite ownership - Add transactional resolution patches with fail-closed diagnostics - Improve visible-value and lexical environment handling - Expand semantic analysis and lambda resolution test coverage - Update frontend implementation documentation --- .../frontend/diagnostic_manager.md | 6 +- .../frontend/frontend_lambda_plan.md | 19 +- ...ontend_variable_analyzer_implementation.md | 5 +- ...d_visible_value_resolver_implementation.md | 15 +- .../gdcc/frontend/scope/CallableScope.java | 52 +++ .../frontend/sema/FrontendSemanticStage.java | 8 +- .../sema/FrontendTypedLexicalEnvironment.java | 57 ++- .../analyzer/FrontendBodyOwnerProcedures.java | 67 ++- .../sema/analyzer/FrontendInterfacePhase.java | 5 + .../sema/analyzer/FrontendSuiteContext.java | 15 +- .../sema/analyzer/FrontendSuiteResolver.java | 269 +++++++++++- .../debug/FrontendAnalysisInspectionTool.java | 31 +- .../patch/FrontendLambdaResolutionPatch.java | 34 ++ .../sema/patch/FrontendOwnerPatch.java | 3 +- .../sema/patch/FrontendPatchTransaction.java | 1 + .../FrontendVisibleValueResolver.java | 29 +- .../sema/FrontendAnalysisDataTest.java | 2 + .../sema/FrontendInterfacePhaseTest.java | 20 +- .../FrontendLambdaSuiteResolutionTest.java | 391 ++++++++++++++++++ ...FrontendSemanticAnalyzerFrameworkTest.java | 30 +- .../sema/FrontendSuiteResolverTest.java | 9 +- .../FrontendCompileCheckAnalyzerTest.java | 15 +- .../FrontendVisibleValueResolverTest.java | 63 ++- 23 files changed, 1042 insertions(+), 104 deletions(-) create mode 100644 src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendLambdaResolutionPatch.java create mode 100644 src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index 500dc400..fcf812a8 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -133,7 +133,7 @@ frontend 当前已经冻结的诊断承载方式如下: - `forIterationPlans` - `typeTestTargets` - `containerLiteralPlans` -- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 起 lambda inventory 绑定进 scope,但 plan 仍不发布,首次发布在阶段 C nested resolve 入口) +- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充) 其中: @@ -229,7 +229,8 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - `sema.binding` - top binding 命中的 blocked / unknown / shadowing 诊断 - `sema.unsupported_binding_subtree` - - top binding 对 parameter default、lambda、`match`、block-local `const` 等明确 unsupported subtree 的边界 error + - top binding 对 parameter default、`match`、block-local `const` 等明确 unsupported subtree 的边界 error + - 已 `recordCallable` 的 lambda 自阶段 C 起改走 nested suite resolution,不再发此诊断;未记录的 lambda(property initializer 等)继续按此边界 fail-closed - top binding 对 missing-scope / skipped subtree 的恢复诊断继续允许使用 warning - `sema.member_resolution` - chain binding 中 blocked / failed member step 的语义错误 @@ -349,6 +350,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - `FrontendSemanticAnalyzer` 当前返回 `FrontendAnalysisData` - analyze 流程围绕同一份共享分析数据推进 - interface/body suite resolver 路径会在每个 body statement boundary 刷新 `FrontendAnalysisData.diagnostics()`,让后一 statement 能读取 current-suite upstream diagnostic snapshot;suite export 在 patch transaction 应用后保留最终 body snapshot +- 已 `recordCallable` 的 lambda 由外层 statement 的 top-binding owner 触发 nested suite resolution:独立 `FrontendCallableExportBatch` 立即应用,`LAMBDA_RESOLUTION` owner patch 首次发布 `lambdaPlans`(capture 声明处类型),body 事实与普通 suite 走同一 statement boundary 诊断刷新 - analyze 现在已经具备独立的多 phase 主链路: - skeleton 结束后先发布 `updateModuleSkeleton(...)` - 再发布一次 pre-scope `updateDiagnostics(...)` diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index 0b5ac2c4..a3e00dce 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,10 +9,12 @@ ## 文档状态 -- 状态:阶段 A、B 已落地;C–I 尚未实施。supported executable body 内的 lambda 已完成 - policy 翻转与 inventory 绑定(param / local / capture 名 + `Variant` 占位), - `lambdaPlans()` 仍不发布;resolver / interface / suite / lowering / backend 链路仍 fail-closed。 -- 更新时间:2026-08-17(阶段 B 落地:policy 翻转 + variable inventory 解封 + self capture) +- 状态:阶段 A、B、C 已落地;D–I 尚未实施。lambda body 已成为 supported executable + suite:resolver / interface / suite 解封,nested resolve 经独立 `LAMBDA_RESOLUTION` + owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型已填充并与 scope 同源)。 + 表达式类型(`GdCallableType`)、LIR 合成、CFG lowering、compile surface 仍 fail-closed。 +- 更新时间:2026-08-17(阶段 C 落地:resolver/interface/suite 解封 + capture 类型填充 + + `lambdaPlans()` 首次发布) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -717,6 +719,15 @@ $result = construct_lambda "" $capture1 $capture2 ... ### 阶段 C — Policy / resolver / interface / suite 解封 +> 状态:已落地(2026-08-17)。resolver 已去掉 lambda AST 边与 kind 封口(callable scope +> gate 改为 policy 驱动);interface 在 `supportedBodyDepth > 0` 时 `recordCallable`; +> suite 四个 helper 已加 `LambdaExpression` 分支;nested resolve 入口填充 capture +> 声明处类型(`declarationSiteLocalSlotType` 严格 overlay 查询 + `resetCaptureType`) +> 并经 `LAMBDA_RESOLUTION` / `FrontendLambdaResolutionPatch` 首次发布完整 plan; +> `walkRootBounded` 剪枝保留;未 record 的 lambda 继续发 unsupported binding/chain。 +> `smallestContainingCallable` 已识别 `LambdaExpression`。已 record lambda 的表达式类型 +> 仍发布 UNSUPPORTED 但不发诊断(`GdCallableType` 属阶段 D)。 + **目标**:lambda body 成为 supported executable suite。shared `analyze()` 进入 body 并发布普通表达式事实。 diff --git a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md index 9a3f6bb5..ba3c0ee3 100644 --- a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md @@ -336,12 +336,9 @@ For iterator 与 parameter、外层 local 或 body local 冲突时仍发布 `sem 后续最直接的增量工作包括: -- lambda capture 声明处类型填充与 `lambdaPlans()` 首次发布(nested suite resolution 入口) -- for iteration planning 与 iterator slot refinement +- lambda 表达式类型发布(`GdCallableType`)与 lowering 链路(阶段 D+) - `match` pattern binding 与 section inventory - block-local `const` inventory -- `frontend.sema.resolver.FrontendVisibleValueResolver` -- `symbolBindings()` 的 use-site 发布 - 参数默认值的真实语义接线 --- diff --git a/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md b/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md index 8e140d08..20f69beb 100644 --- a/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md +++ b/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md @@ -23,7 +23,7 @@ - 不修改 shared `Scope.resolveValue(...)` 协议 - 不让 unsupported 子域伪装成正常 `NOT_FOUND` - 不在 resolver 内直接生产 diagnostics 或写入 `symbolBindings()` - - 不在当前 frontend 中为 parameter default、lambda、`match`、block-local `const` 提供正式 local inventory 解析 + - 不在当前 frontend 中为 parameter default、`match`、block-local `const` 提供正式 local inventory 解析(lambda body 已在 lambda 阶段 C 转正) --- @@ -65,6 +65,7 @@ - `ELSE_BODY` - `WHILE_BODY` - `FOR_BODY` + - `LAMBDA_BODY`(lambda 阶段 C 起) - resolver 与 variable analyzer 必须共用同一 support matrix,不能各自维护名单 ### 2.2 与 shared `Scope` 的分工 @@ -106,16 +107,13 @@ resolver 当前只对 `EXECUTABLE_BODY` 域提供正常 lookup,并要求同时 以下位置当前都必须返回 `DEFERRED_UNSUPPORTED + deferredBoundary(...)`: - parameter default-value expression -- lambda body - block-local `const` initializer subtree - `match` pattern / guard / section body - 任何缺少稳定 scope 记录的 subtree - 任何 current scope 自身就是未发布 inventory 的 scope,例如: - `BlockScopeKind.MATCH_SECTION_BODY` - - `BlockScopeKind.LAMBDA_BODY` - - `CallableScopeKind.LAMBDA_EXPRESSION` -这些域当前不能静默回退到 outer local / class property / global,也不能伪装成普通 `NOT_FOUND`。`ForStatement` 的 iterator type、iterable 与 body edge 已转正,`FOR_BODY` current scope 直接进入 normal lookup。body 内 identifier 的精化类型仍依赖 suite overlay 的 `effectiveBinding`:iterator 声明的 owning scope 必须是 `FOR_BODY` 对象身份(`scope_analyzer_implementation.md` §6.1),否则可能回落到 Interface baseline `Variant`。 +这些域当前不能静默回退到 outer local / class property / global,也不能伪装成普通 `NOT_FOUND`。`ForStatement` 的 iterator type、iterable 与 body edge 已转正,`FOR_BODY` current scope 直接进入 normal lookup;lambda body 自阶段 C 起同样转正:`LAMBDA_BODY` / `LAMBDA_EXPRESSION` current scope 走 policy 驱动 gate(`EXECUTABLE_BODY` 放行),lambda AST 边不再封口,body 内 use-site 直接命中 lambda 自己的 `CAPTURE` / `PARAMETER` / `LOCAL` 绑定。body 内 identifier 的精化类型仍依赖 suite overlay 的 `effectiveBinding`:iterator 声明的 owning scope 必须是 `FOR_BODY` 对象身份(`scope_analyzer_implementation.md` §6.1),否则可能回落到 Interface baseline `Variant`。 ### 3.3 `ClassScope` / `ClassRegistry` 不是封口域 @@ -199,9 +197,9 @@ resolver 当前只对 `EXECUTABLE_BODY` 域提供正常 lookup,并要求同时 `FrontendVisibleValueResolver.resolve(request)` 当前行为冻结为: 1. 若 `request.domain != EXECUTABLE_BODY`,直接返回 `DEFERRED_UNSUPPORTED(domain = request.domain, reason = UNSUPPORTED_DOMAIN)` -2. 先做 AST boundary 检测,识别 parameter default、lambda body、block-local `const` initializer、`match` 等共享外层 scope 的 unsupported 子树;for header/body edge 不封口 +2. 先做 AST boundary 检测,识别 parameter default、block-local `const` initializer、`match` 等共享外层 scope 的 unsupported 子树;for header/body edge 与 lambda body edge 不封口 3. 若 use-site 缺少 current scope 记录,返回 `DEFERRED_UNSUPPORTED` -4. 对 current scope 执行 fail-closed 校验;若 current scope 自身没有已发布 inventory,也返回 `DEFERRED_UNSUPPORTED` +4. 对 current scope 执行 fail-closed 校验(block 与 callable scope 均走 policy 驱动 gate);若 current scope 自身没有已发布 inventory,也返回 `DEFERRED_UNSUPPORTED` 5. 在继续 outer fallback 之前,检查 enclosing supported block 中是否已经出现同名且当前可见的 block-local `const`;若存在,也必须封口为 `BLOCK_LOCAL_CONST_SUBTREE` 6. 只有 AST boundary 与 current-scope gate 都放行后,才对 `BlockScope` / `CallableScope` 做逐层 lookup 7. 当前层若 `FOUND_BLOCKED`,直接返回 `FOUND_BLOCKED`,不能降级成 `NOT_FOUND` @@ -265,7 +263,8 @@ resolver 当前只对 `EXECUTABLE_BODY` 域提供正常 lookup,并要求同时 - parameter / ordinary local / class property 三类基本路径的正向解析 - future local / initializer self-reference 会进入 `filteredHits`,且不会误命中当前 declaration - `FOUND_BLOCKED` 不会被错误降级成 `NOT_FOUND` -- parameter default、lambda、block-local `const`、`match`、missing-scope 都返回 `DEFERRED_UNSUPPORTED` +- parameter default、block-local `const`、`match`、missing-scope 都返回 `DEFERRED_UNSUPPORTED` +- lambda body 内 use-site 正常解析:外层 capture / 当前 lambda 的 parameter / local 都返回 `FOUND_ALLOWED`;lambda 的 `CAPTURE` 绑定不参与 interface body inventory 一致性校验,但仍受 declaration-order 可见性过滤 - for header/body/current-scope lookup 正常进入 resolver,iterator 与 body local 仍受 published inventory、declaration-order 与 self-reference guard 约束 - 真实 AST deferred 场景与 synthetic current-scope remap 场景都已覆盖,证明即使 AST boundary 漏判,resolver 仍按未发布 inventory fail-closed - shared `ClassScope` / `ClassRegistry` 抛出的 `ScopeLookupException` 会原样传播 diff --git a/src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java b/src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java index 1f17ed2c..bf8dfbaa 100644 --- a/src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java +++ b/src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java @@ -9,6 +9,7 @@ import gd.script.gdcc.scope.ScopeValue; import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdType; +import gd.script.gdcc.type.GdVariantType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -80,6 +81,57 @@ public void defineCapture( capturesByName.put(name, new ScopeValue(name, type, ScopeValueKind.CAPTURE, declaration, false, true, false)); } + /// Returns the capture bindings in frozen source order (insertion order). + /// + /// Lambda capture planning freezes operand order at first occurrence in source, so the LIR + /// `` list and the published `FrontendLambdaPlan` both iterate this view. + public @NotNull List captures() { + return List.copyOf(capturesByName.values()); + } + + /// Rewrites the type of an already-published capture binding. + /// + /// Mirrors `BlockScope.resetLocalType`: only an existing `CAPTURE` binding with the same + /// declaration identity is rewritten; missing, mismatched, or non-capture names remain a quiet + /// no-op because earlier phases may already have rejected the source with a diagnostic. Lambda + /// capture filling is monotonic `Variant -> exact`: rewriting one exact type to a different exact + /// type indicates a compiler bug and fails fast instead of silently diverging from the published + /// `FrontendLambdaPlan`. + public void resetCaptureType( + @NotNull String name, + @NotNull Object declaration, + @NotNull GdType type + ) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(declaration, "declaration"); + Objects.requireNonNull(type, "type"); + var existing = capturesByName.get(name); + if (existing == null + || existing.kind() != ScopeValueKind.CAPTURE + || existing.declaration() != declaration) { + return; + } + if (!(existing.type() instanceof GdVariantType) + && (existing.type().getClass() != type.getClass() + || !existing.type().getTypeName().equals(type.getTypeName()))) { + throw new IllegalStateException( + "capture '" + name + "' type already frozen to " + existing.type().getTypeName() + ); + } + capturesByName.put( + name, + new ScopeValue( + existing.name(), + type, + existing.kind(), + existing.declaration(), + existing.constant(), + existing.writable(), + existing.staticMember() + ) + ); + } + @Override public @NotNull ScopeLookupResult resolveValueHere( @NotNull String name, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSemanticStage.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSemanticStage.java index f602d1aa..41ef62e8 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSemanticStage.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSemanticStage.java @@ -11,5 +11,11 @@ public enum FrontendSemanticStage { /// (it consumes iterable/argument typed facts) and before var-type-post (which publishes the /// final source-facing iterator slot type). FOR_ITERATION_RESOLUTION, - VAR_TYPE_POST + VAR_TYPE_POST, + /// Lambda resolution: publishes the first complete `FrontendLambdaPlan` (declaration-site + /// capture types filled, `capturesSelf` aligned) keyed by the `LambdaExpression`. The owner is + /// the lambda's own nested suite resolution; the plan exports with the lambda's independent + /// callable batch before the enclosing statement's expr-type owner could publish a callable + /// type for the same node. + LAMBDA_RESOLUTION } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendTypedLexicalEnvironment.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendTypedLexicalEnvironment.java index d1ef6391..7830a27e 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendTypedLexicalEnvironment.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendTypedLexicalEnvironment.java @@ -6,6 +6,7 @@ import gd.script.gdcc.frontend.sema.patch.FrontendChainBindingPatch; import gd.script.gdcc.frontend.sema.patch.FrontendExprTypePatch; import gd.script.gdcc.frontend.sema.patch.FrontendForIterationResolutionPatch; +import gd.script.gdcc.frontend.sema.patch.FrontendLambdaResolutionPatch; import gd.script.gdcc.frontend.sema.patch.FrontendLocalSlotTypeUpdate; import gd.script.gdcc.frontend.sema.patch.FrontendLocalTypeStabilizationPatch; import gd.script.gdcc.frontend.sema.patch.FrontendOwnerPatch; @@ -184,6 +185,30 @@ public FrontendTypedLexicalEnvironment( return null; } + /// Declaration-anchored local slot type lookup restricted to flushed overlay updates. + /// + /// Lambda capture filling runs mid-suite in the enclosing callable, when the physical slot is + /// still the inventory baseline. This query only matches already flushed + /// `LOCAL_TYPE_STABILIZATION` / `FOR_ITERATION_RESOLUTION` updates by scope + declaration + /// identity along the environment chain; unlike `localSlotType` it never falls back to + /// `slotType(astNode)` (which would read `VAR_TYPE_POST` / function summaries) or to the + /// physical slot. Callers substitute the declared/inventory baseline when this returns null. + public @Nullable GdType declarationSiteLocalSlotType( + @NotNull BlockScope blockScope, + @NotNull String name, + @NotNull Object declaration + ) { + var pendingType = pendingFacts.localSlotType(blockScope, name, declaration); + if (pendingType != null) { + return pendingType; + } + var committedType = committedFacts.localSlotType(blockScope, name, declaration); + if (committedType != null) { + return committedType; + } + return parent != null ? parent.declarationSiteLocalSlotType(blockScope, name, declaration) : null; + } + public void putSymbolBinding( @NotNull FrontendSemanticStage owner, @NotNull Node astNode, @@ -238,6 +263,28 @@ public void putForIterationPlan( ); } + /// Publishes the first complete `FrontendLambdaPlan` for a nested-resolved lambda. + /// + /// The plan carries frozen declaration-site capture types; first-wins merge plus `samePlan` + /// conflict detection reject any later diverging payload for the same lambda node. + public void putLambdaPlan( + @NotNull FrontendSemanticStage owner, + @NotNull Node astNode, + @NotNull FrontendLambdaPlan plan + ) { + requireOwner(owner, FrontendSemanticStage.LAMBDA_RESOLUTION); + FrontendPublishedFactTypeGuard.checkLambdaPlan(plan); + putSideTable( + stableData.lambdaPlans(), + committedFacts.lambdaPlans, + pendingFacts.lambdaPlans, + astNode, + plan, + "lambdaPlans", + FrontendLambdaPlan::samePlan + ); + } + public void putResolvedMember( @NotNull FrontendSemanticStage owner, @NotNull Node astNode, @@ -554,6 +601,7 @@ private static final class OverlayFacts { private final @NotNull FrontendAstSideTable typeTestTargets = new FrontendAstSideTable<>(); private final @NotNull FrontendAstSideTable containerLiteralPlans = new FrontendAstSideTable<>(); + private final @NotNull FrontendAstSideTable lambdaPlans = new FrontendAstSideTable<>(); private @Nullable FrontendResolvedCall resolvedCall(@NotNull Node astNode) { var chainCall = chainResolvedCalls.get(astNode); @@ -630,6 +678,7 @@ private void mergeFrom(@NotNull OverlayFacts incoming) { "containerLiteralPlans", FrontendContainerLiteralPlan::samePlan ); + mergeSideTable(lambdaPlans, incoming.lambdaPlans, "lambdaPlans", FrontendLambdaPlan::samePlan); localSlotTypeUpdates.addAll(incoming.localSlotTypeUpdates); forIterationSlotTypeUpdates.addAll(incoming.forIterationSlotTypeUpdates); } @@ -646,6 +695,7 @@ private void checkNoCompilerOnlyLeaks() { FrontendPublishedFactTypeGuard.checkLocalSlotTypeUpdates(forIterationSlotTypeUpdates); FrontendPublishedFactTypeGuard.checkTypeTestTargets(typeTestTargets); FrontendPublishedFactTypeGuard.checkContainerLiteralPlans(containerLiteralPlans); + FrontendPublishedFactTypeGuard.checkLambdaPlans(lambdaPlans); } private @NotNull List toOwnerPatches() { @@ -676,6 +726,9 @@ private void checkNoCompilerOnlyLeaks() { if (!slotTypes.isEmpty()) { patches.add(new FrontendVarTypePostPatch(slotTypes)); } + if (!lambdaPlans.isEmpty()) { + patches.add(new FrontendLambdaResolutionPatch(lambdaPlans)); + } return patches; } @@ -690,7 +743,8 @@ private boolean hasFacts() { || !forIterationPlans.isEmpty() || !forIterationSlotTypeUpdates.isEmpty() || !typeTestTargets.isEmpty() - || !containerLiteralPlans.isEmpty(); + || !containerLiteralPlans.isEmpty() + || !lambdaPlans.isEmpty(); } private void clear() { @@ -705,6 +759,7 @@ private void clear() { forIterationSlotTypeUpdates.clear(); typeTestTargets.clear(); containerLiteralPlans.clear(); + lambdaPlans.clear(); } private static void mergeSideTable( diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java index 43bdac98..977eaeb2 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java @@ -124,11 +124,36 @@ public void runTopBinding(@NotNull FrontendSuiteContext context, @NotNull Node r } else if (expression instanceof SelfExpression selfExpression) { bindSelf(context, selfExpression); } else if (expression instanceof LambdaExpression lambdaExpression) { - reportUnsupportedBinding(context, lambdaExpression, "lambda subtree"); + if (!tryResolveRecordedLambda(context, lambdaExpression)) { + reportUnsupportedBinding(context, lambdaExpression, "lambda subtree"); + } } }); } + /// Recorded lambdas resolve through the nested suite trigger instead of producing unsupported + /// binding/chain diagnostics (plan §3.2/§3.3). The trigger is idempotent per lambda node: the + /// published plan doubles as the resolved marker. Unrecorded lambdas (property initializers, + /// parameter defaults, skipped subtrees) stay fail-closed. `walkRootBounded` keeps pruning at + /// the lambda node, so the enclosing owner never walks the body as an ordinary expression tree. + private static boolean tryResolveRecordedLambda( + @NotNull FrontendSuiteContext context, + @NotNull LambdaExpression lambdaExpression + ) { + if (!context.interfaceSurface().suiteEntryRoots().containsCallableOwner(lambdaExpression)) { + return false; + } + if (context.analysisData().lambdaPlans().containsKey(lambdaExpression)) { + return true; + } + var nestedResolver = Objects.requireNonNull( + context.nestedLambdaResolver(), + "Recorded lambda has no nested resolve route" + ); + nestedResolver.resolveNestedLambda(context, lambdaExpression); + return true; + } + private void tryApplyAttributeChainHeadTypeMetaBias( @NotNull FrontendSuiteContext context, @NotNull AttributeExpression attributeExpression @@ -225,7 +250,11 @@ public void runChainBinding(@NotNull FrontendSuiteContext context, @NotNull Node publishReduction(context, reduced); } } else if (expression instanceof LambdaExpression lambdaExpression) { - reportUnsupportedChain(context, lambdaExpression, "lambda subtree"); + // Recorded lambdas were already resolved by the top-binding trigger of this same + // statement; only unrecorded ones stay fail-closed here. + if (!context.interfaceSurface().suiteEntryRoots().containsCallableOwner(lambdaExpression)) { + reportUnsupportedChain(context, lambdaExpression, "lambda subtree"); + } } }); } @@ -935,15 +964,14 @@ private static void publishRootExpressionTypes( ); case ExpressionStatement expressionStatement -> publishExpressionType(context, resolver, expressionStatement.expression(), true, null); - case ReturnStatement returnStatement when returnStatement.value() != null -> - publishExpressionType( - context, - resolver, - returnStatement.value(), - false, - // Variant/void return slots do not provide typed-container context. - contextualExpectedOrNull(context.currentCallableReturnType()) - ); + case ReturnStatement returnStatement when returnStatement.value() != null -> publishExpressionType( + context, + resolver, + returnStatement.value(), + false, + // Variant/void return slots do not provide typed-container context. + contextualExpectedOrNull(context.currentCallableReturnType()) + ); case AssertStatement assertStatement -> { publishExpressionType(context, resolver, assertStatement.condition(), false, null); if (assertStatement.message() != null) { @@ -1011,6 +1039,20 @@ private static void publishExpressionType( if (resolver.isRouteHeadOnlyTypeMeta(entry.getKey())) { continue; } + if (entry.getKey() instanceof LambdaExpression lambdaExpression + && context.interfaceSurface().suiteEntryRoots().containsCallableOwner(lambdaExpression)) { + // Recorded lambdas keep publishing the (still unsupported) expression fact so + // downstream readers keep their fail-fast publication contract, but the + // unsupported-route diagnostic is suppressed: the body already resolved through + // the nested suite, and the dedicated lambda typing phase upgrades this fact to + // the callable type. + context.typedEnvironment().putExpressionType( + FrontendSemanticStage.EXPR_TYPE, + entry.getKey(), + entry.getValue() + ); + continue; + } if (!resolver.isAssignmentTargetPrefixExpression(entry.getKey()) && resolver.rootOwnsExpressionDiagnostic(entry.getKey())) { reportExpressionDiagnostic(context, resolver, entry.getKey(), entry.getValue()); @@ -1608,8 +1650,7 @@ private void populateRootExpressionTransientCaches( .expressionType(); case TypeTestExpression typeTestExpression -> resolveTypeTestExpressionType(typeTestExpression, finalizeWindow); - case CastExpression castExpression -> - resolveCastExpressionType(castExpression, finalizeWindow); + case CastExpression castExpression -> resolveCastExpressionType(castExpression, finalizeWindow); case ArrayExpression arrayExpression -> resolveArrayExpressionType(arrayExpression, finalizeWindow, expectedType); case DictionaryExpression dictionaryExpression -> diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java index e51a32fc..820ce600 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java @@ -139,6 +139,11 @@ private void walk(@NotNull Path sourcePath, @NotNull SourceFile sourceFile) { @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { + // Lambdas inside supported executable bodies become suite entries of their own; class + // level (property initializer) lambdas stay unpublished and fail-closed downstream. + if (supportedBodyDepth > 0) { + recordCallable(lambdaExpression, lambdaExpression.parameters(), lambdaExpression.body()); + } return FrontendASTTraversalDirective.SKIP_CHILDREN; } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteContext.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteContext.java index 87d13534..15fd2eab 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteContext.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteContext.java @@ -1,6 +1,7 @@ package gd.script.gdcc.frontend.sema.analyzer; import dev.superice.gdparser.frontend.ast.Block; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; import gd.script.gdcc.frontend.scope.BlockScope; @@ -46,7 +47,8 @@ public record FrontendSuiteContext( @NotNull DiagnosticManager diagnosticManager, @NotNull ClassRegistry classRegistry, @Nullable FrontendCallableExportBatch exportBatch, - @Nullable GdType currentCallableReturnType + @Nullable GdType currentCallableReturnType, + @Nullable NestedLambdaResolver nestedLambdaResolver ) { public FrontendSuiteContext { Objects.requireNonNull(sourcePath, "sourcePath must not be null"); @@ -82,7 +84,8 @@ public record FrontendSuiteContext( diagnosticManager, classRegistry, exportBatch, - currentCallableReturnType + currentCallableReturnType, + nestedLambdaResolver ); } @@ -99,4 +102,12 @@ public record FrontendSuiteContext( } return FrontendBodySemanticSupportPolicy.forBlockScopeKind(currentBlockScope.kind()).visibleValueDomain(); } + + /// Triggers the nested suite resolution of a recorded lambda encountered by an enclosing + /// statement's owner procedure. Supplied by `FrontendSuiteResolver`; null in contexts that can + /// never contain a recorded lambda (property initializers stay fail-closed). + @FunctionalInterface + public interface NestedLambdaResolver { + void resolveNestedLambda(@NotNull FrontendSuiteContext outerContext, @NotNull LambdaExpression lambda); + } } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java index 6c27006b..4e209ff0 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java @@ -3,6 +3,7 @@ import dev.superice.gdparser.frontend.ast.Block; import dev.superice.gdparser.frontend.ast.ConstructorDeclaration; import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.Parameter; import dev.superice.gdparser.frontend.ast.VariableDeclaration; @@ -10,22 +11,34 @@ import gd.script.gdcc.frontend.diagnostic.FrontendRange; import gd.script.gdcc.frontend.scope.BlockScope; import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.frontend.scope.CallableScopeKind; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import gd.script.gdcc.frontend.sema.FrontendBodyStructuralCompleteness; import gd.script.gdcc.frontend.sema.FrontendInterfaceSurface; +import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlan; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.FrontendSemanticStage; import gd.script.gdcc.frontend.sema.FrontendTypedLexicalEnvironment; +import gd.script.gdcc.frontend.sema.LambdaCaptureEntry; import gd.script.gdcc.frontend.sema.analyzer.support.FrontendCallableReturnTypeSupport; import gd.script.gdcc.frontend.sema.analyzer.support.FrontendPropertyInitializerSupport; import gd.script.gdcc.frontend.sema.patch.FrontendCallableExportBatch; 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.scope.ScopeValueKind; +import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /// Body-suite coordinator for the staged semantic pipeline. @@ -37,8 +50,33 @@ public class FrontendSuiteResolver { private static final @NotNull String UNSUPPORTED_BINDING_SUBTREE_CATEGORY = "sema.unsupported_binding_subtree"; private static final @NotNull String UNSUPPORTED_CHAIN_ROUTE_CATEGORY = "sema.unsupported_chain_route"; + /// Capture kind mirrored for the synthetic `self` capture (plan §3.5: parameter-shaped). + private static final @NotNull String SELF_CAPTURE_NAME = "self"; private final @NotNull FrontendStatementResolver statementResolver; + /// Per-owning-class counters backing `_lambda_` synthetic names; resolution order follows + /// source appearance order, so names stay stable across runs of the same module. + private final @NotNull Map lambdaNameCountersByOwningClass = new HashMap<>(); + /// Lazily built reverse view of `FrontendAnalysisData.scopesByAst()` (Scope → declaration + /// node), consumed only by `enclosingNonLambdaCallable(...)` when a lambda needs its nearest + /// non-lambda enclosing callable AST for the plan's `enclosingCallable` identity and the + /// inherited static/instance restriction. + /// + /// Construction rules (see `scopeToAstIndex`): + /// - one-shot build on first lambda resolution, keyed by scope identity (`IdentityHashMap`); + /// - only callable declaration nodes (`FunctionDeclaration` / `ConstructorDeclaration` / + /// `LambdaExpression`) may occupy a `CallableScope` slot, because parameters and body nodes + /// alias the same scope in the forward table; + /// - first-wins (`putIfAbsent`) among the surviving candidates. + /// + /// Boundaries: + /// - valid only because the forward table is frozen before this resolver runs: + /// `FrontendScopeAnalyzer` is its sole writer, and the suite phase never adds node→scope + /// entries (`resetCaptureType` rewrites scope-internal `ScopeValue`s, not the table); + /// - the cache lives and dies with this per-run resolver instance — no cross-run staleness; + /// - if a future phase ever publishes new node→scope entries mid-suite, this index must be + /// invalidated or rebuilt instead of trusted. + private @Nullable Map cachedScopeToAstIndex; public FrontendSuiteResolver() { this(new FrontendStatementResolver(new FrontendBodyOwnerProcedures())); @@ -60,7 +98,14 @@ public void resolve( Objects.requireNonNull(diagnosticManager, "diagnosticManager must not be null"); for (var callableOwner : interfaceSurface.suiteEntryRoots().callableOwners()) { - resolveCallableOwner(interfaceSurface, callableOwner, classRegistry, analysisData, diagnosticManager); + // Recorded lambdas are resolved through the nested trigger while their enclosing + // statement is processed; the top-level loop must not resolve them a second time. + // Lambdas left unresolved (e.g. tests with custom owner procedures that never trigger + // the nested path) still resolve here with no enclosing overlay for capture filling. + if (callableOwner instanceof LambdaExpression && analysisData.lambdaPlans().containsKey(callableOwner)) { + continue; + } + resolveCallableOwner(interfaceSurface, callableOwner, classRegistry, analysisData, diagnosticManager, null); } for (var propertyInitializer : interfaceSurface.suiteEntryRoots().propertyInitializers()) { resolvePropertyInitializer(interfaceSurface, propertyInitializer, classRegistry, analysisData, diagnosticManager); @@ -99,7 +144,8 @@ private void resolveCallableOwner( @NotNull Node callableOwner, @NotNull ClassRegistry classRegistry, @NotNull FrontendAnalysisData analysisData, - @NotNull DiagnosticManager diagnosticManager + @NotNull DiagnosticManager diagnosticManager, + @Nullable FrontendTypedLexicalEnvironment outerEnvironment ) { var body = callableBody(callableOwner); if (body == null) { @@ -109,6 +155,11 @@ private void resolveCallableOwner( if (!(bodyScope instanceof BlockScope blockScope)) { throw new IllegalStateException("Suite entry callable body has no published BlockScope"); } + // A lambda inherits its restriction / static context from the nearest enclosing non-lambda + // callable (plan §3.2); the same node is recorded as `enclosingCallable` in the plan. + var restrictionOwner = callableOwner instanceof LambdaExpression lambdaExpression + ? enclosingNonLambdaCallable(lambdaExpression, analysisData) + : callableOwner; var environment = new FrontendTypedLexicalEnvironment( blockScope, analysisData, @@ -126,8 +177,8 @@ private void resolveCallableOwner( body, blockScope, blockScope, - restrictionForCallable(callableOwner), - isStaticCallable(callableOwner), + restrictionForCallable(restrictionOwner), + isStaticCallable(restrictionOwner), null, interfaceSurface, environment, @@ -135,8 +186,12 @@ private void resolveCallableOwner( diagnosticManager, classRegistry, exportBatch, - currentCallableReturnType + currentCallableReturnType, + this::resolveNestedLambdaOwner ); + if (callableOwner instanceof LambdaExpression lambdaExpression) { + fillAndPublishLambdaPlan(context, lambdaExpression, restrictionOwner, outerEnvironment); + } runCallableEntryVarTypePost(context, callableOwner); resolveSuite(context, body); // Stable export is ordered but non-atomic: queued transactions are not preflighted together, @@ -144,6 +199,206 @@ private void resolveCallableOwner( exportBatch.applyTo(analysisData); } + /// Nested resolve trigger: the only production entry point for recorded lambdas (plan §3.2). + /// + /// The lambda resolves with its own independent export batch applied immediately at completion, + /// so its plan and body facts become stable before the enclosing statement's expr-type owner + /// could publish a callable type for the lambda node. The outer typed environment is threaded + /// through only for declaration-anchored capture type filling; the lambda's own environment + /// stays parentless so body resolution can never read outer locals past the capture boundary. + private void resolveNestedLambdaOwner( + @NotNull FrontendSuiteContext outerContext, + @NotNull LambdaExpression lambda + ) { + resolveCallableOwner( + outerContext.interfaceSurface(), + lambda, + outerContext.classRegistry(), + outerContext.analysisData(), + outerContext.diagnosticManager(), + outerContext.typedEnvironment() + ); + } + + /// Fills declaration-site capture types and publishes the first complete `FrontendLambdaPlan`. + /// + /// Runs at nested resolve entry, before any lambda body statement is processed (plan §3.4): + /// each capture type is resolved from its source binding — parameters keep their declared type, + /// outer captures keep their already frozen type, locals go through the enclosing typed + /// environment's declaration-anchored overlay (falling back to the inventory baseline), and a + /// leading `self` capture takes the enclosing class instance type. Every fill is mirrored to the + /// `CallableScope` capture binding via `resetCaptureType` so body type-check, which reads + /// captures without an overlay, stays same-source with the published plan. + private void fillAndPublishLambdaPlan( + @NotNull FrontendSuiteContext context, + @NotNull LambdaExpression lambda, + @NotNull Node enclosingCallable, + @Nullable FrontendTypedLexicalEnvironment outerEnvironment + ) { + var analysisData = context.analysisData(); + if (!(analysisData.scopesByAst().get(lambda) instanceof CallableScope lambdaScope)) { + throw new IllegalStateException("Recorded lambda has no published CallableScope"); + } + var owningClass = lambdaScope.owningClassOrNull(); + if (owningClass == null) { + throw new IllegalStateException("Recorded lambda has no owning class"); + } + var captures = new ArrayList(); + for (var capture : lambdaScope.captures()) { + var declaration = Objects.requireNonNull( + capture.declaration(), + "capture '" + capture.name() + "' has no source declaration" + ); + var entry = fillCaptureType(lambdaScope, capture, declaration, owningClass.getName(), outerEnvironment); + lambdaScope.resetCaptureType(entry.name(), declaration, entry.type()); + captures.add(entry); + } + var plan = new FrontendLambdaPlan( + lambda, + nextLambdaSyntheticName(owningClass.getName()), + FrontendLambdaCapturePlan.of(captures), + enclosingCallable, + owningClass.getName() + ); + context.typedEnvironment().putLambdaPlan(FrontendSemanticStage.LAMBDA_RESOLUTION, lambda, plan); + } + + private @NotNull LambdaCaptureEntry fillCaptureType( + @NotNull CallableScope lambdaScope, + @NotNull ScopeValue capture, + @NotNull Object declaration, + @NotNull String owningClassName, + @Nullable FrontendTypedLexicalEnvironment outerEnvironment + ) { + if (capture.name().equals(SELF_CAPTURE_NAME)) { + return new LambdaCaptureEntry( + capture.name(), + new GdObjectType(owningClassName), + ScopeValueKind.PARAMETER, + declaration + ); + } + var source = requireCaptureSourceBinding(lambdaScope, capture, declaration); + var sourceType = switch (source.value().kind()) { + case PARAMETER, CAPTURE -> source.value().type(); + case LOCAL -> declarationSiteLocalType(source, capture, outerEnvironment); + default -> throw new IllegalStateException( + "capture '" + capture.name() + "' source kind must be PARAMETER/LOCAL/CAPTURE" + ); + }; + return new LambdaCaptureEntry(capture.name(), sourceType, source.value().kind(), declaration); + } + + /// Reads the declaration-site type of a captured local: the enclosing environment's flushed + /// stabilization / for-iteration update for that exact declaration, or the inventory baseline + /// (explicit declared type or `Variant`) when no update exists. Never reads `VAR_TYPE_POST` or + /// post-declaration refinements (plan §3.4 read-path rules). + private static @NotNull GdType declarationSiteLocalType( + @NotNull CaptureSourceBinding source, + @NotNull ScopeValue capture, + @Nullable FrontendTypedLexicalEnvironment outerEnvironment + ) { + if (outerEnvironment != null && source.scope() instanceof BlockScope blockScope) { + var overlayType = outerEnvironment.declarationSiteLocalSlotType( + blockScope, + capture.name(), + Objects.requireNonNull(capture.declaration(), "capture declaration must not be null") + ); + if (overlayType != null) { + return overlayType; + } + } + return source.value().type(); + } + + /// Re-locates the binding a capture was planned from, walking innermost-first from the lambda + /// boundary. The hit must carry the same declaration identity the inventory phase recorded; + /// anything else means the scope graph drifted between phases and the compiler must fail fast. + private static @NotNull CaptureSourceBinding requireCaptureSourceBinding( + @NotNull CallableScope lambdaScope, + @NotNull ScopeValue capture, + @NotNull Object declaration + ) { + var current = lambdaScope.getParentScope(); + while (current != null) { + var hit = current.resolveValueHere(capture.name()); + if (hit != null) { + if (hit.declaration() != declaration) { + throw new IllegalStateException( + "capture '" + capture.name() + "' source binding declaration drifted" + ); + } + return new CaptureSourceBinding(current, hit); + } + current = current.getParentScope(); + } + throw new IllegalStateException("capture '" + capture.name() + "' has no source binding"); + } + + private record CaptureSourceBinding(@NotNull Scope scope, @NotNull ScopeValue value) { + } + + /// Finds the nearest enclosing non-lambda callable AST through the scope graph: the lambda's + /// callable scope parent chain leads to the enclosing `FUNCTION_DECLARATION` / + /// `CONSTRUCTOR_DECLARATION` scope, which maps back to its declaration node. + private @NotNull Node enclosingNonLambdaCallable( + @NotNull LambdaExpression lambda, + @NotNull FrontendAnalysisData analysisData + ) { + if (!(analysisData.scopesByAst().get(lambda) instanceof CallableScope lambdaScope)) { + throw new IllegalStateException("Recorded lambda has no published CallableScope"); + } + var current = lambdaScope.getParentScope(); + while (current != null) { + if (current instanceof CallableScope callableScope + && callableScope.kind() != CallableScopeKind.LAMBDA_EXPRESSION) { + var node = scopeToAstIndex(analysisData).get(callableScope); + if (node == null) { + throw new IllegalStateException("Enclosing callable scope has no AST declaration"); + } + return node; + } + current = current.getParentScope(); + } + throw new IllegalStateException("Lambda has no enclosing non-lambda callable"); + } + + /// Builds the scope → declaration-node reverse index once per run. + /// + /// The forward table is deliberately many-to-one: `visitCallableBoundary` records the callable + /// declaration itself and then every `Parameter` against the SAME `CallableScope`, so a naive + /// reverse `put` would let the last parameter silently shadow the declaration node (the + /// "parameter shadowing" bug). The reverse query is a semantic choice — "which node declares + /// this scope" — so construction encodes that policy in two steps: + /// + /// 1. Type filter: only callable declaration nodes are eligible candidates; parameters and + /// other aliasing nodes never enter the index at all. + /// 2. First-wins: `scopesByAst` iterates in scope-analyzer publication order, and + /// `visitCallableBoundary` always calls `recordScope(callableOwner, ...)` BEFORE walking + /// the parameter list, so among the surviving candidates the first write for a scope IS + /// its declaration node. `putIfAbsent` therefore deterministically keeps the declaration + /// even if a future shape ever produced duplicate declaration candidates for one scope. + private @NotNull Map scopeToAstIndex(@NotNull FrontendAnalysisData analysisData) { + var index = cachedScopeToAstIndex; + if (index == null) { + index = new IdentityHashMap<>(); + for (var entry : analysisData.scopesByAst().entrySet()) { + if (entry.getKey() instanceof FunctionDeclaration + || entry.getKey() instanceof ConstructorDeclaration + || entry.getKey() instanceof LambdaExpression) { + index.putIfAbsent(entry.getValue(), entry.getKey()); + } + } + cachedScopeToAstIndex = index; + } + return index; + } + + private @NotNull String nextLambdaSyntheticName(@NotNull String owningClassName) { + var index = lambdaNameCountersByOwningClass.merge(owningClassName, 1, Integer::sum); + return "_lambda_" + (index - 1); + } + /// Publishes callable-entry var-type-post facts before the first body statement is resolved. /// /// Parameters are not statement roots, so this complements rather than bypasses the @@ -238,6 +493,8 @@ private void resolvePropertyInitializer( diagnosticManager, classRegistry, null, + null, + // Property initializers stay fail-closed for lambdas: no nested resolve trigger. null ); statementResolver.resolvePropertyInitializer(context, propertyInitializer); @@ -267,6 +524,7 @@ private void resolveChildSuite(@NotNull FrontendSuiteContext parentContext, @Not return switch (callableOwner) { case FunctionDeclaration functionDeclaration -> functionDeclaration.body(); case ConstructorDeclaration constructorDeclaration -> constructorDeclaration.body(); + case LambdaExpression lambdaExpression -> lambdaExpression.body(); default -> null; }; } @@ -275,6 +533,7 @@ private void resolveChildSuite(@NotNull FrontendSuiteContext parentContext, @Not return switch (callableOwner) { case FunctionDeclaration functionDeclaration -> functionDeclaration.parameters(); case ConstructorDeclaration constructorDeclaration -> constructorDeclaration.parameters(); + case LambdaExpression lambdaExpression -> lambdaExpression.parameters(); default -> List.of(); }; } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/debug/FrontendAnalysisInspectionTool.java b/src/main/java/gd/script/gdcc/frontend/sema/debug/FrontendAnalysisInspectionTool.java index 015bc8e2..0f071762 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/debug/FrontendAnalysisInspectionTool.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/debug/FrontendAnalysisInspectionTool.java @@ -632,7 +632,9 @@ private void buildDiagnosticFacts() { private @Nullable Node smallestContainingCallable(@NotNull FrontendRange diagnosticRange) { Node winner = null; for (var node : visitOrder.keySet()) { - if (!(node instanceof FunctionDeclaration) && !(node instanceof ConstructorDeclaration)) { + if (!(node instanceof FunctionDeclaration) + && !(node instanceof ConstructorDeclaration) + && !(node instanceof LambdaExpression)) { continue; } if (!contains(node.range(), diagnosticRange)) { @@ -697,18 +699,21 @@ private boolean isRouteHeadTypeMeta(@NotNull IdentifierExpression identifierExpr private boolean hasUnsupportedOrDeferredAncestor(@NotNull Expression expression) { var current = parents.get(expression); while (current != null) { - if (current instanceof Parameter parameter && parameter.defaultValue() != null) { - return true; - } - if (current instanceof LambdaExpression || current instanceof MatchStatement) { - return true; - } - if (current instanceof dev.superice.gdparser.frontend.ast.ForStatement) { - return true; - } - if (current instanceof VariableDeclaration variableDeclaration - && variableDeclaration.kind() == dev.superice.gdparser.frontend.ast.DeclarationKind.CONST) { - return true; + switch (current) { + case Parameter parameter when parameter.defaultValue() != null -> { + return true; + } + case MatchStatement _ -> { + return true; + } + case dev.superice.gdparser.frontend.ast.ForStatement _ -> { + return true; + } + case VariableDeclaration variableDeclaration when variableDeclaration.kind() == dev.superice.gdparser.frontend.ast.DeclarationKind.CONST -> { + return true; + } + default -> { + } } current = parents.get(current); } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendLambdaResolutionPatch.java b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendLambdaResolutionPatch.java new file mode 100644 index 00000000..d40aa43e --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendLambdaResolutionPatch.java @@ -0,0 +1,34 @@ +package gd.script.gdcc.frontend.sema.patch; + +import gd.script.gdcc.frontend.sema.FrontendAstSideTable; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; +import gd.script.gdcc.frontend.sema.FrontendSemanticStage; +import org.jetbrains.annotations.NotNull; + +/// Lambda resolution owner delta. +/// +/// Publishes the first complete `FrontendLambdaPlan` for each nested-resolved `LambdaExpression`: +/// capture types are already the frozen declaration-site types and `capturesSelf` is aligned with +/// the leading `self` capture. The side table merge is first-wins with `samePlan` conflict +/// detection, so a second different payload for the same lambda fails instead of overwriting. +/// +/// The plan must never be folded into `FrontendExprTypePatch`: it is keyed by the lambda's own +/// nested suite resolution and exports with the lambda's independent callable batch, before the +/// enclosing statement's expr-type owner could publish a callable type for the same node. +/// +/// @param lambdaPlans plans keyed by `LambdaExpression` identity, merged into the stable +/// `lambdaPlans()` side table; every plan is guard-checked to carry no +/// compiler-only capture type +public record FrontendLambdaResolutionPatch( + @NotNull FrontendAstSideTable lambdaPlans +) implements FrontendOwnerPatch { + public FrontendLambdaResolutionPatch { + lambdaPlans = FrontendPatchTables.copySideTable(lambdaPlans, "lambdaPlans"); + FrontendPublishedFactTypeGuard.checkLambdaPlans(lambdaPlans); + } + + @Override + public @NotNull FrontendSemanticStage stage() { + return FrontendSemanticStage.LAMBDA_RESOLUTION; + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java index bf6df274..1cf7af1f 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendOwnerPatch.java @@ -25,7 +25,8 @@ public sealed interface FrontendOwnerPatch permits FrontendChainBindingPatch, FrontendExprTypePatch, FrontendForIterationResolutionPatch, - FrontendVarTypePostPatch { + FrontendVarTypePostPatch, + FrontendLambdaResolutionPatch { @NotNull FrontendSemanticStage stage(); default @NotNull FrontendAstSideTable symbolBindings() { diff --git a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPatchTransaction.java b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPatchTransaction.java index 37f830e8..6aba00ff 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPatchTransaction.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendPatchTransaction.java @@ -59,6 +59,7 @@ private static int order(@NotNull FrontendSemanticStage stage) { case EXPR_TYPE -> 3; case FOR_ITERATION_RESOLUTION -> 4; case VAR_TYPE_POST -> 5; + case LAMBDA_RESOLUTION -> 6; }; } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java b/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java index 3bd62573..c8872054 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java @@ -2,7 +2,6 @@ import gd.script.gdcc.frontend.scope.BlockScope; import gd.script.gdcc.frontend.scope.CallableScope; -import gd.script.gdcc.frontend.scope.CallableScopeKind; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import gd.script.gdcc.frontend.sema.FrontendBodySemanticSupportPolicy; import gd.script.gdcc.frontend.sema.FrontendBodyDeclarationIndex; @@ -11,9 +10,9 @@ import gd.script.gdcc.scope.Scope; import gd.script.gdcc.scope.ScopeLookupResult; import gd.script.gdcc.scope.ScopeValue; +import gd.script.gdcc.scope.ScopeValueKind; import dev.superice.gdparser.frontend.ast.Block; import dev.superice.gdparser.frontend.ast.DeclarationKind; -import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.MatchSection; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.Parameter; @@ -173,9 +172,6 @@ private void indexSubtree(@NotNull Node node, @Nullable Node parentNode) { return switch (parentNode) { case Parameter parameter when parameter.defaultValue() == childNode -> structuralBoundary(FrontendBodySemanticSupportPolicy.PARAMETER_DEFAULT); - case LambdaExpression lambdaExpression when lambdaExpression.body() == childNode -> structuralBoundary( - FrontendBodySemanticSupportPolicy.LAMBDA_SUBTREE - ); case VariableDeclaration variableDeclaration when variableDeclaration.kind() == DeclarationKind.CONST && variableDeclaration.value() == childNode && isDeferredBlockLocalConst(variableDeclaration) -> structuralBoundary( @@ -289,8 +285,7 @@ private boolean isDeferredBlockLocalConst(@NotNull VariableDeclaration variableD ) { return switch (currentScope) { case BlockScope blockScope -> classifyUnsupportedCurrentBlockScopeBoundary(blockScope); - case CallableScope callableScope when callableScope.kind() == CallableScopeKind.LAMBDA_EXPRESSION -> - unsupportedScopeBoundary(FrontendBodySemanticSupportPolicy.LAMBDA_SUBTREE); + case CallableScope callableScope -> classifyUnsupportedCurrentCallableScopeBoundary(callableScope); default -> new FrontendVisibleValueDeferredBoundary( FrontendVisibleValueDomain.EXECUTABLE_BODY, FrontendVisibleValueDeferredReason.UNSUPPORTED_DOMAIN @@ -298,6 +293,18 @@ private boolean isDeferredBlockLocalConst(@NotNull VariableDeclaration variableD }; } + /// Callable scopes follow the same policy-driven gate as block scopes: every callable kind that + /// publishes lexical inventory (functions, constructors, lambdas) resolves normally. + private @Nullable FrontendVisibleValueDeferredBoundary classifyUnsupportedCurrentCallableScopeBoundary( + @NotNull CallableScope callableScope + ) { + var policy = FrontendBodySemanticSupportPolicy.forCallableScopeKind(callableScope.kind()); + if (policy.publishesLexicalInventory()) { + return null; + } + return unsupportedScopeBoundary(policy); + } + /// Deferred-boundary checks must follow AST identity, matching the rest of the resolver's /// parent/index tables, instead of relying on structural record equality. private boolean containsNodeIdentity(@NotNull List nodes, @NotNull Node targetNode) { @@ -343,7 +350,13 @@ private boolean publishesCallableLocalValueInventory(@NotNull Block block) { return null; } if (declaration instanceof VariableDeclaration variableDeclaration) { - checkPublishedLocalInventory(variableDeclaration, value); + // The interface body inventory only indexes LOCAL bindings. A lambda CAPTURE keeps the + // outer declaration identity for provenance but is not part of that inventory, so the + // consistency check must not fire for it; the declaration-order visibility filter below + // still applies (plan §3.4: capture legality follows outer declaration order). + if (value.kind() == ScopeValueKind.LOCAL) { + checkPublishedLocalInventory(variableDeclaration, value); + } if (isVisibleLocal(variableDeclaration, useSite)) { return null; } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java index 10c70306..38c9a633 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendAnalysisDataTest.java @@ -15,6 +15,7 @@ import gd.script.gdcc.frontend.sema.patch.FrontendChainBindingPatch; import gd.script.gdcc.frontend.sema.patch.FrontendExprTypePatch; import gd.script.gdcc.frontend.sema.patch.FrontendForIterationResolutionPatch; +import gd.script.gdcc.frontend.sema.patch.FrontendLambdaResolutionPatch; import gd.script.gdcc.frontend.sema.patch.FrontendLocalSlotTypeUpdate; import gd.script.gdcc.frontend.sema.patch.FrontendLocalTypeStabilizationPatch; import gd.script.gdcc.frontend.sema.patch.FrontendOwnerPatch; @@ -1270,6 +1271,7 @@ private static PassStatement passNode() { localSlotTypeUpdates ); case VAR_TYPE_POST -> new FrontendVarTypePostPatch(slotTypes); + case LAMBDA_RESOLUTION -> new FrontendLambdaResolutionPatch(new FrontendAstSideTable<>()); }; } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendInterfacePhaseTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendInterfacePhaseTest.java index 1ffb3b08..ee0bc729 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendInterfacePhaseTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendInterfacePhaseTest.java @@ -236,9 +236,17 @@ func ping(items, choice): assertEquals(GdVariantType.VARIANT, surface.typedLexicalBaseline().typeFor(fromFor)); assertTrue(surface.suiteEntryRoots().containsSupportedBlock(forStatement.body())); assertFalse(surface.suiteEntryRoots().containsSupportedBlock(matchStatement.sections().getFirst().body())); - assertFalse(surface.suiteEntryRoots().containsSupportedBlock(lambda.body())); + // Lambda bodies inside supported executable bodies are now suite entries of their own. + assertTrue(surface.suiteEntryRoots().containsSupportedBlock(lambda.body())); + assertTrue(surface.suiteEntryRoots().containsCallableOwner(lambda)); assertFalse(surface.bodyDeclarationIndex().containsBodyRoot(matchStatement.sections().getFirst().body())); - assertFalse(surface.bodyDeclarationIndex().containsBodyRoot(lambda.body())); + var lambdaDeclarations = surface.bodyDeclarationIndex().declarationsFor(lambda.body()); + assertEquals(1, lambdaDeclarations.size()); + assertEquals( + "from_lambda", + assertInstanceOf(VariableDeclaration.class, lambdaDeclarations.getFirst().declaration()).name() + ); + assertEquals(FrontendBodyLocalDeclaration.Kind.ORDINARY_VAR, lambdaDeclarations.getFirst().kind()); assertFalse(surface.typedLexicalBaseline().containsDeclaration(answer)); assertTrue(surface.typedLexicalBaseline().containsDeclaration(callback)); } @@ -289,7 +297,7 @@ func ping(%s): } @Test - void nestedForBodiesPublishInventoryWithoutOpeningLambdaMatchOrConstSubtrees() throws Exception { + void nestedForBodiesPublishInventoryAndLambdaBodiesWhileMatchOrConstSubtreesStayClosed() throws Exception { var phaseInput = phaseInput("interface_nested_for_boundaries.gd", """ class_name InterfaceNestedForBoundaries extends Node @@ -331,9 +339,11 @@ func ping(values): assertTrue(surface.suiteEntryRoots().containsSupportedBlock(innerFor.body())); assertTrue(surface.bodyDeclarationIndex().containsBodyRoot(outerFor.body())); assertTrue(surface.bodyDeclarationIndex().containsBodyRoot(innerFor.body())); - assertFalse(surface.suiteEntryRoots().containsSupportedBlock(lambda.body())); + // Lambda nested inside a supported for body is recorded as a suite entry as well. + assertTrue(surface.suiteEntryRoots().containsSupportedBlock(lambda.body())); + assertTrue(surface.suiteEntryRoots().containsCallableOwner(lambda)); assertFalse(surface.suiteEntryRoots().containsSupportedBlock(matchStatement.sections().getFirst().body())); - assertFalse(surface.bodyDeclarationIndex().containsBodyRoot(lambda.body())); + assertTrue(surface.bodyDeclarationIndex().containsBodyRoot(lambda.body())); assertFalse(surface.bodyDeclarationIndex().containsBodyRoot(matchStatement.sections().getFirst().body())); assertFalse(surface.typedLexicalBaseline().containsDeclaration(blocked)); } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java new file mode 100644 index 00000000..7a135ac3 --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java @@ -0,0 +1,391 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.ForStatement; +import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.IdentifierExpression; +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Node; +import dev.superice.gdparser.frontend.ast.SourceFile; +import gd.script.gdcc.exception.FrontendAnalysisPatchException; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.diagnostic.DiagnosticSnapshot; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.FrontendSourceUnit; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.frontend.sema.analyzer.FrontendSemanticAnalyzer; +import gd.script.gdcc.frontend.sema.patch.FrontendLambdaResolutionPatch; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdObjectType; +import gd.script.gdcc.type.GdVariantType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase C lambda suite-resolution contract tests (frontend_lambda_plan.md §阶段C). +/// +/// Anchors both directions: recorded lambdas resolve through the nested trigger and publish the +/// first complete `FrontendLambdaPlan` with declaration-site capture types mirrored onto the +/// `CallableScope` bindings, while unrecorded lambdas (property initializers, match subtrees) +/// stay fail-closed and a diverging re-publish of the same lambda must conflict. +class FrontendLambdaSuiteResolutionTest { + @Test + void nestedResolvePublishesPlanWithDeclarationSiteCaptureTypes() throws Exception { + var analysisData = analyze("lambda_suite_basic.gd", """ + class_name LambdaSuiteBasic + extends Node + + func ping(seed: int): + var a := 1 + var b = 1 + var cb := func(): + return a + b + seed + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var lambdaScope = assertInstanceOf( + CallableScope.class, + analysisData.analysisData().scopesByAst().get(lambda) + ); + + var plan = analysisData.analysisData().lambdaPlans().get(lambda); + + assertNotNull(plan); + assertEquals("_lambda_0", plan.syntheticName()); + assertSame(pingFunction, plan.enclosingCallable()); + assertEquals("LambdaSuiteBasic", plan.owningClassCanonicalName()); + assertFalse(plan.capturesSelf()); + // Capture order is frozen by first source appearance inside the lambda body. + assertEquals(List.of("a", "b", "seed"), plan.captures().stream().map(LambdaCaptureEntry::name).toList()); + // `var a := 1` reads the flushed stabilization update; `var b = 1` never infers; the + // explicit parameter annotation publishes its declared type. + var aCapture = plan.captures().get(0); + assertEquals(GdIntType.INT, aCapture.type()); + assertEquals(ScopeValueKind.LOCAL, aCapture.sourceKind()); + var bCapture = plan.captures().get(1); + assertEquals(GdVariantType.VARIANT, bCapture.type()); + assertEquals(ScopeValueKind.LOCAL, bCapture.sourceKind()); + var seedCapture = plan.captures().get(2); + assertEquals(GdIntType.INT, seedCapture.type()); + assertEquals(ScopeValueKind.PARAMETER, seedCapture.sourceKind()); + assertSame(pingFunction.parameters().getFirst(), seedCapture.sourceDeclaration()); + // The CallableScope capture bindings are reset to the same declaration-site types. + assertEquals(GdIntType.INT, Objects.requireNonNull(lambdaScope.resolveValueHere("a")).type()); + assertEquals(GdVariantType.VARIANT, Objects.requireNonNull(lambdaScope.resolveValueHere("b")).type()); + assertEquals(GdIntType.INT, Objects.requireNonNull(lambdaScope.resolveValueHere("seed")).type()); + // Use sites inside the lambda body bind to the CAPTURE slots. + for (var name : List.of("a", "b", "seed")) { + var useSite = findNode(lambda.body(), IdentifierExpression.class, id -> id.name().equals(name)); + var binding = analysisData.analysisData().symbolBindings().get(useSite); + assertNotNull(binding, name); + assertEquals(FrontendBindingKind.CAPTURE, binding.kind(), name); + } + assertTrue(analysisData.diagnostics().asList().isEmpty()); + } + + @Test + void nestedResolvePublishesSelfCaptureWithOwningClassInstanceType() throws Exception { + var analysisData = analyze("lambda_suite_self.gd", """ + class_name LambdaSuiteSelf + extends Node + + var hp: int = 0 + + func ping(): + var cb := func(): + return hp + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var lambdaScope = assertInstanceOf( + CallableScope.class, + analysisData.analysisData().scopesByAst().get(lambda) + ); + + var plan = analysisData.analysisData().lambdaPlans().get(lambda); + + assertNotNull(plan); + assertTrue(plan.capturesSelf()); + var selfCapture = plan.captures().getFirst(); + assertEquals("self", selfCapture.name()); + assertEquals(new GdObjectType("LambdaSuiteSelf"), selfCapture.type()); + assertEquals(ScopeValueKind.PARAMETER, selfCapture.sourceKind()); + assertSame( + pingFunction, + selfCapture.sourceDeclaration(), + "self capture keeps the enclosing instance callable as its source declaration" + ); + var scopeSelf = Objects.requireNonNull(lambdaScope.resolveValueHere("self")); + assertEquals(new GdObjectType("LambdaSuiteSelf"), scopeSelf.type()); + assertTrue(analysisData.diagnostics().asList().isEmpty()); + } + + @Test + void nestedLambdaCaptureTransfersWithFrozenOuterCaptureType() throws Exception { + var analysisData = analyze("lambda_suite_nested.gd", """ + class_name LambdaSuiteNested + extends Node + + func ping(seed: int): + var cb := func(): + var inner := func(): + return seed + return inner + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var outerLambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var innerLambda = findNode(outerLambda.body(), LambdaExpression.class, _ -> true); + + var outerPlan = analysisData.analysisData().lambdaPlans().get(outerLambda); + var innerPlan = analysisData.analysisData().lambdaPlans().get(innerLambda); + + assertNotNull(outerPlan); + assertNotNull(innerPlan); + // Synthetic names follow resolution (source appearance) order: outer first, then inner. + assertEquals("_lambda_0", outerPlan.syntheticName()); + assertEquals("_lambda_1", innerPlan.syntheticName()); + assertSame(pingFunction, outerPlan.enclosingCallable()); + assertSame(pingFunction, innerPlan.enclosingCallable()); + assertEquals(1, outerPlan.captures().size()); + assertEquals(1, innerPlan.captures().size()); + var outerCapture = outerPlan.captures().getFirst(); + assertEquals("seed", outerCapture.name()); + assertEquals(ScopeValueKind.PARAMETER, outerCapture.sourceKind()); + assertEquals(GdIntType.INT, outerCapture.type()); + var transferredCapture = innerPlan.captures().getFirst(); + assertEquals("seed", transferredCapture.name()); + // The inner capture transfers through the outer lambda's own CAPTURE slot (kind CAPTURE), + // shares the original declaration identity, and freezes the outer capture's filled type. + assertEquals(ScopeValueKind.CAPTURE, transferredCapture.sourceKind()); + assertEquals(GdIntType.INT, transferredCapture.type()); + assertSame(outerCapture.sourceDeclaration(), transferredCapture.sourceDeclaration()); + assertTrue(analysisData.diagnostics().asList().isEmpty()); + } + + @Test + void forIteratorCaptureUsesFlushedIterationRefinement() throws Exception { + var analysisData = analyze("lambda_suite_for_iterator.gd", """ + class_name LambdaSuiteForIterator + extends Node + + func ping(items: Array[int]): + for item in items: + var cb := func(): + return item + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var forStatement = findNode(pingFunction.body(), ForStatement.class, _ -> true); + var lambda = findNode(forStatement.body(), LambdaExpression.class, _ -> true); + + var plan = analysisData.analysisData().lambdaPlans().get(lambda); + + assertNotNull(plan); + assertEquals(1, plan.captures().size()); + var iteratorCapture = plan.captures().getFirst(); + assertEquals("item", iteratorCapture.name()); + assertEquals(ScopeValueKind.LOCAL, iteratorCapture.sourceKind()); + // The inventory baseline is Variant; the capture freezes the for-iteration refinement. + assertEquals(GdIntType.INT, iteratorCapture.type()); + assertSame(forStatement, iteratorCapture.sourceDeclaration()); + assertTrue(analysisData.diagnostics().asList().isEmpty()); + } + + @Test + void divergingRepublishOfSameLambdaConflictsAndKeepsStablePlan() throws Exception { + var analysisData = analyze("lambda_plan_conflict.gd", """ + class_name LambdaPlanConflict + extends Node + + func ping(seed: int): + var cb := func(): + return seed + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var published = analysisData.analysisData().lambdaPlans().get(lambda); + assertNotNull(published); + + var diverging = new FrontendLambdaPlan( + lambda, + "_lambda_99", + published.capturePlan(), + published.enclosingCallable(), + published.owningClassCanonicalName() + ); + var divergingTable = new FrontendAstSideTable(); + divergingTable.put(lambda, diverging); + + assertThrows( + FrontendAnalysisPatchException.class, + () -> analysisData.analysisData().applyPatch(new FrontendLambdaResolutionPatch(divergingTable)) + ); + assertSame(published, analysisData.analysisData().lambdaPlans().get(lambda)); + + // Re-publishing a logically equal payload is the idempotent merge case and must pass. + var equal = new FrontendLambdaPlan( + lambda, + published.syntheticName(), + published.capturePlan(), + published.enclosingCallable(), + published.owningClassCanonicalName() + ); + var equalTable = new FrontendAstSideTable(); + equalTable.put(lambda, equal); + analysisData.analysisData().applyPatch(new FrontendLambdaResolutionPatch(equalTable)); + assertSame(published, analysisData.analysisData().lambdaPlans().get(lambda)); + } + + @Test + void propertyInitializerLambdaStaysUnrecordedAndFailClosed() throws Exception { + var analysisData = analyze("lambda_property_initializer.gd", """ + class_name LambdaPropertyInitializer + extends Node + + var cb = func(): + return 1 + """); + var lambda = findNode(analysisData.unit().ast(), LambdaExpression.class, _ -> true); + + assertNull(analysisData.analysisData().lambdaPlans().get(lambda)); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_binding_subtree") + && diagnostic.message().contains("lambda subtree") + )); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_chain_route") + && diagnostic.message().contains("lambda subtree") + )); + } + + @Test + void lambdaInsideMatchSectionStaysUnrecordedAndFailClosed() throws Exception { + var analysisData = analyze("lambda_match_boundary.gd", """ + class_name LambdaMatchBoundary + extends Node + + func ping(choice): + match choice: + 0: + var cb := func(): + return choice + """); + var lambda = findNode(analysisData.unit().ast(), LambdaExpression.class, _ -> true); + + assertNull(analysisData.analysisData().lambdaPlans().get(lambda)); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_variable_inventory_subtree") + )); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_binding_subtree") + )); + } + + @Test + void captureDeclaredAfterLambdaStatementIsRegisteredButUseSiteStaysFiltered() throws Exception { + var analysisData = analyze("lambda_capture_after_use.gd", """ + class_name LambdaCaptureAfterUse + extends Node + + func ping(): + var cb := func(): + return late + var late := 1 + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var lateUseSite = findNode(lambda.body(), IdentifierExpression.class, id -> id.name().equals("late")); + + var plan = analysisData.analysisData().lambdaPlans().get(lambda); + + // The planner may register the name, but the declaration is not visible at the lambda + // statement: no stabilization update exists yet, so the inventory baseline is frozen. + assertNotNull(plan); + assertEquals(1, plan.captures().size()); + assertEquals(GdVariantType.VARIANT, plan.captures().getFirst().type()); + // The use site itself stays fail-closed through the ordinary declaration-after-use route: + // it binds UNKNOWN (never CAPTURE) and keeps the source-level binding diagnostic. + var lateBinding = analysisData.analysisData().symbolBindings().get(lateUseSite); + assertNotNull(lateBinding); + assertEquals(FrontendBindingKind.UNKNOWN, lateBinding.kind()); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.binding") + )); + } + + private static @NotNull AnalyzedLambdaInput analyze( + @NotNull String fileName, + @NotNull String source + ) throws Exception { + var parserService = new GdScriptParserService(); + var diagnostics = new DiagnosticManager(); + var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnostics); + assertTrue(diagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + diagnostics.snapshot()); + var analysisData = new FrontendSemanticAnalyzer().analyze( + new FrontendModule("test_module", List.of(unit)), + new ClassRegistry(ExtensionApiLoader.loadDefault()), + diagnostics + ); + return new AnalyzedLambdaInput(unit, analysisData, diagnostics.snapshot()); + } + + private static @NotNull FunctionDeclaration findFunction( + @NotNull SourceFile sourceFile, + @NotNull String name + ) { + return findNode( + sourceFile, + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals(name) + ); + } + + private static @NotNull T findNode( + @NotNull Node root, + @NotNull Class nodeType, + @NotNull Predicate predicate + ) { + var matches = new ArrayList(); + collectMatchingNodes(root, nodeType, predicate, matches); + return matches.stream() + .findFirst() + .orElseThrow(() -> new AssertionError("Node not found: " + nodeType.getSimpleName())); + } + + private static void collectMatchingNodes( + @NotNull Node node, + @NotNull Class nodeType, + @NotNull Predicate predicate, + @NotNull List matches + ) { + if (nodeType.isInstance(node) && predicate.test(nodeType.cast(node))) { + matches.add(nodeType.cast(node)); + } + for (var child : node.getChildren()) { + collectMatchingNodes(child, nodeType, predicate, matches); + } + } + + private record AnalyzedLambdaInput( + @NotNull FrontendSourceUnit unit, + @NotNull FrontendAnalysisData analysisData, + @NotNull DiagnosticSnapshot diagnostics + ) { + } +} 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 821d7463..11c06df8 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendSemanticAnalyzerFrameworkTest.java @@ -464,7 +464,12 @@ func ping(player, i, seed = helper()): IdentifierExpression.class, identifierExpression -> identifierExpression.name().equals("player") ); - assertNull(result.symbolBindings().get(lambdaPlayerUseSite)); + // Lambda bodies are resolved through their own nested suite: the captured use site binds + // to the lambda's CAPTURE slot instead of staying unpublished. + assertEquals( + FrontendBindingKind.CAPTURE, + Objects.requireNonNull(result.symbolBindings().get(lambdaPlayerUseSite)).kind() + ); assertEquals("move", Objects.requireNonNull(result.resolvedCalls().get(moveStep)).callableName()); assertEquals( FrontendCallResolutionStatus.RESOLVED, @@ -472,11 +477,11 @@ func ping(player, i, seed = helper()): ); assertEquals("get_player", Objects.requireNonNull(result.resolvedCalls().get(getPlayerCall)).callableName()); - assertEquals(5, result.symbolBindings().size()); + assertEquals(6, result.symbolBindings().size()); assertEquals(2, result.resolvedMembers().size()); assertEquals(2, result.resolvedCalls().size()); assertEquals( - 2, + 1, result.diagnostics().asList().stream() .filter(diagnostic -> diagnostic.category().equals("sema.unsupported_binding_subtree")) .count() @@ -488,13 +493,13 @@ func ping(player, i, seed = helper()): .count() ); assertEquals( - 2, + 1, result.diagnostics().asList().stream() .filter(diagnostic -> diagnostic.category().equals("sema.unsupported_chain_route")) .count() ); assertEquals( - 1, + 0, result.diagnostics().asList().stream() .filter(diagnostic -> diagnostic.category().equals("sema.unsupported_expression_route")) .count() @@ -504,26 +509,11 @@ func ping(player, i, seed = helper()): && diagnostic.severity() == FrontendDiagnosticSeverity.ERROR && diagnostic.message().contains("parameter default") )); - assertTrue(result.diagnostics().asList().stream().anyMatch(diagnostic -> - diagnostic.category().equals("sema.unsupported_binding_subtree") - && diagnostic.severity() == FrontendDiagnosticSeverity.ERROR - && diagnostic.message().contains("lambda subtree") - )); assertTrue(result.diagnostics().asList().stream().anyMatch(diagnostic -> diagnostic.category().equals("sema.unsupported_chain_route") && diagnostic.severity() == FrontendDiagnosticSeverity.ERROR && diagnostic.message().contains("parameter default") )); - assertTrue(result.diagnostics().asList().stream().anyMatch(diagnostic -> - diagnostic.category().equals("sema.unsupported_chain_route") - && diagnostic.severity() == FrontendDiagnosticSeverity.ERROR - && diagnostic.message().contains("lambda subtree") - )); - assertTrue(result.diagnostics().asList().stream().anyMatch(diagnostic -> - diagnostic.category().equals("sema.unsupported_expression_route") - && diagnostic.severity() == FrontendDiagnosticSeverity.ERROR - && diagnostic.message().contains("Lambda expression typing is not supported") - )); } @Test diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendSuiteResolverTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendSuiteResolverTest.java index 83c756de..e5fbf4ca 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendSuiteResolverTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendSuiteResolverTest.java @@ -209,7 +209,7 @@ func ping(value, other): } @Test - void forBodyResolvesWhileUnsupportedFeatureOwnedBodiesRemainFailClosed() throws Exception { + void forBodyAndLambdaResolveWhileUnsupportedFeatureOwnedBodiesRemainFailClosed() throws Exception { var phaseInput = phaseInput("suite_fail_closed.gd", """ class_name SuiteFailClosed extends Node @@ -276,13 +276,15 @@ func ping(items, choice): assertTrue(surface.suiteEntryRoots().containsSupportedBlock(forStatement.body())); assertFalse(surface.suiteEntryRoots().containsSupportedBlock(matchStatement.sections().getFirst().body())); - assertFalse(surface.suiteEntryRoots().containsSupportedBlock(lambdaExpression.body())); + assertTrue(surface.suiteEntryRoots().containsSupportedBlock(lambdaExpression.body())); + assertTrue(surface.suiteEntryRoots().containsCallableOwner(lambdaExpression)); assertFalse(ownerProcedures.unsupportedRoots().contains(forStatement)); assertTrue(ownerProcedures.unsupportedRoots().contains(matchStatement)); assertTrue(ownerProcedures.unsupportedRoots().contains(answer)); assertTrue(hasOwnerEvent(ownerProcedures.events(), fromFor)); assertFalse(hasOwnerEvent(ownerProcedures.events(), fromMatch)); - assertFalse(hasOwnerEvent(ownerProcedures.events(), fromLambda)); + // The recorded lambda body resolves as its own suite and produces owner events. + assertTrue(hasOwnerEvent(ownerProcedures.events(), fromLambda)); assertFalse(hasOwnerEvent(ownerProcedures.events(), answer)); assertTrue(hasOwnerEvent(ownerProcedures.events(), callback)); assertTrue(hasOwnerEvent(ownerProcedures.events(), after)); @@ -1925,6 +1927,7 @@ private static void assertCertificateFailure( phaseInput.diagnostics(), phaseInput.registry(), null, + null, null ); } 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 e9b4fd80..2cb1b6c4 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 @@ -882,7 +882,9 @@ func ping(seed = [1]): compiled.diagnostics(), "sema.unsupported_binding_subtree" ); - assertEquals(4, unsupportedBindingDiagnostics.size()); + // parameter default + block-local const + match stay fail-closed; the recorded lambda + // resolves through its own nested suite and no longer contributes a diagnostic. + assertEquals(3, unsupportedBindingDiagnostics.size()); } @Test @@ -1833,7 +1835,8 @@ func ping(vec: Vector2, dict: Dictionary): diagnostic.message().contains("Qualified method-reference 'clear'") && !diagnostic.range().equals(FrontendRange.fromAstRange(dictStep.range())) ), compileDiagnostics::toString); - assertFalse(diagnosticsByCategory(compiled.diagnostics(), "sema.unsupported_binding_subtree").isEmpty()); + // The recorded lambda no longer reports unsupported binding/chain subtrees (plan §3.3). + assertTrue(diagnosticsByCategory(compiled.diagnostics(), "sema.unsupported_binding_subtree").isEmpty()); } @Test @@ -1852,14 +1855,16 @@ func ping(sig: Signal): var compiled = analyzeForCompile("compile_check_direct_lambda_connect.gd", source); var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); - var unsupportedBindingDiagnostics = diagnosticsByCategory( + var unsupportedExpressionDiagnostics = diagnosticsByCategory( compiled.diagnostics(), - "sema.unsupported_binding_subtree" + "sema.unsupported_expression_route" ); assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); - assertFalse(unsupportedBindingDiagnostics.isEmpty(), compiled.diagnostics()::toString); + // The recorded lambda body resolves, but the lambda expression type stays unsupported + // until the dedicated typing phase, so the connect argument remains blocked. + assertFalse(unsupportedExpressionDiagnostics.isEmpty(), compiled.diagnostics()::toString); } @Test diff --git a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java index b4b146df..927b27f6 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java @@ -23,6 +23,7 @@ import dev.superice.gdparser.frontend.ast.FunctionDeclaration; import dev.superice.gdparser.frontend.ast.ForStatement; import dev.superice.gdparser.frontend.ast.IdentifierExpression; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.SourceFile; import dev.superice.gdparser.frontend.ast.VariableDeclaration; @@ -366,9 +367,9 @@ func ping(value, alias = value): } @Test - void resolveSealsLambdaBodyAsDeferredUnsupported() throws Exception { - var analyzedInput = analyzedInput("lambda_body_deferred.gd", """ - class_name LambdaBodyDeferred + void resolveFindsOuterCaptureAcrossLambdaEdge() throws Exception { + var analyzedInput = analyzedInput("lambda_body_resolved.gd", """ + class_name LambdaBodyResolved extends Node func ping(seed: int): @@ -385,12 +386,52 @@ func ping(seed: int): FrontendVisibleValueDomain.EXECUTABLE_BODY )); - assertEquals(FrontendVisibleValueStatus.DEFERRED_UNSUPPORTED, result.status()); - assertEquals(FrontendVisibleValueDomain.LAMBDA_SUBTREE, result.deferredBoundary().domain()); - assertEquals( - FrontendVisibleValueDeferredReason.UNSUPPORTED_DOMAIN, - result.deferredBoundary().reason() - ); + // The lambda edge no longer seals: the use site sees the lambda's own CAPTURE binding, + // filled with the declaration-site type of the outer parameter. + assertEquals(FrontendVisibleValueStatus.FOUND_ALLOWED, result.status()); + assertNotNull(result.visibleValue()); + assertEquals(ScopeValueKind.CAPTURE, result.visibleValue().kind()); + assertEquals(GdIntType.INT, result.visibleValue().type()); + assertTrue(result.filteredHits().isEmpty()); + assertNull(result.deferredBoundary()); + } + + @Test + void resolveFindsLambdaParameterAndLocalAcrossLambdaEdge() throws Exception { + var analyzedInput = analyzedInput("lambda_body_own_bindings.gd", """ + class_name LambdaBodyOwnBindings + extends Node + + func ping(): + var f = func(step): + var local := step + return local + """); + var pingFunction = findFunction(analyzedInput.unit().ast(), "ping"); + var lambda = findLambdaExpression(pingFunction.body()); + var stepUseSite = findIdentifierExpression(lambda.body(), "step"); + var localUseSite = findIdentifierExpression(lambda.body(), "local"); + var resolver = new FrontendVisibleValueResolver(analyzedInput.analysisData()); + + var parameterResult = resolver.resolve(new FrontendVisibleValueResolveRequest( + "step", + stepUseSite, + FrontendVisibleValueDomain.EXECUTABLE_BODY + )); + var localResult = resolver.resolve(new FrontendVisibleValueResolveRequest( + "local", + localUseSite, + FrontendVisibleValueDomain.EXECUTABLE_BODY + )); + + assertEquals(FrontendVisibleValueStatus.FOUND_ALLOWED, parameterResult.status()); + assertNotNull(parameterResult.visibleValue()); + assertEquals(ScopeValueKind.PARAMETER, parameterResult.visibleValue().kind()); + assertNull(parameterResult.deferredBoundary()); + assertEquals(FrontendVisibleValueStatus.FOUND_ALLOWED, localResult.status()); + assertNotNull(localResult.visibleValue()); + assertEquals(ScopeValueKind.LOCAL, localResult.visibleValue().kind()); + assertNull(localResult.deferredBoundary()); } @Test @@ -791,6 +832,10 @@ func ping(): ); } + private static @NotNull LambdaExpression findLambdaExpression(@NotNull Node root) { + return findNode(root, LambdaExpression.class, _ -> true); + } + private static @NotNull T findNode( @NotNull Node root, @NotNull Class nodeType, From 6c799c0730143c682cd42ac65ab0fdddac5dffb5 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Tue, 18 Aug 2026 00:43:42 +0800 Subject: [PATCH 5/9] feat(frontend): enable lambda expression typing and body analysis - Publish Callable types for recorded lambda expressions - Type-check nested lambda bodies with inherited callable context - Preserve Variant inference for silent lambda initializers - Add compile-time blockers until lambda lowering is implemented - Expand semantic analysis tests and implementation documentation --- .../frontend/diagnostic_manager.md | 7 +- .../frontend/frontend_lambda_plan.md | 76 ++++++++++-- ...local_type_stabilization_implementation.md | 2 +- ...end_top_binding_analyzer_implementation.md | 8 +- ...tend_type_check_analyzer_implementation.md | 5 +- .../analyzer/FrontendBodyOwnerProcedures.java | 26 ++-- .../FrontendCompileCheckAnalyzer.java | 8 +- .../analyzer/FrontendTypeCheckAnalyzer.java | 51 +++++++- .../FrontendExpressionSemanticSupport.java | 14 ++- .../FrontendLambdaSuiteResolutionTest.java | 105 ++++++++++++++++ ...ontendBodyOwnerProceduresExprTypeTest.java | 9 +- .../FrontendCompileCheckAnalyzerTest.java | 28 +++-- .../FrontendTypeCheckAnalyzerTest.java | 116 +++++++++++++++++- 13 files changed, 401 insertions(+), 54 deletions(-) diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index fcf812a8..224e64bf 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -133,7 +133,7 @@ frontend 当前已经冻结的诊断承载方式如下: - `forIterationPlans` - `typeTestTargets` - `containerLiteralPlans` -- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充) +- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充;阶段 D 起已 record lambda 的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`) 其中: @@ -248,6 +248,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - expr analyzer 对 assignment / subscript / generic deferred expression 等 expression-only deferred root 的 warning - `sema.unsupported_expression_route` - expr analyzer 对当前明确不支持的 direct-callable-invocation 等 expression route 的 error + - 已 `recordCallable` 的 lambda 自阶段 D 起不再发此诊断(表达式类型已转正为 `RESOLVED(GdCallableType)`);未记录的 lambda(property initializer / parameter default / skipped subtree)继续按此边界 fail-closed - `sema.discarded_expression` - expr analyzer 对 bare expression statement 中被丢弃的非 `void` 结果发出的 warning - `sema.unsafe_call_argument` @@ -350,7 +351,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - `FrontendSemanticAnalyzer` 当前返回 `FrontendAnalysisData` - analyze 流程围绕同一份共享分析数据推进 - interface/body suite resolver 路径会在每个 body statement boundary 刷新 `FrontendAnalysisData.diagnostics()`,让后一 statement 能读取 current-suite upstream diagnostic snapshot;suite export 在 patch transaction 应用后保留最终 body snapshot -- 已 `recordCallable` 的 lambda 由外层 statement 的 top-binding owner 触发 nested suite resolution:独立 `FrontendCallableExportBatch` 立即应用,`LAMBDA_RESOLUTION` owner patch 首次发布 `lambdaPlans`(capture 声明处类型),body 事实与普通 suite 走同一 statement boundary 诊断刷新 +- 已 `recordCallable` 的 lambda 由外层 statement 的 top-binding owner 触发 nested suite resolution:独立 `FrontendCallableExportBatch` 立即应用,`LAMBDA_RESOLUTION` owner patch 首次发布 `lambdaPlans`(capture 声明处类型),body 事实与普通 suite 走同一 statement boundary 诊断刷新;阶段 D 起该 lambda 节点的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`,silent 局部稳定化不解析 lambda initializer(slot 保持 inventory `Variant`) - analyze 现在已经具备独立的多 phase 主链路: - skeleton 结束后先发布 `updateModuleSkeleton(...)` - 再发布一次 pre-scope `updateDiagnostics(...)` @@ -363,7 +364,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - 调用 `FrontendVarTypePostAnalyzer.analyze(...)` 发布 callable-local `slotTypes()` - 调用 `FrontendAnnotationUsageAnalyzer.analyze(...)` 对 retained annotation 的合法挂载位置发出 `sema.annotation_usage` - 调用 `FrontendVirtualOverrideAnalyzer.analyze(...)` 对 engine virtual override 签名发出 `sema.virtual_override` - - 调用 `FrontendTypeCheckAnalyzer.analyze(...)` 对 ordinary local / class property / return typed contract 发出 `sema.type_check`,并对 property hint 发出 `sema.type_hint` + - 调用 `FrontendTypeCheckAnalyzer.analyze(...)` 对 ordinary local / class property / return typed contract 发出 `sema.type_check`,并对 property hint 发出 `sema.type_hint`;自阶段 D 起经 `scanNestedLambdaBodies` 显式 re-entry 遍历已 record lambda 的 body(plan 存在性为闸门,继承 enclosing callable 的 restriction/static context) - 调用 `FrontendLoopControlFlowAnalyzer.analyze(...)` 对非法 `break` / `continue` 发出 `sema.loop_control_flow` - 每个 phase 结束后都再次 `updateDiagnostics(...)`,把阶段边界快照刷新到最新 shared manager 状态 - `analyzeForCompile(...)` 在共享 11 phase 之后追加: diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index a3e00dce..3f568e4b 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,12 +9,15 @@ ## 文档状态 -- 状态:阶段 A、B、C 已落地;D–I 尚未实施。lambda body 已成为 supported executable +- 状态:阶段 A、B、C、D 已落地;E–I 尚未实施。lambda body 已成为 supported executable suite:resolver / interface / suite 解封,nested resolve 经独立 `LAMBDA_RESOLUTION` - owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型已填充并与 scope 同源)。 - 表达式类型(`GdCallableType`)、LIR 合成、CFG lowering、compile surface 仍 fail-closed。 -- 更新时间:2026-08-17(阶段 C 落地:resolver/interface/suite 解封 + capture 类型填充 - + `lambdaPlans()` 首次发布) + owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型已填充并与 scope 同源); + 已 record lambda 的表达式类型首次发布 `RESOLVED(GdCallableType)`(silent 稳定化不解析 + lambda initializer,`:=` slot 保持 inventory `Variant`),type-check 经显式 re-entry + walk 已 record lambda 的 body;compile gate 对 lambda 以形态级 `sema.compile_check` + blocker 保持 fail-closed。LIR 合成、CFG lowering、compile surface 解封仍留待后续阶段。 +- 更新时间:2026-08-17(阶段 D 落地:表达式类型 `GdCallableType` + type-check body walk + + compile gate 形态级 blocker) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -109,9 +112,9 @@ Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 | Suite owner | `FrontendSuiteResolver.callableBody/callableParameters` | 只认 Function/Constructor;lambda 返回 `null` / empty | | Visible value | `FrontendVisibleValueResolver` | AST 边 + current-scope backstop → `DEFERRED_UNSUPPORTED` / `LAMBDA_SUBTREE` | | Top / chain binding | `FrontendBodyOwnerProcedures` | `sema.unsupported_binding_subtree` / `sema.unsupported_chain_route`;`walkRootBounded` 剪枝 | -| Expr typing | `FrontendExpressionSemanticSupport.resolveLambdaExpressionType` | `UNSUPPORTED`:"Lambda expression typing is not supported..." | -| Type-check | `FrontendTypeCheckAnalyzer.handleLambdaExpression` | `SKIP_CHILDREN` | -| Compile surface | `FrontendCompileCheckAnalyzer.walkExpression` | 不进入 lambda;依赖上游 unsupported | +| Expr typing | `FrontendExpressionSemanticSupport.resolveLambdaExpressionType` | **阶段 D 已翻面**:已 record → `RESOLVED(GdCallableType)`;未记录仍 `UNSUPPORTED` | +| Type-check | `FrontendTypeCheckAnalyzer.handleLambdaExpression` | **阶段 D 已翻面**:walk 已 record lambda 的 body(plan 闸门 + 继承 restriction/static) | +| Compile surface | `FrontendCompileCheckAnalyzer.walkExpression` | **阶段 D 起**形态级 `sema.compile_check` blocker;阶段 I 才解封 | | Inspection | `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` | 把 `LambdaExpression` 硬编码为 deferred 祖先 | | Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef` | | Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | 不扫描表达式里的 lambda | @@ -789,6 +792,20 @@ $result = construct_lambda "" $capture1 $capture2 ... ### 阶段 D — 表达式类型与 Callable 值 +> 状态:已落地(2026-08-17)。`resolveLambdaExpressionType` 新增 recorded 判定参数: +> 已 record lambda 首次发布 `RESOLVED(GdCallableType)`(未参数化,与 method-as-value +> 对齐);未记录 lambda 保持 `UNSUPPORTED` + 既有 unsupported 诊断三路由。 +> `runLocalTypeStabilization` 对 lambda initializer 显式 fail-closed(slot 保持 +> inventory `Variant`,silent 路径不解析 lambda);`publishExpressionType` 的 +> recorded-lambda 静默 UNSUPPORTED 分支已随 RESOLVED 化删除。 +> `FrontendTypeCheckAnalyzer` 新增 `scanNestedLambdaBodies` 显式 re-entry, +> `handleLambdaExpression` 以 plan 存在性为闸门 walk body(继承 enclosing +> restriction/static;return slot 暂为 `GdVariantType.VARIANT`,声明返回类型的 +> 检查留待阶段 E 合成函数落地)。`FrontendCompileCheckAnalyzer.walkExpression` +> 对 lambda 改发形态级 `sema.compile_check` blocker(与 preload / get-node 同款 +> “已识别但 lowering 未落地”),analyzeForCompile 在阶段 I 前保持 +> “compile surface 上有 lambda 则 blocker”。 + **目标**:lambda 表达式发布稳定 `RESOLVED(GdCallableType)`。 **实施内容**: @@ -800,6 +817,43 @@ $result = construct_lambda "" $capture1 $capture2 ... - 赋值 `var cb: Callable = func(): pass` 走既有 ordinary boundary。 - `var x: int = func(): pass` 发既有 `sema.type_check` 不兼容。 +**落地记录**: + +- 表达式类型:`FrontendExpressionSemanticSupport.resolveLambdaExpressionType` 新增 + `recordedLambda` 参数(调用点以 + `suiteEntryRoots().containsCallableOwner(lambda)` 判定)。已 record lambda + 经既有 EXPR_TYPE owner 单点首次发布 `RESOLVED(GdCallableType)`——满足 + `sameExpressionType` 严格相等约束,无"先 UNSUPPORTED 再升级"的 patch 冲突。 + `GdCallableType` 是 `GdMetaType`(非 compiler-only),通过 published-fact 类型守卫。 +- Silent 稳定化闸门:`runLocalTypeStabilization` 在 initializer 为 + `LambdaExpression` 时直接返回——`var cb := func(): ...` 的 slot 保持 + inventory `Variant`;`checkInferredLocalTypeConsistency` 对 Variant slot + 短路灯塔,无需改动。阶段 D 不实现非 silent 写回。 +- Type-check body walk:`AstWalkerTypeCheckVisitor.scanNestedLambdaBodies` + 镜像 loop-control 的 `scanNestedCallableBoundaries`,在 varDecl / return / + assert / expression statement / if / elif / while / for 八个语句 handler 上 + 扫描表达式载荷;`handleLambdaExpression` 以 + `lambdaPlans().containsKey(lambda)` 为闸门(未记录 lambda 无 plan、无 body + 事实,保持 fail-closed),经 `walkCallableBody(lambda, body, Variant, + currentRestriction, currentStaticContext)` 继承外层可调用上下文。 +- Compile gate:`walkExpression` 的 lambda 分支由静默跳过改为 + `reportExplicitCompileBlock(lambda, expressionCompileBlockedMessage("Lambda expression"))`, + 防止阶段 I 前带 lambda 的模块漏进 lowering。 +- 测试:翻转 `analyzeLeavesInferredLocalsAsVariantWhenInitializerCannotPublishStableType` + (initializer RESOLVED + slot 保持 Variant)、 + `analyzeForCompileBlocksDirectLambdaConnectArgument`(unsupported_expression_route + 清空,compile_check 恰为 lambda blocker)、 + `analyzeForCompileSkipsExplicitCompileBlocksOutsideCompileSurface` / + `analyzeSkipsGenericCompileBlocksOutsideCompileSurface` / + `analyzeForCompileSkipsBareSignalValueReferencesOutsideCompileSurface` + (compile_check 恰为 1 条 lambda blocker,off-surface 事实仍不被扫描); + `analyzeChecksOnlyExplicitOrdinaryLocalDeclaredSlotsAndSkipsUnstableInitializerFacts` + 的 lambda fixture 行改为 `Worker.VALUE`(保持"跳过不稳定 initializer"的原意图)。 + 新增 `recordedLambdaPublishesCallableExpressionTypeAlongsidePlan`、 + `analyzeChecksLambdaInitializersAgainstDeclaredSlots`、 + `analyzeWalksRecordedLambdaBodiesWithInheritedCallableContext`、 + `analyzeKeepsUnrecordedLambdaBodiesOutsideTypeCheckSurface`。 + **验收细则**: - happy path:`FrontendTypeCheckAnalyzerTest` / @@ -1055,9 +1109,9 @@ FrontendCompileCheckAnalyzerTest,ConstructLambdaInsnGenTest,DomLirParserTest,Lir | `FrontendVisibleValueResolverTest.resolveSealsLambdaBodyAsDeferredUnsupported` | `LAMBDA_SUBTREE` | 可解析 | | `FrontendVisibleValueResolverTest.resolveRejectsSyntheticLambdaBody...` | current-scope 封口 | 不再因 kind 封口 | | `FrontendBodySemanticSupportPolicyTest` lambda 行 | `LAMBDA_SUBTREE` | **阶段 B** 即翻成 `EXECUTABLE_BODY` | -| `FrontendTypeCheckAnalyzerTest` lambda initializer | `UNSUPPORTED` | `RESOLVED(Callable)` 或类型不兼容 | -| `FrontendBodyOwnerProceduresExprTypeTest` inferred lambda local | `UNSUPPORTED` | `Callable` | -| `FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` | unsupported binding,无 compile_check | 阶段 I 后 compile-ready | +| `FrontendTypeCheckAnalyzerTest` lambda initializer | `UNSUPPORTED` | `RESOLVED(Callable)` 或类型不兼容(**阶段 D 已翻转**) | +| `FrontendBodyOwnerProceduresExprTypeTest` inferred lambda local | `UNSUPPORTED` | `Callable`(**阶段 D 已翻转**,slot 保持 `Variant`) | +| `FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` | unsupported binding,无 compile_check | 阶段 D:unsupported 清空 + 形态级 lambda blocker;阶段 I 后 compile-ready | | `FrontendCompileCheckAnalyzerTest.analyzeForCompileReleasesBuiltinAndStaticMethodReferences` 的 lambda_cb | unsupported binding | 无该 error | ### 6.2 必须保持 diff --git a/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md b/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md index ecdfa8b0..aabdbe4b 100644 --- a/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md +++ b/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md @@ -278,7 +278,7 @@ package-private `probe(...)` 仅作为测试观察窗口存在,用来运行与 以下输入当前仍不进入 local stabilization 的正式支持面: - parameter default -- lambda / capture +- lambda initializer / capture(lambda 计划阶段 D 起,lambda 表达式自身已能发布 `RESOLVED(GdCallableType)`,但 silent 稳定化对 lambda initializer 显式 fail-closed:`var cb := func(): ...` 的 slot 保持 inventory `Variant`,不写 side table、不发诊断、不触发 nested resolve;`:=` 精化只允许来自 nested resolve 完成后的非 silent 写回) - `match` - block-local `const` - class `const` 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 156dd223..1f7a4f2c 100644 --- a/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md @@ -381,7 +381,7 @@ builtin static namespace 当前仍 direct-only,因为 ExtensionAPI builtin met 以下位置当前都必须显式封口,不能伪装成正常 binding miss: - parameter default subtree -- lambda subtree +- lambda subtree(仅未记录的 lambda:property initializer / parameter default / skipped subtree;已 `recordCallable` 的 lambda 自 lambda 计划阶段 C 起由 top-binding 触发 nested suite resolution,不再封口) - `match` subtree - block-local `const` initializer subtree - 任何缺少稳定 `scopesByAst()` 记录的 skipped subtree @@ -465,7 +465,7 @@ builtin static namespace 当前仍 direct-only,因为 ExtensionAPI builtin met 当前 `sema.unsupported_binding_subtree` 覆盖以下语义: - parameter default subtree -- lambda subtree +- lambda subtree(仅未记录的 lambda;已 `recordCallable` 的 lambda 自阶段 C 起走 nested suite resolution,不产生此 category) - `match` subtree - block-local `const` initializer subtree - missing-scope / skipped subtree @@ -658,7 +658,9 @@ func ping(value): return value ``` -- lambda subtree 当前 deferred +- 已 `recordCallable` 的 lambda 自 lambda 计划阶段 C/D 起转正:top-binding 触发 nested + suite resolution,表达式类型发布 `RESOLVED(GdCallableType)`;仅未记录位置 + (property initializer / parameter default / skipped subtree)的 lambda 仍 deferred ```gdscript func ping(): diff --git a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md index fba494d4..4c27df55 100644 --- a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md @@ -33,7 +33,8 @@ - 不在这里新增 `FrontendAnalysisData` side table - 不在这里重做表达式求值、binding、member/call 解析或 scope 构建 - 不在这里补 suite merge、missing-return、all-path return exhaustiveness 分析 - - 不在这里转正 `lambda`、`match`、parameter default、block-local `const`、class `const` 的正式 body 语义 + - 不在这里转正 `match`、parameter default、block-local `const`、class `const` 的正式 body 语义 + - `lambda` body 遍历已于 lambda 计划阶段 D 落地:已 record lambda 经 `scanNestedLambdaBodies` 显式 re-entry 由 `handleLambdaExpression` walk body(以 `lambdaPlans()` 存在性为闸门,继承 enclosing callable 的 restriction/static context,return slot 暂为 `Variant`——声明返回类型的检查随阶段 E 合成函数落地);未记录 lambda(property initializer / match / parameter default)保持 fail-closed,不进入 body - 不在这里实现或发布 `FrontendForIterationPlan`(它由 for-iteration resolution owner 发布);type-check 只消费已发布 plan 做 route-aware header 校验(range arity / argument int slot / 显式 iterator element conversion)并遍历 for body,不重新推导 iterable route - 不在这里实现 property-side inference/backfill,也不在 type-check analyzer 内维护平行 implicit conversion 规则;`int -> float`、同维度 `Vector*i -> Vector*` 与 `StringName` / `String` 互转只通过 `frontend_implicit_conversion_matrix.md` 与 shared boundary helper 生效 - 不在这里实现 frontend -> LIR 的 truthiness lowering 或 `@onready` 的 runtime / ready-time 语义 @@ -411,7 +412,7 @@ owner 分工固定为: - property-side inference / metadata backfill - frontend -> LIR 的 truthiness / condition normalization - `@onready` runtime / ready-time lowering -- `lambda`、`match`、parameter default、block-local `const`、class `const` 的正式 body semantics +- `match`、parameter default、block-local `const`、class `const` 的正式 body semantics - for-in route-aware type-check 已落地:`handleForStatement(...)` 消费 `FrontendForIterationPlan` 校验 range arity / argument int slot / int shorthand stop / 显式 iterator element conversion,并遍历 for body。已注册 route 的 compile gate / CFG / body lowering 已闭环;`OBJECT_CUSTOM` 仍未注册 lowering contract。完整合同见 `frontend_for_range_loop_implementation.md` 后续工程若继续扩展本区域,必须遵守以下约束: diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java index 977eaeb2..29370ffc 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java @@ -218,6 +218,13 @@ public void runLocalTypeStabilization(@NotNull FrontendSuiteContext context, @No return; } var initializer = variableDeclaration.value(); + if (initializer instanceof LambdaExpression) { + // Silent stabilization must not resolve lambda initializers (plan §3.2): the slot + // keeps its inventory Variant, and any `:=` refinement to `Callable` may only come + // from a non-silent write-back after nested resolve completed — never from resolving + // the lambda expression on this silent path. + return; + } var guardedFailure = typeMetaOrdinaryValueInitializerFailure(context, initializer); if (guardedFailure == null) { guardedFailure = assignmentOrdinaryValueInitializerFailure(initializer); @@ -1039,20 +1046,6 @@ private static void publishExpressionType( if (resolver.isRouteHeadOnlyTypeMeta(entry.getKey())) { continue; } - if (entry.getKey() instanceof LambdaExpression lambdaExpression - && context.interfaceSurface().suiteEntryRoots().containsCallableOwner(lambdaExpression)) { - // Recorded lambdas keep publishing the (still unsupported) expression fact so - // downstream readers keep their fail-fast publication contract, but the - // unsupported-route diagnostic is suppressed: the body already resolved through - // the nested suite, and the dedicated lambda typing phase upgrades this fact to - // the callable type. - context.typedEnvironment().putExpressionType( - FrontendSemanticStage.EXPR_TYPE, - entry.getKey(), - entry.getValue() - ); - continue; - } if (!resolver.isAssignmentTargetPrefixExpression(entry.getKey()) && resolver.rootOwnsExpressionDiagnostic(entry.getKey())) { reportExpressionDiagnostic(context, resolver, entry.getKey(), entry.getValue()); @@ -1631,7 +1624,10 @@ private void populateRootExpressionTransientCaches( lambdaExpression, this::resolveExpressionType, false, - finalizeWindow + finalizeWindow, + context.interfaceSurface() + .suiteEntryRoots() + .containsCallableOwner(lambdaExpression) ) .expressionType(); case UnaryExpression unaryExpression -> expressionSemanticSupport diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java index 0b92ed79..1ab1b974 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java @@ -552,10 +552,10 @@ private void walkExpression(@Nullable Expression expression) { return; } switch (expression) { - case LambdaExpression _ -> { - // Lambdas remain outside the current compile surface and keep their upstream - // unsupported-subtree owner. - } + case LambdaExpression lambdaExpression -> reportExplicitCompileBlock( + lambdaExpression, + expressionCompileBlockedMessage("Lambda expression") + ); case ConditionalExpression conditionalExpression -> reportExplicitCompileBlock( conditionalExpression, conditionalCompileBlockedMessage() diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java index 1094e850..936bc3bf 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java @@ -120,7 +120,7 @@ public void analyze( } } - protected void visitOrdinaryLocalInitializer( + protected void visitOrdinaryLocalInitializer( @NotNull TypeCheckAccess access, @NotNull VariableDeclaration variableDeclaration ) { @@ -1067,6 +1067,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara public @NotNull FrontendASTTraversalDirective handleVariableDeclaration( @NotNull VariableDeclaration variableDeclaration ) { + scanNestedLambdaBodies(variableDeclaration.value()); if (supportedExecutableBlockDepth > 0) { if (isOrdinaryLocalInitializer(variableDeclaration)) { visitOrdinaryLocalInitializer(callbackAccess(), variableDeclaration); @@ -1086,6 +1087,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitReturnStatement(callbackAccess(), returnStatement); + scanNestedLambdaBodies(returnStatement.value()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1099,6 +1101,8 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara if (assertStatement.message() != null) { visitNestedCastExpressions(access, assertStatement.message()); } + scanNestedLambdaBodies(assertStatement.condition()); + scanNestedLambdaBodies(assertStatement.message()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1110,6 +1114,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitNestedCastExpressions(callbackAccess(), expressionStatement.expression()); + scanNestedLambdaBodies(expressionStatement.expression()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1119,6 +1124,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitConditionExpression(callbackAccess(), ifStatement.condition(), ifStatement); + scanNestedLambdaBodies(ifStatement.condition()); walkSupportedExecutableBlock(ifStatement.body()); for (var elifClause : ifStatement.elifClauses()) { astWalker.walk(elifClause); @@ -1135,6 +1141,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitConditionExpression(callbackAccess(), elifClause.condition(), elifClause); + scanNestedLambdaBodies(elifClause.condition()); walkSupportedExecutableBlock(elifClause.body()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1145,6 +1152,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitConditionExpression(callbackAccess(), whileStatement.condition(), whileStatement); + scanNestedLambdaBodies(whileStatement.condition()); walkSupportedExecutableBlock(whileStatement.body()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1159,12 +1167,32 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } visitForHeader(callbackAccess(), forStatement); + scanNestedLambdaBodies(forStatement.iterable()); walkSupportedExecutableBlock(forStatement.body()); return FrontendASTTraversalDirective.SKIP_CHILDREN; } + /// Recorded lambdas re-enter here from `scanNestedLambdaBodies`: the body already resolved + /// through its nested suite, so type-check walks it as an independent callable island that + /// inherits the enclosing callable's restriction/static context (plan §3.2). The return + /// slot stays `Variant` until the synthetic-function phase resolves the declared return + /// type, so `return expr` stays compatible via the ordinary pack boundary and bare + /// `return` remains legal. Unrecorded lambdas (property initializer / parameter default / + /// skipped subtrees) publish no plan and no body facts, so they stay fail-closed. @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { + if (isNotPublished(lambdaExpression) + || isNotPublished(lambdaExpression.body()) + || !analysisData.lambdaPlans().containsKey(lambdaExpression)) { + return FrontendASTTraversalDirective.SKIP_CHILDREN; + } + walkCallableBody( + lambdaExpression, + lambdaExpression.body(), + GdVariantType.VARIANT, + currentRestriction, + currentStaticContext + ); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -1173,6 +1201,27 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara return FrontendASTTraversalDirective.SKIP_CHILDREN; } + /// Lambda bodies are separate suite-entry roots that the statement walk never reaches, so + /// nested lambdas must be re-entered explicitly from the expression trees carrying them — + /// mirroring the loop-control analyzer's callable-boundary scan. The scan stops at each + /// lambda node; `handleLambdaExpression` decides whether its body has published facts. + private void scanNestedLambdaBodies(@Nullable Node node) { + if (node == null) { + return; + } + if (node instanceof LambdaExpression lambdaExpression) { + astWalker.walk(lambdaExpression); + return; + } + for (var child : node.getChildren()) { + if (child instanceof LambdaExpression lambdaExpression) { + astWalker.walk(lambdaExpression); + continue; + } + scanNestedLambdaBodies(child); + } + } + private void walkClassContainer(@NotNull Node classOwner, @NotNull List statements) { var previousClass = currentClass; var previousRestriction = currentRestriction; 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 47e2f5de..a14cfafc 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 @@ -525,11 +525,18 @@ private boolean shouldContinueBlockedBareCallResolution( )); } + /// Lambda typing contract (plan §3.3): a recorded lambda — one the interface phase registered + /// as a callable owner and whose body already resolved through its nested suite — publishes the + /// unparameterized `Callable` type, aligned with method-as-value / `construct_callable` facts. + /// Lambdas outside supported executable bodies (property initializers, parameter defaults, + /// skipped subtrees) stay fail-closed with the unsupported route. The nested-resolve trigger + /// itself lives in the top-binding owner (`tryResolveRecordedLambda`), not here. public @NotNull ExpressionSemanticResult resolveLambdaExpressionType( @NotNull LambdaExpression lambdaExpression, @NotNull NestedExpressionResolver nestedResolver, boolean resolveNestedChildren, - boolean finalizeWindow + boolean finalizeWindow, + boolean recordedLambda ) { var dependencyIssue = resolveNestedChildren ? firstNestedDependencyIssue(lambdaExpression, nestedResolver, finalizeWindow) @@ -537,8 +544,11 @@ private boolean shouldContinueBlockedBareCallResolution( if (dependencyIssue != null) { return propagated(dependencyIssue); } + if (recordedLambda) { + return rootOutcome(FrontendExpressionType.resolved(new GdCallableType())); + } return rootOutcome(FrontendExpressionType.unsupported( - "Lambda expression typing is not supported by the current frontend expression-typing contract" + "Lambda expression typing is only supported inside a supported executable body" )); } diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java index 7a135ac3..64cc8158 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java @@ -12,6 +12,7 @@ import gd.script.gdcc.frontend.parse.FrontendModule; import gd.script.gdcc.frontend.parse.FrontendSourceUnit; import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.frontend.scope.BlockScope; import gd.script.gdcc.frontend.scope.CallableScope; import gd.script.gdcc.frontend.sema.analyzer.FrontendSemanticAnalyzer; import gd.script.gdcc.frontend.sema.patch.FrontendLambdaResolutionPatch; @@ -100,6 +101,106 @@ func ping(seed: int): assertTrue(analysisData.diagnostics().asList().isEmpty()); } + @Test + void recordedLambdaPublishesCallableExpressionTypeAlongsidePlan() throws Exception { + var analysisData = analyze("lambda_expression_type.gd", """ + class_name LambdaExpressionType + extends RefCounted + + func ping(): + var cb := func(x: int): + return x + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + + // Phase C/D contract: the nested-resolved plan (LAMBDA_RESOLUTION owner) and the callable + // expression fact (EXPR_TYPE owner) coexist for the same recorded lambda node. + assertNotNull(analysisData.analysisData().lambdaPlans().get(lambda)); + var expressionType = analysisData.analysisData().expressionTypes().get(lambda); + assertNotNull(expressionType); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, expressionType.status()); + assertEquals("Callable", expressionType.publishedType().getTypeName()); + + // Silent stabilization never resolves the lambda initializer, so the `:=` slot keeps its + // inventory Variant; only a non-silent write-back could refine it (phase D adds none). + var bodyScope = assertInstanceOf( + BlockScope.class, + analysisData.analysisData().scopesByAst().get(pingFunction.body()) + ); + assertEquals(GdVariantType.VARIANT, Objects.requireNonNull(bodyScope.resolveValue("cb")).type()); + + assertFalse(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_expression_route") + )); + } + + @Test + void lambdaContainingMatchKeepsCallableTypeAndUpstreamMatchDiagnostics() throws Exception { + var analysisData = analyze("lambda_match_inside.gd", """ + class_name LambdaMatchInside + extends RefCounted + + func ping(): + var cb := func(): + match 1: + 1: + pass + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + + // A deferred `match` inside the body must not drag the lambda itself back to + // UNSUPPORTED: the plan and the Callable expression fact stay published, while the + // match keeps its own upstream diagnostic owners. + assertNotNull(analysisData.analysisData().lambdaPlans().get(lambda)); + var expressionType = analysisData.analysisData().expressionTypes().get(lambda); + assertNotNull(expressionType); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, expressionType.status()); + assertEquals("Callable", expressionType.publishedType().getTypeName()); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_variable_inventory_subtree") + )); + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_binding_subtree") + )); + assertFalse(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_expression_route") + )); + } + + @Test + void lambdaInContainerCallAndReturnPositionsAllResolve() throws Exception { + var analysisData = analyze("lambda_positions.gd", """ + class_name LambdaPositions + extends RefCounted + + func foo(c): + return c + + func ping(): + var arr = [func(): return 1] + var cb = foo(func(): return 2) + return func(): return 3 + """); + var pingFunction = findFunction(analysisData.unit().ast(), "ping"); + var lambdas = new ArrayList(); + collectMatchingNodes(pingFunction.body(), LambdaExpression.class, _ -> true, lambdas); + + // Interface traversal reaches lambdas in every expression position (container element, + // call argument, return value); each one is recorded, nested-resolved, and publishes + // the unparameterized Callable type. + assertEquals(3, lambdas.size()); + for (var lambda : lambdas) { + assertNotNull(analysisData.analysisData().lambdaPlans().get(lambda)); + var expressionType = analysisData.analysisData().expressionTypes().get(lambda); + assertNotNull(expressionType); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, expressionType.status()); + assertEquals("Callable", expressionType.publishedType().getTypeName()); + } + assertTrue(analysisData.diagnostics().asList().isEmpty()); + } + @Test void nestedResolvePublishesSelfCaptureWithOwningClassInstanceType() throws Exception { var analysisData = analyze("lambda_suite_self.gd", """ @@ -272,6 +373,10 @@ void propertyInitializerLambdaStaysUnrecordedAndFailClosed() throws Exception { diagnostic.category().equals("sema.unsupported_chain_route") && diagnostic.message().contains("lambda subtree") )); + // Unrecorded lambdas keep the unsupported expression route with its diagnostic (§3.3). + assertTrue(analysisData.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_expression_route") + )); } @Test diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java index dd990bbc..835561a6 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java @@ -2602,7 +2602,8 @@ static func ping(): var deferredInitializerType = analyzed.analysisData().expressionTypes().get(deferredDeclaration.value()); assertNotNull(deferredInitializerType); - assertEquals(FrontendExpressionTypeStatus.UNSUPPORTED, deferredInitializerType.status()); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, deferredInitializerType.status()); + assertEquals("Callable", deferredInitializerType.publishedType().getTypeName()); var unsupportedInitializerType = analyzed.analysisData().expressionTypes().get(unsupportedDeclaration.value()); assertNotNull(unsupportedInitializerType); @@ -2619,7 +2620,7 @@ static func ping(): analyzed, deferredUse, deferredDeclaration, - FrontendExpressionTypeStatus.UNSUPPORTED + FrontendExpressionTypeStatus.RESOLVED ) ); assertSame( @@ -2641,6 +2642,10 @@ static func ping(): ) ); + // Silent local stabilization never resolves lambda initializers (plan §3.2), so the + // `:=` slot keeps its inventory Variant even though the lambda itself resolved to + // `Callable`; refinement may only come from a non-silent write-back, which phase D + // deliberately does not add. assertEquals(GdVariantType.VARIANT, bodyScope.resolveValue("deferred_value").type()); assertEquals(GdVariantType.VARIANT, bodyScope.resolveValue("unsupported_value").type()); assertEquals(GdVariantType.VARIANT, bodyScope.resolveValue("blocked_value").type()); 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 2cb1b6c4..498419d9 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 @@ -877,7 +877,10 @@ func ping(seed = [1]): var compiled = analyzeForCompile("compile_check_skipped_surface.gd", source); var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); - assertEquals(0, compileDiagnostics.size()); + // The lambda node itself sits on the compile surface and keeps its form-level blocker + // until lowering support lands; the explicit blocks inside its body stay skipped. + assertEquals(1, compileDiagnostics.size()); + assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); var unsupportedBindingDiagnostics = diagnosticsByCategory( compiled.diagnostics(), "sema.unsupported_binding_subtree" @@ -1398,7 +1401,11 @@ func ping(seed = 1): runCompileCheck(preparedInput); - assertTrue(diagnosticsByCategory(preparedInput.analysisData().diagnostics(), "sema.compile_check").isEmpty()); + // Only the form-level lambda blocker fires; the synthetic deferred/failed facts living + // inside the lambda body stay outside the compile surface and are never scanned. + var compileDiagnostics = diagnosticsByCategory(preparedInput.analysisData().diagnostics(), "sema.compile_check"); + assertEquals(1, compileDiagnostics.size()); + assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); } @Test @@ -1861,10 +1868,12 @@ func ping(sig: Signal): ); assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); - assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); - // The recorded lambda body resolves, but the lambda expression type stays unsupported - // until the dedicated typing phase, so the connect argument remains blocked. - assertFalse(unsupportedExpressionDiagnostics.isEmpty(), compiled.diagnostics()::toString); + // The recorded lambda now publishes a resolved Callable type, so the connect argument no + // longer routes through the unsupported-expression boundary; compilation stays blocked + // only by the form-level lambda compile gate until lowering support lands (phase I). + assertEquals(1, compileDiagnostics.size(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); + assertTrue(unsupportedExpressionDiagnostics.isEmpty(), compiled.diagnostics()::toString); } @Test @@ -1933,8 +1942,11 @@ func ping(seed = pinged, handler = _handler): """; var compiled = analyzeForCompile("compile_check_signal_skipped_surface.gd", source); - assertTrue(diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check").isEmpty(), - () -> compiled.diagnostics().asList().toString()); + // The bare signal references hidden inside the lambda body / match sections stay outside + // the compile surface; the only compile blocker is the lambda node itself (phase I gate). + var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); + assertEquals(1, compileDiagnostics.size(), () -> compiled.diagnostics().asList().toString()); + assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); } @Test 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 515c5292..ad3c8a75 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 @@ -466,8 +466,7 @@ static func ping(worker): var skipped_blocked: int = self.payload var skipped_deferred: int = 1 + 2 var skipped_failed: int = Worker - var skipped_unsupported: int = func(offset: int): - return offset + var skipped_unsupported: int = Worker.VALUE """); new FrontendTypeCheckAnalyzer().analyze( @@ -532,6 +531,119 @@ static func ping(worker): ).isEmpty()); } + @Test + void analyzeChecksLambdaInitializersAgainstDeclaredSlots() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_assignment.gd", """ + class_name TypeCheckLambdaAssignment + extends RefCounted + + func ping(): + var cb: Callable = func(): pass + var bad: int = func(): pass + var inferred := func(): pass + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + var pingFunction = findFunction(preparedInput.unit().ast(), "ping"); + var callableInitializer = requireInitializerType(pingFunction.body().statements(), "cb", preparedInput); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, callableInitializer.status()); + assertEquals("Callable", callableInitializer.publishedType().getTypeName()); + assertEquals( + FrontendExpressionTypeStatus.RESOLVED, + requireInitializerType(pingFunction.body().statements(), "bad", preparedInput).status() + ); + + var typeCheckDiagnostics = diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ); + // The `Callable` slot accepts the lambda through the ordinary boundary and the `:=` slot + // packs into Variant, so only the `: int` slot reports the existing mismatch diagnostic. + assertEquals(1, typeCheckDiagnostics.size()); + var mismatch = typeCheckDiagnostics.getFirst(); + assertEquals(FrontendDiagnosticSeverity.ERROR, mismatch.severity()); + assertTrue(mismatch.message().contains("bad")); + assertTrue(mismatch.message().contains("Callable")); + assertTrue(mismatch.message().contains("int")); + } + + @Test + void analyzeWalksRecordedLambdaBodiesWithInheritedCallableContext() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_body_walk.gd", """ + class_name TypeCheckLambdaBodyWalk + extends RefCounted + + func ping(): + var cb := func(): + var bad_local: int = "text" + var nested := func(): + var inner_bad: int = 1.5 + return inner_bad + return nested + assert(true, func(): + var assert_bad: int = "msg" + return "") + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + var typeCheckDiagnostics = diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ); + // Both the outer and the nested lambda body are walked as independent callable islands, + // including the lambda carried by the assert-message position. + assertEquals(3, typeCheckDiagnostics.size(), typeCheckDiagnostics::toString); + assertTrue(typeCheckDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.message().contains("bad_local") && diagnostic.message().contains("String") + )); + assertTrue(typeCheckDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.message().contains("inner_bad") && diagnostic.message().contains("float") + )); + assertTrue(typeCheckDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.message().contains("assert_bad") && diagnostic.message().contains("String") + )); + } + + @Test + void analyzeKeepsUnrecordedLambdaBodiesOutsideTypeCheckSurface() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_unwalked.gd", """ + class_name TypeCheckLambdaUnwalked + extends RefCounted + + var prop = func(): + var hidden_bad: int = "text" + + func ping(choice): + match choice: + 0: + var in_match = func(): + var match_bad: int = "text" + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + // Property-initializer and match-section lambdas are never recorded, so their bodies have + // no published facts and type-check must not descend into them. + assertTrue(diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ).isEmpty()); + } + @Test void analyzeChecksPropertyInitializersAgainstPublishedSkeletonSlotsAndWarnsForMissingExplicitTypes() throws Exception { From e1be83c37fc4a6101a3bd5597122ad16470abc6c Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Tue, 18 Aug 2026 16:43:22 +0800 Subject: [PATCH 6/9] feat(frontend): implement lambda shell synthesis and body lowering - Materialize hidden static lambda functions with typed parameters, returns, and captures - Lower nested lambda bodies through the executable CFG and instruction pipeline - Propagate declared lambda return types through semantic analysis and lowering - Reserve the synthetic `_lambda_` namespace and fail fast on missing plans or collisions - Expand frontend tests and implementation documentation --- .../frontend/diagnostic_manager.md | 2 +- .../frontend/frontend_lambda_plan.md | 67 ++- ...ontend_lowering_cfg_pass_implementation.md | 8 +- ...d_lowering_func_pre_pass_implementation.md | 13 +- .../frontend/frontend_lowering_plan.md | 2 +- ...wering_skeleton_pre_pass_implementation.md | 6 + doc/module_impl/frontend/frontend_rules.md | 2 +- ...tend_type_check_analyzer_implementation.md | 2 +- .../lowering/FunctionLoweringContext.java | 6 +- .../pass/FrontendLoweringBodyInsnPass.java | 4 +- .../pass/FrontendLoweringBuildCfgPass.java | 18 + ...ontendLoweringFunctionPreparationPass.java | 204 ++++++++- .../frontend/sema/FrontendLambdaPlan.java | 8 + ...rontendSyntheticPropertyHelperSupport.java | 30 +- .../sema/analyzer/FrontendSuiteResolver.java | 12 + .../analyzer/FrontendTypeCheckAnalyzer.java | 11 +- .../lowering/FrontendLambdaLoweringTest.java | 388 ++++++++++++++++++ ...ndLoweringFunctionPreparationPassTest.java | 3 +- .../sema/FrontendClassSkeletonTest.java | 43 ++ .../FrontendLambdaCapturePlannerTest.java | 3 + .../sema/FrontendLambdaPlanSideTableTest.java | 3 + .../FrontendLambdaSuiteResolutionTest.java | 2 + .../FrontendTypeCheckAnalyzerTest.java | 78 ++++ 23 files changed, 877 insertions(+), 38 deletions(-) create mode 100644 src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index 224e64bf..fc2049e6 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -133,7 +133,7 @@ frontend 当前已经冻结的诊断承载方式如下: - `forIterationPlans` - `typeTestTargets` - `containerLiteralPlans` -- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充;阶段 D 起已 record lambda 的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`) +- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充;阶段 D 起已 record lambda 的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`;阶段 E 起 plan 携带 `returnType`——nested resolve 入口一次解析的声明返回类型,type-check return slot 与 lowering shell 共用) 其中: diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index 3f568e4b..b6d23daf 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,15 +9,18 @@ ## 文档状态 -- 状态:阶段 A、B、C、D 已落地;E–I 尚未实施。lambda body 已成为 supported executable +- 状态:阶段 A、B、C、D、E 已落地;F–I 尚未实施。lambda body 已成为 supported executable suite:resolver / interface / suite 解封,nested resolve 经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型已填充并与 scope 同源); 已 record lambda 的表达式类型首次发布 `RESOLVED(GdCallableType)`(silent 稳定化不解析 lambda initializer,`:=` slot 保持 inventory `Variant`),type-check 经显式 re-entry - walk 已 record lambda 的 body;compile gate 对 lambda 以形态级 `sema.compile_check` - blocker 保持 fail-closed。LIR 合成、CFG lowering、compile surface 解封仍留待后续阶段。 -- 更新时间:2026-08-17(阶段 D 落地:表达式类型 `GdCallableType` + type-check body walk - + compile gate 形态级 blocker) + walk 已 record lambda 的 body;prep pass 已按 plan 合成 hidden `_lambda_` shell + (`is_lambda` + `is_hidden` + static、capture 与 plan 同源、无注入 `self` 参数), + 并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda body 分支;compile + gate 对 lambda 以形态级 `sema.compile_check` blocker 保持 fail-closed。外层 body 的 + `construct_lambda` lowering、C runtime、compile surface 解封仍留待后续阶段。 +- 更新时间:2026-08-18(阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` + + `_lambda_` 保留前缀) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -116,11 +119,11 @@ Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 | Type-check | `FrontendTypeCheckAnalyzer.handleLambdaExpression` | **阶段 D 已翻面**:walk 已 record lambda 的 body(plan 闸门 + 继承 restriction/static) | | Compile surface | `FrontendCompileCheckAnalyzer.walkExpression` | **阶段 D 起**形态级 `sema.compile_check` blocker;阶段 I 才解封 | | Inspection | `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` | 把 `LambdaExpression` 硬编码为 deferred 祖先 | -| Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef` | -| Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | 不扫描表达式里的 lambda | +| Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef`;**阶段 E 起** `_lambda_` 前缀并入 `RESERVED_PREFIXES`,source member 复用即 `sema.class_skeleton` + skip | +| Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | **阶段 E 已翻面**:发现式 walk 可执行 body(含 lambda body 递归),按已发布 plan 合成 `_lambda_` shell + `LAMBDA_BODY` 上下文;缺 plan fail-fast | | CFG alias | `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` | `CAPTURE` fail-fast | | C codegen | `ConstructInsnGen` | 无 `CONSTRUCT_LAMBDA` case | -| DOM serialize | `DomLirSerializer` | 写空 ``,丢失 capture 描述 | +| DOM serialize | `DomLirSerializer` | **已修复**:`is_lambda` 函数序列化真实 `` 列表,`DomLirParser` 对称回读 | ### 1.4 Godot 参考(语义对齐,不复制 VM) @@ -539,7 +542,10 @@ MVP 冻结: **禁止**再写 `setStatic(false)`。 - 参数表 = lambda 源码参数(含类型);**不含** `self` 参数。 - `` = `FrontendLambdaCapturePlan` 冻结列表。 -- 返回类型 = `FrontendDeclaredTypeSupport.resolveTypeOrVariant(lambda.returnType())`。 +- 返回类型 = `FrontendDeclaredTypeSupport.resolveTypeOrVariant(lambda.returnType())` 语义, + 但在 nested resolve 入口解析**一次**并随 `FrontendLambdaPlan.returnType` 首次发布; + type-check 的 return slot 与 lowering 的 shell 返回类型都消费该已发布值,禁止两处 + 各自重复解析(避免重复 `sema.type_resolution` 告警与双源漂移)。 - 不进入 `ClassScope.defineFunction` / 不进 ClassDB bind。 - 命名不得与用户函数或 `_field_init_` / `_field_getter_` / `_field_setter_` 冲突; `_lambda_` 前缀视为 compiler-owned(若用户声明同名前缀,skeleton 发 @@ -557,6 +563,7 @@ IdentityHashMap - 合成名 `_lambda_` - 有序列表 `captures: List` - `capturesSelf: boolean` +- `returnType`(声明返回类型,nested resolve 入口一次解析并冻结) - enclosing callable AST - owning class canonical name @@ -906,6 +913,48 @@ $result = construct_lambda "" $capture1 $capture2 ... - negative path:缺 `FrontendLambdaPlan` 的 lambda 进入该 pass → fail-fast, 不静默跳过。 +**阶段 E 落地记录(2026-08-18)**: + +- `FunctionLoweringContext.Kind` 新增 `LAMBDA_BODY`:`sourceOwner` 为 + `LambdaExpression`,`loweringRoot` 为其 body `Block`。编译器穷举 switch 强制 + BuildCfg / BodyInsn 两个 pass 同时接线,不存在"静默跳过"窗口。 +- `FrontendLoweringFunctionPreparationPass`:每个 executable 上下文 + 建立后,经 `collectLambdaContexts` 发现式 walk 其 body(遇 lambda 则合成并递归进其 + body 找嵌套)。发现的 lambda 必须携带已发布 plan,否则 + `requireLambdaPlan` fail-fast(`IllegalStateException`)。property-initializer + 表达式不在扫描面内(其 lambda 未记录且带有上游 error 诊断,pipeline 在 AnalysisPass + 即停止)。 +- 合成(`synthesizeLambdaShell`)严格按 §3.7:`new LirFunctionDef(plan.syntheticName())` + → `setLambda(true)`(必须先于 `addCapture`)→ `setHidden(true)` → `setStatic(true)` + → 返回类型 = `plan.returnType()`(nested resolve 入口已由 + `resolveTypeOrVariant(lambda.returnType(), lambdaScope, ...)` 解析一次并随 plan 发布; + type-check 的 return slot 与 shell 共用该值,未知标注只告警一次)→ + 参数取自 lambda `CallableScope` 的 PARAMETER 绑定(inventory 已解析的声明类型, + 避免 lowering 二次解析产生重复告警;缺绑定即 fail-fast)→ 按 plan 顺序 + `addCapture(new LirCaptureDef(name, type, function))` → `owningClass.addFunction`。 + 不调 `ensureExecutableSelfParameter`;`plan.owningClassCanonicalName()` 与发现处 + class 不一致、或合成名与既有函数冲突(`_lambda_` 保留名规则的防御侧)均 fail-fast。 +- `FrontendLoweringBuildCfgPass`:`LAMBDA_BODY` 分支校验 `sourceOwner instanceof + LambdaExpression` 后,与 `EXECUTABLE_BODY` 共享新抽取的 + `publishExecutableBlockGraph`(Block loweringRoot 校验 + shell-only 校验 + + `buildExecutableBody` + graph/region/for-slot 发布)。 +- `FrontendLoweringBodyInsnPass`:`LAMBDA_BODY` 并入 + `EXECUTABLE_BODY, PROPERTY_INIT` 分支跑共享 `FrontendBodyLoweringSession`; + session 无 Kind 分支,`setStatic(true)` 使 `declareSelfSlotIfNeeded` 不产生 + stray `self`,`addCapture` 预登记的 capture 变量与 body 内 CAPTURE 符号读取 + (opaque `AssignInsn` 路由)衔接。 +- `FrontendSyntheticPropertyHelperSupport`:新增 `LAMBDA_FUNCTION_PREFIX = "_lambda_"` + 并入 `RESERVED_PREFIXES`;诊断消息按前缀分支出 "reserved synthetic lambda-function + prefix" 文案(property-helper 既有文案与测试锚点不变)。 +- 边界说明:外层 body 表达式树中的 `LambdaExpression` 仍由 `FrontendCfgGraphBuilder` + fail-fast(阶段 F 才建 `construct_lambda` item),因此阶段 E 的 CFG/body 测试只把 + LAMBDA_BODY 上下文送入后续 pass。 +- 测试:`FrontendLambdaLoweringTest`(7 例:嵌套合成/无 capture/self leading capture/ + 缺 plan fail-fast/合成名冲突 fail-fast/CFG 接线/body 接线); + `FrontendClassSkeletonTest.buildRejectsReservedLambdaFunctionPrefixButKeepsBoundaryNamesAlive`; + `FrontendLoweringFunctionPreparationPassTest` 的 Kind 全集断言补 `LAMBDA_BODY`。 + 全套 1216 frontend 测试 + 全项目 `test` 任务均通过。 + ### 阶段 F — CFG + `construct_lambda` lowering **目标**:外层 body 把 lambda 值 lower 成 `ConstructLambdaInsn`; diff --git a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md index d695d76e..2bd1f525 100644 --- a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md @@ -61,6 +61,9 @@ - 发布 expression-rooted frontend CFG graph - 不伪造 `Block` 或 property-init-only node kind - 复用同一套 body lowering session materialize `LirBasicBlock` / instruction +- `LAMBDA_BODY`(lambda 计划阶段 E 起) + - 复用 executable-block 构图与共享 body session materialize lambda body + - 合成 shell 的 capture 变量已由 `addCapture` 预登记,body 内 CAPTURE 读取走 opaque 符号路由 - `PARAMETER_DEFAULT_INIT` - 只保留 context kind 与模型槽位,不接入默认 pipeline @@ -462,12 +465,13 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 当前只负责: -- 消费 compile-ready `EXECUTABLE_BODY` / `PROPERTY_INIT` context +- 消费 compile-ready `EXECUTABLE_BODY` / `PROPERTY_INIT` / `LAMBDA_BODY` context - 调用 `frontend.lowering.cfg` 下的 builder - 发布 `frontendCfgGraph` - 为 executable body 发布 `frontendCfgRegions` - 为 compile-ready `for-in` 发布 `frontendForSourceIteratorSlots` 与 `frontendForIteratorStateSlots` registry - 对 property initializer 校验 `sourceOwner == property declaration`、`loweringRoot == initializer expression` +- 对 lambda body(lambda 计划阶段 E 起)校验 `sourceOwner instanceof LambdaExpression`、`loweringRoot instanceof Block`,随后与 `EXECUTABLE_BODY` 共享同一 `publishExecutableBlockGraph`(`buildExecutableBody` + graph/region/for-slot 发布);外层 body 表达式树中的 `LambdaExpression` 本身仍由 CFG builder fail-fast,等阶段 F 的 `construct_lambda` item 落地 当前不负责: @@ -493,7 +497,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 当前内部组织也已经冻结为以下形状: -- `FrontendLoweringBodyInsnPass` 本体只保留 compile-ready function context 调度;当前默认 pipeline 覆盖 executable-body 与 property-init,parameter-default 继续显式 fail-fast +- `FrontendLoweringBodyInsnPass` 本体只保留 compile-ready function context 调度;当前默认 pipeline 覆盖 executable-body、property-init 与 lambda-body(阶段 E 起并入共享 session 分支;合成 shell 的 `setStatic(true)` 保证 `declareSelfSlotIfNeeded` 不产生 stray `self`),parameter-default 继续显式 fail-fast - 真实的 per-function lowering state 收口到 `frontend.lowering.pass.body.FrontendBodyLoweringSession` - CFG node、`SequenceItem`、opaque expression root、assignment target / attribute step 都通过 `FrontendInsnLoweringProcessor` 注册表按“当前节点实际类型”动态分派 - `FrontendInsnLoweringProcessor` 现在还显式返回 lowering 结束后的当前 continuation block;`SequenceNode` 会把这个 block 一路传给后续 item,因此 writable-route runtime gate 生成的 synthetic `apply/skip/continue` blocks 不会把后续 lowering 误挂回原始 node entry block diff --git a/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md index 7aa50932..6bbf194f 100644 --- a/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md @@ -33,10 +33,11 @@ function pre-pass 是 `FrontendLoweringAnalysisPass` 与 `FrontendLoweringClassSkeletonPass` 之后的第三个固定 lowering pass。它只负责把 compile-ready 的 function-shaped lowering 单元整理成统一 scaffold,供后续 CFG/body lowering 继续消费。 -当前稳定覆盖的 lowering 单元只有两类: +当前稳定覆盖的 lowering 单元有三类: - `EXECUTABLE_BODY` - `PROPERTY_INIT` +- `LAMBDA_BODY`(lambda 计划阶段 E 起:每个已发布 `FrontendLambdaPlan` 的 lambda 合成 hidden `_lambda_` shell 并发布对应 context) `PARAMETER_DEFAULT_INIT` 当前只保留模型槽位与合同,不实际收集。 @@ -80,12 +81,14 @@ pre-pass 不新增新的 public lowering 入口,也不接受 `FrontendAnalysis - `EXECUTABLE_BODY` - `PROPERTY_INIT` - `PARAMETER_DEFAULT_INIT` +- `LAMBDA_BODY` 冻结边界: - executable callable 使用 declaration-level owner,lowering root 是 body `Block` - property initializer 使用 property declaration 作为 owner,lowering root 是 initializer expression - future parameter default 使用 parameter/default declaration 作为 owner,lowering root 只指向 default-value expression +- lambda 使用 `LambdaExpression` 作为 owner,lowering root 是其 body `Block`;target function 是本 pass 按 plan 合成的 hidden shell(`is_lambda` + `is_hidden` + static),self 仅以 §3.5 capture 存在,不得注入 `self` 参数 - 后续 pass 必须统一经由 `FunctionLoweringContext.analysisData()` 读取: - `scopesByAst()` - `symbolBindings()` @@ -112,7 +115,8 @@ pre-pass 不新增新的 public lowering 入口,也不接受 `FrontendAnalysis - 为 supported property initializer 发布 `PROPERTY_INIT` context - 在 `LirPropertyDef.initFunc` 缺失时补 `_field_init_` hidden synthetic function shell - 当 `LirPropertyDef.initFunc` 已预先指向 synthetic shell 时,只在 shell 仍满足 property-signature 与 shell-only 合同时复用 -- `_field_init_` / `_field_getter_` / `_field_setter_` helper namespace 属于 compiler-owned synthetic surface;source member 若以这些前缀开头,必须早于本 pass 在 skeleton/scope 恢复边界被诊断并跳过 +- `_field_init_` / `_field_getter_` / `_field_setter_` / `_lambda_` namespace 属于 compiler-owned synthetic surface;source member 若以这些前缀开头,必须早于本 pass 在 skeleton/scope 恢复边界被诊断并跳过 +- 发现式 walk 每个 executable body(含 lambda body 递归)中的 `LambdaExpression`,按已发布 `FrontendLambdaPlan` 合成 hidden `_lambda_` shell 并发布 `LAMBDA_BODY` context;发现的 lambda 缺 plan、plan 的 owning class 与发现处不一致、或合成名与既有函数冲突时 fail-fast `FrontendLoweringFunctionPreparationPass` 明确不负责: @@ -223,8 +227,9 @@ function pre-pass 结束后,`LirModule` 仍必须保持 shell-only 中间态 - 在 owning class 上追加 hidden property-init synthetic shell - 对应 property 的 `initFunc` 指向该 shell +- 按已发布 `FrontendLambdaPlan` 在 owning class 上追加 hidden synthesized lambda shell(lambda 计划阶段 E 起,`is_lambda` + `is_hidden` + static,capture 列表与 plan 同源) -这条 shell-only 约束只适用于 pre-pass 产物,不得外推到默认 lowering pipeline 终态;后续 CFG/body pass 会继续把 executable body 与 supported property initializer 写成真实函数体。 +这条 shell-only 约束只适用于 pre-pass 产物,不得外推到默认 lowering pipeline 终态;后续 CFG/body pass 会继续把 executable body、supported property initializer 与 lambda body 写成真实函数体。 当前 no-initializer property 的边界也固定为: @@ -238,6 +243,7 @@ function pre-pass 结束后,`LirModule` 仍必须保持 shell-only 中间态 涉及 function pre-pass 合同的改动,至少要继续覆盖以下回归锚点: - compile-ready executable/property-init context 发布 +- lambda shell 合成与 `LAMBDA_BODY` context 发布(capture 列表与 plan 同源、无注入 `self` 参数) - context 总数与 kind 分布 - executable callable 对应 owning class / target function / constructor `_init` - property initializer 对应 owning class / property / target init function @@ -253,6 +259,7 @@ negative path 至少要锚定: - 缺失 published fact 时 fail-fast - shell-only contract 被破坏时 fail-fast +- 发现的 lambda 缺已发布 `FrontendLambdaPlan`、plan owning class 与发现处不一致、或合成名与既有函数冲突时 fail-fast,不得静默跳过或覆盖 - 不得把 pre-pass 的 shell-only 中间态误写成整个 lowering pipeline 的最终合同 - 不存在 silent skip 掩盖协议破坏 diff --git a/doc/module_impl/frontend/frontend_lowering_plan.md b/doc/module_impl/frontend/frontend_lowering_plan.md index 34f08bf2..00dd0d8a 100644 --- a/doc/module_impl/frontend/frontend_lowering_plan.md +++ b/doc/module_impl/frontend/frontend_lowering_plan.md @@ -118,7 +118,7 @@ - `for` 的 `OBJECT_CUSTOM`(Object `_iter_*`);其余已注册 route 已闭环,见 `frontend_for_range_loop_implementation.md` - `match` -- `lambda` +- 外层 body 表达式树中的 `LambdaExpression`(lambda 计划阶段 F 的 `construct_lambda` item;lambda **body** 的 CFG/body lowering 已于阶段 E 经 `LAMBDA_BODY` context 接入共享管线) - `assert` - `ConditionalExpression` diff --git a/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md index 203f4c53..8977a5cd 100644 --- a/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md @@ -157,11 +157,13 @@ void run(FrontendLoweringContext context) - `EXECUTABLE_BODY` - `PROPERTY_INIT` - `PARAMETER_DEFAULT_INIT` +- `LAMBDA_BODY` 当前实现已实际发布: - `EXECUTABLE_BODY` - `PROPERTY_INIT` +- `LAMBDA_BODY`(lambda 计划阶段 E 起:prep pass 按已发布 `FrontendLambdaPlan` 合成 hidden `_lambda_` shell) `PARAMETER_DEFAULT_INIT` 当前只冻结模型,不实际收集。 @@ -253,6 +255,10 @@ void run(FrontendLoweringContext context) - `initFunc` 为空时当前兼容命名基线为 `_field_init_` - 若 `initFunc` 已存在,对应 shell 必须保持 hidden、property-compatible 且无 body,才能继续复用 - shell 仍无 basic block / `entryBlockId` +- 同一 pass 允许按已发布 `FrontendLambdaPlan` 追加 hidden synthesized lambda shell(lambda 计划阶段 E 起): + - 名称来自 plan 的 `_lambda_`,`is_lambda` + `is_hidden` + static 同时为真 + - 参数为源码参数(无注入 `self`),`` 与 plan 同源,shell 仍无 basic block / `entryBlockId` + - 发现的 lambda 缺 plan、owning class 不一致或合成名冲突时 fail-fast - 所有 `LirFunctionDef` 当前都保持 shell-only 状态: - `basicBlockCount == 0` - `entryBlockId` 为空 diff --git a/doc/module_impl/frontend/frontend_rules.md b/doc/module_impl/frontend/frontend_rules.md index 2db60242..dfef34d0 100644 --- a/doc/module_impl/frontend/frontend_rules.md +++ b/doc/module_impl/frontend/frontend_rules.md @@ -35,7 +35,7 @@ - 普通非-virtual 方法缺失显式类型时,现有 `resolveTypeOrVariant(...) -> Variant` fallback 合同保持不变;只有命中 engine virtual 的 override 路径才额外 fail-closed。 - `break` / `continue` 的位置合法性属于 shared semantic contract;`FrontendLoopControlFlowAnalyzer` 必须在进入 compile-only gate 前就对非法 loop control 发出 `sema.loop_control_flow`,lowering 中的 loop-frame fail-fast 只能保留为实现不变量保护。 - `for-in` 的可迭代性属于 shared type-check contract:`FrontendForLoopSupport.classifyIterableSemantics(...)` 是 plan 构造与诊断的统一分类真源。静态已知不可迭代的 hard type 必须由 `FrontendTypeCheckAnalyzer` 在 iterable expression 上发 `sema.type_check` `Unable to iterate on value of type "X"`;`Variant`、`Object` 与静态未知类型保持 runtime-open,不得误报。该诊断不得阻断 plan 发布或 for body 遍历;iterable 已有 upstream 不稳定事实时不得重复发错。 -- `_field_init_`、`_field_getter_`、`_field_setter_` 是 compiler-owned synthetic property helper 前缀;source class member 一旦以这些前缀开头,skeleton phase 必须发出清晰的 `sema.class_skeleton` 并跳过该 member subtree,而不是等到 lowering/backend 再因 helper 名冲突抛异常。 +- `_field_init_`、`_field_getter_`、`_field_setter_` 是 compiler-owned synthetic property helper 前缀;`_lambda_` 是 compiler-owned synthesized lambda function 前缀(lambda 计划 §3.7,阶段 E 起并入 `RESERVED_PREFIXES`)。source class member 一旦以这些前缀开头,skeleton phase 必须发出清晰的 `sema.class_skeleton` 并跳过该 member subtree,而不是等到 lowering/backend 再因 helper 名冲突抛异常。 - GDCC class 新声明的 signal 不得覆盖 inherited engine/native signal;skeleton 必须发 `sema.class_skeleton` 并跳过该 `SignalStatement`。inherited GDCC signal 的 nearest-child shadow 保持允许,不得回退 `ScopeSignalResolver` 合同。 - `FrontendCompileCheckAnalyzer` 只能挂在 compile-only 入口上;默认共享 `FrontendSemanticAnalyzer.analyze(...)`、inspection 与未来 LSP 入口不得隐式附带 compile-only gate。 - compile-only gate 只允许扫描未来 lowering 会消费的 compile surface:supported executable body 与 supported property initializer island;不得重新深入 parameter default、lambda、`match`、block-local `const` 或 skipped subtree。`ForStatement` 已进入 shared semantic,compile-only gate 按 route-aware policy 处理:`ForLoweringContractRegistry` 中已注册的 route 放行并进入 body 重扫 semantic facts,未注册 contract 的 route(当前仅 `OBJECT_CUSTOM`)在 statement root 发 route-not-ready blocker;已注册 route 的 CFG/body lowering 已落地。 diff --git a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md index 4c27df55..917f75d9 100644 --- a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md @@ -34,7 +34,7 @@ - 不在这里重做表达式求值、binding、member/call 解析或 scope 构建 - 不在这里补 suite merge、missing-return、all-path return exhaustiveness 分析 - 不在这里转正 `match`、parameter default、block-local `const`、class `const` 的正式 body 语义 - - `lambda` body 遍历已于 lambda 计划阶段 D 落地:已 record lambda 经 `scanNestedLambdaBodies` 显式 re-entry 由 `handleLambdaExpression` walk body(以 `lambdaPlans()` 存在性为闸门,继承 enclosing callable 的 restriction/static context,return slot 暂为 `Variant`——声明返回类型的检查随阶段 E 合成函数落地);未记录 lambda(property initializer / match / parameter default)保持 fail-closed,不进入 body + - `lambda` body 遍历已于 lambda 计划阶段 D 落地:已 record lambda 经 `scanNestedLambdaBodies` 显式 re-entry 由 `handleLambdaExpression` walk body(以 `lambdaPlans()` 存在性为闸门,继承 enclosing callable 的 restriction/static context);阶段 E 起 return slot 为 `FrontendLambdaPlan.returnType()`(nested resolve 入口一次解析的声明返回类型),return 不匹配或声明非 void/Variant 下的 bare `return` 走普通 `sema.type_check` 诊断;未记录 lambda(property initializer / match / parameter default)保持 fail-closed,不进入 body - 不在这里实现或发布 `FrontendForIterationPlan`(它由 for-iteration resolution owner 发布);type-check 只消费已发布 plan 做 route-aware header 校验(range arity / argument int slot / 显式 iterator element conversion)并遍历 for body,不重新推导 iterable route - 不在这里实现 property-side inference/backfill,也不在 type-check analyzer 内维护平行 implicit conversion 规则;`int -> float`、同维度 `Vector*i -> Vector*` 与 `StringName` / `String` 互转只通过 `frontend_implicit_conversion_matrix.md` 与 shared boundary helper 生效 - 不在这里实现 frontend -> LIR 的 truthiness lowering 或 `@onready` 的 runtime / ready-time 语义 diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java b/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java index 205bb85a..dbd62912 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java @@ -286,7 +286,11 @@ public void publishForIteratorStateSlot( public enum Kind { EXECUTABLE_BODY, PROPERTY_INIT, - PARAMETER_DEFAULT_INIT + PARAMETER_DEFAULT_INIT, + /// Synthesized hidden lambda shell (`_lambda_`): `sourceOwner` is the `LambdaExpression` + /// and `loweringRoot` is its body `Block`. The body lowers through the same executable-body + /// pipeline, but self stays a §3.5 capture instead of an injected parameter. + LAMBDA_BODY } private static @NotNull String describeCfgAstNode(@NotNull Node astNode) { diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java index e629bfb4..328b2fec 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java @@ -19,7 +19,9 @@ public void run(@NotNull FrontendLoweringContext context) { throw new IllegalStateException("Function lowering context must reuse the published analysis snapshot"); } switch (functionContext.kind()) { - case EXECUTABLE_BODY, PROPERTY_INIT -> + // Lambda bodies share the executable-body session: the synthetic shell is static + // (no self slot) and carries its own published CFG graph (lambda plan phase E). + case EXECUTABLE_BODY, PROPERTY_INIT, LAMBDA_BODY -> new FrontendBodyLoweringSession(functionContext, context.classRegistry()).run(); case PARAMETER_DEFAULT_INIT -> throw new IllegalStateException( "Frontend body lowering pass does not support parameter default initializer contexts yet" diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java index e396f842..a25ebc2c 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java @@ -9,6 +9,7 @@ import dev.superice.gdparser.frontend.ast.ConstructorDeclaration; import dev.superice.gdparser.frontend.ast.Expression; import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.VariableDeclaration; import org.jetbrains.annotations.NotNull; @@ -31,6 +32,7 @@ public void run(@NotNull FrontendLoweringContext context) { validateTargetFunctionMembership(functionContext, lirModule); switch (functionContext.kind()) { case EXECUTABLE_BODY -> publishStraightLineExecutableGraph(functionContext); + case LAMBDA_BODY -> publishLambdaBodyGraph(functionContext); case PROPERTY_INIT -> publishPropertyInitializerGraph(functionContext); case PARAMETER_DEFAULT_INIT -> throw new IllegalStateException( "Frontend CFG build pass does not support parameter default initializer contexts yet" @@ -44,6 +46,22 @@ private void publishStraightLineExecutableGraph(@NotNull FunctionLoweringContext && !(functionContext.sourceOwner() instanceof ConstructorDeclaration)) { throw new IllegalStateException(describeContext(functionContext) + " must keep a callable declaration as sourceOwner"); } + publishExecutableBlockGraph(functionContext); + } + + /// Lambda bodies reuse the executable-block CFG build verbatim (lambda plan phase E): the + /// lowering root is the lambda's own `Block`, captures/parameters are ordinary published scope + /// facts by this point, and the synthetic shell stays the graph's only owner. + private void publishLambdaBodyGraph(@NotNull FunctionLoweringContext functionContext) { + if (!(functionContext.sourceOwner() instanceof LambdaExpression)) { + throw new IllegalStateException( + describeContext(functionContext) + " must keep a lambda expression as sourceOwner" + ); + } + publishExecutableBlockGraph(functionContext); + } + + private void publishExecutableBlockGraph(@NotNull FunctionLoweringContext functionContext) { if (!(functionContext.loweringRoot() instanceof Block rootBlock)) { throw new IllegalStateException(describeContext(functionContext) + " must expose a Block loweringRoot"); } diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java index 9f368597..c2a7a2c9 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java @@ -3,19 +3,24 @@ import gd.script.gdcc.frontend.lowering.FrontendLoweringContext; import gd.script.gdcc.frontend.lowering.FrontendLoweringPass; import gd.script.gdcc.frontend.lowering.FunctionLoweringContext; +import gd.script.gdcc.frontend.scope.CallableScope; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.FrontendSourceClassRelation; import gd.script.gdcc.frontend.sema.analyzer.support.FrontendPropertyInitializerSupport; +import gd.script.gdcc.lir.LirCaptureDef; import gd.script.gdcc.lir.LirClassDef; import gd.script.gdcc.lir.LirFunctionDef; import gd.script.gdcc.lir.LirModule; import gd.script.gdcc.lir.LirParameterDef; import gd.script.gdcc.lir.LirPropertyDef; +import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdObjectType; import dev.superice.gdparser.frontend.ast.ClassDeclaration; import dev.superice.gdparser.frontend.ast.ConstructorDeclaration; import dev.superice.gdparser.frontend.ast.DeclarationKind; import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.Statement; import dev.superice.gdparser.frontend.ast.VariableDeclaration; @@ -32,8 +37,10 @@ /// The pass keeps the current LIR in skeleton/shell form: /// - executable callables reuse their published `LirFunctionDef` /// - property initializers get hidden synthetic helper scaffolds +/// - recorded lambdas get hidden synthesized `_lambda_` shells from their published +/// `FrontendLambdaPlan` (lambda plan phase E / §3.7) /// - no basic blocks or instructions are emitted yet; later CFG/body passes materialize the default -/// pipeline's executable callable and property-init bodies +/// pipeline's executable callable, property-init and lambda bodies public final class FrontendLoweringFunctionPreparationPass implements FrontendLoweringPass { /// Compiler-owned helper namespace. Source members that start with this prefix must already have /// been rejected by skeleton-driven skipped-subtree recovery before preparation runs. @@ -99,10 +106,36 @@ private void visitStatements( ) { for (var statement : statements) { switch (statement) { - case FunctionDeclaration functionDeclaration -> - contexts.add(buildExecutableContext(sourceClassRelation, owningClass, functionDeclaration, analysisData)); - case ConstructorDeclaration constructorDeclaration -> - contexts.add(buildExecutableContext(sourceClassRelation, owningClass, constructorDeclaration, analysisData)); + case FunctionDeclaration functionDeclaration -> { + contexts.add(buildExecutableContext( + sourceClassRelation, + owningClass, + functionDeclaration, + analysisData + )); + collectLambdaContexts( + functionDeclaration.body(), + sourceClassRelation, + owningClass, + analysisData, + contexts + ); + } + case ConstructorDeclaration constructorDeclaration -> { + contexts.add(buildExecutableContext( + sourceClassRelation, + owningClass, + constructorDeclaration, + analysisData + )); + collectLambdaContexts( + constructorDeclaration.body(), + sourceClassRelation, + owningClass, + analysisData, + contexts + ); + } case VariableDeclaration variableDeclaration -> { var propertyInitContext = buildPropertyInitContextOrNull( sourceClassRelation, @@ -216,6 +249,167 @@ private FunctionLoweringContext buildPropertyInitContextOrNull( ); } + /// Discovers every `LambdaExpression` reachable from a supported executable body and appends a + /// synthesized `LAMBDA_BODY` context for each one (lambda plan phase E). Discovered lambdas are + /// exactly the ones the interface phase records, so each must carry a published + /// `FrontendLambdaPlan`; a missing plan means published-fact corruption and fails fast instead + /// of silently skipping the lambda (plan §阶段E negative / §3.9). Nested lambdas are found by + /// recursing into each discovered lambda body. Property-initializer expressions stay outside + /// this scan surface: their lambdas are unrecorded and already carry upstream error + /// diagnostics, so the pipeline stops before preparation. + private void collectLambdaContexts( + @NotNull Node root, + @NotNull FrontendSourceClassRelation sourceClassRelation, + @NotNull LirClassDef owningClass, + @NotNull FrontendAnalysisData analysisData, + @NotNull List contexts + ) { + for (var child : root.getChildren()) { + if (child instanceof LambdaExpression lambdaExpression) { + contexts.add(buildLambdaContext( + sourceClassRelation, + owningClass, + lambdaExpression, + analysisData + )); + collectLambdaContexts( + lambdaExpression.body(), + sourceClassRelation, + owningClass, + analysisData, + contexts + ); + continue; + } + collectLambdaContexts( + child, + sourceClassRelation, + owningClass, + analysisData, + contexts + ); + } + } + + private @NotNull FunctionLoweringContext buildLambdaContext( + @NotNull FrontendSourceClassRelation sourceClassRelation, + @NotNull LirClassDef owningClass, + @NotNull LambdaExpression lambdaExpression, + @NotNull FrontendAnalysisData analysisData + ) { + requirePublishedScope(lambdaExpression.body(), "lambda body", analysisData); + var plan = requireLambdaPlan(lambdaExpression, owningClass, analysisData); + if (!plan.owningClassCanonicalName().equals(owningClass.getName())) { + throw new IllegalStateException( + "Lambda plan '" + + plan.syntheticName() + + "' belongs to class '" + + plan.owningClassCanonicalName() + + "' but was discovered while preparing '" + + owningClass.getName() + + "'" + ); + } + var lambdaScope = requireLambdaCallableScope(lambdaExpression, analysisData); + var targetFunction = synthesizeLambdaShell( + owningClass, + lambdaExpression, + lambdaScope, + plan + ); + return new FunctionLoweringContext( + FunctionLoweringContext.Kind.LAMBDA_BODY, + sourceClassRelation.unit().path(), + sourceClassRelation, + owningClass, + targetFunction, + lambdaExpression, + lambdaExpression.body(), + analysisData + ); + } + + private @NotNull FrontendLambdaPlan requireLambdaPlan( + @NotNull LambdaExpression lambdaExpression, + @NotNull LirClassDef owningClass, + @NotNull FrontendAnalysisData analysisData + ) { + var plan = analysisData.lambdaPlans().get(lambdaExpression); + if (plan == null) { + throw new IllegalStateException( + "lambdaPlans() is missing a published FrontendLambdaPlan for LambdaExpression at " + + lambdaExpression.range() + + " while preparing class '" + + owningClass.getName() + + "'; recorded lambdas must publish a complete plan before lowering" + ); + } + return plan; + } + + private @NotNull CallableScope requireLambdaCallableScope( + @NotNull LambdaExpression lambdaExpression, + @NotNull FrontendAnalysisData analysisData + ) { + var scope = analysisData.scopesByAst().get(lambdaExpression); + if (!(scope instanceof CallableScope callableScope)) { + throw new IllegalStateException( + "lambda expression scope has not been published for LambdaExpression@" + + System.identityHashCode(lambdaExpression) + ); + } + return callableScope; + } + + /// Materializes the hidden lambda shell frozen by lambda plan §3.7: + /// `setLambda(true)` + `setHidden(true)` + `setStatic(true)`, source parameters with their + /// inventory-resolved types (no injected `self`; self only ever arrives as a §3.5 capture), + /// `` from the published plan, and the declared return type published on the plan. + /// `setLambda(true)` must precede `addCapture` because captures are only legal on lambda + /// functions. + private @NotNull LirFunctionDef synthesizeLambdaShell( + @NotNull LirClassDef owningClass, + @NotNull LambdaExpression lambdaExpression, + @NotNull CallableScope lambdaScope, + @NotNull FrontendLambdaPlan plan + ) { + if (owningClass.hasFunction(plan.syntheticName())) { + throw new IllegalStateException( + "Class '" + + owningClass.getName() + + "' already declares a function named '" + + plan.syntheticName() + + "'; source members must be rejected by the reserved '_lambda_' prefix" + ); + } + var function = new LirFunctionDef(plan.syntheticName()); + function.setLambda(true); + function.setHidden(true); + function.setStatic(true); + function.setReturnType(plan.returnType()); + for (var parameter : lambdaExpression.parameters()) { + var parameterName = parameter.name().trim(); + var binding = lambdaScope.resolveValueHere(parameterName); + if (binding == null || binding.kind() != ScopeValueKind.PARAMETER) { + throw new IllegalStateException( + "Lambda parameter '" + + parameterName + + "' has no published PARAMETER binding on its CallableScope; " + + "variable inventory must bind every planned lambda parameter" + ); + } + function.addParameter(new LirParameterDef(parameterName, binding.type(), null, function)); + if (parameter.variadic()) { + function.setVararg(true); + } + } + for (var capture : plan.captures()) { + function.addCapture(new LirCaptureDef(capture.name(), capture.type(), function)); + } + owningClass.addFunction(function); + return function; + } + private void requirePublishedScope( @NotNull Node astNode, @NotNull String role, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java index 01667899..e55d953e 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java @@ -2,6 +2,7 @@ import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; +import gd.script.gdcc.type.GdType; import org.jetbrains.annotations.NotNull; import java.util.List; @@ -15,6 +16,10 @@ /// @param lambda AST identity of the lambda expression /// @param syntheticName compiler-owned function name, conventionally `_lambda_` /// @param capturePlan ordered captures and `capturesSelf` +/// @param returnType declared return type resolved once at nested-resolve entry +/// (`resolveTypeOrVariant` semantics); consumed identically by +/// type-check (return slot) and lowering (shell return type) so the +/// two cannot drift /// @param enclosingCallable nearest non-lambda callable AST (`FunctionDeclaration` / /// `ConstructorDeclaration`); identity, not a reconstructed node /// @param owningClassCanonicalName canonical name of the owning `LirClassDef` @@ -22,6 +27,7 @@ public record FrontendLambdaPlan( @NotNull LambdaExpression lambda, @NotNull String syntheticName, @NotNull FrontendLambdaCapturePlan capturePlan, + @NotNull GdType returnType, @NotNull Node enclosingCallable, @NotNull String owningClassCanonicalName ) { @@ -32,6 +38,7 @@ public record FrontendLambdaPlan( throw new IllegalArgumentException("syntheticName must not be blank"); } Objects.requireNonNull(capturePlan, "capturePlan must not be null"); + Objects.requireNonNull(returnType, "returnType must not be null"); Objects.requireNonNull(enclosingCallable, "enclosingCallable must not be null"); Objects.requireNonNull(owningClassCanonicalName, "owningClassCanonicalName must not be null"); if (owningClassCanonicalName.isBlank()) { @@ -54,6 +61,7 @@ public static boolean samePlan(@NotNull FrontendLambdaPlan first, @NotNull Front Objects.requireNonNull(second, "second must not be null"); return first.syntheticName().equals(second.syntheticName()) && FrontendLambdaCapturePlan.samePlan(first.capturePlan(), second.capturePlan()) + && FrontendAnalysisData.sameType(first.returnType(), second.returnType()) && first.enclosingCallable() == second.enclosingCallable() && first.owningClassCanonicalName().equals(second.owningClassCanonicalName()); } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java index 5b377809..b00cfc4e 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java @@ -6,19 +6,23 @@ import java.util.List; import java.util.Objects; -/// Shared frontend contract for compiler-owned synthetic property helper names. +/// Shared frontend contract for compiler-owned synthetic member names. /// /// These prefixes are reserved because later lowering/backend phases materialize hidden helper -/// functions under the same namespace for property init/getter/setter support. Source members that -/// reuse them must be rejected before lowering starts. +/// functions under the same namespace for property init/getter/setter support and synthesized +/// lambda shells. Source members that reuse them must be rejected before lowering starts. public final class FrontendSyntheticPropertyHelperSupport { public static final @NotNull String PROPERTY_INIT_PREFIX = "_field_init_"; public static final @NotNull String PROPERTY_GETTER_PREFIX = "_field_getter_"; public static final @NotNull String PROPERTY_SETTER_PREFIX = "_field_setter_"; + /// Compiler-owned namespace for synthesized lambda shells (lambda plan §3.7). Source members + /// reusing it collide with the hidden `LirFunctionDef` materialized per `LambdaExpression`. + public static final @NotNull String LAMBDA_FUNCTION_PREFIX = "_lambda_"; public static final @NotNull List RESERVED_PREFIXES = List.of( PROPERTY_INIT_PREFIX, PROPERTY_GETTER_PREFIX, - PROPERTY_SETTER_PREFIX + PROPERTY_SETTER_PREFIX, + LAMBDA_FUNCTION_PREFIX ); private FrontendSyntheticPropertyHelperSupport() { @@ -39,13 +43,25 @@ private FrontendSyntheticPropertyHelperSupport() { @NotNull String memberName, @NotNull String matchedPrefix ) { + var trimmedName = Objects.requireNonNull(memberName, "memberName must not be null").trim(); + var prefix = Objects.requireNonNull(matchedPrefix, "matchedPrefix must not be null"); + if (prefix.equals(LAMBDA_FUNCTION_PREFIX)) { + return Objects.requireNonNull(memberKind, "memberKind must not be null") + + " '" + + trimmedName + + "' uses reserved synthetic lambda-function prefix '" + + prefix + + "' and will be skipped; the '" + + LAMBDA_FUNCTION_PREFIX + + "' prefix is compiler-owned for synthesized lambda functions"; + } return Objects.requireNonNull(memberKind, "memberKind must not be null") + " '" - + Objects.requireNonNull(memberName, "memberName must not be null").trim() + + trimmedName + "' uses reserved synthetic property-helper prefix '" - + Objects.requireNonNull(matchedPrefix, "matchedPrefix must not be null") + + prefix + "' and will be skipped; prefixes " - + String.join(", ", RESERVED_PREFIXES) + + String.join(", ", List.of(PROPERTY_INIT_PREFIX, PROPERTY_GETTER_PREFIX, PROPERTY_SETTER_PREFIX)) + " are compiler-owned for synthetic property init/getter/setter helpers"; } } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java index 4e209ff0..b40c1759 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java @@ -14,6 +14,7 @@ import gd.script.gdcc.frontend.scope.CallableScopeKind; import gd.script.gdcc.frontend.sema.FrontendAnalysisData; import gd.script.gdcc.frontend.sema.FrontendBodyStructuralCompleteness; +import gd.script.gdcc.frontend.sema.FrontendDeclaredTypeSupport; import gd.script.gdcc.frontend.sema.FrontendInterfaceSurface; import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlan; import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; @@ -253,10 +254,21 @@ private void fillAndPublishLambdaPlan( lambdaScope.resetCaptureType(entry.name(), declaration, entry.type()); captures.add(entry); } + // The declared return type resolves exactly once here, at nested-resolve entry: type-check + // (return slot) and lowering (shell return type) both consume the published plan value, so + // an unknown annotation warns once and the two consumers can never drift (plan §3.7). + var returnType = FrontendDeclaredTypeSupport.resolveTypeOrVariant( + lambda.returnType(), + lambdaScope, + analysisData.moduleSkeleton().topLevelCanonicalNameMap(), + context.sourcePath(), + context.diagnosticManager() + ); var plan = new FrontendLambdaPlan( lambda, nextLambdaSyntheticName(owningClass.getName()), FrontendLambdaCapturePlan.of(captures), + returnType, enclosingCallable, owningClass.getName() ); diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java index 936bc3bf..951e24a6 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java @@ -1175,10 +1175,11 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara /// Recorded lambdas re-enter here from `scanNestedLambdaBodies`: the body already resolved /// through its nested suite, so type-check walks it as an independent callable island that /// inherits the enclosing callable's restriction/static context (plan §3.2). The return - /// slot stays `Variant` until the synthetic-function phase resolves the declared return - /// type, so `return expr` stays compatible via the ordinary pack boundary and bare - /// `return` remains legal. Unrecorded lambdas (property initializer / parameter default / - /// skipped subtrees) publish no plan and no body facts, so they stay fail-closed. + /// slot is the declared return type published on the `FrontendLambdaPlan` (phase E), so a + /// mismatched or bare `return` gets the ordinary `sema.type_check` diagnostics instead of + /// drifting against the synthesized shell's return boundary downstream. Unrecorded lambdas + /// (property initializer / parameter default / skipped subtrees) publish no plan and no + /// body facts, so they stay fail-closed. @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { if (isNotPublished(lambdaExpression) @@ -1189,7 +1190,7 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara walkCallableBody( lambdaExpression, lambdaExpression.body(), - GdVariantType.VARIANT, + Objects.requireNonNull(analysisData.lambdaPlans().get(lambdaExpression)).returnType(), currentRestriction, currentStaticContext ); diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java new file mode 100644 index 00000000..e11cbf31 --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java @@ -0,0 +1,388 @@ +package gd.script.gdcc.frontend.lowering; + +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Node; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringBodyInsnPass; +import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringBuildCfgPass; +import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringClassSkeletonPass; +import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringFunctionPreparationPass; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.FrontendSourceUnit; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.frontend.sema.FrontendAnalysisData; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; +import gd.script.gdcc.frontend.sema.analyzer.FrontendSemanticAnalyzer; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.lir.LirFunctionDef; +import gd.script.gdcc.lir.LirInstruction; +import gd.script.gdcc.lir.insn.ReturnInsn; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.type.GdObjectType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase E lambda shell-synthesis contract tests (frontend_lambda_plan.md §阶段E / §3.7). +/// +/// The compile gate still blocks lambdas in `analyzeForCompile` until phase I, so these tests run +/// the shared `analyze()` pipeline and then drive the lowering passes manually — the same recipe +/// `FrontendLoweringFunctionPreparationPassTest` uses for non-lambda modules. Assertions anchor both +/// directions: published plans materialize hidden `is_lambda` shells with faithful captures, while +/// missing plans or name collisions fail fast instead of silently skipping synthesis. +final class FrontendLambdaLoweringTest { + + /// 验收 happy path:一个含两层嵌套 lambda 的函数在 class 上得到两个 hidden `is_lambda` 函数, + /// capture 列表与已发布 plan 一致(外层按 §3.4 规则 5/9 传递 `seed`)。 + @Test + void synthesizesHiddenLambdaShellsForNestedLambdas() throws Exception { + var prepared = analyzeAndSkeleton("lambda_shell_nested.gd", """ + class_name LambdaShellNested + extends RefCounted + + func ping(): + var seed := 40 + var outer := func(): + var inner := func(): + return seed + 2 + return inner + return outer + """); + + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + + var contexts = prepared.context().requireFunctionLoweringContexts(); + assertEquals(1, contexts.stream().filter(context -> + context.kind() == FunctionLoweringContext.Kind.EXECUTABLE_BODY + ).count()); + var lambdaContexts = requireLambdaContexts(prepared.context(), 2); + var lambdaFunctions = lambdaFunctions(requireClass(prepared, "LambdaShellNested"), 2); + + for (var lambdaContext : lambdaContexts) { + var lambda = assertInstanceOf(LambdaExpression.class, lambdaContext.sourceOwner()); + assertSame(lambda.body(), lambdaContext.loweringRoot()); + var plan = requirePlan(prepared.analysisData(), lambda); + var shell = lambdaContext.targetFunction(); + assertSame(shell, lambdaFunctions.stream() + .filter(function -> function.getName().equals(plan.syntheticName())) + .findFirst() + .orElseThrow()); + assertLambdaShellShape(shell, plan); + // Both layers transit-capture `seed` with its declaration-site stabilized type (int). + assertEquals(1, shell.getCaptureCount()); + var capture = shell.getCapture("seed"); + assertNotNull(capture); + assertEquals("int", capture.type().getTypeName()); + } + } + + /// 验收 happy path:无 capture 的 lambda 没有 `` 条目,`getCaptureCount() == 0`, + /// 参数表保持源码形状(含类型、无注入 `self`),返回类型来自声明标注。 + @Test + void synthesizesLambdaShellWithoutCapturesOrSelfParameter() throws Exception { + var prepared = analyzeAndSkeleton("lambda_shell_plain.gd", """ + class_name LambdaShellPlain + extends RefCounted + + func ping(): + var cb := func(x: int) -> int: + return x + 1 + return cb + """); + + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + + var lambdaContext = requireLambdaContexts(prepared.context(), 1).getFirst(); + var lambda = assertInstanceOf(LambdaExpression.class, lambdaContext.sourceOwner()); + var plan = requirePlan(prepared.analysisData(), lambda); + var shell = lambdaContext.targetFunction(); + + assertLambdaShellShape(shell, plan); + assertEquals(0, shell.getCaptureCount()); + assertTrue(shell.getCaptures().isEmpty()); + assertEquals(1, shell.getParameterCount()); + assertEquals("x", shell.getParameter(0).name()); + assertEquals("int", shell.getParameter(0).type().getTypeName()); + assertNull(shell.getParameter("self")); + assertEquals("int", shell.getReturnType().getTypeName()); + } + + /// 验收 happy path(§3.5):引用外层实例方法的 lambda 把 `self` 作为 leading capture,类型为 + /// enclosing class 的 `GdObjectType`;`setStatic(true)` 保证不注入第二份 `self` 参数。 + @Test + void synthesizesLambdaShellWithLeadingSelfCapture() throws Exception { + var prepared = analyzeAndSkeleton("lambda_shell_self.gd", """ + class_name LambdaShellSelf + extends RefCounted + + func helper(): + return 1 + + func ping(): + var cb := func(): + return helper() + return cb + """); + + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + + var lambdaContext = requireLambdaContexts(prepared.context(), 1).getFirst(); + var lambda = assertInstanceOf(LambdaExpression.class, lambdaContext.sourceOwner()); + var plan = requirePlan(prepared.analysisData(), lambda); + var shell = lambdaContext.targetFunction(); + + assertLambdaShellShape(shell, plan); + assertTrue(plan.capturesSelf()); + assertEquals(1, shell.getCaptureCount()); + var selfCapture = shell.getCaptureList().getFirst(); + assertEquals("self", selfCapture.getName()); + assertEquals(new GdObjectType("LambdaShellSelf").getTypeName(), selfCapture.getType().getTypeName()); + assertEquals(0, shell.getParameterCount()); + assertNull(shell.getParameter("self")); + } + + /// 验收 negative path:进入 preparation 的 lambda 缺已发布 plan → fail-fast,不静默跳过。 + @Test + void preparationFailsFastWhenLambdaPlanIsMissing() throws Exception { + var prepared = analyzeAndSkeleton("lambda_missing_plan.gd", """ + class_name LambdaMissingPlan + extends RefCounted + + func ping(): + var cb := func(): + return 1 + return cb + """); + var lambda = findNode(prepared.unit().ast(), LambdaExpression.class, _ -> true); + prepared.analysisData().lambdaPlans().remove(lambda); + + var exception = assertThrows( + IllegalStateException.class, + () -> new FrontendLoweringFunctionPreparationPass().run(prepared.context()) + ); + assertTrue(exception.getMessage().contains("lambdaPlans() is missing"), exception::getMessage); + assertTrue(exception.getMessage().contains("LambdaMissingPlan"), exception::getMessage); + } + + /// 验收 negative path 的防御侧:`_lambda_` 前缀在 skeleton 阶段已被保留名规则拦截,若合成名仍 + /// 撞上既有函数则属于不变量破坏,preparation 必须 fail-fast 而不是覆盖既有函数。 + @Test + void preparationFailsFastWhenSyntheticNameIsAlreadyTaken() throws Exception { + var prepared = analyzeAndSkeleton("lambda_name_collision.gd", """ + class_name LambdaNameCollision + extends RefCounted + + func ping(): + var cb := func(): + return 1 + return cb + """); + var lambda = findNode(prepared.unit().ast(), LambdaExpression.class, _ -> true); + var plan = requirePlan(prepared.analysisData(), lambda); + var owningClass = requireClass(prepared, "LambdaNameCollision"); + owningClass.addFunction(new LirFunctionDef(plan.syntheticName())); + + var exception = assertThrows( + IllegalStateException.class, + () -> new FrontendLoweringFunctionPreparationPass().run(prepared.context()) + ); + assertTrue(exception.getMessage().contains("already declares a function named"), exception::getMessage); + assertTrue(exception.getMessage().contains(plan.syntheticName()), exception::getMessage); + } + + /// 阶段 E 接线:BuildCfgPass 接受 `sourceOwner instanceof LambdaExpression` + + /// `loweringRoot instanceof Block` 的 LAMBDA_BODY 上下文,body 复用 executable-block CFG 构建 + /// (capture 读取经 opaque 符号路由),且函数仍保持 shell-only(块由 body pass 创建)。 + /// + /// 注意:外层函数 body 的 LambdaExpression 表达式 lowering 属于阶段 F,因此这里只对 lambda + /// 上下文运行 CFG pass。 + @Test + void buildCfgPublishesExecutableGraphForLambdaBodyContexts() throws Exception { + var prepared = prepareLambdaOnlyContexts(); + var lambdaContext = requireLambdaContexts(prepared.context(), 1).getFirst(); + + new FrontendLoweringBuildCfgPass().run(prepared.context()); + + assertTrue(lambdaContext.hasFrontendCfgGraph()); + assertEquals(0, lambdaContext.targetFunction().getBasicBlockCount()); + assertTrue(lambdaContext.targetFunction().getEntryBlockId().isEmpty()); + } + + /// 阶段 E 接线:BodyInsnPass 把 LAMBDA_BODY 并入共享 `FrontendBodyLoweringSession` 分支, + /// lambda body 与合成 shell 的变量面(capture 变量类型与 plan 同源)真实落地。 + @Test + void bodyInsnMaterializesLambdaBodyThroughSharedSession() throws Exception { + var prepared = prepareLambdaOnlyContexts(); + var lambdaContext = requireLambdaContexts(prepared.context(), 1).getFirst(); + new FrontendLoweringBuildCfgPass().run(prepared.context()); + + new FrontendLoweringBodyInsnPass().run(prepared.context()); + + var shell = lambdaContext.targetFunction(); + assertTrue(shell.getBasicBlockCount() > 0); + assertTrue(!shell.getEntryBlockId().isEmpty()); + assertTrue(allInstructions(shell).stream().anyMatch(ReturnInsn.class::isInstance)); + var captureVariable = shell.getVariableById("seed"); + assertNotNull(captureVariable); + assertEquals("int", captureVariable.type().getTypeName()); + } + + /// Runs the compile-ready CFG fixture through analysis/skeleton/preparation, then re-publishes + /// only the lambda contexts so the outer body (still phase-F territory) never reaches the CFG + /// pass. + private static @NotNull PreparedLambdaModule prepareLambdaOnlyContexts() throws Exception { + var prepared = analyzeAndSkeleton("lambda_cfg_capture.gd", """ + class_name LambdaCfgCapture + extends RefCounted + + func ping(): + var seed := 40 + var cb := func(): + return seed + 2 + return cb + """); + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + prepared.context().publishFunctionLoweringContexts( + requireLambdaContexts(prepared.context(), 1) + ); + return prepared; + } + + private static void assertLambdaShellShape( + @NotNull LirFunctionDef shell, + @NotNull FrontendLambdaPlan plan + ) { + assertEquals(plan.syntheticName(), shell.getName()); + assertTrue(shell.isLambda()); + assertTrue(shell.isHidden()); + assertTrue(shell.isStatic()); + // Shell-only contract: blocks and entry metadata belong to the body pass. + assertEquals(0, shell.getBasicBlockCount()); + assertTrue(shell.getEntryBlockId().isEmpty()); + // Capture order and types must mirror the frozen plan list exactly. + assertEquals(plan.captures().size(), shell.getCaptureCount()); + var captureList = shell.getCaptureList(); + for (var index = 0; index < plan.captures().size(); index++) { + assertEquals(plan.captures().get(index).name(), captureList.get(index).getName()); + assertEquals( + plan.captures().get(index).type().getTypeName(), + captureList.get(index).getType().getTypeName() + ); + } + } + + private static @NotNull List requireLambdaContexts( + @NotNull FrontendLoweringContext context, + int expectedCount + ) { + var lambdaContexts = context.requireFunctionLoweringContexts().stream() + .filter(functionContext -> functionContext.kind() == FunctionLoweringContext.Kind.LAMBDA_BODY) + .toList(); + assertEquals(expectedCount, lambdaContexts.size()); + return lambdaContexts; + } + + private static @NotNull List lambdaFunctions( + @NotNull LirClassDef classDef, + int expectedCount + ) { + var lambdaFunctions = classDef.getFunctions().stream().filter(LirFunctionDef::isLambda).toList(); + assertEquals(expectedCount, lambdaFunctions.size()); + return lambdaFunctions; + } + + private static @NotNull FrontendLambdaPlan requirePlan( + @NotNull FrontendAnalysisData analysisData, + @NotNull LambdaExpression lambda + ) { + var plan = analysisData.lambdaPlans().get(lambda); + assertNotNull(plan); + return plan; + } + + private static @NotNull LirClassDef requireClass( + @NotNull PreparedLambdaModule prepared, + @NotNull String className + ) { + return prepared.context().requireLirModule().getClassDefs().stream() + .filter(classDef -> classDef.getName().equals(className)) + .findFirst() + .orElseThrow(() -> new AssertionError("Class not found: " + className)); + } + + private static @NotNull List allInstructions(@NotNull LirFunctionDef function) { + var instructions = new ArrayList(); + for (var block : function) { + instructions.addAll(block.getInstructions()); + } + return instructions; + } + + /// Lambdas never survive `analyzeForCompile` before phase I, so the lowering pipeline is fed + /// from the shared semantic analysis directly, mirroring the production pass order afterwards. + private static @NotNull PreparedLambdaModule analyzeAndSkeleton( + @NotNull String fileName, + @NotNull String source + ) throws Exception { + var parserService = new GdScriptParserService(); + var diagnostics = new DiagnosticManager(); + var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnostics); + assertTrue(diagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + diagnostics.snapshot()); + var module = new FrontendModule("test_module", List.of(unit)); + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + var analysisData = new FrontendSemanticAnalyzer().analyze(module, registry, diagnostics); + assertTrue(diagnostics.isEmpty(), () -> "Unexpected sema diagnostics: " + diagnostics.snapshot()); + var context = new FrontendLoweringContext(module, registry, diagnostics); + context.publishAnalysisData(analysisData); + new FrontendLoweringClassSkeletonPass().run(context); + return new PreparedLambdaModule(unit, module, analysisData, context, diagnostics); + } + + private static @NotNull T findNode( + @NotNull Node root, + @NotNull Class nodeType, + @NotNull Predicate predicate + ) { + var matches = new ArrayList(); + collectMatchingNodes(root, nodeType, predicate, matches); + return matches.stream() + .findFirst() + .orElseThrow(() -> new AssertionError("Node not found: " + nodeType.getSimpleName())); + } + + private static void collectMatchingNodes( + @NotNull Node node, + @NotNull Class nodeType, + @NotNull Predicate predicate, + @NotNull List matches + ) { + if (nodeType.isInstance(node) && predicate.test(nodeType.cast(node))) { + matches.add(nodeType.cast(node)); + } + for (var child : node.getChildren()) { + collectMatchingNodes(child, nodeType, predicate, matches); + } + } + + private record PreparedLambdaModule( + @NotNull FrontendSourceUnit unit, + @NotNull FrontendModule module, + @NotNull FrontendAnalysisData analysisData, + @NotNull FrontendLoweringContext context, + @NotNull DiagnosticManager diagnostics + ) { + } +} 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 afba13b6..0eab76c9 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLoweringFunctionPreparationPassTest.java @@ -64,7 +64,8 @@ void runPublishesExecutableAndPropertyInitContextsWhileKeepingSkeletonOnlyLir() EnumSet.of( FunctionLoweringContext.Kind.EXECUTABLE_BODY, FunctionLoweringContext.Kind.PROPERTY_INIT, - FunctionLoweringContext.Kind.PARAMETER_DEFAULT_INIT + FunctionLoweringContext.Kind.PARAMETER_DEFAULT_INIT, + FunctionLoweringContext.Kind.LAMBDA_BODY ), EnumSet.allOf(FunctionLoweringContext.Kind.class) ); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java index c30024be..b541da28 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java @@ -1078,6 +1078,49 @@ func ok(): )); } + /// Lambda plan §3.7/§3.9: the `_lambda_` namespace is compiler-owned for synthesized lambda + /// shells, so source members reusing it are rejected like `_field_*` helpers. Boundary names + /// that merely contain the prefix (without the trailing separator) stay alive. + @Test + void buildRejectsReservedLambdaFunctionPrefixButKeepsBoundaryNamesAlive() throws IOException { + var parserService = new GdScriptParserService(); + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + var classSkeletonBuilder = new FrontendClassSkeletonBuilder(); + var diagnostics = new DiagnosticManager(); + var analysisData = FrontendAnalysisData.bootstrap(); + var unit = parserService.parseUnit(Path.of("tmp", "reserved_lambda_names.gd"), """ + class_name ReservedLambdaNames + extends RefCounted + + func _lambda_0(): + pass + + func _lambda(): + pass + + func ok(): + pass + """, diagnostics); + + var result = classSkeletonBuilder.build( + new FrontendModule("test_module", List.of(unit)), + registry, + diagnostics, + analysisData + ); + var classDef = findClassByName(topLevelClassDefs(result), "ReservedLambdaNames"); + var skeletonDiagnostics = result.diagnostics().asList().stream() + .filter(diagnostic -> diagnostic.category().equals("sema.class_skeleton")) + .toList(); + + assertNull(findFunctionByNameOrNull(classDef, "_lambda_0")); + assertNotNull(findFunctionByNameOrNull(classDef, "_lambda")); + assertNotNull(findFunctionByNameOrNull(classDef, "ok")); + assertEquals(1, skeletonDiagnostics.size()); + assertTrue(skeletonDiagnostics.getFirst().message().contains("reserved synthetic lambda-function prefix")); + assertTrue(skeletonDiagnostics.getFirst().message().contains("_lambda_")); + } + @Test void buildRejectsGdccSignalThatShadowsInheritedEngineSignalButKeepsOtherMembersAlive() throws IOException { var parserService = new GdScriptParserService(); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java index 56c06392..a4e92f68 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java @@ -260,6 +260,7 @@ void lambdaPlanSamePlanIgnoresAstIdentityAndComparesPayload() { lambda, "_lambda_0", new FrontendLambdaCapturePlan(List.of(capture), false), + GdVariantType.VARIANT, enclosing, "Hero" ); @@ -267,6 +268,7 @@ void lambdaPlanSamePlanIgnoresAstIdentityAndComparesPayload() { lambda, "_lambda_0", new FrontendLambdaCapturePlan(List.of(capture.withType(GdIntType.INT)), false), + GdVariantType.VARIANT, enclosing, "Hero" ); @@ -276,6 +278,7 @@ void lambdaPlanSamePlanIgnoresAstIdentityAndComparesPayload() { lambda, "_lambda_1", first.capturePlan(), + first.returnType(), enclosing, "Hero" ); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java index c7e4375d..59c2b423 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java @@ -10,6 +10,7 @@ import gd.script.gdcc.frontend.sema.patch.FrontendTopBindingPatch; import gd.script.gdcc.scope.ScopeValueKind; import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdccForRangeIterType; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -57,6 +58,7 @@ void updateLambdaPlansRejectsCompilerOnlyCaptureTypes() { )), false ), + GdVariantType.VARIANT, emptyFunction(), "Hero" )); @@ -91,6 +93,7 @@ void applyPatchFromExistingOwnerDoesNotClearPublishedLambdaPlans() { List.of(new LambdaCaptureEntry("seed", GdIntType.INT, ScopeValueKind.LOCAL, new Object())), false ), + GdVariantType.VARIANT, emptyFunction(), "Hero" ); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java index 64cc8158..71887c41 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java @@ -327,6 +327,7 @@ func ping(seed: int): lambda, "_lambda_99", published.capturePlan(), + published.returnType(), published.enclosingCallable(), published.owningClassCanonicalName() ); @@ -344,6 +345,7 @@ func ping(seed: int): lambda, published.syntheticName(), published.capturePlan(), + published.returnType(), published.enclosingCallable(), published.owningClassCanonicalName() ); 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 ad3c8a75..c1e60025 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 @@ -614,6 +614,84 @@ func ping(): )); } + @Test + void analyzeChecksLambdaReturnAgainstPublishedDeclaredReturnType() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_return_slot.gd", """ + class_name TypeCheckLambdaReturnSlot + extends RefCounted + + func ping(): + var cb := func() -> int: + return "text" + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + // Phase E: the lambda return slot is the plan-published declared type, so a mismatching + // return value gets the ordinary return diagnostic instead of a Variant pass-through. + var typeCheckDiagnostics = diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ); + assertEquals(1, typeCheckDiagnostics.size(), typeCheckDiagnostics::toString); + assertTrue(typeCheckDiagnostics.getFirst().message() + .contains("Return value type 'String' is not assignable to callable return slot type 'int'")); + } + + @Test + void analyzeRejectsBareReturnInLambdaWithDeclaredReturnType() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_bare_return.gd", """ + class_name TypeCheckLambdaBareReturn + extends RefCounted + + func ping(): + var cb := func() -> int: + return + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + var typeCheckDiagnostics = diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ); + assertEquals(1, typeCheckDiagnostics.size(), typeCheckDiagnostics::toString); + assertTrue(typeCheckDiagnostics.getFirst().message().contains("Bare 'return' is only allowed")); + } + + @Test + void analyzeAcceptsLambdaReturnMatchingDeclaredReturnType() throws Exception { + var preparedInput = prepareTypeCheckInput("type_check_lambda_return_ok.gd", """ + class_name TypeCheckLambdaReturnOk + extends RefCounted + + func ping(): + var cb := func() -> int: + return 1 + var plain := func(): + return + """); + + new FrontendTypeCheckAnalyzer().analyze( + preparedInput.classRegistry(), + preparedInput.analysisData(), + preparedInput.diagnosticManager() + ); + + assertTrue(diagnosticsByCategory( + preparedInput.diagnosticManager().snapshot(), + "sema.type_check" + ).isEmpty()); + } + @Test void analyzeKeepsUnrecordedLambdaBodiesOutsideTypeCheckSurface() throws Exception { var preparedInput = prepareTypeCheckInput("type_check_lambda_unwalked.gd", """ From 9cf4665a011544c5cdcc78145ef1609a80c82f1e Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Tue, 18 Aug 2026 18:07:04 +0800 Subject: [PATCH 7/9] feat(frontend): implement lambda construction lowering - Lower recorded lambda expressions into construct operations - Preserve local, self, and nested capture bindings - Validate synthesized lambda metadata during instruction lowering - Thread result value identities through CFG construction - Expand frontend tests and implementation documentation --- .../frontend/frontend_lambda_plan.md | 53 +++- ...ontend_lowering_cfg_pass_implementation.md | 11 +- .../frontend/frontend_lowering_plan.md | 3 +- .../lowering/FrontendBodyLoweringSupport.java | 5 + .../lowering/cfg/FrontendCfgGraphBuilder.java | 52 ++++ .../cfg/item/LambdaConstructItem.java | 76 +++++ .../lowering/cfg/item/ValueOpItem.java | 2 +- .../body/FrontendBodyLoweringSession.java | 6 + ...endSequenceItemInsnLoweringProcessors.java | 69 +++++ .../gdcc/lir/insn/ConstructLambdaInsn.java | 7 + .../lowering/FrontendLambdaLoweringTest.java | 275 ++++++++++++++++++ 11 files changed, 546 insertions(+), 13 deletions(-) create mode 100644 src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index b6d23daf..76c230c2 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,18 +9,21 @@ ## 文档状态 -- 状态:阶段 A、B、C、D、E 已落地;F–I 尚未实施。lambda body 已成为 supported executable +- 状态:阶段 A、B、C、D、E、F 已落地;G–I 尚未实施。lambda body 已成为 supported executable suite:resolver / interface / suite 解封,nested resolve 经独立 `LAMBDA_RESOLUTION` - owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型已填充并与 scope 同源); + owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型与返回类型已填充并与 scope 同源); 已 record lambda 的表达式类型首次发布 `RESOLVED(GdCallableType)`(silent 稳定化不解析 lambda initializer,`:=` slot 保持 inventory `Variant`),type-check 经显式 re-entry - walk 已 record lambda 的 body;prep pass 已按 plan 合成 hidden `_lambda_` shell - (`is_lambda` + `is_hidden` + static、capture 与 plan 同源、无注入 `self` 参数), - 并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda body 分支;compile - gate 对 lambda 以形态级 `sema.compile_check` blocker 保持 fail-closed。外层 body 的 - `construct_lambda` lowering、C runtime、compile surface 解封仍留待后续阶段。 -- 更新时间:2026-08-18(阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` - + `_lambda_` 保留前缀) + walk 已 record lambda 的 body(return slot 即 plan 返回类型);prep pass 已按 plan 合成 + hidden `_lambda_` shell(`is_lambda` + `is_hidden` + static、capture 与 plan 同源、 + 无注入 `self` 参数),并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda + body 分支;外层 body 的已 record lambda 经 `LambdaConstructItem` lowering 成 + `construct_lambda "" $capture...`(self capture 走专用 `SELF_SLOT` descriptor), + 且 item 与 shell 的 capture 数/名序在 body lowering 处端到端校验。compile gate 对 lambda + 以形态级 `sema.compile_check` blocker 保持 fail-closed;C runtime 与 compile surface + 解封仍留待后续阶段。 +- 更新时间:2026-08-18(阶段 F 落地:`LambdaConstructItem` + `construct_lambda` 发射; + 阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` + `_lambda_` 保留前缀) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -121,6 +124,7 @@ Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 | Inspection | `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` | 把 `LambdaExpression` 硬编码为 deferred 祖先 | | Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef`;**阶段 E 起** `_lambda_` 前缀并入 `RESERVED_PREFIXES`,source member 复用即 `sema.class_skeleton` + skip | | Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | **阶段 E 已翻面**:发现式 walk 可执行 body(含 lambda body 递归),按已发布 plan 合成 `_lambda_` shell + `LAMBDA_BODY` 上下文;缺 plan fail-fast | +| CFG construct | `FrontendCfgGraphBuilder.buildValue` | **阶段 F 已翻面**:已 record lambda → `LambdaConstructItem`(capture operand 按名读外层 slot,self 走 `SELF_SLOT` descriptor);缺 plan 仍 fail-fast | | CFG alias | `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` | `CAPTURE` fail-fast | | C codegen | `ConstructInsnGen` | 无 `CONSTRUCT_LAMBDA` case | | DOM serialize | `DomLirSerializer` | **已修复**:`is_lambda` 函数序列化真实 `` 列表,`DomLirParser` 对称回读 | @@ -991,6 +995,37 @@ lambda body 走既有 CFG / body insn pass。 `CAPTURE` alias 的 fail-fast **保留**。 - negative path:plan 与 CFG capture 数量不一致 → fail-fast。 +> 状态:已落地(2026-08-18)。 +> +> - `LambdaConstructItem`(`cfg/item`):持有 `lambdaAnchor`(`LambdaExpression`,供 +> materialization 取 `RESOLVED(GdCallableType)` 结果类型)、合成名、有序 +> `CaptureOperand` 列表与 `resultValueId`;`operandValueIds()` 为空(capture 是外层 +> frame 的直接 slot 读,不经 value-id 数据流)。`CaptureOperand` 两个实现: +> `VariableSlotOperand(slotId)`(LOCAL/PARAMETER/外层 CAPTURE 按名读)与 +> `SelfSlotOperand.SELF_SLOT`(enum 单例 descriptor,slotId 即 +> `FrontendLambdaCapturePlan.SELF_CAPTURE_NAME`,不伪造 `IdentifierExpression + SELF`)。 +> - `FrontendCfgGraphBuilder.buildValue` 新增 `case LambdaExpression`: +> `buildLambdaConstructValue` 从 `lambdaPlans()` 取 plan(缺失即 fail-fast),按 plan +> 顺序生成 operand(`capturesSelf` 时防御性校验 leading 项名为 `self`), +> `preferredResultValueId` 照常穿透。 +> - `FrontendBodyLoweringSupport.requireProducedValueMaterialization` 新增 case:结果类型 +> 取 `expressionTypes()[lambdaAnchor]`,`TEMP_SLOT`。 +> - `FrontendLambdaConstructInsnLoweringProcessor`(已注册进 sequence-item registry): +> 先在 owning class 上找回 shell 并端到端校验(存在且 `is_lambda`、capture 数一致、 +> 名序一致——operand slotId 按构造即 capture 名),再发射 +> `ConstructLambdaInsn(cfg_tmp_, name, captures…)`。`ConstructLambdaInsn` +> 补了防御性 compact ctor(name 非 blank、captures 非空拷贝)。 +> - `FrontendBodyLoweringSession` 新增包级 `functionContext()` accessor 供 processor 取 +> owning class。 +> - 测试(`FrontendLambdaLoweringTest` 新增 7 例):无 capture / 本地 capture(`$seed`)/ +> self capture(`SELF_SLOT` descriptor + `$self`)/ 嵌套外层 CAPTURE slot 读(内层 insn +> 落在外层 lambda body,operand 名与外层 capture 同名)/ return 位置 preferred id 穿透; +> negative:builder 缺 plan fail-fast、item 与 shell capture 数不一致 fail-fast +> (手工给 shell 追加 phantom capture 模拟漂移)。既有 +> `buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot` 保持不变并通过。 +> - 外层 body 对 lambda 的其它表达式位置(如 call argument)走同一 `buildValue` 路径, +> 无额外分支。 + ### 阶段 G — C runtime 与 codegen **目标**:`construct_lambda` 可编译、可调用、可释放。 diff --git a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md index 2bd1f525..76ee15b0 100644 --- a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md @@ -199,6 +199,7 @@ fully-terminated 的 `if` / `elif` / `else` 允许把 region `mergeId` 指向 `S - `SignalLoadItem` - `CallableLoadItem` - `StandaloneCallableLoadItem` +- `LambdaConstructItem`(lambda 计划阶段 F 起) - `SubscriptLoadItem` - `CallItem` - `MergeValueItem` @@ -218,6 +219,13 @@ fully-terminated 的 `if` / `elif` / `else` 允许把 region `mergeId` 指向 `S item 携带的 operation descriptor 直接来自 `FrontendForLoweringContract`,lowering 不重新查询 route 或硬编码 intrinsic 名称。 +`LambdaConstructItem`(lambda 计划阶段 F 起)表示外层 body 中一处已 record lambda 的构造: + +- 持有 `lambdaAnchor`(`LambdaExpression`,materialization 结果类型取其 `RESOLVED(GdCallableType)`)、合成函数名、有序 `CaptureOperand` 列表与 `resultValueId` +- capture operand 是外层 frame 的直接 slot 读,不经 value-id 数据流,故 `operandValueIds()` 恒为空:`VariableSlotOperand(slotId)` 按名读外层 `LOCAL_VAR` / `PARAMETER` / 外层 `CAPTURE` slot(slot id == capture 条目名);leading `self` capture 用 `SelfSlotOperand.SELF_SLOT` 专用 descriptor,禁止伪造 `IdentifierExpression + SELF` +- body lowering 发射 `ConstructLambdaInsn` 前在 owning class 上找回合成 shell 并端到端校验:shell 存在且 `is_lambda`、capture 数量一致、名序一致(operand slotId 按构造即 capture 名);任一漂移 fail-fast +- lambda body 本身的 CFG/insn 仍由阶段 E 的 `LAMBDA_BODY` context 承担,本 item 只承载外层构造点 + 这里的核心合同是: - child subtree 先产出 value id @@ -471,7 +479,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 - 为 executable body 发布 `frontendCfgRegions` - 为 compile-ready `for-in` 发布 `frontendForSourceIteratorSlots` 与 `frontendForIteratorStateSlots` registry - 对 property initializer 校验 `sourceOwner == property declaration`、`loweringRoot == initializer expression` -- 对 lambda body(lambda 计划阶段 E 起)校验 `sourceOwner instanceof LambdaExpression`、`loweringRoot instanceof Block`,随后与 `EXECUTABLE_BODY` 共享同一 `publishExecutableBlockGraph`(`buildExecutableBody` + graph/region/for-slot 发布);外层 body 表达式树中的 `LambdaExpression` 本身仍由 CFG builder fail-fast,等阶段 F 的 `construct_lambda` item 落地 +- 对 lambda body(lambda 计划阶段 E 起)校验 `sourceOwner instanceof LambdaExpression`、`loweringRoot instanceof Block`,随后与 `EXECUTABLE_BODY` 共享同一 `publishExecutableBlockGraph`(`buildExecutableBody` + graph/region/for-slot 发布);外层 body 表达式树中的已 record `LambdaExpression` 自阶段 F 起由 CFG builder 建 `LambdaConstructItem`(缺已发布 plan 仍 fail-fast) 当前不负责: @@ -536,6 +544,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 - constructor materialization - `TypeTestExpression` (`is` / `is not`) with unified `is_instance_of` or folded bool lowering - callable-local slot type published contract +- 已 record lambda 的外层构造(`LambdaConstructItem` → `construct_lambda`,lambda 计划阶段 F 起;C backend 对 `CONSTRUCT_LAMBDA` 的 codegen 仍属阶段 G) plain assignment 的 compile-ready surface 明确包含 direct explicit-self property assignment: diff --git a/doc/module_impl/frontend/frontend_lowering_plan.md b/doc/module_impl/frontend/frontend_lowering_plan.md index 00dd0d8a..e79146e5 100644 --- a/doc/module_impl/frontend/frontend_lowering_plan.md +++ b/doc/module_impl/frontend/frontend_lowering_plan.md @@ -118,7 +118,6 @@ - `for` 的 `OBJECT_CUSTOM`(Object `_iter_*`);其余已注册 route 已闭环,见 `frontend_for_range_loop_implementation.md` - `match` -- 外层 body 表达式树中的 `LambdaExpression`(lambda 计划阶段 F 的 `construct_lambda` item;lambda **body** 的 CFG/body lowering 已于阶段 E 经 `LAMBDA_BODY` context 接入共享管线) - `assert` - `ConditionalExpression` @@ -224,7 +223,7 @@ - `for` 的 `OBJECT_CUSTOM`(Object `_iter_*` 精确协议)仍属 post-MVP;其余已注册 route 已接入,见 `frontend_for_range_loop_implementation.md` - `match` -- `lambda` +- `lambda`(前端 sema/lowering 已于 lambda 计划阶段 A–F 落地至外层 `construct_lambda` + lambda body 全管线;C runtime/codegen 与 compile gate 解封仍属 post-MVP) - 参数默认值语义本身 - block-local `const` - signal coroutine use-site(`await signal`;`.emit` / `.connect` 已闭环,见 `frontend_signal_support.md`) diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/FrontendBodyLoweringSupport.java b/src/main/java/gd/script/gdcc/frontend/lowering/FrontendBodyLoweringSupport.java index 9ab9fac5..35ff36b3 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/FrontendBodyLoweringSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/FrontendBodyLoweringSupport.java @@ -233,6 +233,11 @@ private static void collectProducedValueMaterialization( CfgValueMaterializationKind.TEMP_SLOT, null ); + case LambdaConstructItem lambdaConstructItem -> new CfgValueMaterialization( + requireExpressionType(analysisData, lambdaConstructItem.lambdaAnchor()), + CfgValueMaterializationKind.TEMP_SLOT, + null + ); case SubscriptLoadItem subscriptLoadItem -> new CfgValueMaterialization( requireSubscriptResultType(analysisData, classRegistry, subscriptLoadItem, resolvedMaterializations), CfgValueMaterializationKind.TEMP_SLOT, 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 01f40c98..e07bbfad 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 @@ -15,6 +15,7 @@ import gd.script.gdcc.frontend.lowering.cfg.item.ForLoopNextItem; import gd.script.gdcc.frontend.lowering.cfg.item.ForLoopShouldContinueItem; import gd.script.gdcc.frontend.lowering.cfg.item.FrontendWritableRoutePayload; +import gd.script.gdcc.frontend.lowering.cfg.item.LambdaConstructItem; import gd.script.gdcc.frontend.lowering.cfg.item.LocalDeclarationItem; import gd.script.gdcc.frontend.lowering.cfg.item.CallableLoadItem; import gd.script.gdcc.frontend.lowering.cfg.item.MemberLoadItem; @@ -42,6 +43,7 @@ import gd.script.gdcc.frontend.sema.FrontendContainerLiteralPlan; import gd.script.gdcc.frontend.sema.FrontendExpressionTypeStatus; import gd.script.gdcc.frontend.sema.FrontendForIterationPlan; +import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlan; import gd.script.gdcc.frontend.sema.FrontendMemberResolutionStatus; import gd.script.gdcc.frontend.sema.FrontendReceiverKind; import gd.script.gdcc.frontend.sema.FrontendResolvedCall; @@ -71,6 +73,7 @@ import dev.superice.gdparser.frontend.ast.ForStatement; import dev.superice.gdparser.frontend.ast.IdentifierExpression; import dev.superice.gdparser.frontend.ast.IfStatement; +import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.LiteralExpression; import dev.superice.gdparser.frontend.ast.Node; import dev.superice.gdparser.frontend.ast.PassStatement; @@ -859,6 +862,8 @@ case BinaryExpression binaryExpression when isShortCircuitBinaryExpression(binar buildDictionaryLiteralValue(cursor, dictionaryExpression, preferredResultValueId); case TypeTestExpression typeTestExpression -> buildTypeTestValue(cursor, typeTestExpression, preferredResultValueId); + case LambdaExpression lambdaExpression -> + buildLambdaConstructValue(cursor, lambdaExpression, preferredResultValueId); case ConditionalExpression conditionalExpression -> buildConditionalExpressionValue(conditionalExpression); case UnaryExpression unaryExpression -> { var operandBuild = buildValue(cursor, unaryExpression.operand(), null); @@ -891,6 +896,53 @@ yield emitOpaqueValue( }; } + /// Builds an outer-body occurrence of a recorded lambda as one `LambdaConstructItem` (lambda + /// plan phase F). The lambda body itself is lowered separately through its own `LAMBDA_BODY` + /// context; here only the enclosing-frame capture operands are collected in plan order. + /// + /// Operand sources follow the frozen plan entries: named slots for `LOCAL_VAR` / `PARAMETER` / + /// outer `CAPTURE` sources (slot id == capture entry name, the same names-always-match rule the + /// body session uses), and the dedicated `SELF_SLOT` descriptor for a leading `self` capture - + /// never a fabricated `IdentifierExpression` with a SELF binding. + private @NotNull ValueBuild buildLambdaConstructValue( + @NotNull BuildCursor cursor, + @NotNull LambdaExpression lambdaExpression, + @Nullable String preferredResultValueId + ) { + var plan = requireAnalysisData().lambdaPlans().get(lambdaExpression); + if (plan == null) { + throw new IllegalStateException( + "Frontend CFG builder reached a lowering-ready LambdaExpression without a published lambda plan" + ); + } + var capturePlan = plan.capturePlan(); + var captures = capturePlan.captures(); + var captureOperands = new ArrayList(captures.size()); + for (var index = 0; index < captures.size(); index++) { + var capture = captures.get(index); + if (capturePlan.capturesSelf() && index == 0) { + // The capture planner guarantees a leading SELF_CAPTURE_NAME entry when capturesSelf. + if (!capture.name().equals(FrontendLambdaCapturePlan.SELF_CAPTURE_NAME)) { + throw new IllegalStateException( + "Lambda plan for '" + plan.syntheticName() + + "' declares capturesSelf but its leading capture is '" + capture.name() + "'" + ); + } + captureOperands.add(LambdaConstructItem.SelfSlotOperand.SELF_SLOT); + } else { + captureOperands.add(new LambdaConstructItem.VariableSlotOperand(capture.name())); + } + } + var resultValueId = chooseResultValueId(preferredResultValueId); + cursor.currentSequence().items().add(new LambdaConstructItem( + lambdaExpression, + plan.syntheticName(), + captureOperands, + resultValueId + )); + return valueRootBuild(cursor, lambdaExpression, resultValueId); + } + /// Attribute chains are expanded step by step so later lowering receives explicit intermediate /// value ids instead of having to rerun chain reduction over the full outer expression root. private @NotNull ValueBuild buildAttributeExpressionValue( diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java new file mode 100644 index 00000000..63e50ec4 --- /dev/null +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java @@ -0,0 +1,76 @@ +package gd.script.gdcc.frontend.lowering.cfg.item; + +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Node; +import gd.script.gdcc.frontend.lowering.cfg.FrontendCfgGraph; +import gd.script.gdcc.frontend.sema.FrontendLambdaCapturePlan; +import gd.script.gdcc.util.StringUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +/// Outer-body construction of a recorded lambda value (lambda plan phase F). +/// +/// The item carries the synthesized lambda function name plus the ordered capture operands read +/// from the ENCLOSING frame; the lambda body itself lowers separately through its own +/// `LAMBDA_BODY` context (phase E). Capture operands are direct enclosing-frame slot reads +/// modeled by `CaptureOperand` - a leading `self` capture uses the dedicated +/// `SelfSlotOperand.SELF_SLOT` descriptor instead of a fabricated `IdentifierExpression` + SELF +/// binding. +public record LambdaConstructItem( + @NotNull LambdaExpression lambdaAnchor, + @NotNull String lambdaName, + @NotNull List captureOperands, + @NotNull String resultValueId +) implements ValueOpItem { + + public LambdaConstructItem { + Objects.requireNonNull(lambdaAnchor, "lambdaAnchor must not be null"); + lambdaName = StringUtil.requireNonBlank(lambdaName, "lambdaName"); + captureOperands = List.copyOf(Objects.requireNonNull(captureOperands, "captureOperands must not be null")); + resultValueId = FrontendCfgGraph.validateValueId(resultValueId, "resultValueId"); + } + + @Override + public @NotNull Node anchor() { + return lambdaAnchor; + } + + @Override + public @NotNull String resultValueIdOrNull() { + return resultValueId; + } + + /// Capture operands read enclosing-frame slots directly at construction time, so this item + /// consumes no frontend value ids. + @Override + public @NotNull List operandValueIds() { + return List.of(); + } + + /// One capture operand of a lambda construction, resolved against the enclosing frame. + public sealed interface CaptureOperand { + /// Enclosing-frame slot id read by this operand; by construction this always equals the + /// capture entry name, which lets body lowering cross-check operand order against the + /// synthesized shell's capture list. + @NotNull String slotId(); + } + + /// Reads a named enclosing-frame slot (`LOCAL_VAR` / `PARAMETER` / outer `CAPTURE` source). + public record VariableSlotOperand(@NotNull String slotId) implements CaptureOperand { + public VariableSlotOperand { + slotId = StringUtil.requireNonBlank(slotId, "slotId"); + } + } + + /// The dedicated enclosing `self` slot descriptor; never fabricated as an identifier read. + public enum SelfSlotOperand implements CaptureOperand { + SELF_SLOT; + + @Override + public @NotNull String slotId() { + return FrontendLambdaCapturePlan.SELF_CAPTURE_NAME; + } + } +} diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/ValueOpItem.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/ValueOpItem.java index ff64c1c4..5eecb885 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/ValueOpItem.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/ValueOpItem.java @@ -23,7 +23,7 @@ /// call lowering can consume it without inventing a dead temp first public sealed interface ValueOpItem extends SequenceItem permits OpaqueExprValueItem, DirectSlotAliasValueItem, LocalDeclarationItem, AssignmentItem, CompoundAssignmentBinaryOpItem, MemberLoadItem, SignalLoadItem, - CallableLoadItem, StandaloneCallableLoadItem, SubscriptLoadItem, + CallableLoadItem, StandaloneCallableLoadItem, SubscriptLoadItem, LambdaConstructItem, CallItem, CastItem, TypeTestItem, MergeValueItem, BoolConstantItem, ForLoopInitItem, ForLoopShouldContinueItem, ForLoopGetItem, ForLoopNextItem, ContainerLiteralItem { /// Result value id published by this item, or `null` when the item only commits state. 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 613dd8e9..ee1efe3a 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 @@ -182,6 +182,12 @@ public void run() { return classRegistry; } + /// Sequence-item processors occasionally need the enclosing lowering context, e.g. resolving + /// a synthesized lambda shell on the owning class for the phase-F consistency check. + @NotNull FunctionLoweringContext functionContext() { + return functionContext; + } + /// The top-binding owner procedure only publishes binding kind `SELF` for explicit /// `SelfExpression`. Any identifier node that still carries `SELF` means some earlier /// publication step leaked an impossible surface into lowering, so all body-lowering entry diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java index 082ccd4a..9f6a73ca 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java @@ -14,6 +14,7 @@ import gd.script.gdcc.frontend.lowering.cfg.item.ForLoopInitItem; import gd.script.gdcc.frontend.lowering.cfg.item.ForLoopNextItem; import gd.script.gdcc.frontend.lowering.cfg.item.ForLoopShouldContinueItem; +import gd.script.gdcc.frontend.lowering.cfg.item.LambdaConstructItem; import gd.script.gdcc.frontend.lowering.cfg.item.LocalDeclarationItem; import gd.script.gdcc.frontend.lowering.cfg.item.CallableLoadItem; import gd.script.gdcc.frontend.lowering.cfg.item.MemberLoadItem; @@ -42,6 +43,7 @@ import gd.script.gdcc.lir.insn.ConstructContainerLiteralInsn; import gd.script.gdcc.lir.insn.ConstructObjectInsn; import gd.script.gdcc.lir.insn.ConstructCallableInsn; +import gd.script.gdcc.lir.insn.ConstructLambdaInsn; import gd.script.gdcc.lir.insn.ConstructSignalInsn; import gd.script.gdcc.lir.insn.ConstructStandaloneCallableInsn; import gd.script.gdcc.frontend.sema.analyzer.support.FrontendVariantBoundaryCompatibility; @@ -123,6 +125,7 @@ private FrontendSequenceItemInsnLoweringProcessors() { new FrontendSignalLoadInsnLoweringProcessor(), new FrontendCallableLoadInsnLoweringProcessor(), new FrontendStandaloneCallableLoadInsnLoweringProcessor(), + new FrontendLambdaConstructInsnLoweringProcessor(), new FrontendSubscriptLoadInsnLoweringProcessor(), new FrontendCompoundAssignmentBinaryInsnLoweringProcessor(), new FrontendAssignmentInsnLoweringProcessor(), @@ -922,6 +925,72 @@ private static final class FrontendStandaloneCallableLoadInsnLoweringProcessor } } + /// Emits `construct_lambda` for an outer-body lambda occurrence (lambda plan phase F). + /// + /// The item carries only the synthetic name plus ordered enclosing-frame capture slot reads; + /// the processor re-validates the synthesized shell on the owning class (existence, capture + /// count, and name order) so a drifted plan/shell pair fails fast here instead of silently + /// emitting a mismatched instruction. Capture operand slot ids equal the capture entry names + /// by construction (the SELF_SLOT descriptor resolves to `SELF_CAPTURE_NAME`), which is what + /// makes the name-order cross-check meaningful. + private static final class FrontendLambdaConstructInsnLoweringProcessor + implements FrontendInsnLoweringProcessor { + @Override + public @NotNull Class nodeType() { + return LambdaConstructItem.class; + } + + @Override + public @NotNull LirBasicBlock lower( + @NotNull FrontendBodyLoweringSession session, + @NotNull LirBasicBlock block, + @NotNull LambdaConstructItem node, + @Nullable Void context + ) { + var lambdaFunction = session.functionContext().owningClass().getFunctions().stream() + .filter(function -> function.getName().equals(node.lambdaName())) + .findFirst() + .orElse(null); + if (lambdaFunction == null || !lambdaFunction.isLambda()) { + throw session.unsupportedSequenceItem( + node, + "lambda construct requires a synthesized lambda shell named '" + + node.lambdaName() + "' on the owning class" + ); + } + var shellCaptures = lambdaFunction.getCaptureList(); + if (shellCaptures.size() != node.captureOperands().size()) { + throw session.unsupportedSequenceItem( + node, + "lambda construct capture count mismatch: item carries " + + node.captureOperands().size() + " operand(s) but shell '" + + node.lambdaName() + "' declares " + shellCaptures.size() + " capture(s)" + ); + } + for (var index = 0; index < shellCaptures.size(); index++) { + if (!shellCaptures.get(index).getName().equals(node.captureOperands().get(index).slotId())) { + throw session.unsupportedSequenceItem( + node, + "lambda construct capture order mismatch at index " + index + ": item operand '" + + node.captureOperands().get(index).slotId() + "' but shell '" + + node.lambdaName() + "' declares capture '" + + shellCaptures.get(index).getName() + "'" + ); + } + } + var resultSlotId = FrontendBodyLoweringSupport.cfgTempSlotId(node.resultValueId()); + block.appendNonTerminatorInstruction(new ConstructLambdaInsn( + resultSlotId, + node.lambdaName(), + node.captureOperands().stream() + .map(operand -> + new LirInstruction.VariableOperand(operand.slotId())) + .toList() + )); + return block; + } + } + /// Lowers a published subscript read, including attribute-step intermediate named loads. /// /// The processor relies only on published receiver/key types to pick the final instruction diff --git a/src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java b/src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java index 89773eb9..faa7bf45 100644 --- a/src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java +++ b/src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java @@ -1,15 +1,22 @@ package gd.script.gdcc.lir.insn; import gd.script.gdcc.enums.GdInstruction; +import gd.script.gdcc.util.StringUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public record ConstructLambdaInsn(@Nullable String resultId, @NotNull String lambdaName, @NotNull List captures) implements ConstructionInstruction { + public ConstructLambdaInsn { + lambdaName = StringUtil.requireNonBlank(lambdaName, "lambdaName"); + captures = List.copyOf(Objects.requireNonNull(captures, "captures must not be null")); + } + @Override public GdInstruction opcode() { return GdInstruction.CONSTRUCT_LAMBDA; diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java index e11cbf31..a046a685 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java @@ -3,6 +3,9 @@ import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.Node; import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.lowering.cfg.FrontendCfgGraph; +import gd.script.gdcc.frontend.lowering.cfg.item.DirectSlotAliasValueItem; +import gd.script.gdcc.frontend.lowering.cfg.item.LambdaConstructItem; import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringBodyInsnPass; import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringBuildCfgPass; import gd.script.gdcc.frontend.lowering.pass.FrontendLoweringClassSkeletonPass; @@ -14,11 +17,14 @@ import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.analyzer.FrontendSemanticAnalyzer; import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirCaptureDef; import gd.script.gdcc.lir.LirClassDef; import gd.script.gdcc.lir.LirFunctionDef; import gd.script.gdcc.lir.LirInstruction; +import gd.script.gdcc.lir.insn.ConstructLambdaInsn; import gd.script.gdcc.lir.insn.ReturnInsn; import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.type.GdIntType; import gd.script.gdcc.type.GdObjectType; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; @@ -37,6 +43,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /// Phase E lambda shell-synthesis contract tests (frontend_lambda_plan.md §阶段E / §3.7). +/// Phase F adds the outer-body construction contract (§阶段F): recorded lambda occurrences +/// lower to `LambdaConstructItem` / `construct_lambda` with enclosing-frame capture operands. /// /// The compile gate still blocks lambdas in `analyzeForCompile` until phase I, so these tests run /// the shared `analyze()` pipeline and then drive the lowering passes manually — the same recipe @@ -240,6 +248,273 @@ void bodyInsnMaterializesLambdaBodyThroughSharedSession() throws Exception { assertEquals("int", captureVariable.type().getTypeName()); } + /// 阶段 F 验收 happy path:无 capture 的 lambda 在外层 body 产生一条 + /// `construct_lambda "_lambda_0"`(无 capture operand),且 CFG item 不发布任何 + /// operand value id、绝不走 `DirectSlotAliasValueItem`;`_lambda_0` shell 仍带参数与 return。 + @Test + void outerBodyConstructsCapturelessLambdaValue() throws Exception { + var prepared = prepareFullPipelineContexts("lambda_construct_plain.gd", """ + class_name LambdaConstructPlain + extends RefCounted + + func ping(): + var cb := func(x: int): + return x + return cb + """); + + var outerContext = requireExecutableContext(prepared.context(), "ping"); + var item = requireSingleLambdaConstructItem(outerContext); + assertEquals("_lambda_0", item.lambdaName()); + assertTrue(item.captureOperands().isEmpty()); + assertTrue(item.operandValueIds().isEmpty()); + var graph = outerContext.requireFrontendCfgGraph(); + assertTrue(graph.nodeIds().stream() + .map(graph::requireNode) + .filter(FrontendCfgGraph.SequenceNode.class::isInstance) + .flatMap(node -> ((FrontendCfgGraph.SequenceNode) node).items().stream()) + .noneMatch(DirectSlotAliasValueItem.class::isInstance)); + + var insn = requireSingleConstructLambdaInsn(outerContext.targetFunction()); + assertEquals("_lambda_0", insn.lambdaName()); + assertTrue(insn.captures().isEmpty()); + assertNotNull(insn.resultId()); + var shell = lambdaFunctions(requireClass(prepared, "LambdaConstructPlain"), 1).getFirst(); + assertEquals(1, shell.getParameterCount()); + assertEquals("x", shell.getParameter(0).name()); + assertTrue(allInstructions(shell).stream().anyMatch(ReturnInsn.class::isInstance)); + } + + /// 阶段 F 验收 happy path:捕获外层 `seed` 时 insn 恰好携带 `$seed` operand;capture 类型 + /// 仍是外层绑定的声明处类型(§3.4),而不是外层函数末尾的 slot 类型。 + @Test + void outerBodyConstructInsnReadsCapturedLocalSlot() throws Exception { + var prepared = prepareFullPipelineContexts("lambda_construct_capture.gd", """ + class_name LambdaConstructCapture + extends RefCounted + + func ping(): + var seed := 40 + var cb := func(): + return seed + 2 + return cb + """); + + var outerContext = requireExecutableContext(prepared.context(), "ping"); + var item = requireSingleLambdaConstructItem(outerContext); + assertEquals(1, item.captureOperands().size()); + var operand = assertInstanceOf( + LambdaConstructItem.VariableSlotOperand.class, + item.captureOperands().getFirst() + ); + assertEquals("seed", operand.slotId()); + + var insn = requireSingleConstructLambdaInsn(outerContext.targetFunction()); + assertEquals(List.of("seed"), captureOperandIds(insn)); + var shell = lambdaFunctions(requireClass(prepared, "LambdaConstructCapture"), 1).getFirst(); + assertEquals("int", shell.getCapture("seed").type().getTypeName()); + } + + /// 阶段 F 验收 happy path(§3.5):使用外层实例成员的 lambda 以 leading `self` capture + /// 构造,item 侧是专用 `SelfSlotOperand.SELF_SLOT` descriptor(而非伪造的 SELF 标识符 + /// 读取),insn 侧对应 `$self` operand。 + @Test + void outerBodyConstructInsnUsesSelfSlotForSelfCapture() throws Exception { + var prepared = prepareFullPipelineContexts("lambda_construct_self.gd", """ + class_name LambdaConstructSelf + extends RefCounted + + func helper(): + return 1 + + func ping(): + var cb := func(): + return helper() + return cb + """); + + var outerContext = requireExecutableContext(prepared.context(), "ping"); + var item = requireSingleLambdaConstructItem(outerContext); + assertEquals(1, item.captureOperands().size()); + assertSame(LambdaConstructItem.SelfSlotOperand.SELF_SLOT, item.captureOperands().getFirst()); + + var insn = requireSingleConstructLambdaInsn(outerContext.targetFunction()); + assertEquals(List.of("self"), captureOperandIds(insn)); + var shell = lambdaFunctions(requireClass(prepared, "LambdaConstructSelf"), 1).getFirst(); + assertEquals("self", shell.getCaptureList().getFirst().getName()); + } + + /// 阶段 F 验收 happy path:嵌套 lambda 的 capture operand 从外层 lambda 的 CAPTURE slot + /// 按名读取——内层 `construct_lambda` 出现在外层 lambda body 中且 operand 名与外层 + /// shell 的 capture 名一致。 + @Test + void nestedLambdaConstructInsnReadsEnclosingCaptureSlot() throws Exception { + var prepared = prepareFullPipelineContexts("lambda_construct_nested.gd", """ + class_name LambdaConstructNested + extends RefCounted + + func ping(): + var seed := 40 + var outer := func(): + var inner := func(): + return seed + 2 + return inner + return outer + """); + + var outerContext = requireExecutableContext(prepared.context(), "ping"); + var outerConstruct = requireSingleConstructLambdaInsn(outerContext.targetFunction()); + assertEquals("_lambda_0", outerConstruct.lambdaName()); + assertEquals(List.of("seed"), captureOperandIds(outerConstruct)); + + var lambdaContexts = requireLambdaContexts(prepared.context(), 2); + var outerLambdaContext = lambdaContexts.stream() + .filter(context -> context.targetFunction().getName().equals("_lambda_0")) + .findFirst() + .orElseThrow(); + var innerConstruct = requireSingleConstructLambdaInsn(outerLambdaContext.targetFunction()); + assertEquals("_lambda_1", innerConstruct.lambdaName()); + assertEquals(List.of("seed"), captureOperandIds(innerConstruct)); + } + + /// 阶段 F 验收 happy path:return 位置的 lambda 把 preferred result value id 直接穿到 + /// construct item 上,stop node 的 returnValueId 与 item 的 resultValueId 一致,insn 的 + /// result slot 就是该 value id 对应的 `cfg_tmp_*`。 + @Test + void returnPositionLambdaConstructThreadsPreferredResultId() throws Exception { + var prepared = prepareFullPipelineContexts("lambda_construct_return.gd", """ + class_name LambdaConstructReturn + extends RefCounted + + func ping(): + return func(x: int): + return x + """); + + var outerContext = requireExecutableContext(prepared.context(), "ping"); + var item = requireSingleLambdaConstructItem(outerContext); + var graph = outerContext.requireFrontendCfgGraph(); + var stopNode = graph.nodeIds().stream() + .map(graph::requireNode) + .filter(FrontendCfgGraph.StopNode.class::isInstance) + .map(FrontendCfgGraph.StopNode.class::cast) + .findFirst() + .orElseThrow(); + assertEquals(item.resultValueId(), stopNode.returnValueIdOrNull()); + + var insn = requireSingleConstructLambdaInsn(outerContext.targetFunction()); + assertEquals(FrontendBodyLoweringSupport.cfgTempSlotId(item.resultValueId()), insn.resultId()); + } + + /// 阶段 F 验收 negative path:进入 CFG 构建的 lowering-ready lambda 缺已发布 plan 时 + /// builder fail-fast(prep pass 在更早处已有同类闸门,这里锚定 builder 自身的防御)。 + @Test + void buildCfgFailsFastWhenLambdaPlanIsMissing() throws Exception { + var prepared = analyzeAndSkeleton("lambda_construct_missing_plan.gd", """ + class_name LambdaConstructMissingPlan + extends RefCounted + + func ping(): + var cb := func(): + return 1 + return cb + """); + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + var lambda = findNode(prepared.unit().ast(), LambdaExpression.class, _ -> true); + prepared.analysisData().lambdaPlans().remove(lambda); + + var exception = assertThrows( + IllegalStateException.class, + () -> new FrontendLoweringBuildCfgPass().run(prepared.context()) + ); + assertTrue(exception.getMessage().contains("without a published lambda plan"), exception::getMessage); + } + + /// 阶段 F 验收 negative path:plan→item 与 plan→shell 任一漂移(此处通过给 shell 手工 + /// 追加 phantom capture 模拟)都会让 body lowering 在 item capture 数与 shell capture 数 + /// 不一致时 fail-fast,而不是静默发射不匹配的 `construct_lambda`。 + @Test + void bodyInsnFailsFastWhenItemCaptureCountDivergesFromShell() throws Exception { + var prepared = analyzeAndSkeleton("lambda_construct_count_mismatch.gd", """ + class_name LambdaConstructCountMismatch + extends RefCounted + + func ping(): + var seed := 40 + var cb := func(): + return seed + 2 + return cb + """); + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + var shell = lambdaFunctions(requireClass(prepared, "LambdaConstructCountMismatch"), 1).getFirst(); + shell.addCapture(new LirCaptureDef("phantom", GdIntType.INT, shell)); + new FrontendLoweringBuildCfgPass().run(prepared.context()); + + var exception = assertThrows( + IllegalStateException.class, + () -> new FrontendLoweringBodyInsnPass().run(prepared.context()) + ); + assertTrue(exception.getMessage().contains("capture count mismatch"), exception::getMessage); + assertTrue(exception.getMessage().contains("_lambda_0"), exception::getMessage); + } + + /// Phase F pipeline: unlike `prepareLambdaOnlyContexts`, the full context set stays published + /// so the outer executable body (lambda construction) and the lambda bodies both flow through + /// the CFG and body-insn passes. + private static @NotNull PreparedLambdaModule prepareFullPipelineContexts( + @NotNull String fileName, + @NotNull String source + ) throws Exception { + var prepared = analyzeAndSkeleton(fileName, source); + new FrontendLoweringFunctionPreparationPass().run(prepared.context()); + new FrontendLoweringBuildCfgPass().run(prepared.context()); + new FrontendLoweringBodyInsnPass().run(prepared.context()); + return prepared; + } + + private static @NotNull FunctionLoweringContext requireExecutableContext( + @NotNull FrontendLoweringContext context, + @NotNull String functionName + ) { + return context.requireFunctionLoweringContexts().stream() + .filter(functionContext -> functionContext.kind() == FunctionLoweringContext.Kind.EXECUTABLE_BODY) + .filter(functionContext -> functionContext.targetFunction().getName().equals(functionName)) + .findFirst() + .orElseThrow(() -> new AssertionError("Executable context not found: " + functionName)); + } + + private static @NotNull LambdaConstructItem requireSingleLambdaConstructItem( + @NotNull FunctionLoweringContext functionContext + ) { + var graph = functionContext.requireFrontendCfgGraph(); + var items = graph.nodeIds().stream() + .map(graph::requireNode) + .filter(FrontendCfgGraph.SequenceNode.class::isInstance) + .flatMap(node -> ((FrontendCfgGraph.SequenceNode) node).items().stream()) + .filter(LambdaConstructItem.class::isInstance) + .map(LambdaConstructItem.class::cast) + .toList(); + assertEquals(1, items.size()); + return items.getFirst(); + } + + private static @NotNull ConstructLambdaInsn requireSingleConstructLambdaInsn( + @NotNull LirFunctionDef function + ) { + var insns = allInstructions(function).stream() + .filter(ConstructLambdaInsn.class::isInstance) + .map(ConstructLambdaInsn.class::cast) + .toList(); + assertEquals(1, insns.size()); + return insns.getFirst(); + } + + private static @NotNull List captureOperandIds(@NotNull ConstructLambdaInsn insn) { + return insn.captures().stream() + .map(operand -> assertInstanceOf(LirInstruction.VariableOperand.class, operand).id()) + .toList(); + } + /// Runs the compile-ready CFG fixture through analysis/skeleton/preparation, then re-publishes /// only the lambda contexts so the outer body (still phase-F territory) never reaches the CFG /// pass. From 1b5b9a014cd29fd02dc2827361596b07fb320add Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Tue, 18 Aug 2026 20:36:14 +0800 Subject: [PATCH 8/9] feat(backend): implement lambda Callable construction and runtime support - Generate lambda Callable callbacks with capture storage and lifecycle handling - Preserve captured values and self object identity across lambda invocation - Add backend, frontend, and integration coverage for lambda construction - Update lambda implementation and runtime documentation --- doc/gdcc_low_ir.md | 14 +- doc/gdcc_runtime_lib.md | 14 +- .../frontend/frontend_lambda_plan.md | 71 ++-- ...ontend_lowering_cfg_pass_implementation.md | 2 +- .../codegen/include_451/gdcc/gdcc_callable.h | 36 ++ src/main/c/codegen/template_451/entry.h.ftl | 232 ++++++++++++- .../script/gdcc/backend/c/gen/CCodegen.java | 40 +++ .../script/gdcc/backend/c/gen/CGenHelper.java | 82 +++++ .../backend/c/gen/insn/ConstructInsnGen.java | 147 +++++++- .../gen/ConstructLambdaInsnGenEngineTest.java | 248 ++++++++++++++ .../c/gen/ConstructLambdaInsnGenTest.java | 314 ++++++++++++++++++ .../c/gen/FuncHeaderCaptureTemplateTest.java | 11 + .../FrontendLambdaExpressionTypeTest.java | 142 ++++++++ .../sema/FrontendLambdaInventoryTest.java | 184 ++++++++++ 14 files changed, 1504 insertions(+), 33 deletions(-) create mode 100644 src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java create mode 100644 src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java create mode 100644 src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java create mode 100644 src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java diff --git a/doc/gdcc_low_ir.md b/doc/gdcc_low_ir.md index 5f34f33d..bd4b8e9c 100644 --- a/doc/gdcc_low_ir.md +++ b/doc/gdcc_low_ir.md @@ -246,10 +246,16 @@ $ = construct_standalone_callable "" "" " #### construct_lambda Constructs a new Callable from a lambda function in this compiling unit. -For implementation, `godot_callable_custom_create2` is used. -All captures are copied into a tmp struct and passed to the lambda via `callable_userdata`. -If there are no captures, NULL is passed as `callable_userdata`. -`free_func` in `GDExtensionCallableCustomInfo2` must be set to destruct the captures. +Backend uses `gdcc_new_lambda_callable` → `godot_callable_custom_create2`. +Captures are copied into a heap `${Class}_Capture_${func}` and stored as +`callable_userdata`. If there are no captures, userdata is `NULL` and +`free_func` is a no-op. With captures, `free_func` destroys each destroyable +field and then `godot_mem_free`s the block. +The generated lambda C function takes source parameters plus a trailing +`_capture` pointer when `captureCount > 0`. After locals are declared and +before `__prepare__`, the backend copies `_capture->name` into the matching +`$name` slot. Capture locals are excluded from `__prepare__` default +construction so destroyable types are not leaked. ``` $ = construct_lambda "" $ $ ... diff --git a/doc/gdcc_runtime_lib.md b/doc/gdcc_runtime_lib.md index 78049f91..4cfff4b7 100644 --- a/doc/gdcc_runtime_lib.md +++ b/doc/gdcc_runtime_lib.md @@ -82,11 +82,15 @@ extend the runtime-provided `godot_*` surface. wrapper pointer conversion helpers, compatibility constructors, UTF-8 formatting helpers, Variant type guards, GDScript `is` type-test helpers, Variant writeback classification and `godot_Variant_call(...)`. It also pulls in sibling headers such as `gdcc_callable.h`. -- `gdcc_callable.h`: no-receiver custom Callables for `construct_standalone_callable`. Owns - `gdcc_new_standalone_callable`, a growable heap intern table of `gdcc_standalone_callable_spec` - (one `godot_mem_alloc` per unique `(kind, owner, name)`), custom `GDExtensionCallableCustomInfo2` - callbacks, and `ClassDB.class_call_static` forwarding. `gdcc_standalone_callable_registry_destroy_all()` - frees the table on GDExtension unload. The includer must declare `class_library` first. +- `gdcc_callable.h`: custom Callables for `construct_standalone_callable` and + `construct_lambda`. Owns `gdcc_new_standalone_callable` (growable heap intern table of + `gdcc_standalone_callable_spec`, one `godot_mem_alloc` per unique `(kind, owner, name)`, + `ClassDB.class_call_static` forwarding, `gdcc_standalone_callable_registry_destroy_all()` + on unload) and `gdcc_new_lambda_callable(userdata, object_id, call/is_valid/free/argc)`. + Lambda `object_id` is supplied by the caller from a cached fat-pointer `instance_id` + when the lambda captures `self`; otherwise it is `0`. The helper never recovers an ID + from a raw object pointer. Hash/equal stay Godot's default (`call_func` + userdata + pointer identity). The includer must declare `class_library` first. - Object **values** in generated code are per-type fat pointers (`gdcc__fat_ptr` from module `object_fat_ptr_types.h`); `gdcc_helper.h` owns the shared raw/ID query and lifecycle surface used by those helpers. diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md index 76c230c2..bd802575 100644 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ b/doc/module_impl/frontend/frontend_lambda_plan.md @@ -9,21 +9,25 @@ ## 文档状态 -- 状态:阶段 A、B、C、D、E、F 已落地;G–I 尚未实施。lambda body 已成为 supported executable - suite:resolver / interface / suite 解封,nested resolve 经独立 `LAMBDA_RESOLUTION` - owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型与返回类型已填充并与 scope 同源); - 已 record lambda 的表达式类型首次发布 `RESOLVED(GdCallableType)`(silent 稳定化不解析 - lambda initializer,`:=` slot 保持 inventory `Variant`),type-check 经显式 re-entry - walk 已 record lambda 的 body(return slot 即 plan 返回类型);prep pass 已按 plan 合成 - hidden `_lambda_` shell(`is_lambda` + `is_hidden` + static、capture 与 plan 同源、 - 无注入 `self` 参数),并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda - body 分支;外层 body 的已 record lambda 经 `LambdaConstructItem` lowering 成 +- 状态:阶段 A、B、C、D、E、F、G、H 已落地;I 尚未实施。lambda body 已成为 supported + executable suite:resolver / interface / suite 解封,nested resolve 经独立 + `LAMBDA_RESOLUTION` owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型与返回 + 类型已填充并与 scope 同源);已 record lambda 的表达式类型首次发布 + `RESOLVED(GdCallableType)`(silent 稳定化不解析 lambda initializer,`:=` slot 保持 + inventory `Variant`),type-check 经显式 re-entry walk 已 record lambda 的 body + (return slot 即 plan 返回类型);prep pass 已按 plan 合成 hidden `_lambda_` shell + (`is_lambda` + `is_hidden` + static、capture 与 plan 同源、无注入 `self` 参数), + 并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda body 分支;外层 + body 的已 record lambda 经 `LambdaConstructItem` lowering 成 `construct_lambda "" $capture...`(self capture 走专用 `SELF_SLOT` descriptor), - 且 item 与 shell 的 capture 数/名序在 body lowering 处端到端校验。compile gate 对 lambda - 以形态级 `sema.compile_check` blocker 保持 fail-closed;C runtime 与 compile surface - 解封仍留待后续阶段。 -- 更新时间:2026-08-18(阶段 F 落地:`LambdaConstructItem` + `construct_lambda` 发射; - 阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` + `_lambda_` 保留前缀) + 且 item 与 shell 的 capture 数/名序在 body lowering 处端到端校验。C backend 已注册 + `CONSTRUCT_LAMBDA`:heap `${Class}_Capture_${func}` + `gdcc_new_lambda_callable`, + `object_id` 来自 cached `self.instance_id`(无 self 则为 0),lambda 入口 prologue + 从 `_capture` 拷入 local,capture local 已从 `__prepare__` 默认构造排除。compile + gate 对 lambda 仍以形态级 `sema.compile_check` blocker 保持 fail-closed,解封属阶段 I。 +- 更新时间:2026-08-18(阶段 G/H 落地:`gdcc_new_lambda_callable` + `ConstructInsnGen` + `CONSTRUCT_LAMBDA` + 回归锚点;阶段 F 落地:`LambdaConstructItem` + `construct_lambda` + 发射;阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` + `_lambda_` 保留前缀) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/scope/**` @@ -126,7 +130,7 @@ Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 | Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | **阶段 E 已翻面**:发现式 walk 可执行 body(含 lambda body 递归),按已发布 plan 合成 `_lambda_` shell + `LAMBDA_BODY` 上下文;缺 plan fail-fast | | CFG construct | `FrontendCfgGraphBuilder.buildValue` | **阶段 F 已翻面**:已 record lambda → `LambdaConstructItem`(capture operand 按名读外层 slot,self 走 `SELF_SLOT` descriptor);缺 plan 仍 fail-fast | | CFG alias | `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` | `CAPTURE` fail-fast | -| C codegen | `ConstructInsnGen` | 无 `CONSTRUCT_LAMBDA` case | +| C codegen | `ConstructInsnGen` | **阶段 G 已翻面**:注册 `CONSTRUCT_LAMBDA`,分配 capture 块并调 `gdcc_new_lambda_callable`;未知名 / capture 数或名序不一致 fail-fast | | DOM serialize | `DomLirSerializer` | **已修复**:`is_lambda` 函数序列化真实 `` 列表,`DomLirParser` 对称回读 | ### 1.4 Godot 参考(语义对齐,不复制 VM) @@ -590,10 +594,8 @@ $result = construct_lambda "" $capture1 $capture2 ... 不得解引用 `_capture->self`。 - 用户可见 arity = lambda 源码参数个数(**不含** capture)。 - `DomLirSerializer` 必须写出真实 ``,不得再写空节点。 -- `func.ftl` 现有 bug:`<#list func.captureList as capture>` 对 **每个** - capture 元素都发射一次 `_capture` 形参。`captureCount == 1` 碰巧正确; - `captureCount > 1` 会得到多个无逗号分隔的 `_capture` 形参。必须改成 - `captureCount > 0` 时只发射一次。 +- `func.ftl` 的 `_capture` 重复形参 bug **已修复**(阶段 A):`captureCount > 0` + 时只发射一次 `_capture`,由 `FuncHeaderCaptureTemplateTest` 锚定。 - lambda 函数 prologue:把 `_capture->name` 拷入 `LirFunctionDef` 为该 capture 登记的 local `$name`(`addCapture` 已 `variables.put(name, ...)`)。 - `CCodegen.generateFunctionPrepareBlock` 会给每个非参数 variable @@ -1073,6 +1075,25 @@ lambda body 走既有 CFG / body insn pass。 - negative path:未知 lambda 名 / capture 数与函数定义不一致 → codegen fail-fast,不生成半残 C。 +> 状态:已落地(2026-08-18)。 +> +> - `gdcc_new_lambda_callable(...)` 加入 `gdcc_callable.h`:调用方传入 userdata / +> cached `object_id` / 四个 per-lambda 回调;hash/equal 留空,走 Godot 默认 +> `call_func + userdata` 身份。 +> - `entry.h.ftl`:仅当 `captureCount > 0` 才发 `${Class}_Capture_${func}`(字段用 +> `renderGdTypeInC`,对象为 fat pointer);并生成 `_call` / `_free` / +> `_is_valid` / `_get_argument_count`。`capturesSelf` 的 `is_valid` 用 +> `godot_object_get_instance_from_id(captures->self.instance_id)`。 +> - `ConstructInsnGen` 注册 `CONSTRUCT_LAMBDA`:按 shell 校验名序后分配 heap +> 块、逐字段 copy(destroyable 走 copy helper;对象 first-write + retain)、 +> 调 helper。未知名 / 数量或名序不一致 / 无 self 槽却捕获 self → +> `InvalidInsnException`。 +> - `CCodegen.generateFunctionPrepareBlock` 跳过 `func.getCapture(name) != null` +> 的 local;`generateFuncBody` 入口 prologue 把 `_capture->name` 拷入 `$name`。 +> - 测试:`ConstructLambdaInsnGenTest`(正反路径 + prepare/prologue + wrapper +> object_id/ObjectDB)与 `ConstructLambdaInsnGenEngineTest`(Zig + 可选 +> `GODOT_BIN`:常量 / String capture / self `object_id` / free 后 invalid)。 + ### 阶段 H — 回归锚点补齐(仍可选择暂不解封 gate) **目标**:focused tests 覆盖 happy / negative;test_suite 仍可暂避 lambda, @@ -1104,6 +1125,18 @@ lambda body 走既有 CFG / body insn pass。 capture 类型;`CallableScope` 的 CAPTURE 绑定与 `LambdaCaptureEntry.type` 同源。 +> 状态:已落地(2026-08-18)。 +> +> - 新建具名类:`FrontendLambdaInventoryTest`(supported inventory happy + +> property-initializer skip / sibling 继续)、`FrontendLambdaExpressionTypeTest` +> (recorded `RESOLVED(Callable)` + unrecorded 不污染 sibling)。 +> - 既有覆盖继续充当计划建议类:`FrontendLambdaCapturePlannerTest`、 +> `FrontendLambdaSuiteResolutionTest`、`FrontendLambdaLoweringTest`、 +> `ConstructLambdaInsnGenTest`。 +> - 未翻转:`FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` +> (阶段 I)、`FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary`。 +> - §3.4 类型表已由 suite / planner 测试锚定,本阶段不重写。 + ### 阶段 I — Compile gate 解封与文档吸收 **目标**:满足 `frontend_compile_check_analyzer_implementation.md` §7 五条件。 diff --git a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md index 76ee15b0..45110fc9 100644 --- a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md @@ -544,7 +544,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 - constructor materialization - `TypeTestExpression` (`is` / `is not`) with unified `is_instance_of` or folded bool lowering - callable-local slot type published contract -- 已 record lambda 的外层构造(`LambdaConstructItem` → `construct_lambda`,lambda 计划阶段 F 起;C backend 对 `CONSTRUCT_LAMBDA` 的 codegen 仍属阶段 G) +- 已 record lambda 的外层构造(`LambdaConstructItem` → `construct_lambda`,lambda 计划阶段 F 起;C backend `CONSTRUCT_LAMBDA` 已于阶段 G 落地) plain assignment 的 compile-ready surface 明确包含 direct explicit-self property assignment: diff --git a/src/main/c/codegen/include_451/gdcc/gdcc_callable.h b/src/main/c/codegen/include_451/gdcc/gdcc_callable.h index fb15b999..8182a3db 100644 --- a/src/main/c/codegen/include_451/gdcc/gdcc_callable.h +++ b/src/main/c/codegen/include_451/gdcc/gdcc_callable.h @@ -401,4 +401,40 @@ static inline godot_Callable gdcc_new_standalone_callable( return result; } +/// Builds a lambda custom Callable. `object_id` is supplied by the caller from a cached +/// fat-pointer `instance_id` when the lambda captures `self`; otherwise it must be `0`. +/// This helper never recovers an ID from a raw object pointer. +/// +/// `call_func` / `free_func` / `is_valid_func` / `get_argument_count_func` are generated +/// per lambda. Hash / equal stay Godot's default (`call_func` + userdata pointer identity). +static inline godot_Callable gdcc_new_lambda_callable( + void *userdata, + GDObjectInstanceID object_id, + GDExtensionCallableCustomCall call_func, + GDExtensionCallableCustomIsValid is_valid_func, + GDExtensionCallableCustomFree free_func, + GDExtensionCallableCustomGetArgumentCount get_argument_count_func +) { + godot_Callable result; + if (call_func == NULL) { + memset(&result, 0, sizeof(result)); + return result; + } + GDExtensionCallableCustomInfo2 info = { + .callable_userdata = userdata, + .token = class_library, + .object_id = object_id, + .call_func = call_func, + .is_valid_func = is_valid_func, + .free_func = free_func, + .hash_func = NULL, + .equal_func = NULL, + .less_than_func = NULL, + .to_string_func = NULL, + .get_argument_count_func = get_argument_count_func, + }; + godot_callable_custom_create2((GDExtensionUninitializedTypePtr)&result, &info); + return result; +} + #endif //GDCC_CALLABLE_H diff --git a/src/main/c/codegen/template_451/entry.h.ftl b/src/main/c/codegen/template_451/entry.h.ftl index 032a866e..eee7c262 100644 --- a/src/main/c/codegen/template_451/entry.h.ftl +++ b/src/main/c/codegen/template_451/entry.h.ftl @@ -68,16 +68,242 @@ void ${classDef.name}_class_call_virtual_with_data(GDExtensionClassInstancePtr p // Methods for ${classDef.name} <#list classDef.functions as func> -<#-- Lambda function --> -<#if func.lambda> +<#-- Lambda capture heap block. Empty structs are omitted so C stay portable. --> +<#if func.lambda && func.captureCount gt 0> typedef struct <@lambdaCaptureName classDef func/> { <#list func.captureList as capture> - ${helper.renderGdTypeRefInC(capture.type)} ${capture.name}; + ${helper.renderLambdaCaptureFieldTypeInC(capture.type)} ${capture.name}; } <@lambdaCaptureName classDef func/>; <#-- Normal function --> <@funcHeader helper classDef func/>; +<#if func.lambda> +static GDExtensionInt ${helper.renderLambdaGetArgumentCountFuncName(classDef, func)}(void *userdata, GDExtensionBool *r_is_valid) { + (void)userdata; + if (r_is_valid != NULL) { + *r_is_valid = true; + } + return ${func.parameterCount}; +} + +static GDExtensionBool ${helper.renderLambdaIsValidFuncName(classDef, func)}(void *userdata) { +<#if func.captureCount gt 0> + if (userdata == NULL) { + return false; + } + <#assign leadingCapture = func.captureList[0]> + <#if leadingCapture.name == "self" && helper.checkObjectType(leadingCapture.type)> + <@lambdaCaptureName classDef func/> *captures = (<@lambdaCaptureName classDef func/> *)userdata; + // ObjectDB is the authority: Godot disconnects / rejects calls when object_id is dead. + return godot_object_get_instance_from_id(captures->self.instance_id) != NULL; + <#else> + (void)userdata; + return true; + +<#else> + (void)userdata; + return true; + +} + +static void ${helper.renderLambdaFreeFuncName(classDef, func)}(void *userdata) { +<#if func.captureCount gt 0> + if (userdata == NULL) { + return; + } + <@lambdaCaptureName classDef func/> *captures = (<@lambdaCaptureName classDef func/> *)userdata; + <#list func.captureList as capture> + <#assign freeStmt = helper.renderLambdaCaptureFreeStmt(capture.type, "captures->" + capture.name)> + <#if freeStmt?has_content> + ${freeStmt} + + + godot_mem_free(userdata); +<#else> + (void)userdata; + +} + +static void ${helper.renderLambdaCallFuncName(classDef, func)}( + void *userdata, + const GDExtensionConstVariantPtr *p_args, + GDExtensionInt p_argument_count, + GDExtensionVariantPtr r_return, + GDExtensionCallError *r_error) { + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_OK; + r_error->argument = 0; + r_error->expected = 0; + } + if (p_argument_count < ${func.parameterCount}) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_TOO_FEW_ARGUMENTS; + r_error->expected = ${func.parameterCount}; + } + return; + } + if (p_argument_count > ${func.parameterCount}) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_TOO_MANY_ARGUMENTS; + r_error->expected = ${func.parameterCount}; + } + return; + } +<#if func.captureCount gt 0> + if (userdata == NULL) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_INVALID_METHOD; + } + return; + } + <@lambdaCaptureName classDef func/> *captures = (<@lambdaCaptureName classDef func/> *)userdata; +<#else> + (void)userdata; + + <#list func.parameters as paramType> + <#if paramType.type.typeName != "Variant"> + const GDExtensionVariantType arg${paramType_index}_type = godot_variant_get_type(p_args[${paramType_index}]); + if (!${helper.renderCallWrapperVariantTypeGate(paramType.type, "arg${paramType_index}_type")}) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT; + r_error->expected = GDEXTENSION_VARIANT_TYPE_${paramType.type.gdExtensionType.name()}; + r_error->argument = ${paramType_index}; + } + return; + } + + + <#list func.parameters as paramType> + <#if helper.needsTypedArrayCallGuard(paramType.type)> + <#assign probeVarName = "probe" + paramType_index> + <#assign expectedBuiltinType = helper.renderTypedArrayGuardBuiltinTypeLiteral(paramType.type)> + { + godot_Array ${probeVarName} = godot_new_Array_with_Variant((GDExtensionVariantPtr)p_args[${paramType_index}]); + godot_bool typed_mismatch = godot_Array_get_typed_builtin(&${probeVarName}) != ${expectedBuiltinType}; + <#if helper.isTypedArrayGuardObjectLeaf(paramType.type)> + <#assign expectedClassNameExpr = helper.renderTypedArrayGuardClassNameExpr(paramType.type)> + if (!typed_mismatch) { + godot_StringName ${probeVarName}_class_name = godot_Array_get_typed_class_name(&${probeVarName}); + godot_Variant ${probeVarName}_script = godot_Array_get_typed_script(&${probeVarName}); + godot_Variant ${probeVarName}_script_nil = godot_new_Variant_nil(); + godot_Variant ${probeVarName}_script_is_null_result; + godot_bool ${probeVarName}_script_is_null_valid = false; + godot_variant_evaluate(GDEXTENSION_VARIANT_OP_EQUAL, &${probeVarName}_script, &${probeVarName}_script_nil, (GDExtensionUninitializedVariantPtr)&${probeVarName}_script_is_null_result, &${probeVarName}_script_is_null_valid); + const godot_bool ${probeVarName}_script_is_null = ${probeVarName}_script_is_null_valid && godot_new_bool_with_Variant(&${probeVarName}_script_is_null_result); + typed_mismatch = !godot_StringName_op_equal_StringName(&${probeVarName}_class_name, ${expectedClassNameExpr}) || !${probeVarName}_script_is_null; + if (${probeVarName}_script_is_null_valid) { + godot_Variant_destroy(&${probeVarName}_script_is_null_result); + } + godot_Variant_destroy(&${probeVarName}_script_nil); + godot_Variant_destroy(&${probeVarName}_script); + godot_StringName_destroy(&${probeVarName}_class_name); + } + + godot_Array_destroy(&${probeVarName}); + if (typed_mismatch) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT; + r_error->expected = GDEXTENSION_VARIANT_TYPE_ARRAY; + r_error->argument = ${paramType_index}; + } + return; + } + } + + + <#list func.parameters as paramType> + <#if helper.needsTypedDictionaryCallGuard(paramType.type)> + <#assign probeVarName = "probe" + paramType_index> + { + godot_Dictionary ${probeVarName} = godot_new_Dictionary_with_Variant((GDExtensionVariantPtr)p_args[${paramType_index}]); + godot_bool typed_mismatch = false; + <#list ["key", "value"] as typedSide> + <#assign expectedBuiltinType = helper.renderTypedDictionaryGuardBuiltinTypeLiteral(paramType.type, typedSide)> + if (!typed_mismatch) { + typed_mismatch = godot_Dictionary_get_typed_${typedSide}_builtin(&${probeVarName}) != ${expectedBuiltinType}; + } + <#if helper.isTypedDictionaryGuardObjectLeaf(paramType.type, typedSide)> + <#assign expectedClassNameExpr = helper.renderTypedDictionaryGuardClassNameExpr(paramType.type, typedSide)> + if (!typed_mismatch) { + godot_StringName ${probeVarName}_${typedSide}_class_name = godot_Dictionary_get_typed_${typedSide}_class_name(&${probeVarName}); + godot_Variant ${probeVarName}_${typedSide}_script = godot_Dictionary_get_typed_${typedSide}_script(&${probeVarName}); + godot_Variant ${probeVarName}_${typedSide}_script_nil = godot_new_Variant_nil(); + godot_Variant ${probeVarName}_${typedSide}_script_is_null_result; + godot_bool ${probeVarName}_${typedSide}_script_is_null_valid = false; + godot_variant_evaluate(GDEXTENSION_VARIANT_OP_EQUAL, &${probeVarName}_${typedSide}_script, &${probeVarName}_${typedSide}_script_nil, (GDExtensionUninitializedVariantPtr)&${probeVarName}_${typedSide}_script_is_null_result, &${probeVarName}_${typedSide}_script_is_null_valid); + const godot_bool ${probeVarName}_${typedSide}_script_is_null = ${probeVarName}_${typedSide}_script_is_null_valid && godot_new_bool_with_Variant(&${probeVarName}_${typedSide}_script_is_null_result); + typed_mismatch = !godot_StringName_op_equal_StringName(&${probeVarName}_${typedSide}_class_name, ${expectedClassNameExpr}) || !${probeVarName}_${typedSide}_script_is_null; + if (${probeVarName}_${typedSide}_script_is_null_valid) { + godot_Variant_destroy(&${probeVarName}_${typedSide}_script_is_null_result); + } + godot_Variant_destroy(&${probeVarName}_${typedSide}_script_nil); + godot_Variant_destroy(&${probeVarName}_${typedSide}_script); + godot_StringName_destroy(&${probeVarName}_${typedSide}_class_name); + } + + + godot_Dictionary_destroy(&${probeVarName}); + if (typed_mismatch) { + godot_variant_new_nil(r_return); + if (r_error != NULL) { + r_error->error = GDEXTENSION_CALL_ERROR_INVALID_ARGUMENT; + r_error->expected = GDEXTENSION_VARIANT_TYPE_DICTIONARY; + r_error->argument = ${paramType_index}; + } + return; + } + } + + + <#list func.parameters as paramType> + <#assign argCleanupStmt = helper.renderCallWrapperDestroyStmt(paramType.type, "arg${paramType_index}")> + <#if paramType.type.typeName != "Variant"> + <#assign argTypeExpr = "arg${paramType_index}_type"> + <#else> + <#assign argTypeExpr = "NULL"> + + <#if argCleanupStmt?has_content> + ${helper.renderGdTypeInC(paramType.type)} arg${paramType_index} = ${helper.renderCallWrapperUnpackExpr(paramType.type, "(GDExtensionVariantPtr)p_args[${paramType_index}]", argTypeExpr)}; + <#else> + const ${helper.renderGdTypeInC(paramType.type)} arg${paramType_index} = ${helper.renderCallWrapperUnpackExpr(paramType.type, "(GDExtensionVariantPtr)p_args[${paramType_index}]", argTypeExpr)}; + + + <#assign callArgs> + <#list func.parameters as paramType>${helper.renderValueRef(paramType.type, "arg${paramType_index}")}<#if paramType_has_next || func.captureCount gt 0>, <#if func.captureCount gt 0>captures + + <#if func.returnType.typeName != "void"> + ${helper.renderGdTypeInC(func.returnType)} r = ${classDef.name}_${func.name}(${callArgs?trim}); + godot_Variant ret = ${helper.renderPackFunctionName(func.returnType)}(${helper.renderValueRef(func.returnType, "r")}); + godot_variant_new_copy(r_return, &ret); + godot_Variant_destroy(&ret); + <#assign returnObjectConsumeStmt = helper.renderCallWrapperOwnedObjectReturnConsumeStmt(func.returnType, "r")> + <#if returnObjectConsumeStmt?has_content> + ${returnObjectConsumeStmt} + + <#assign returnCleanupStmt = helper.renderCallWrapperDestroyStmt(func.returnType, "r")> + <#if returnCleanupStmt?has_content> + ${returnCleanupStmt} + + <#else> + godot_variant_new_nil(r_return); + (${classDef.name}_${func.name}(${callArgs?trim})); + + <#assign argCount = func.parameters?size> + <#list func.parameters?reverse as paramType> + <#assign argIndex = argCount - paramType_index - 1> + <#assign argCleanupStmt = helper.renderCallWrapperDestroyStmt(paramType.type, "arg${argIndex}")> + <#if argCleanupStmt?has_content> + ${argCleanupStmt} + + +} + diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CCodegen.java b/src/main/java/gd/script/gdcc/backend/c/gen/CCodegen.java index e73d375b..dc1f78c5 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CCodegen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CCodegen.java @@ -196,6 +196,42 @@ private void generateDefaultGetterSetterInitialization() { } } + /// Copies `_capture->name` into the matching lambda local before `__prepare__`. + /// Capture slots are excluded from default construction, so this is their first write. + private void emitLambdaCapturePrologue( + @NotNull CBodyBuilder bodyBuilder, + @NotNull LirClassDef clazz, + @NotNull LirFunctionDef func + ) { + if (!func.isLambda() || func.getCaptureCount() == 0) { + return; + } + for (var capture : func.getCaptures().values()) { + var local = func.getVariableById(capture.getName()); + if (local == null) { + throw new IllegalStateException( + "Lambda '" + clazz.getName() + "." + func.getName() + + "' is missing the local slot for capture '" + capture.getName() + "'" + ); + } + var sourceExpr = "_capture->" + capture.getName(); + if (capture.getType() instanceof GdObjectType objectType) { + bodyBuilder.applyPropertyInitializerFirstWrite( + "$" + local.id(), + objectType, + sourceExpr, + objectType, + CBodyBuilder.PtrKind.FAT_PTR, + CBodyBuilder.OwnershipKind.BORROWED + ); + } else { + bodyBuilder.appendRaw("$" + local.id() + " = " + + helper.renderLambdaCaptureCopyExpr(capture.getType(), sourceExpr) + + ";\n"); + } + } + } + private void generateFunctionPrepareBlock() { for (var classDef : module.getClassDefs()) { for (var func : classDef.getFunctions()) { @@ -212,6 +248,9 @@ private void generateFunctionPrepareBlock() { if (parameterNames.contains(variable.id())) { continue; } + if (func.getCapture(variable.id()) != null) { + continue; + } if (variable.ref()) { continue; } @@ -468,6 +507,7 @@ private boolean shouldInsertAutoGeneratedFinallyDestruct(@NotNull LirVariable va throw new IllegalArgumentException("Function " + func.getName() + " has invalid entry block ID: " + func.getEntryBlockId()); } var bodyBuilder = new CBodyBuilder(helper, clazz, func, usageBuffer); + emitLambdaCapturePrologue(bodyBuilder, clazz, func); // generate blocks bodyBuilder.appendRaw("goto " + func.getEntryBlockId() + ";\n"); for (var bb : func) { diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java b/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java index 8764cf1d..73ae8210 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/CGenHelper.java @@ -375,6 +375,88 @@ private void collectBindingData(@NotNull List classDefs) { }; } + /// Capture-struct typedef name `${Class}_Capture_${func}` shared by `func.ftl` / `entry.h.ftl`. + public @NotNull String renderLambdaCaptureTypeName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return classDef.getName() + "_Capture_" + function.getName(); + } + + public @NotNull String renderLambdaImplName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return classDef.getName() + "_" + function.getName(); + } + + public @NotNull String renderLambdaCallFuncName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return renderLambdaImplName(classDef, function) + "_call"; + } + + public @NotNull String renderLambdaFreeFuncName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return renderLambdaImplName(classDef, function) + "_free"; + } + + public @NotNull String renderLambdaIsValidFuncName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return renderLambdaImplName(classDef, function) + "_is_valid"; + } + + public @NotNull String renderLambdaGetArgumentCountFuncName(@NotNull ClassDef classDef, @NotNull FunctionDef function) { + return renderLambdaImplName(classDef, function) + "_get_argument_count"; + } + + /// Heap / local capture-struct field storage. Object captures stay fat pointers by value. + public @NotNull String renderLambdaCaptureFieldTypeInC(@NotNull GdType captureType) { + TypeCheckUtil.requireNonCompilerOnly(captureType, "lambda capture field"); + return renderGdTypeInC(captureType); + } + + /// First-write copy from an enclosing-function slot into the heap capture block. + /// Parameter slots are already pointers (`ref=true`); locals need an explicit address. + public @NotNull String renderLambdaCaptureCopyFromSlot( + @NotNull GdType captureType, + @NotNull LirVariable source + ) { + TypeCheckUtil.requireNonCompilerOnly(captureType, "lambda capture copy"); + var valueExpr = "$" + source.id(); + if (captureType instanceof GdObjectType || captureType instanceof GdPrimitiveType) { + return valueExpr; + } + var copyFunc = renderCopyAssignFunctionName(captureType); + if (copyFunc.isEmpty()) { + return valueExpr; + } + var sourcePtr = source.ref() ? valueExpr : "&(" + valueExpr + ")"; + return copyFunc + "(" + sourcePtr + ")"; + } + + /// First-write copy from a value-shaped capture-struct field into the lambda-local slot. + public @NotNull String renderLambdaCaptureCopyExpr(@NotNull GdType captureType, @NotNull String sourceExpr) { + TypeCheckUtil.requireNonCompilerOnly(captureType, "lambda capture copy"); + if (captureType instanceof GdObjectType || captureType instanceof GdPrimitiveType) { + return sourceExpr; + } + var copyFunc = renderCopyAssignFunctionName(captureType); + if (copyFunc.isEmpty()) { + return sourceExpr; + } + return copyFunc + "(&(" + sourceExpr + "))"; + } + + /// `free_func` cleanup for one heap capture field. Object fields release via the fat-pointer + /// live raw + cached `instance_id`; destroyable builtins use the ordinary destroy helper. + public @NotNull String renderLambdaCaptureFreeStmt(@NotNull GdType captureType, @NotNull String fieldExpr) { + TypeCheckUtil.requireNonCompilerOnly(captureType, "lambda capture free"); + if (captureType instanceof GdObjectType objectType) { + var fatType = renderObjectFatPtrStorageType(objectType); + var liveExpr = fatType + "_live_object(" + fieldExpr + ")"; + return switch (context.classRegistry().getRefCountedStatus(objectType)) { + case YES -> "release_object(" + liveExpr + ");"; + case UNKNOWN -> "try_release_object(" + liveExpr + ", " + fieldExpr + ".instance_id);"; + case NO -> ""; + }; + } + if (!captureType.isDestroyable()) { + return ""; + } + return renderDestroyFunctionName(captureType) + "(&(" + fieldExpr + "));"; + } + public @NotNull String renderValueRef(@NotNull GdType gdType, @NotNull String v) { return switch (gdType) { // Fat pointer structs and primitives are value-shaped; other value types pass storage addresses. diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java index 180c4eb0..e36b7573 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java @@ -9,6 +9,7 @@ import gd.script.gdcc.lir.insn.ConstructArrayInsn; import gd.script.gdcc.lir.insn.ConstructBuiltinInsn; import gd.script.gdcc.lir.insn.ConstructDictionaryInsn; +import gd.script.gdcc.lir.insn.ConstructLambdaInsn; import gd.script.gdcc.lir.insn.ConstructObjectInsn; import gd.script.gdcc.lir.insn.ConstructCallableInsn; import gd.script.gdcc.lir.insn.ConstructSignalInsn; @@ -45,6 +46,7 @@ /// - construct_signal /// - construct_callable /// - construct_standalone_callable +/// - construct_lambda public final class ConstructInsnGen implements CInsnGen { private record ObjectConstructTarget( @NotNull GdObjectType constructedType, @@ -62,7 +64,8 @@ private record ObjectConstructTarget( GdInstruction.CONSTRUCT_OBJECT, GdInstruction.CONSTRUCT_SIGNAL, GdInstruction.CONSTRUCT_CALLABLE, - GdInstruction.CONSTRUCT_STANDALONE_CALLABLE + GdInstruction.CONSTRUCT_STANDALONE_CALLABLE, + GdInstruction.CONSTRUCT_LAMBDA ); } @@ -162,6 +165,15 @@ case ConstructStandaloneCallableInsn(_, var kind, var ownerName, var callableNam } emitConstructStandaloneCallable(bodyBuilder, target, resultVar, kind, ownerName, callableName); } + case ConstructLambdaInsn(_, var lambdaName, var captures) -> { + if (!(resultVar.type() instanceof GdCallableType)) { + throw bodyBuilder.invalidInsn( + "Result variable ID '" + resultVar.id() + + "' must be Callable type for construct_lambda" + ); + } + emitConstructLambda(bodyBuilder, target, resultVar, lambdaName, captures); + } default -> throw bodyBuilder.invalidInsn( "Unsupported construction instruction: " + instruction.opcode().opcode() ); @@ -428,6 +440,139 @@ private void emitConstructStandaloneCallable( return found; } + private void emitConstructLambda( + @NotNull CBodyBuilder bodyBuilder, + @NotNull CBodyBuilder.TargetRef target, + @NotNull LirVariable resultVar, + @NotNull String lambdaName, + @NotNull List captures + ) { + var lambda = resolveLambdaFunction(bodyBuilder, lambdaName); + var expectedCaptures = List.copyOf(lambda.getCaptures().values()); + if (captures.size() != expectedCaptures.size()) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture count " + + captures.size() + " does not match function definition " + + expectedCaptures.size() + ); + } + for (var i = 0; i < expectedCaptures.size(); i++) { + var expected = expectedCaptures.get(i); + if (!(captures.get(i) instanceof LirInstruction.VariableOperand(var captureId))) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture #" + (i + 1) + + " must be a variable operand" + ); + } + if (!expected.getName().equals(captureId)) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture #" + (i + 1) + + " expected '" + expected.getName() + "' but got '" + captureId + "'" + ); + } + var captureVar = bodyBuilder.func().getVariableById(captureId); + if (captureVar == null) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture variable '" + + captureId + "' is not declared in the enclosing function" + ); + } + if (!bodyBuilder.helper().context().classRegistry().checkAssignable( + captureVar.type(), + expected.getType() + )) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture '" + captureId + + "' type '" + captureVar.type().getTypeName() + + "' is not assignable to '" + expected.getType().getTypeName() + "'" + ); + } + } + + var helper = bodyBuilder.helper(); + var clazz = bodyBuilder.clazz(); + var userdataExpr = "NULL"; + if (!expectedCaptures.isEmpty()) { + var captureType = helper.renderLambdaCaptureTypeName(clazz, lambda); + var blockName = bodyBuilder.newTempVariable("lambda_captures", resultVar.type()).name() + "_block"; + bodyBuilder.appendRaw(captureType + " *" + blockName + + " = (" + captureType + " *)godot_mem_alloc(sizeof(" + captureType + "));\n"); + bodyBuilder.appendRaw("if (" + blockName + " == NULL) {\n"); + bodyBuilder.appendLine(" GDCC_PRINT_RUNTIME_ERROR(\"construct_lambda failed to allocate capture block\", __func__, __FILE__, __LINE__);"); + bodyBuilder.appendRaw("} else {\n"); + for (var capture : expectedCaptures) { + var sourceVar = bodyBuilder.func().getVariableById(capture.getName()); + if (sourceVar == null) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + "' capture variable '" + + capture.getName() + "' is not declared in the enclosing function" + ); + } + var field = blockName + "->" + capture.getName(); + if (capture.getType() instanceof GdObjectType objectType) { + bodyBuilder.applyPropertyInitializerFirstWrite( + field, + objectType, + "$" + sourceVar.id(), + objectType, + CBodyBuilder.PtrKind.FAT_PTR, + CBodyBuilder.OwnershipKind.BORROWED + ); + } else { + bodyBuilder.appendRaw(field + " = " + + helper.renderLambdaCaptureCopyFromSlot(capture.getType(), sourceVar) + + ";\n"); + } + } + bodyBuilder.appendRaw("}\n"); + userdataExpr = blockName; + } + + var objectIdExpr = "0"; + if (!expectedCaptures.isEmpty() && "self".equals(expectedCaptures.getFirst().getName())) { + var selfVar = bodyBuilder.func().getVariableById("self"); + if (selfVar == null || !(selfVar.type() instanceof GdObjectType)) { + throw bodyBuilder.invalidInsn( + "construct_lambda '" + lambdaName + + "' captures self but the enclosing function has no object-typed self slot" + ); + } + objectIdExpr = "$self.instance_id"; + } + + bodyBuilder.assignVar( + target, + bodyBuilder.valueOfExpr( + "gdcc_new_lambda_callable(" + + userdataExpr + ", " + + objectIdExpr + ", " + + helper.renderLambdaCallFuncName(clazz, lambda) + ", " + + helper.renderLambdaIsValidFuncName(clazz, lambda) + ", " + + helper.renderLambdaFreeFuncName(clazz, lambda) + ", " + + helper.renderLambdaGetArgumentCountFuncName(clazz, lambda) + + ")", + resultVar.type() + ) + ); + } + + private @NotNull FunctionDef resolveLambdaFunction( + @NotNull CBodyBuilder bodyBuilder, + @NotNull String lambdaName + ) { + for (var function : bodyBuilder.clazz().getFunctions()) { + if (function.getName().equals(lambdaName)) { + if (!function.isLambda()) { + throw bodyBuilder.invalidInsn( + "construct_lambda target '" + lambdaName + "' exists but is not a lambda function" + ); + } + return function; + } + } + throw bodyBuilder.invalidInsn("construct_lambda target '" + lambdaName + "' is not defined on this class"); + } + private @NotNull String escapeCString(@NotNull String value) { return StringUtil.escapeStringLiteral(value); } diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java new file mode 100644 index 00000000..463b8756 --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java @@ -0,0 +1,248 @@ +package gd.script.gdcc.backend.c.gen; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.c.build.COptimizationLevel; +import gd.script.gdcc.backend.c.build.CProjectBuilder; +import gd.script.gdcc.backend.c.build.CProjectInfo; +import gd.script.gdcc.backend.c.build.GodotGdextensionTestRunner; +import gd.script.gdcc.backend.c.build.TargetPlatform; +import gd.script.gdcc.backend.c.build.ZigUtil; +import gd.script.gdcc.enums.GodotVersion; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirBasicBlock; +import gd.script.gdcc.lir.LirCaptureDef; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.lir.LirFunctionDef; +import gd.script.gdcc.lir.LirInstruction; +import gd.script.gdcc.lir.LirModule; +import gd.script.gdcc.lir.LirParameterDef; +import gd.script.gdcc.lir.LirPropertyDef; +import gd.script.gdcc.lir.insn.ConstructLambdaInsn; +import gd.script.gdcc.lir.insn.LiteralIntInsn; +import gd.script.gdcc.lir.insn.LiteralStringInsn; +import gd.script.gdcc.lir.insn.ReturnInsn; +import gd.script.gdcc.lir.insn.StorePropertyInsn; +import gd.script.gdcc.scope.ClassRegistry; +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.GdVoidType; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase G e2e: generated lambda Callables must call, copy captures, and bind `object_id`. +final class ConstructLambdaInsnGenEngineTest { + @Test + @DisplayName("construct_lambda should compile and run captureless / capture / self-object_id cases") + void constructLambdaShouldRunInRealGodot() throws IOException, InterruptedException { + if (!hasZig()) { + Assumptions.abort("Zig not found; skipping integration test"); + return; + } + + var tempDir = Path.of("tmp/test/construct_lambda_engine"); + Files.createDirectories(tempDir); + + var projectInfo = new CProjectInfo( + "construct_lambda_engine", + GodotVersion.V451, + tempDir, + COptimizationLevel.DEBUG, + TargetPlatform.getNativePlatform() + ); + var builder = new CProjectBuilder(); + builder.initProject(projectInfo); + + var clazz = newLambdaEngineClass(); + var module = new LirModule("construct_lambda_engine_module", List.of(clazz)); + var api = ExtensionApiLoader.loadVersion(GodotVersion.V451); + var codegen = new CCodegen(); + codegen.prepare(new CodegenContext(projectInfo, new ClassRegistry(api)), module); + + var buildResult = builder.buildProject(projectInfo, codegen); + assertTrue(buildResult.success(), "Compilation should succeed. Build log:\n" + buildResult.buildLog()); + assertFalse(buildResult.artifacts().isEmpty(), "Compilation should produce extension artifacts."); + var entrySource = Files.readString(tempDir.resolve("entry.c")); + var headerSource = Files.readString(tempDir.resolve("entry.h")); + assertTrue(entrySource.contains("gdcc_new_lambda_callable("), entrySource); + assertTrue(headerSource.contains("GDLambdaEngineNode_Capture__lambda_1"), headerSource); + assertTrue(headerSource.contains("godot_object_get_instance_from_id(captures->self.instance_id)"), headerSource); + + if (GodotGdextensionTestRunner.findGodotBinaryFromEnv() == null) { + Assumptions.abort("GODOT_BIN not found; skipping runtime integration test"); + return; + } + + var runner = new GodotGdextensionTestRunner(Path.of("test_project")); + runner.prepareProject(new GodotGdextensionTestRunner.ProjectSetup( + buildResult.artifacts(), + List.of(new GodotGdextensionTestRunner.SceneNodeSpec( + "LambdaNode", + clazz.getName(), + ".", + Map.of() + )), + new GodotGdextensionTestRunner.TestScriptSpec(testScript()) + )); + + var runResult = runner.run(true); + var combinedOutput = runResult.combinedOutput(); + assertTrue(runResult.stopSignalSeen(), "Godot run should emit stop signal.\nOutput:\n" + combinedOutput); + assertTrue(combinedOutput.contains("captureless lambda check passed."), combinedOutput); + assertTrue(combinedOutput.contains("captured string check passed."), combinedOutput); + assertTrue(combinedOutput.contains("self object_id check passed."), combinedOutput); + assertTrue(combinedOutput.contains("freed self-lambda stays invalid check passed."), combinedOutput); + assertFalse(combinedOutput.contains("check failed"), combinedOutput); + } + + private static boolean hasZig() { + return ZigUtil.findZig() != null; + } + + private static LirClassDef newLambdaEngineClass() { + var clazz = new LirClassDef("GDLambdaEngineNode", "Node"); + clazz.setSourceFile("construct_lambda_engine.gd"); + clazz.addProperty(new LirPropertyDef("flag", GdIntType.INT)); + var selfType = new GdObjectType(clazz.getName()); + + var constLambda = newLambda("_lambda_0", GdIntType.INT); + constLambda.createAndAddVariable("result", GdIntType.INT); + lambdaEntry(constLambda).appendInstruction(new LiteralIntInsn("result", 7)); + lambdaEntry(constLambda).appendInstruction(new ReturnInsn("result")); + clazz.addFunction(constLambda); + + var stringLambda = newLambda("_lambda_1", GdStringType.STRING); + stringLambda.addCapture(new LirCaptureDef("label", GdStringType.STRING, stringLambda)); + lambdaEntry(stringLambda).appendInstruction(new ReturnInsn("label")); + clazz.addFunction(stringLambda); + + var selfLambda = newLambda("_lambda_2", GdVoidType.VOID); + selfLambda.addCapture(new LirCaptureDef("self", selfType, selfLambda)); + selfLambda.createAndAddVariable("one", GdIntType.INT); + lambdaEntry(selfLambda).appendInstruction(new LiteralIntInsn("one", 1)); + lambdaEntry(selfLambda).appendInstruction(new StorePropertyInsn("flag", "self", "one")); + lambdaEntry(selfLambda).appendInstruction(new ReturnInsn(null)); + clazz.addFunction(selfLambda); + + clazz.addFunction(newConstMaker(selfType)); + clazz.addFunction(newStringMaker(selfType)); + clazz.addFunction(newSelfMaker(selfType)); + return clazz; + } + + private static LirFunctionDef newLambda(String name, gd.script.gdcc.type.GdType returnType) { + var lambda = new LirFunctionDef(name, "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(returnType); + lambda.addBasicBlock(new LirBasicBlock("entry")); + return lambda; + } + + private static LirBasicBlock lambdaEntry(LirFunctionDef lambda) { + return lambda.getBasicBlock("entry"); + } + + private static LirFunctionDef newConstMaker(GdObjectType selfType) { + var func = newMethod("make_const_cb", new GdCallableType(), selfType); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn("cb", "_lambda_0", List.of())); + entry(func).appendInstruction(new ReturnInsn("cb")); + return func; + } + + private static LirFunctionDef newStringMaker(GdObjectType selfType) { + var func = newMethod("make_label_cb", new GdCallableType(), selfType); + func.createAndAddVariable("label", GdStringType.STRING); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new LiteralStringInsn("label", "hello")); + entry(func).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_1", + List.of(new LirInstruction.VariableOperand("label")) + )); + entry(func).appendInstruction(new ReturnInsn("cb")); + return func; + } + + private static LirFunctionDef newSelfMaker(GdObjectType selfType) { + var func = newMethod("make_self_cb", new GdCallableType(), selfType); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_2", + List.of(new LirInstruction.VariableOperand("self")) + )); + entry(func).appendInstruction(new ReturnInsn("cb")); + return func; + } + + private static LirFunctionDef newMethod(String name, gd.script.gdcc.type.GdType returnType, GdObjectType selfType) { + var func = new LirFunctionDef(name); + func.setReturnType(returnType); + func.addParameter(new LirParameterDef("self", selfType, null, func)); + func.addBasicBlock(new LirBasicBlock("entry")); + func.setEntryBlockId("entry"); + return func; + } + + private static LirBasicBlock entry(LirFunctionDef functionDef) { + return functionDef.getBasicBlock("entry"); + } + + private static String testScript() { + return """ + extends Node + + const TARGET_NODE_NAME = "LambdaNode" + + func _ready() -> void: + var target = get_parent().get_node_or_null(TARGET_NODE_NAME) + if target == null: + push_error("Target node missing.") + return + + var const_cb: Callable = target.call("make_const_cb") + if const_cb.get_argument_count() == 0 and const_cb.call() == 7: + print("captureless lambda check passed.") + else: + push_error("captureless lambda check failed.") + + var label_cb: Callable = target.call("make_label_cb") + if label_cb.call() == "hello": + print("captured string check passed.") + else: + push_error("captured string check failed.") + + var self_cb: Callable = target.call("make_self_cb") + self_cb.call() + if self_cb.get_object() == target and self_cb.is_valid() and target.flag == 1: + print("self object_id check passed.") + else: + push_error("self object_id check failed.") + + var helper := Node.new() + helper.add_user_signal("fired") + helper.connect("fired", self_cb) + target.free() + helper.emit_signal("fired") + if not self_cb.is_valid(): + print("freed self-lambda stays invalid check passed.") + else: + push_error("freed self-lambda stays invalid check failed.") + helper.free() + """; + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java new file mode 100644 index 00000000..ee86a97e --- /dev/null +++ b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java @@ -0,0 +1,314 @@ +package gd.script.gdcc.backend.c.gen; + +import gd.script.gdcc.backend.CodegenContext; +import gd.script.gdcc.backend.GeneratedFile; +import gd.script.gdcc.backend.ProjectInfo; +import gd.script.gdcc.backend.c.gen.insn.ConstructInsnGen; +import gd.script.gdcc.enums.GdInstruction; +import gd.script.gdcc.enums.GodotVersion; +import gd.script.gdcc.exception.InvalidInsnException; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.lir.LirBasicBlock; +import gd.script.gdcc.lir.LirCaptureDef; +import gd.script.gdcc.lir.LirClassDef; +import gd.script.gdcc.lir.LirFunctionDef; +import gd.script.gdcc.lir.LirInstruction; +import gd.script.gdcc.lir.LirModule; +import gd.script.gdcc.lir.LirParameterDef; +import gd.script.gdcc.lir.insn.ConstructLambdaInsn; +import gd.script.gdcc.lir.insn.LiteralIntInsn; +import gd.script.gdcc.lir.insn.LiteralStringInsn; +import gd.script.gdcc.lir.insn.ReturnInsn; +import gd.script.gdcc.scope.ClassRegistry; +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.GdVoidType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase G contract: `construct_lambda` emits `gdcc_new_lambda_callable` plus a capture +/// heap block, and codegen fail-fasts when the lambda shell and operand list diverge. +final class ConstructLambdaInsnGenTest { + @Test + @DisplayName("construct_lambda opcode is registered on ConstructInsnGen") + void constructLambdaOpcodeIsRegisteredForDispatch() { + assertTrue(new ConstructInsnGen().getInsnOpcodes().contains(GdInstruction.CONSTRUCT_LAMBDA)); + } + + @Test + @DisplayName("captureless construct_lambda uses NULL userdata and object_id 0") + void capturelessConstructLambdaEmitsNullUserdataAndZeroObjectId() throws Exception { + var clazz = newTestClass(); + addCapturelessLambda(clazz); + var func = newFunction("make"); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn("cb", "_lambda_0", List.of())); + clazz.addFunction(func); + + var body = generateBody(clazz, func); + assertTrue(body.contains("gdcc_new_lambda_callable("), body); + assertTrue(body.contains("NULL"), body); + assertTrue(body.contains("Worker__lambda_0_call"), body); + assertTrue(body.contains("Worker__lambda_0_free"), body); + assertTrue(body.contains("Worker__lambda_0_is_valid"), body); + assertTrue(body.contains("Worker__lambda_0_get_argument_count"), body); + assertFalse(body.contains("godot_mem_alloc"), body); + assertFalse(body.contains("$self.instance_id"), body); + } + + @Test + @DisplayName("local int capture is copied by value into the heap capture block") + void localIntCaptureCopiesByValueIntoHeapBlock() throws Exception { + var clazz = newTestClass(); + addIntCaptureLambda(clazz); + var func = newFunction("make"); + func.createAndAddVariable("seed", GdIntType.INT); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_0", + List.of(new LirInstruction.VariableOperand("seed")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func); + assertTrue(body.contains("Worker_Capture__lambda_0 *"), body); + assertTrue(body.contains("godot_mem_alloc(sizeof(Worker_Capture__lambda_0))"), body); + assertTrue(body.contains("->seed = $seed;"), body); + assertTrue(body.contains("gdcc_new_lambda_callable("), body); + assertFalse(body.contains("$self.instance_id"), body); + } + + @Test + @DisplayName("self capture writes cached instance_id into object_id") + void selfCaptureWritesCachedInstanceId() throws Exception { + var clazz = newTestClass(); + addSelfCaptureLambda(clazz); + var func = newFunction("make"); + func.addParameter(new LirParameterDef("self", new GdObjectType(clazz.getName()), null, func)); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_0", + List.of(new LirInstruction.VariableOperand("self")) + )); + clazz.addFunction(func); + + var body = generateBody(clazz, func); + assertTrue(body.contains("gdcc_new_lambda_callable("), body); + assertTrue(body.contains("$self.instance_id"), body); + assertTrue(body.contains("->self = $self;"), body); + assertTrue(body.contains("own_object("), body); + } + + @Test + @DisplayName("unknown lambda name fail-fasts before generating a callable") + void unknownLambdaNameFailsFast() throws Exception { + var clazz = newTestClass(); + var func = newFunction("make"); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn("cb", "_lambda_missing", List.of())); + clazz.addFunction(func); + + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(clazz, func)); + assertTrue(ex.getMessage().contains("is not defined on this class"), ex.getMessage()); + } + + @Test + @DisplayName("capture count mismatch fail-fasts") + void captureCountMismatchFailsFast() throws Exception { + var clazz = newTestClass(); + addIntCaptureLambda(clazz); + var func = newFunction("make"); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn("cb", "_lambda_0", List.of())); + clazz.addFunction(func); + + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(clazz, func)); + assertTrue(ex.getMessage().contains("capture count"), ex.getMessage()); + } + + @Test + @DisplayName("capture name order mismatch fail-fasts") + void captureNameOrderMismatchFailsFast() throws Exception { + var clazz = newTestClass(); + addIntCaptureLambda(clazz); + var func = newFunction("make"); + func.createAndAddVariable("offset", GdIntType.INT); + func.createAndAddVariable("cb", new GdCallableType()); + entry(func).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_0", + List.of(new LirInstruction.VariableOperand("offset")) + )); + clazz.addFunction(func); + + var ex = assertThrows(InvalidInsnException.class, () -> generateBody(clazz, func)); + assertTrue(ex.getMessage().contains("expected 'seed'"), ex.getMessage()); + } + + @Test + @DisplayName("generate() excludes capture locals from __prepare__ and copies them in the prologue") + void generateExcludesCaptureLocalsFromPrepareAndCopiesInPrologue() throws Exception { + var clazz = newTestClass(); + var lambda = new LirFunctionDef("_lambda_0", "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(GdStringType.STRING); + lambda.addCapture(new LirCaptureDef("label", GdStringType.STRING, lambda)); + lambda.createAndAddVariable("result", GdStringType.STRING); + var lambdaEntry = new LirBasicBlock("entry"); + lambdaEntry.appendInstruction(new LiteralStringInsn("result", "ok")); + lambdaEntry.appendInstruction(new ReturnInsn("result")); + lambda.addBasicBlock(lambdaEntry); + clazz.addFunction(lambda); + + var maker = newFunction("make"); + maker.createAndAddVariable("label", GdStringType.STRING); + maker.createAndAddVariable("cb", new GdCallableType()); + entry(maker).appendInstruction(new LiteralStringInsn("label", "seed")); + entry(maker).appendInstruction(new ConstructLambdaInsn( + "cb", + "_lambda_0", + List.of(new LirInstruction.VariableOperand("label")) + )); + entry(maker).appendInstruction(new ReturnInsn(null)); + clazz.addFunction(maker); + + var files = generateFiles(clazz); + var entryC = generatedFileText(files, "entry.c"); + var entryH = generatedFileText(files, "entry.h"); + + assertTrue(entryH.contains("typedef struct Worker_Capture__lambda_0"), entryH); + assertTrue(entryH.contains("godot_String label;"), entryH); + assertTrue(entryH.contains("godot_String_destroy(&(captures->label));"), entryH); + assertTrue(entryC.contains("$label = godot_new_String_with_String(&(_capture->label));"), entryC); + assertFalse(entryC.contains("__prepare__:\n $label = godot_new_String"), entryC); + assertTrue(entryC.contains("gdcc_new_lambda_callable("), entryC); + assertTrue(entryC.contains("godot_new_String_with_String(&($label))"), entryC); + } + + @Test + @DisplayName("non-self-capturing lambda wrappers keep object_id 0 and no ObjectDB check") + void nonSelfCapturingLambdaWrappersKeepStandaloneValidity() throws Exception { + var clazz = newTestClass(); + addIntCaptureLambda(clazz); + var files = generateFiles(clazz); + var entryH = generatedFileText(files, "entry.h"); + + assertTrue(entryH.contains("Worker__lambda_0_is_valid"), entryH); + assertFalse(entryH.contains("godot_object_get_instance_from_id"), entryH); + assertTrue(entryH.contains("return true;"), entryH); + } + + @Test + @DisplayName("self-capturing lambda wrappers consult ObjectDB by cached instance_id") + void selfCapturingLambdaWrappersConsultObjectDb() throws Exception { + var clazz = newTestClass(); + addSelfCaptureLambda(clazz); + var files = generateFiles(clazz); + var entryH = generatedFileText(files, "entry.h"); + + assertTrue(entryH.contains("godot_object_get_instance_from_id(captures->self.instance_id)"), entryH); + assertTrue(entryH.contains("release_object("), entryH); + } + + private static void addCapturelessLambda(LirClassDef clazz) { + var lambda = new LirFunctionDef("_lambda_0", "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(GdIntType.INT); + lambda.createAndAddVariable("result", GdIntType.INT); + var entry = new LirBasicBlock("entry"); + entry.appendInstruction(new LiteralIntInsn("result", 7)); + entry.appendInstruction(new ReturnInsn("result")); + lambda.addBasicBlock(entry); + clazz.addFunction(lambda); + } + + private static void addIntCaptureLambda(LirClassDef clazz) { + var lambda = new LirFunctionDef("_lambda_0", "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(GdIntType.INT); + lambda.addCapture(new LirCaptureDef("seed", GdIntType.INT, lambda)); + var entry = new LirBasicBlock("entry"); + entry.appendInstruction(new ReturnInsn("seed")); + lambda.addBasicBlock(entry); + clazz.addFunction(lambda); + } + + private static void addSelfCaptureLambda(LirClassDef clazz) { + var lambda = new LirFunctionDef("_lambda_0", "entry"); + lambda.setLambda(true); + lambda.setHidden(true); + lambda.setStatic(true); + lambda.setReturnType(GdVoidType.VOID); + lambda.addCapture(new LirCaptureDef("self", new GdObjectType(clazz.getName()), lambda)); + var entry = new LirBasicBlock("entry"); + entry.appendInstruction(new ReturnInsn(null)); + lambda.addBasicBlock(entry); + clazz.addFunction(lambda); + } + + private static LirClassDef newTestClass() { + return new LirClassDef("Worker", "RefCounted"); + } + + private static LirFunctionDef newFunction(String name) { + var func = new LirFunctionDef(name); + func.setReturnType(GdVoidType.VOID); + var entry = new LirBasicBlock("entry"); + func.addBasicBlock(entry); + func.setEntryBlockId("entry"); + return func; + } + + private static LirBasicBlock entry(LirFunctionDef functionDef) { + return functionDef.getBasicBlock("entry"); + } + + private static String generateBody(LirClassDef clazz, LirFunctionDef func) throws Exception { + var module = new LirModule("test_module", List.of(clazz)); + var codegen = newCodegen(module); + return codegen.generateFuncBody(clazz, func); + } + + private static List generateFiles(LirClassDef clazz) throws Exception { + var module = new LirModule("test_module", List.of(clazz)); + var codegen = newCodegen(module); + return codegen.generate(); + } + + private static CCodegen newCodegen(LirModule module) throws Exception { + var classRegistry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + classRegistry.addGdccClass(module.getClassDefs().getFirst()); + ProjectInfo projectInfo = new ProjectInfo("TestProject", GodotVersion.V451, Path.of(".")) { + }; + var ctx = new CodegenContext(projectInfo, classRegistry); + var codegen = new CCodegen(); + codegen.prepare(ctx, module); + return codegen; + } + + private static String generatedFileText(List files, String filePath) { + for (var file : files) { + if (file.filePath().equals(filePath)) { + return new String(file.contentWriter()); + } + } + throw new AssertionError("Missing generated file: " + filePath); + } +} diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java index ff9408d4..f714f5a7 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java @@ -23,6 +23,7 @@ import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /// Anchors the Phase A `func.ftl` contract: a lambda with more than one capture still emits @@ -52,6 +53,16 @@ void lambdaWithOneCaptureStillEmitsSingleCaptureParameter() throws Exception { assertTrue(header.contains("godot_int seed;"), header); } + @Test + void capturelessLambdaOmitsEmptyCaptureStructAndParameter() throws Exception { + var header = renderHeader(lambdaWithCaptures()); + + assertFalse(header.contains("typedef struct Hero_Capture__lambda_0"), header); + assertEquals(0, countOccurrences(header, "_capture"), header); + assertTrue(header.contains("Hero__lambda_0_call("), header); + assertTrue(header.contains("Hero__lambda_0_free("), header); + } + @Test void nonLambdaDoesNotEmitCaptureParameter() throws Exception { var ordinary = new LirFunctionDef("run", "entry"); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java new file mode 100644 index 00000000..34fc40f7 --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java @@ -0,0 +1,142 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Node; +import dev.superice.gdparser.frontend.ast.SourceFile; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.diagnostic.DiagnosticSnapshot; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.FrontendSourceUnit; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.frontend.sema.analyzer.FrontendSemanticAnalyzer; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.scope.ClassRegistry; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Phase H expression-type anchors (frontend_lambda_plan.md §阶段H / §3.3). +/// +/// Recorded lambdas publish `RESOLVED(Callable)`. Unrecorded ones stay fail-closed without +/// poisoning a sibling recorded lambda in the same module. +final class FrontendLambdaExpressionTypeTest { + @Test + void recordedLambdaPublishesResolvedCallableType() throws Exception { + var analyzed = analyze("lambda_expr_type_happy.gd", """ + class_name LambdaExprTypeHappy + extends RefCounted + + func ping(): + var cb := func(x: int): + return x + """); + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var expressionType = analyzed.analysisData().expressionTypes().get(lambda); + + assertNotNull(analyzed.analysisData().lambdaPlans().get(lambda)); + assertNotNull(expressionType); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, expressionType.status()); + assertEquals("Callable", expressionType.publishedType().getTypeName()); + assertTrue(analyzed.diagnostics().asList().stream().noneMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_expression_route") + )); + } + + @Test + void unrecordedPropertyInitializerStaysUnsupportedWhileSiblingRecordedLambdaPublishes() throws Exception { + var analyzed = analyze("lambda_expr_type_negative.gd", """ + class_name LambdaExprTypeNegative + extends RefCounted + + var leftover = func(): + return 1 + + func ping(): + var cb := func(): + return 2 + """); + var pingFunction = findFunction(analyzed.unit().ast(), "ping"); + var recorded = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); + var leftover = findNode( + analyzed.unit().ast(), + LambdaExpression.class, + lambda -> lambda != recorded + ); + + assertNull(analyzed.analysisData().lambdaPlans().get(leftover)); + assertNotNull(analyzed.analysisData().lambdaPlans().get(recorded)); + var leftoverType = analyzed.analysisData().expressionTypes().get(leftover); + var recordedType = analyzed.analysisData().expressionTypes().get(recorded); + assertTrue( + leftoverType == null || leftoverType.status() != FrontendExpressionTypeStatus.RESOLVED, + () -> String.valueOf(leftoverType) + ); + assertNotNull(recordedType); + assertEquals(FrontendExpressionTypeStatus.RESOLVED, recordedType.status()); + assertEquals("Callable", recordedType.publishedType().getTypeName()); + assertTrue(analyzed.diagnostics().asList().stream().anyMatch(diagnostic -> + diagnostic.category().equals("sema.unsupported_expression_route") + )); + } + + private static @NotNull AnalyzedInput analyze(@NotNull String fileName, @NotNull String source) throws Exception { + var parserService = new GdScriptParserService(); + var diagnostics = new DiagnosticManager(); + var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnostics); + assertTrue(diagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + diagnostics.snapshot()); + var analysisData = new FrontendSemanticAnalyzer().analyze( + new FrontendModule("test_module", List.of(unit)), + new ClassRegistry(ExtensionApiLoader.loadDefault()), + diagnostics + ); + return new AnalyzedInput(unit, analysisData, diagnostics.snapshot()); + } + + private static @NotNull FunctionDeclaration findFunction(@NotNull SourceFile sourceFile, @NotNull String name) { + return findNode(sourceFile, FunctionDeclaration.class, functionDeclaration -> functionDeclaration.name().equals(name)); + } + + private static @NotNull T findNode( + @NotNull Node root, + @NotNull Class nodeType, + @NotNull Predicate predicate + ) { + var matches = new ArrayList(); + collectMatchingNodes(root, nodeType, predicate, matches); + return matches.stream() + .findFirst() + .orElseThrow(() -> new AssertionError("Node not found: " + nodeType.getSimpleName())); + } + + private static void collectMatchingNodes( + @NotNull Node node, + @NotNull Class nodeType, + @NotNull Predicate predicate, + @NotNull List matches + ) { + if (nodeType.isInstance(node) && predicate.test(nodeType.cast(node))) { + matches.add(nodeType.cast(node)); + } + for (var child : node.getChildren()) { + collectMatchingNodes(child, nodeType, predicate, matches); + } + } + + private record AnalyzedInput( + @NotNull FrontendSourceUnit unit, + @NotNull FrontendAnalysisData analysisData, + @NotNull DiagnosticSnapshot diagnostics + ) { + } +} diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java new file mode 100644 index 00000000..8533e224 --- /dev/null +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java @@ -0,0 +1,184 @@ +package gd.script.gdcc.frontend.sema; + +import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.LambdaExpression; +import dev.superice.gdparser.frontend.ast.Statement; +import dev.superice.gdparser.frontend.ast.VariableDeclaration; +import gd.script.gdcc.frontend.diagnostic.DiagnosticManager; +import gd.script.gdcc.frontend.diagnostic.DiagnosticSnapshot; +import gd.script.gdcc.frontend.diagnostic.FrontendDiagnostic; +import gd.script.gdcc.frontend.parse.FrontendModule; +import gd.script.gdcc.frontend.parse.FrontendSourceUnit; +import gd.script.gdcc.frontend.parse.GdScriptParserService; +import gd.script.gdcc.frontend.scope.BlockScope; +import gd.script.gdcc.frontend.scope.CallableScope; +import gd.script.gdcc.frontend.sema.analyzer.FrontendScopeAnalyzer; +import gd.script.gdcc.frontend.sema.analyzer.FrontendVariableAnalyzer; +import gd.script.gdcc.gdextension.ExtensionApiLoader; +import gd.script.gdcc.scope.ClassRegistry; +import gd.script.gdcc.scope.ScopeValueKind; +import gd.script.gdcc.type.GdIntType; +import gd.script.gdcc.type.GdVariantType; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.assertTrue; + +/// Phase H inventory anchors (frontend_lambda_plan.md §阶段H / §3.2). +/// +/// The existing `FrontendVariableAnalyzerTest` already covers the Phase B walk. This class +/// keeps a smaller happy/negative pair so the named Phase H surface stays explicit. +final class FrontendLambdaInventoryTest { + @Test + void analyzeBindsSupportedLambdaInventoryAndLeavesPlanUnpublished() throws Exception { + var phaseInput = publishedPhaseInput("lambda_inventory_happy.gd", """ + class_name LambdaInventoryHappy + extends Node + + func ping(seed: int): + var builder := func(item: int): + var lambda_local := item + return lambda_local + seed + """); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var builderDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("builder") + ); + var builderLambda = assertInstanceOf(LambdaExpression.class, builderDeclaration.value()); + var lambdaScope = assertInstanceOf(CallableScope.class, phaseInput.analysisData().scopesByAst().get(builderLambda)); + var lambdaBodyScope = assertInstanceOf(BlockScope.class, phaseInput.analysisData().scopesByAst().get(builderLambda.body())); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + assertEquals(0, newDiagnostics(diagnosticsBefore, phaseInput.diagnostics().snapshot()).size()); + assertEquals(ScopeValueKind.PARAMETER, requireHere(lambdaScope, "item").kind()); + assertEquals(GdIntType.INT, requireHere(lambdaScope, "item").type()); + assertEquals(ScopeValueKind.LOCAL, requireHere(lambdaBodyScope, "lambda_local").kind()); + var seedCapture = requireHere(lambdaScope, "seed"); + assertEquals(ScopeValueKind.CAPTURE, seedCapture.kind()); + assertEquals(GdVariantType.VARIANT, seedCapture.type()); + assertSame(pingFunction.parameters().getFirst(), seedCapture.declaration()); + assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); + } + + @Test + void analyzeSkipsPropertyInitializerLambdaAndStillBindsSiblingFunctionInventory() throws Exception { + var phaseInput = publishedPhaseInput("lambda_inventory_negative.gd", """ + class_name LambdaInventoryNegative + extends Node + + var cb = func(item): + return item + + func ping(seed: int): + var builder := func(): + return seed + return seed + """); + var propertyDeclaration = findStatement( + phaseInput.unit().ast().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("cb") + ); + var propertyLambda = assertInstanceOf(LambdaExpression.class, propertyDeclaration.value()); + var pingFunction = findStatement( + phaseInput.unit().ast().statements(), + FunctionDeclaration.class, + functionDeclaration -> functionDeclaration.name().equals("ping") + ); + var builderDeclaration = findStatement( + pingFunction.body().statements(), + VariableDeclaration.class, + variableDeclaration -> variableDeclaration.name().equals("builder") + ); + var builderLambda = assertInstanceOf(LambdaExpression.class, builderDeclaration.value()); + var diagnosticsBefore = phaseInput.diagnostics().snapshot(); + + new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); + + assertEquals(0, newDiagnostics(diagnosticsBefore, phaseInput.diagnostics().snapshot()).size()); + var propertyScope = assertInstanceOf( + CallableScope.class, + phaseInput.analysisData().scopesByAst().get(propertyLambda) + ); + assertNull(propertyScope.resolveValueHere("item")); + var builderScope = assertInstanceOf( + CallableScope.class, + phaseInput.analysisData().scopesByAst().get(builderLambda) + ); + assertEquals(ScopeValueKind.CAPTURE, requireHere(builderScope, "seed").kind()); + assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); + } + + private static PhaseInput publishedPhaseInput(@NotNull String fileName, @NotNull String source) throws Exception { + var parserService = new GdScriptParserService(); + var diagnostics = new DiagnosticManager(); + var unit = parserService.parseUnit(Path.of("tmp", fileName), source, diagnostics); + assertTrue(diagnostics.isEmpty(), () -> "Unexpected parse diagnostics: " + diagnostics.snapshot()); + var registry = new ClassRegistry(ExtensionApiLoader.loadDefault()); + var analysisData = FrontendAnalysisData.bootstrap(); + var moduleSkeleton = new FrontendClassSkeletonBuilder().build( + new FrontendModule("test_module", List.of(unit)), + registry, + diagnostics, + analysisData + ); + analysisData.updateModuleSkeleton(moduleSkeleton); + analysisData.updateDiagnostics(diagnostics.snapshot()); + new FrontendScopeAnalyzer().analyze(registry, analysisData, diagnostics); + analysisData.updateDiagnostics(diagnostics.snapshot()); + return new PhaseInput(unit, analysisData, diagnostics); + } + + private static @NotNull gd.script.gdcc.scope.ScopeValue requireHere( + @NotNull gd.script.gdcc.scope.Scope scope, + @NotNull String name + ) { + var value = scope.resolveValueHere(name); + assertNotNull(value, name); + return value; + } + + private static @NotNull List newDiagnostics( + @NotNull DiagnosticSnapshot before, + @NotNull DiagnosticSnapshot after + ) { + return after.asList().subList(before.size(), after.size()); + } + + private static T findStatement( + @NotNull List statements, + @NotNull Class statementType, + @NotNull Predicate predicate + ) { + return statements.stream() + .filter(statementType::isInstance) + .map(statementType::cast) + .filter(predicate) + .findFirst() + .orElseThrow(() -> new AssertionError("Statement not found: " + statementType.getSimpleName())); + } + + private record PhaseInput( + @NotNull FrontendSourceUnit unit, + @NotNull FrontendAnalysisData analysisData, + @NotNull DiagnosticManager diagnostics + ) { + } +} From 64c1b1afb1d375a6ab2b6d77eb8bee0eee2ae5b3 Mon Sep 17 00:00:00 2001 From: Iridium-Zero Date: Wed, 19 Aug 2026 00:14:28 +0800 Subject: [PATCH 9/9] feat(frontend): complete lambda validation and integration - Finalize lambda semantic analysis, capture propagation, and lowering across nested scopes - Add compile-time diagnostics for invalid and unsupported lambda constructs - Align Callable construction and generated capture handling with backend runtime requirements - Expand unit and integration coverage for lambdas and signal connections - Consolidate frontend lambda documentation and implementation tracking --- ...e_resolution_pipeline_execution_summary.md | 6 +- ...ntend_semantic_analyzer_research_report.md | 2 +- doc/benchmark.md | 2 +- .../frontend/diagnostic_manager.md | 22 +- ..._chain_binding_expr_type_implementation.md | 4 +- ...d_compile_check_analyzer_implementation.md | 19 +- ..._complex_writable_target_implementation.md | 4 +- ...ontend_container_literal_implementation.md | 4 +- ...nd_dynamic_call_lowering_implementation.md | 4 +- .../frontend_lambda_implementation.md | 588 +++++++ .../frontend/frontend_lambda_plan.md | 1360 ----------------- ...local_type_stabilization_implementation.md | 4 +- ...op_control_flow_analyzer_implementation.md | 2 +- ...ontend_lowering_cfg_pass_implementation.md | 18 +- ...d_lowering_func_pre_pass_implementation.md | 6 +- .../frontend/frontend_lowering_plan.md | 1 - ...wering_skeleton_pre_pass_implementation.md | 8 +- ...tend_resolution_pipeline_implementation.md | 11 +- doc/module_impl/frontend/frontend_rules.md | 10 +- .../frontend/frontend_signal_support.md | 25 +- ...end_top_binding_analyzer_implementation.md | 12 +- ...tend_type_check_analyzer_implementation.md | 2 +- ...ontend_variable_analyzer_implementation.md | 3 +- ...d_visible_value_resolver_implementation.md | 6 +- .../scope_architecture_refactor_plan.md | 4 +- ...t_suite_engine_integration_known_limits.md | 14 +- doc/test_suite.md | 1 + .../backend/c/gen/insn/ConstructInsnGen.java | 6 +- .../lowering/FunctionLoweringContext.java | 2 +- .../lowering/cfg/FrontendCfgGraphBuilder.java | 4 +- .../cfg/item/LambdaConstructItem.java | 4 +- .../pass/FrontendLoweringBodyInsnPass.java | 2 +- .../pass/FrontendLoweringBuildCfgPass.java | 2 +- ...ontendLoweringFunctionPreparationPass.java | 10 +- ...endSequenceItemInsnLoweringProcessors.java | 2 +- .../frontend/sema/FrontendAnalysisData.java | 4 +- .../FrontendBodySemanticSupportPolicy.java | 8 +- .../sema/FrontendLambdaCapturePlanner.java | 8 +- .../frontend/sema/FrontendLambdaPlan.java | 5 +- ...rontendSyntheticPropertyHelperSupport.java | 4 +- .../frontend/sema/LambdaCaptureEntry.java | 4 +- .../analyzer/FrontendBodyOwnerProcedures.java | 13 +- .../FrontendCompileCheckAnalyzer.java | 37 +- .../sema/analyzer/FrontendSuiteResolver.java | 12 +- .../analyzer/FrontendTypeCheckAnalyzer.java | 4 +- .../analyzer/FrontendVariableAnalyzer.java | 34 +- .../FrontendExpressionSemanticSupport.java | 2 +- .../FrontendVisibleValueResolver.java | 2 +- .../gen/ConstructLambdaInsnGenEngineTest.java | 2 +- .../c/gen/ConstructLambdaInsnGenTest.java | 4 +- .../c/gen/FuncHeaderCaptureTemplateTest.java | 2 +- .../lowering/FrontendLambdaLoweringTest.java | 96 +- ...FrontendBodySemanticSupportPolicyTest.java | 4 +- .../sema/FrontendClassSkeletonTest.java | 2 +- .../FrontendLambdaCapturePlannerTest.java | 2 +- .../FrontendLambdaExpressionTypeTest.java | 2 +- .../sema/FrontendLambdaInventoryTest.java | 6 +- .../sema/FrontendLambdaPlanSideTableTest.java | 4 +- .../FrontendLambdaSuiteResolutionTest.java | 6 +- .../sema/FrontendVariableAnalyzerTest.java | 14 +- ...ontendBodyOwnerProceduresExprTypeTest.java | 2 +- .../FrontendCompileCheckAnalyzerTest.java | 202 ++- .../FrontendTypeCheckAnalyzerTest.java | 2 +- .../FrontendVisibleValueResolverTest.java | 2 +- .../GdScriptUnitTestCompileRunnerTest.java | 15 + .../unit_test/script/lambda/captures.gd | 61 + .../script/lambda/control_flow_bodies.gd | 60 + .../script/lambda/self_nested_return.gd | 57 + .../script/lambda/signal_and_engine.gd | 41 + .../script/lambda/value_call_and_arity.gd | 62 + .../script/member/signal_connect_lambda.gd | 17 + .../unit_test/validation/lambda/captures.gd | 47 + .../validation/lambda/control_flow_bodies.gd | 35 + .../validation/lambda/self_nested_return.gd | 39 + .../validation/lambda/signal_and_engine.gd | 36 + .../validation/lambda/value_call_and_arity.gd | 55 + .../member/signal_connect_lambda.gd | 20 + 77 files changed, 1581 insertions(+), 1627 deletions(-) create mode 100644 doc/module_impl/frontend/frontend_lambda_implementation.md delete mode 100644 doc/module_impl/frontend/frontend_lambda_plan.md create mode 100644 src/test/test_suite/unit_test/script/lambda/captures.gd create mode 100644 src/test/test_suite/unit_test/script/lambda/control_flow_bodies.gd create mode 100644 src/test/test_suite/unit_test/script/lambda/self_nested_return.gd create mode 100644 src/test/test_suite/unit_test/script/lambda/signal_and_engine.gd create mode 100644 src/test/test_suite/unit_test/script/lambda/value_call_and_arity.gd create mode 100644 src/test/test_suite/unit_test/script/member/signal_connect_lambda.gd create mode 100644 src/test/test_suite/unit_test/validation/lambda/captures.gd create mode 100644 src/test/test_suite/unit_test/validation/lambda/control_flow_bodies.gd create mode 100644 src/test/test_suite/unit_test/validation/lambda/self_nested_return.gd create mode 100644 src/test/test_suite/unit_test/validation/lambda/signal_and_engine.gd create mode 100644 src/test/test_suite/unit_test/validation/lambda/value_call_and_arity.gd create mode 100644 src/test/test_suite/unit_test/validation/member/signal_connect_lambda.gd diff --git a/doc/analysis/frontend_segmented_type_resolution_pipeline_execution_summary.md b/doc/analysis/frontend_segmented_type_resolution_pipeline_execution_summary.md index c5a32911..6bc5e050 100644 --- a/doc/analysis/frontend_segmented_type_resolution_pipeline_execution_summary.md +++ b/doc/analysis/frontend_segmented_type_resolution_pipeline_execution_summary.md @@ -102,7 +102,7 @@ Body procedure 必须是 root-bounded、statement-local 的实现。每个 owner - `WhileStatement`。 - `ForStatement`:header-only statement boundary 完成后,通过普通 `resolveChildSuite(...)` 进入结构完整的 `FOR_BODY`。 -`MatchStatement`、`LambdaExpression` 与 block-local `const` 仍保持结构性 deferred / unsupported;它们不创建 lifecycle state,也不能因 typed result 改变 body entry。 +`MatchStatement` 与 block-local `const` 仍保持结构性 deferred / unsupported;它们不创建 lifecycle state,也不能因 typed result 改变 body entry。已记录 `LambdaExpression` 已转正为 nested callable owner;未记录 lambda 保持 fail-closed。 ## 5. Statement 内 Owner 顺序 @@ -274,8 +274,8 @@ Guard 必须在 pending overlay write 时 fail-fast,不能等 suite export 时 Resolver 保留三类彼此独立的结构检查: 1. Request-domain hard boundary:只有 `EXECUTABLE_BODY` 进入 ordinary lookup。 -2. AST boundary:parameter default、lambda、match 与 block-local `const` 返回精确 deferred domain;for header/body edge 已转正。 -3. Current-scope backstop:lambda callable/body 与 match section body 即使 AST edge 缺失也继续 fail-closed;`FOR_BODY` 是 supported executable scope。 +2. AST boundary:parameter default、match 与 block-local `const` 返回精确 deferred domain;for header/body edge 与已记录 lambda AST 边已转正。 +3. Current-scope backstop:`MATCH_SECTION_BODY` 即使 AST edge 缺失也继续 fail-closed;`FOR_BODY` 与已记录 lambda 的 `LAMBDA_BODY` / `LAMBDA_EXPRESSION` 是 supported executable scope。未记录 lambda 保持 fail-closed。 这些检查不读取 typed overlay 以决定结构支持,也没有 pending/published lifecycle。`FrontendSuiteContext` 根据 structural policy 创建 request;`FrontendSuiteResolver` 在进入 root/child body前使用 completeness certificate 验证 interface facts。Typed overlay 只改变过滤通过后的 effective type/binding payload。 diff --git a/doc/analysis/frontend_semantic_analyzer_research_report.md b/doc/analysis/frontend_semantic_analyzer_research_report.md index 58d08b7c..d5588b8f 100644 --- a/doc/analysis/frontend_semantic_analyzer_research_report.md +++ b/doc/analysis/frontend_semantic_analyzer_research_report.md @@ -393,7 +393,7 @@ 1. **没有 frontend -> LIR lowering。** 当前语义主链仍止步于 side table、diagnostics 与 class skeleton;函数体不会继续产生 LIR。 2. **compile-ready 与 shared semantic 仍然分离。** `FrontendCompileCheckAnalyzer` 已经提供 compile-only final gate,但 `ConditionalExpression`、`ArrayExpression`、`DictionaryExpression`、`PreloadExpression`、`GetNodeExpression`、`CastExpression`、`TypeTestExpression` 与 `assert` 仍需要在进入 lowering 前被显式封口。 -3. **若干 executable-body 域仍保持 deferred / unsupported。** 参数默认值、lambda capture、`match` pattern binding、block-local `const` 等尚未进入当前正式支持面。`for` iterator/body inventory 已在后续阶段 B/D0/L 转正,但 iteration planning 与 lowering route 仍未完成。 +3. **若干 executable-body 域仍保持 deferred / unsupported。** 参数默认值、`match` pattern binding、block-local `const` 等尚未进入当前正式支持面。已记录 lambda 的 param / local / capture inventory 与 compile gate 已转正。`for` iterator/body inventory 与已注册 route 的 lowering 已落地;`OBJECT_CUSTOM` 仍未注册 contract。 4. **`self` 核心语义已经接通,但 signal use-site 与 coroutine 语义仍未闭环。** 当前代码已经支持 `self` 的 top binding 发布、static context fail-closed、property initializer fail-closed,以及将 `self` 解析为当前类实例 receiver;仍未形成稳定 frontend 合同的是 `.emit(...)`、`await signal` 等 signal/coroutine use-site,以及更完整的 context-sensitive diagnostics。 5. **property initializer 仍不是完整实例初始化模型。** 当前支持面是“published subtree facts”,而不是 declaration-order / default-state / cycle-aware 的 class-member initializer 语义。 6. **header superclass 的支持面仍受 MVP 限制。** path-based `extends`、autoload superclass、global-script-class superclass 与跨多个 gdcc module 的 superclass 绑定依然没有接通。 diff --git a/doc/benchmark.md b/doc/benchmark.md index 922a7c6b..41a3af24 100644 --- a/doc/benchmark.md +++ b/doc/benchmark.md @@ -233,7 +233,7 @@ Bundled benchmark case families currently cover: Authoring constraints: - keep compiled benchmark scripts within the currently supported frontend and backend feature set -- avoid unsupported constructs in compiled scripts such as `for`, `match`, and `lambda` +- avoid unsupported constructs in compiled scripts such as `match`; `for` and recorded `lambda` are now compile-ready, but keep new cases on already-proven surfaces unless the benchmark is specifically exercising them - avoid array and dictionary literal patterns that compile mode still rejects - for stateful cases, reset reusable state in `prepare()` instead of relying on cross-sample carry-over - when `prepare()` rebuilds state, reset auxiliary counters and cached derived data there as well so diff --git a/doc/module_impl/frontend/diagnostic_manager.md b/doc/module_impl/frontend/diagnostic_manager.md index fc2049e6..87297a59 100644 --- a/doc/module_impl/frontend/diagnostic_manager.md +++ b/doc/module_impl/frontend/diagnostic_manager.md @@ -5,8 +5,8 @@ ## 文档状态 -- 状态:事实源维护中(parser / skeleton / scope / variable / top-binding / chain-binding / expr-typing / type-check / loop-control / compile-check / exception 诊断链路已落地) -- 更新时间:2026-08-14 +- 状态:事实源维护中(parser / skeleton / scope / variable / top-binding / chain-binding / expr-typing / type-check / loop-control / compile-check / exception 诊断链路已落地;已记录 lambda 的 compile gate 已解封) +- 更新时间:2026-08-18 - 适用范围: - `src/main/java/gd/script/gdcc/frontend/diagnostic/**` - `src/main/java/gd/script/gdcc/frontend/parse/**` @@ -17,6 +17,7 @@ - `doc/module_impl/frontend/scope_architecture_refactor_plan.md` - `doc/module_impl/frontend/frontend_loop_control_flow_analyzer_implementation.md` - `doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_lambda_implementation.md` - `doc/analysis/frontend_semantic_analyzer_research_report.md` --- @@ -133,7 +134,7 @@ frontend 当前已经冻结的诊断承载方式如下: - `forIterationPlans` - `typeTestTargets` - `containerLiteralPlans` -- `lambdaPlans`(阶段 A 数据面已接线;阶段 B 把 lambda inventory 绑定进 scope;阶段 C 起由 nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充;阶段 D 起已 record lambda 的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`;阶段 E 起 plan 携带 `returnType`——nested resolve 入口一次解析的声明返回类型,type-check return slot 与 lowering shell 共用) +- `lambdaPlans`(nested resolve 入口经独立 `LAMBDA_RESOLUTION` owner 首次发布完整 plan,capture 声明处类型已填充;已 record lambda 的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`;plan 携带 `returnType`——nested resolve 入口一次解析的声明返回类型,type-check return slot 与 lowering shell 共用) 其中: @@ -221,7 +222,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - variable analyzer 对 parameter default value 当前尚未接线时发出的 feature-boundary error - `sema.unsupported_variable_inventory_subtree` - variable analyzer 对 `match` / block-local `const` inventory 边界发出的 feature-boundary error - - lambda 已在阶段 B 移出该边界:supported executable body 内的 lambda 会绑定 param / local / capture,不再发此诊断 + - lambda 已移出该边界:supported executable body 内的 lambda 会绑定 param / local / capture,不再发此诊断 - `sema.variable_slot_publication` - var-type-post analyzer 对 supported callable-local `var` 因 earlier duplicate/shadowing reject 而无法发布 `slotTypes()` 时发出的 warning - warning message 若能在当前 callable 边界内找到幸存的 accepted local / parameter / capture,必须带出该幸存绑定的语义类别与声明位置 @@ -230,7 +231,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - top binding 命中的 blocked / unknown / shadowing 诊断 - `sema.unsupported_binding_subtree` - top binding 对 parameter default、`match`、block-local `const` 等明确 unsupported subtree 的边界 error - - 已 `recordCallable` 的 lambda 自阶段 C 起改走 nested suite resolution,不再发此诊断;未记录的 lambda(property initializer 等)继续按此边界 fail-closed + - 已 `recordCallable` 的 lambda 改走 nested suite resolution,不再发此诊断;未记录的 lambda(property initializer 等)继续按此边界 fail-closed - top binding 对 missing-scope / skipped subtree 的恢复诊断继续允许使用 warning - `sema.member_resolution` - chain binding 中 blocked / failed member step 的语义错误 @@ -248,7 +249,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - expr analyzer 对 assignment / subscript / generic deferred expression 等 expression-only deferred root 的 warning - `sema.unsupported_expression_route` - expr analyzer 对当前明确不支持的 direct-callable-invocation 等 expression route 的 error - - 已 `recordCallable` 的 lambda 自阶段 D 起不再发此诊断(表达式类型已转正为 `RESOLVED(GdCallableType)`);未记录的 lambda(property initializer / parameter default / skipped subtree)继续按此边界 fail-closed + - 已 `recordCallable` 的 lambda 不再发此诊断(表达式类型已转正为 `RESOLVED(GdCallableType)`);未记录的 lambda(property initializer / parameter default / skipped subtree)继续按此边界 fail-closed - `sema.discarded_expression` - expr analyzer 对 bare expression statement 中被丢弃的非 `void` 结果发出的 warning - `sema.unsafe_call_argument` @@ -283,13 +284,14 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - `sema.compile_check` - compile-only `FrontendCompileCheckAnalyzer` 对进入 lowering 前仍不可编译的 surface 发出的最终 error - 同时覆盖: - - 当前首批显式封口的 `assert`、`ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`,以及按 route-aware policy 处理的 `ForStatement`;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression` 与 `CastExpression` 不属于显式封口列表 + - 当前首批显式封口的 `assert`、`ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`,以及按 route-aware policy 处理的 `ForStatement`;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression`、`CastExpression` 与已记录 `LambdaExpression` 不属于无条件显式封口列表 - compile surface 上 `expressionTypes()` / `resolvedMembers()` / `resolvedCalls()` 中仍残留的 `BLOCKED` / `DEFERRED` / `FAILED` / `UNSUPPORTED` - feature-specific RESOLVED blocker:当前仅 Dictionary 实例 method-reference(`METHOD && BUILTIN && GdDictionaryType`)与 builtin type-meta static method-reference(`STATIC_METHOD && ownerKind == BUILTIN`)。signal 值读取、`.emit`、`.connect`/`.disconnect`、Object/self `METHOD`、非 Dictionary builtin 实例、GDCC/engine 静态与 bare utility 值读取已放行。bare blocker 按 published `symbolBindings().kind()` 定位,必须排除 `CallExpression.callee()`。详见 `frontend_signal_support.md` 与 `frontend_compile_check_analyzer_implementation.md` §4.2。 - supported callable-local `var` 因 `sema.variable_slot_publication` warning 仍缺失 `slotTypes()` 的 lowering-only fact 缺洞 - `assert` 在这里仍只是 compile-only blocked;共享 type-check 继续保留 Godot-compatible condition contract,不把它回退成 strict-bool `sema.type_check` - `ForStatement` 已进入 shared semantic 并由 compile gate 按 route-aware policy 处理:读取 `forIterationPlans()` 与 `ForLoweringContractRegistry`,已注册 lowering contract 的 route 放行并进入 body 重扫 facts,未注册 contract 的 route(当前 `OBJECT_CUSTOM`)在 statement root 发 route-not-ready blocker(说明缺少 lowering route,而非 `FOR_SUBTREE` unsupported);已注册 route 的 CFG/body lowering 已落地 - - 上述 3 类表达式(即 `ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`,不含 statement 级 `assert` 与 route-aware 的 `ForStatement`;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression` 与 `CastExpression` 已完成 lowering/backend 闭环)属于 frontend 已识别但 lowering 尚未接通的 temporary compile intercept,不代表 parser / grammar / shared semantic 路径已经把它们判成不支持语法 + - 已记录 `LambdaExpression`(published `FrontendLambdaPlan` + body)放上 compile surface 并递归扫描 body facts;未记录 lambda 保持 fail-closed,且不得在上游 unsupported owner 上重复包一层 `sema.compile_check`。合同见 `frontend_lambda_implementation.md` + - 上述 3 类表达式(即 `ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`,不含 statement 级 `assert` 与 route-aware 的 `ForStatement`;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression`、`CastExpression` 与已记录 `LambdaExpression` 已完成 lowering/backend 闭环)属于 frontend 已识别但 lowering 尚未接通的 temporary compile intercept,不代表 parser / grammar / shared semantic 路径已经把它们判成不支持语法 - `ConditionalExpression` 当前单独被列入这份清单,是因为真正的 value-merge / branch-result materialization 尚未接通;compile gate 必须继续拦截。CFG 构建已由 `FrontendLoweringBuildCfgPass` 承接,不再依赖已移除的 metadata-only `FrontendLoweringCfgPass` - `DYNAMIC` 不属于 compile blocker;它保留为 frontend 已接受的 runtime-open 事实,而不是 lowering 未实现状态 - 该 category 只属于 compile-only 入口,不属于默认共享语义 / inspection / 未来 LSP 入口 @@ -351,7 +353,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - `FrontendSemanticAnalyzer` 当前返回 `FrontendAnalysisData` - analyze 流程围绕同一份共享分析数据推进 - interface/body suite resolver 路径会在每个 body statement boundary 刷新 `FrontendAnalysisData.diagnostics()`,让后一 statement 能读取 current-suite upstream diagnostic snapshot;suite export 在 patch transaction 应用后保留最终 body snapshot -- 已 `recordCallable` 的 lambda 由外层 statement 的 top-binding owner 触发 nested suite resolution:独立 `FrontendCallableExportBatch` 立即应用,`LAMBDA_RESOLUTION` owner patch 首次发布 `lambdaPlans`(capture 声明处类型),body 事实与普通 suite 走同一 statement boundary 诊断刷新;阶段 D 起该 lambda 节点的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`,silent 局部稳定化不解析 lambda initializer(slot 保持 inventory `Variant`) +- 已 `recordCallable` 的 lambda 由外层 statement 的 top-binding owner 触发 nested suite resolution:独立 `FrontendCallableExportBatch` 立即应用,`LAMBDA_RESOLUTION` owner patch 首次发布 `lambdaPlans`(capture 声明处类型),body 事实与普通 suite 走同一 statement boundary 诊断刷新;该 lambda 节点的 `expressionTypes` 由 EXPR_TYPE owner 首次发布 `RESOLVED(GdCallableType)`,silent 局部稳定化不解析 lambda initializer(slot 保持 inventory `Variant`) - analyze 现在已经具备独立的多 phase 主链路: - skeleton 结束后先发布 `updateModuleSkeleton(...)` - 再发布一次 pre-scope `updateDiagnostics(...)` @@ -364,7 +366,7 @@ deferred / unsupported diagnostics 一律通过 `DiagnosticManager` 发布。 - 调用 `FrontendVarTypePostAnalyzer.analyze(...)` 发布 callable-local `slotTypes()` - 调用 `FrontendAnnotationUsageAnalyzer.analyze(...)` 对 retained annotation 的合法挂载位置发出 `sema.annotation_usage` - 调用 `FrontendVirtualOverrideAnalyzer.analyze(...)` 对 engine virtual override 签名发出 `sema.virtual_override` - - 调用 `FrontendTypeCheckAnalyzer.analyze(...)` 对 ordinary local / class property / return typed contract 发出 `sema.type_check`,并对 property hint 发出 `sema.type_hint`;自阶段 D 起经 `scanNestedLambdaBodies` 显式 re-entry 遍历已 record lambda 的 body(plan 存在性为闸门,继承 enclosing callable 的 restriction/static context) + - 调用 `FrontendTypeCheckAnalyzer.analyze(...)` 对 ordinary local / class property / return typed contract 发出 `sema.type_check`,并对 property hint 发出 `sema.type_hint`;经 `scanNestedLambdaBodies` 显式 re-entry 遍历已 record lambda 的 body(plan 存在性为闸门,继承 enclosing callable 的 restriction/static context) - 调用 `FrontendLoopControlFlowAnalyzer.analyze(...)` 对非法 `break` / `continue` 发出 `sema.loop_control_flow` - 每个 phase 结束后都再次 `updateDiagnostics(...)`,把阶段边界快照刷新到最新 shared manager 状态 - `analyzeForCompile(...)` 在共享 11 phase 之后追加: 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 cc6e2a55..8de182c1 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 @@ -34,7 +34,7 @@ - 不引入 whole-module fixpoint,不把 body 语义改造成多轮全局收敛 - 不新增新的全局 side table,也不让已有 side table 互相越权 - 不把 `FrontendBinding` 重塑为 usage-aware 模型 - - 不在这里转正 parameter default、lambda、`match`、block-local `const`、class constant 的正式 body 语义 + - 不在这里转正 parameter default、`match`、block-local `const`、class constant 的正式 body 语义;已记录 lambda 合同见 `frontend_lambda_implementation.md` - 不在这里实现 for iteration planning、iterator slot refinement 或 lowering route classification - 不在这里扩张 keyed builtin、numeric promotion 或其它 typed-boundary 兼容矩阵;`StringName` / `String` 互转由 `frontend_implicit_conversion_matrix.md` 与 shared boundary helper 独立管理 @@ -479,7 +479,7 @@ writable / compatibility 规则为: 当前仍显式封口的 body-phase 边界包括: - parameter default -- lambda subtree +- 未记录 lambda subtree(property initializer / parameter default / skipped subtree;已记录 lambda 由 nested suite resolution 承接,见 `frontend_lambda_implementation.md`) - `match` subtree - block-local `const` - class constant diff --git a/doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md b/doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md index e31ec5d1..c03c8d22 100644 --- a/doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md @@ -4,8 +4,8 @@ ## 文档状态 -- 状态:事实源维护中(compile-only final gate、for route-aware compile policy、显式 AST 封口、generic published-fact blocker、signal/method-reference feature-specific RESOLVED blocker、shared/compile 分流边界与 SuiteResolver stable facts 已落地) -- 更新时间:2026-08-14(signal/Callable 值引用已闭环;compile gate 只拦 Dictionary 实例 method-ref 与 builtin type-meta static method-ref) +- 状态:事实源维护中(compile-only final gate、for route-aware compile policy、显式 AST 封口、generic published-fact blocker、signal/method-reference feature-specific RESOLVED blocker、shared/compile 分流边界与 SuiteResolver stable facts 已落地;lambda compile gate 已按 published plan 解封) +- 更新时间:2026-08-18(已记录 lambda 纳入 compile surface 并递归扫描 body facts;未记录 lambda 保持 fail-closed) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/sema/analyzer/**` @@ -19,6 +19,7 @@ - `diagnostic_manager.md` - `frontend_chain_binding_expr_type_implementation.md` - `frontend_signal_support.md` + - `frontend_lambda_implementation.md` - `frontend_unary_binary_expr_semantic_implementation.md` - `frontend_type_check_analyzer_implementation.md` - `frontend_analysis_inspection_tool_implementation.md` @@ -101,13 +102,15 @@ compile gate 可以沿 callable body 和支持岛 property initializer 继续递 以下区域继续保留 upstream owner,不被 compile gate 重新深入: - parameter default -- lambda subtree +- 未记录 lambda(property initializer / parameter default / skipped subtree 中缺 published `FrontendLambdaPlan` 的 `LambdaExpression`) - `match` subtree - block-local `const` - missing-scope / skipped subtree 这条边界的目的不是“少报错”,而是避免 compile gate 把已经被上游明确封口的恢复域重新打平成 lowering surface。`ForStatement` 不属于该跳过集合:它已进入 shared semantic,compile gate 会按 route-aware policy 处理——已注册 lowering contract 的 route 会命中 statement root 并进入 body 重扫 facts,未注册 contract 的 route 则在 statement root 发 route-not-ready blocker。 +**已记录 lambda**(supported executable body 内、`lambdaPlans()` 已发布且 body 已发布的 `LambdaExpression`)同样不属于跳过集合:compile gate 会把它放上 compile surface 并像普通 executable block 一样递归扫描其 body facts。缺 published plan / body 的 lambda 保持 fail-closed(见 §3.3)。 + --- ## 3. 显式 AST Compile-Block 清单 @@ -163,6 +166,12 @@ compile gate 可以沿 callable body 和支持岛 property initializer 继续递 `ArrayExpression` / `DictionaryExpression` 不属于当前显式 compile-block 列表:shared semantic 发布 `FrontendContainerLiteralPlan`,CFG/body 经 `ContainerLiteralItem` 发射 `construct_container_literal`,backend `ContainerLiteralInsnGen` 已闭环(见 `frontend_container_literal_implementation.md`)。 +`LambdaExpression` 不再无条件形态级封口,而是按 published plan 分流: + +- **已记录 lambda**(`lambdaPlans()` 含该节点且 body 已发布):放上 compile surface 并递归扫描 body facts(与普通 executable block 相同)。`construct_lambda` lowering 合同、合成 `_lambda_` 函数与 C backend 均已落地(见 `frontend_lambda_implementation.md`)。 +- **未记录 lambda**(property initializer / parameter default / skipped subtree,缺 published plan):保持形态级 `sema.compile_check` blocker,不静默放行;若上游已在同一 exact range 发布 unsupported 诊断,则按统一去重合同省略补发。 +- lambda body 内的 `match` 仍走 `handleMatchStatement` 跳过:compile 失败由上游 `sema.unsupported_binding_subtree` owner 持有,compile gate 不再额外包一层 `sema.compile_check`。 + `TypeTestExpression` 不属于当前显式 compile-block 列表:shared semantic 发布 `RESOLVED(bool)` + `typeTestTargets()`,body lowering 发射统一 `is_instance_of` / 常量 bool,backend `IsInstanceOfInsnGen` 分派 + runtime helpers 已落地。 `CastExpression` 同样不属于当前显式 compile-block 列表:shared semantic 发布 `RESOLVED(targetType)` 与 `sema.unsafe_cast` / `sema.type_check` diagnostic,body lowering 按 `ExplicitCastDecision` 发射 assign / pack / `builtin_cast` / `object_cast`,backend generator 与 runtime helpers 已闭环(见 `frontend_cast_expression_implementation.md`)。 @@ -413,6 +422,7 @@ compile gate 当前统一使用: `TypeTestExpression` 已从显式 compile-block 列表移除(见 `frontend_is_type_test_implementation.md`)。 `CastExpression` 已从显式 compile-block 列表移除(见 `frontend_cast_expression_implementation.md`)。 `ArrayExpression` / `DictionaryExpression` 已从显式 compile-block 列表移除(见 `frontend_container_literal_implementation.md`)。 +`LambdaExpression`(已记录、published plan + body)已从无条件形态级 compile-block 移除并纳入 compile surface;未记录 lambda 仍 fail-closed(见 `frontend_lambda_implementation.md`)。 在满足这些条件之前,它们都必须继续由 compile-only gate 拦截,而不是因为“frontend 已识别”就提前放行。 @@ -436,6 +446,7 @@ compile gate 当前统一使用: - cast / type-test value-operand 传播去重:`missing as int` / `missing is int` / 链式 `(missing as int) as float` 不在 root 补 `sema.compile_check` - cast / type-test root-owned target failure 仍由 `sema.expression_resolution` 持有,exact-range 去重后无 root `compile_check` - for route-aware compile policy:`RANGE_CALL` / `INT_SHORTHAND` 凭已注册 contract 放行(无 `sema.compile_check`,且释放后进入 body 扫描其中 `assert` 等封口节点);`GENERIC_VARIANT` 在 statement root 发 route-not-ready blocker 且说明缺少 lowering route;同一 ForStatement anchor 已有 upstream error 时不补发同级 `sema.compile_check` + - lambda compile gate:`sig.connect(func(): ...)` 直接实参放行(无 `compile_check`/unsupported);已记录 lambda body 内 `preload`/`$Node`/`assert` 会被递归扫描并各自发 blocker;property-initializer / parameter-default 未记录 lambda 保持 fail-closed(上游 unsupported owner 持有,不补发 `compile_check`);lambda body 内 `match` 仍由上游 `unsupported_binding_subtree` 持有,不被 `compile_check` 重复包一层 - `FrontendSemanticAnalyzerFrameworkTest` - `analyze(...)` 与 `analyzeForCompile(...)` 的分离 - compile gate 在 type-check 之后执行 @@ -454,7 +465,7 @@ compile gate 当前统一使用: - frontend -> LIR lowering 入口必须强制使用 `analyzeForCompile(...)` - lowering 在继续前必须检查 `diagnostics().hasErrors() == false` -- `assert` 与 3 类显式拦截表达式(`ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`)的真正 lowering/backend 支持仍待后续补齐;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression` 与 `CastExpression` 已完成 shared semantic、CFG/body lowering 与 backend 闭环;`for` 已注册 route 的 CFG/lowering 已落地,compile gate 为 route-aware policy(registry 已注册 route 放行,`OBJECT_CUSTOM` 等未注册 route 发 route-not-ready blocker) +- `assert` 与 3 类显式拦截表达式(`ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`)的真正 lowering/backend 支持仍待后续补齐;`ArrayExpression` / `DictionaryExpression`、`TypeTestExpression` 与 `CastExpression` 已完成 shared semantic、CFG/body lowering 与 backend 闭环;`for` 已注册 route 的 CFG/lowering 已落地,compile gate 为 route-aware policy(registry 已注册 route 放行,`OBJECT_CUSTOM` 等未注册 route 发 route-not-ready blocker);已记录 `LambdaExpression` 的 shared semantic、`construct_lambda` lowering 与 C backend 已闭环,compile gate 按 published plan 放行并递归扫描 body 若未来需要为 LSP 单独呈现 compile-only blocker,正确方向仍是: diff --git a/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md b/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md index bc0ef2d8..77cb9d31 100644 --- a/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md +++ b/doc/module_impl/frontend/frontend_complex_writable_target_implementation.md @@ -291,11 +291,11 @@ runtime-gated reverse commit 可能插入 `apply / skip / continue` block,因 ### 4.4 CAPTURE 的当前结论 -`CAPTURE` 当前保留在绑定/作用域模型里,但 lambda/capture lowering 与 capture storage semantics 仍未冻结,因此: +`CAPTURE` 已进入 lambda storage 合同(`construct_lambda` + capture block 拷贝),但 **capture-backed live-slot alias surface 仍未开放**,因此: - `CAPTURE` 不参与 alias eligibility - 一旦 capture-backed identifier 进入 alias path,当前实现必须 fail-fast -- future lambda 工程若要开放 capture alias,必须先建立独立的 storage / rebinding / alias-safety 证明链 +- 若未来要开放 capture alias,必须先建立独立的 storage / rebinding / alias-safety 证明链 ## 5. writeback family 与 runtime helper 合同 diff --git a/doc/module_impl/frontend/frontend_container_literal_implementation.md b/doc/module_impl/frontend/frontend_container_literal_implementation.md index 52d6c04f..b2c2b60c 100644 --- a/doc/module_impl/frontend/frontend_container_literal_implementation.md +++ b/doc/module_impl/frontend/frontend_container_literal_implementation.md @@ -151,7 +151,7 @@ record DictEntry(Expression key, Expression value, Range range) - vararg tail 没有具体 element slot 类型时。 - 目标为 `Variant`。 - expression statement。 -- 当前仍 deferred 的 conditional/lambda/match/default-argument 上下文。 +- 当前仍 deferred 的 conditional / 未记录 lambda / match / default-argument 上下文。已记录 lambda body 是 supported executable suite,其中的 typed literal 走普通 body 合同。 ### 2.3 元素级兼容性 @@ -632,7 +632,7 @@ engine test 必须验证 typed fill 后 `is_typed()`、typed element/key/value m `FrontendCompileCheckAnalyzer.walkExpression` **不再** 为 `ArrayExpression` / `DictionaryExpression` 建立显式 blocker;两者进入 default compile surface recursion。 - 字面量不产生 `sema.compile_check`。 -- 当前仍被显式 intercept 的表达式固定为 `ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`;`LambdaExpression` 保持在 compile surface 之外。 +- 当前仍被显式 intercept 的表达式固定为 `ConditionalExpression`、`PreloadExpression`、`GetNodeExpression`。已记录 `LambdaExpression`(published plan + body)已纳入 compile surface 并递归扫描 body;未记录 lambda 保持 fail-closed。合同见 `frontend_lambda_implementation.md`。 - 移除 Array/Dictionary blocker 时不得顺带改变其他 feature 的 intercept 状态。 ## 10. 核心实现落点 diff --git a/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md b/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md index 30a4a269..f43a3028 100644 --- a/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md +++ b/doc/module_impl/frontend/frontend_dynamic_call_lowering_implementation.md @@ -96,11 +96,11 @@ call result type 的正式真源是 call anchor 对应的 `analysisData.expressi - 同一个 payload 仅负责 exact route 的 post-call reverse commit - direct-slot mutating receiver 会直接发布 alias-backed receiver value,因此 exact route 继续只消费 dedicated `receiverValueIdOrNull`,而不是再由 call lowering 额外解释“synthetic CFG temp -> 真实源 slot” - 与之对应,non-mutating / runtime-open 的 direct-slot receiver 继续停留在 ordinary temp-backed value surface;frontend 不会把 alias publication 泛化到所有 identifier/self ordinary read - - 这里的 direct-slot publication surface 只包含 explicit `SelfExpression` 与 `IdentifierExpression + LOCAL_VAR/PARAMETER`;`CAPTURE` 目前仍留在 deferred lambda/capture 语义范围内,`receiverValueIdOrNull == null` 时由 `resolveInstanceCallReceiver(...)` fallback 到 `self` 的 implicit self receiver 仍属于 call execution fallback,不属于 alias publication + - 这里的 direct-slot publication surface 只包含 explicit `SelfExpression` 与 `IdentifierExpression + LOCAL_VAR/PARAMETER`;`CAPTURE` 虽已进入 lambda storage 合同,但仍不在 alias publication surface 内。`receiverValueIdOrNull == null` 时由 `resolveInstanceCallReceiver(...)` fallback 到 `self` 的 implicit self receiver 仍属于 call execution fallback,不属于 alias publication - `IdentifierExpression + SELF` 不是合法的 published receiver surface:当前 analyzer 只会对 explicit `SelfExpression` 发布 `SELF`,所以 builder 与 body lowering 遇到它都必须 fail-fast,而不是再把 identifier 静默恢复成 `"self"` - 对 identifier-backed direct-slot alias,builder 现在额外要求:后续 arguments 必须停留在 proven no-rebinding 子集;若参数包含 nested `CallExpression` / `AttributeCallStep` 或其它当前尚未证明安全的 effect-open surface,则回退 ordinary temp snapshot,而不是继续发布 live-slot alias - explicit `SelfExpression` 不受这条参数分类限制,因为它的稳定性来自 `self` slot 本身不可被用户代码重绑定 - - `CAPTURE` 当前也不参与这条 identifier-backed alias 分类:在 lambda/capture lowering 真正落地前,这类 binding 若意外进入 alias path 必须直接 fail-fast + - `CAPTURE` 当前也不参与这条 identifier-backed alias 分类:lambda/capture storage 已落地,但 capture-backed live-slot alias 仍未开放;这类 binding 若意外进入 alias path 必须直接 fail-fast - mutating dynamic instance route 现在也正式纳入 receiver-side writeback 合同: - `FrontendCallMutabilitySupport` 对 `DYNAMIC_FALLBACK + INSTANCE` 保守返回 may-mutate - 这条保守策略不读取“方法名是否看起来 const-like”这类弱事实;例如 `size()`、`length()` 之类 diff --git a/doc/module_impl/frontend/frontend_lambda_implementation.md b/doc/module_impl/frontend/frontend_lambda_implementation.md new file mode 100644 index 00000000..a563f2ab --- /dev/null +++ b/doc/module_impl/frontend/frontend_lambda_implementation.md @@ -0,0 +1,588 @@ +# Frontend Lambda 实现说明 + +> 本文档作为 GDScript `func(...)` / `LambdaExpression` 在 frontend shared semantic、type-check、 +> compile gate、CFG、body lowering 与 C backend 中的**长期事实源**。 +> 本文档吸收并取代原 `frontend_lambda_plan.md` 中的架构与合同描述, +> **不再保留**分阶段实施步骤、验收流水账或进度状态表。 + +## 文档状态 + +- 状态:事实源维护中(scope 双层图、lexical inventory、nested suite resolution、 + `FrontendLambdaPlan` 首次发布、`RESOLVED(GdCallableType)`、hidden `_lambda_` shell、 + `LambdaConstructItem` / `construct_lambda`、C custom Callable 与 compile gate 按 published plan + 放行已落地;property initializer / parameter default / skipped subtree 中的未记录 lambda + 以及 lambda 自己的 parameter default、body 内 `match` / block-local `const` / `await` 仍 fail-closed) +- 更新时间:2026-08-18 +- 适用范围: + - `src/main/java/gd/script/gdcc/frontend/sema/**` + - `src/main/java/gd/script/gdcc/frontend/scope/**` + - `src/main/java/gd/script/gdcc/frontend/lowering/**` + - `src/main/java/gd/script/gdcc/lir/**` + - `src/main/java/gd/script/gdcc/backend/c/gen/**` + - `src/main/c/codegen/include_451/gdcc/**` + - `src/main/c/codegen/template_451/**` + - `src/test/java/gd/script/gdcc/frontend/**` + - `src/test/java/gd/script/gdcc/lir/**` + - `src/test/java/gd/script/gdcc/backend/**` +- 关联文档: + - `doc/module_impl/common_rules.md` + - `doc/module_impl/frontend/frontend_rules.md` + - `doc/module_impl/frontend/diagnostic_manager.md` + - `doc/module_impl/frontend/frontend_variable_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md` + - `doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md` + - `doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md` + - `doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md` + - `doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md` + - `doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md` + - `doc/module_impl/frontend/frontend_complex_writable_target_implementation.md` + - `doc/module_impl/frontend/frontend_signal_support.md` + - `doc/module_impl/frontend/frontend_for_range_loop_implementation.md` + - `doc/gdcc_low_ir.md`(`construct_lambda`、`is_lambda`、``) + - `doc/gdcc_runtime_lib.md` + - `doc/gdcc_ownership_lifecycle_spec.md` + - `doc/module_impl/frontend/frontend_gdcompiler_type_implementation.md` + - `doc/test_error/test_suite_engine_integration_known_limits.md` +- 明确非目标: + - 不在此转正 `match`、parameter default、block-local `const`、`await` + - 不实现 Godot `OPCODE_CREATE_SELF_LAMBDA` / `GDScriptLambdaSelfCallable` 的独立 opcode; + `self` 走既有 `construct_lambda` capture,`capturesSelf` 时 custom Callable 的 `object_id` + 绑 enclosing instance(见 §3.5) + - 不把 `CAPTURE` 纳入 direct-slot alias publication(见 §3.6) + - 不复用 `construct_callable` / `construct_standalone_callable` 承载 lambda + (`frontend_signal_support.md` 已禁止) + - 不把 `GdCompilerType` 写入 lambda `` 或 lambda 参数/返回 ABI + - 不在 property initializer / parameter default / skipped subtree 中开放 lambda + - 不把 lambda 注册进 ClassDB / `_class_bind_methods` + - 不实现 `Callable.bind` / `unbind` 新 lowering + - 不更改 Godot 对普通局部/参数的 copy-on-capture 语义 + +--- + +## 1. 当前职责与集成位置 + +### 1.1 Pipeline 位置 + +`LambdaExpression` 是新的 nested callable,不是 for 那种 header-only statement。职责拆分如下: + +| 阶段 | 组件 | 职责 | +|------|------|------| +| Scope | `FrontendScopeAnalyzer.handleLambdaExpression` | `CallableScope(LAMBDA_EXPRESSION)` + `BlockScope(LAMBDA_BODY)`;图形状已冻结 | +| Inventory | `FrontendVariableAnalyzer` | bind param / ordinary local;按名 `defineCapture`(`Variant` 占位)。**不**写 `lambdaPlans()` | +| Interface | `FrontendInterfacePhase` | `supportedBodyDepth > 0` 时 `recordCallable`;body 进 suite entry | +| Suite | `FrontendSuiteResolver.resolveNestedLambdaOwner` | 独立 `FrontendCallableExportBatch`;填声明处 capture 类型并首次发布 `FrontendLambdaPlan` | +| Expr typing | `FrontendExpressionSemanticSupport` | 已记录节点发布 `RESOLVED(GdCallableType)` | +| Type-check | `FrontendTypeCheckAnalyzer.scanNestedLambdaBodies` | 以 plan 为闸门 walk body;return slot 消费 `plan.returnType()` | +| Loop | `FrontendLoopControlFlowAnalyzer` | callable boundary,重置 loop depth | +| Skeleton / pre-pass | `FrontendLoweringFunctionPreparationPass` | 发现式 walk 已记录 lambda,合成 hidden `_lambda_` shell + `LAMBDA_BODY` 上下文 | +| CFG | `FrontendCfgGraphBuilder` | 外层构造点建 `LambdaConstructItem`;`CAPTURE` 仍非 alias root | +| Body lowering | `FrontendLambdaConstructInsnLoweringProcessor` | 发射 `ConstructLambdaInsn`;body 走共享 `FrontendBodyLoweringSession` | +| Backend | `ConstructInsnGen` + `gdcc_callable.h` + 模板 | custom Callable + capture copy / free | +| Compile gate | `FrontendCompileCheckAnalyzer` | 已记录 lambda 纳入 compile surface 并递归扫描 body;未记录 fail-closed | + +强制顺序(任一环不得靠 `PENDING` / typed readiness 决定是否进入 lambda body): + +``` +scope graph + → 完整 lexical inventory(param / local / capture 名) + → declaration index / typed baseline / suite entry + → SuiteResolver body entry(无条件) + → typed resolution(capture 类型取外层绑定的声明处类型,含 `:=` 推断) + → type-check + → 合成 LirFunctionDef + CFG + construct_lambda + → C backend + → compile gate 放行 +``` + +compile gate 的 readiness 是 lowering 边界,不是 semantic body entry gate。 + +### 1.2 三层支持面解耦 + +1. **Shared body semantic**:inventory / declaration index / suite entry **不依赖** capture 最终类型或 lowering readiness。 +2. **Type-check**:已记录 lambda 作为独立 callable island 遍历 body;未记录保持 fail-closed。 +3. **Compile / CFG / lowering**:只放行已发布 `FrontendLambdaPlan` + 已发布 body 的节点。 + +### 1.3 当前不负责 + +- 在 property initializer / parameter default / class-level 表达式中 `recordCallable` +- 把 `CAPTURE` 当 live-slot alias +- 为 lambda 注入第二份 `self` 参数 +- 在 lowering 侧从 scope 现场重推导 capture +- 把 lambda 注册进 ClassDB 或 source function namespace + +### 1.4 源码形态 + +GDScript lambda 是表达式级匿名函数: + +```gdscript +var cb := func(offset: int) -> int: + return seed + offset +sig.connect(func(): + pinged.emit()) +``` + +Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,提供 +`parameters()` / `returnType()` / `body()`。它不是 `FunctionDeclaration`, +没有源码级函数名,也不进入 class member namespace。 + +--- + +## 2. 当前支持面 + +正式支持: + +- 出现在 function / constructor executable body(含 `if` / `while` / `for` body、普通 block) + 中的 `LambdaExpression`。Interface / Suite 只在 `supportedBodyDepth > 0` 时把 lambda + 记为 callable owner +- 嵌套 lambda(内层 capture 必须同时写入中间层,见 §3.4) +- 显式参数类型、显式/缺省返回类型(缺省按既有 `resolveTypeOrVariant → Variant`) +- 无 capture、捕获外层 `ScopeValueKind.PARAMETER` / `LOCAL`(含 ordinary `var` 与 for 迭代器)/ + 外层 `CAPTURE` +- 捕获 enclosing instance 的 `self`(作为普通 capture 名,见 §3.5) +- lambda 值赋给 `var` / `Callable` 参数 / `Signal.connect` / `Callable.call` + +继续 fail-closed: + +- property initializer、parameter default、class-level 表达式中的 lambda +- lambda 自己的 parameter default +- lambda body 内的 `match`、block-local `const`、`await` +- `CAPTURE` 作为 direct-slot alias root +- 把 class member / global / utility / type-meta 收成 capture +- 静态函数里使用 `self` 或 instance member(沿用既有 `ResolveRestriction`) + +--- + +## 3. 冻结合同 + +### 3.1 结构策略 + +`FrontendBodySemanticSupportPolicy` 当前为: + +- `forBlockScopeKind(LAMBDA_BODY)` → `EXECUTABLE_BODY` +- `forCallableScopeKind(LAMBDA_EXPRESSION)` → `EXECUTABLE_BODY` + +`FrontendVisibleValueDomain.LAMBDA_SUBTREE` 枚举值保留,但生产路径不再映射到它。 +`MATCH_SUBTREE` / `PARAMETER_DEFAULT` / `BLOCK_LOCAL_CONST_SUBTREE` 不得被这次合同打开。 + +`FrontendVariableAnalyzer.bindLocal` 经 +`FrontendExecutableInventorySupport.canPublishCallableLocalValueInventory(kind)` +读取 `publishesLexicalInventory()`;`LAMBDA_BODY` 必须保持 `EXECUTABLE_BODY`, +否则 body local 会误报成 `sema.variable_binding`。 + +### 3.2 Lambda 是 nested callable owner + +1. `FrontendInterfacePhase.handleLambdaExpression` 仅当 `supportedBodyDepth > 0` 时调用 + `recordCallable(lambda, parameters, body)`。depth=0(property initializer / class-level + 表达式)必须 `SKIP_CHILDREN` 且不进 `callableOwners`。 +2. `FrontendSuiteResolver.callableBody/callableParameters/restrictionForCallable/isStaticCallable` + 识别 `LambdaExpression`。 +3. **解析时机**: + - Interface 对 supported-body 内的 lambda 先无条件发布 inventory / baseline / suite entry。 + - 外层 **非 silent** 的 expr-type owner 路径在发布 `GdCallableType` **之前**,若该 + lambda 已 `recordCallable` 且尚未解析,则调用 + `FrontendSuiteResolver.resolveCallableOwner(lambda)`(独立 + `FrontendCallableExportBatch`)。这是 nested resolve 的**唯一**生产触发点。 + - 局部稳定化的 **silent resolver** 必须把 `LambdaExpression` initializer 列入 + fail-closed(`var cb := func(): ...` 的 slot 保持 inventory `Variant`,不写 + side table、不发诊断、不触发 nested resolve)。`:=` 推断为 `GdCallableType` + 若要发生,只能由非 silent 路径在 nested resolve 完成、表达式类型已发布后, + 按 `frontend_local_type_stabilization` 既有单调写回决定。 + - 未 `recordCallable` 的 lambda(property-init / default / skipped)**不得**触发 + nested resolve;保持 deferred / unsupported。 + - 用 AST-identity side table 标记已解析,避免顶层循环二次解析。 +4. lambda 的 static/instance restriction **继承 enclosing callable**: + - enclosing `FunctionDeclaration.isStatic()` → `ResolveRestriction.staticContext()` + - 否则 `instanceContext()` + - 嵌套 lambda 继续继承最外层 enclosing 非 lambda callable。 + +lambda 的 `FrontendCallableExportBatch` 不得写进外层函数的 statement overlay,必须独立 apply。 + +### 3.3 表达式类型 + +`resolveLambdaExpressionType` 成功时发布: + +- `FrontendExpressionType.resolved(new GdCallableType())` +- 不填 `arguments` / 特化 `returnType`(与 method-as-value / `construct_callable` 的 + unparameterized `Callable` 对齐) +- 不稳定依赖按既有 `propagated(...)` 传递,不得伪装成 `UNSUPPORTED` + +仅 **已 `recordCallable`** 的 lambda 节点不再发 +`sema.unsupported_expression_route` / `sema.unsupported_binding_subtree` / +`sema.unsupported_chain_route`。未记录的 lambda 必须继续由 +`FrontendBodyOwnerProcedures` 按位置发 unsupported,避免对无 suite entry 的 body +触发 `FrontendBodyStructuralCompleteness` fail-fast。 + +### 3.4 Capture 推导 + +`FrontendLambdaCapturePlanner` 在 variable inventory 阶段按**名字**推导。capture **类型** +取外层绑定的**声明处类型**(显式标注,或 `:=` 在该声明上的推断/稳定化结果), +不是 inventory baseline,也不是声明之后的断言或精化。 + +**可捕获**(scope lookup 命中的是 `ScopeValueKind`,不是 `FrontendBindingKind`): + +- 最近 enclosing supported callable 中的 `PARAMETER` +- 最近 enclosing supported executable block 中的 `LOCAL`(ordinary `var` 与 for 迭代器) +- 外层 lambda 已发布的 `CAPTURE`(嵌套传递) +- enclosing instance callable 的 `self`(§3.5) + +**不可捕获**(按普通词法继续解析,不进 ``): + +- 当前 lambda 自己的 parameter / local +- class property / signal / method / 内层 class type-meta +- global / singleton / utility / enum +- 尚未转正域里的名字(match pattern、block-local const、parameter default) + +**算法(对齐 Godot innermost-first,但不依赖 Godot AST)**: + +1. 先 bind 当前 lambda 的 parameters,再 bind body ordinary locals。 +2. 扫描 lambda body 中所有 `IdentifierExpression`(含嵌套表达式;跳过声明站点本身)。 +3. **从该 identifier 自己的 scope 向上**做 value lookup,**禁止**一律从 lambda + `CallableScope.parent` 查找。 +4. 仅当第一次命中落在**当前 lambda callable 边界之外**,且 `ScopeValueKind` 为 + `PARAMETER` / `LOCAL` / `CAPTURE` 时,才记为当前 lambda 的 capture。 +5. 若该名沿 parent lambda 链仍可见,中间层也 `defineCapture`(嵌套传递)。 +6. 同一名字只记一次;顺序按**首次出现的源码顺序**冻结,供 LIR operand 顺序使用。 +7. capture 与 parameter 冲突时 `ensureCallableValueSlotAvailable` 仍 fail-fast; + 生产路径必须在 define 前做 diagnostic + skip。 +8. planner 按 **post-order** 运行:先完成嵌套 lambda 的 param/local bind 与其自身 + capture plan,再扫描外层。 +9. 中间层传递:当且仅当 source binding 位于中间层 lambda 边界之外,且中间层无同名 + param/local 遮蔽时,中间层 `defineCapture` 同名条目(所有传递层共享同一个 + `sourceDeclaration`,声明处类型同源)。中间层遮蔽时传递在该层终止。 + +反例(内层 local 遮蔽外层,不得捕获): + +```gdscript +func f(): + var x = 1 + var cb := func(): + var x = 2 + return x # 指 lambda local,不得捕获外层 x +``` + +**`lambdaPlans()` 首次发布**: + +- inventory **不得**把 `Variant` 占位写入 `FrontendAnalysisData.lambdaPlans()`。 + inspection / inventory 测试读 scope 上的 `CAPTURE` 绑定,不读 side table。 +- nested resolve 入口填完声明处类型后,**第一次** publish 完整 `FrontendLambdaPlan` + (`LambdaCaptureEntry.type` 已是声明处类型,`capturesSelf` 已按 §3.5 与 leading + `self` 对齐)。 +- `applyPatch` / `mergeSideTable` 保持 first-wins。禁止对同一 `LambdaExpression` + 先发占位再 `withType` 走 patch 覆盖。`LambdaCaptureEntry.withType` 只用于构造 + 最终 plan 的本地组装,不是 side-table 回写 API。 +- 独立 semantic stage + overlay 槽 + 专用 owner patch:`LAMBDA_RESOLUTION` / + `FrontendLambdaResolutionPatch`。**不得**把 plan 折进 `FrontendExprTypePatch`。 + 该 stage 必须能在外层 EXPR_TYPE 发布 `GdCallableType` **之前**随 nested resolve export。 +- lowering 只消费已发布的完整 plan;缺 plan 仍 fail-fast,禁止现场从 scope 重推导。 + +填充必须同时更新三处: + +- `LambdaCaptureEntry.type`(随第一次 publish 写入 `lambdaPlans()`) +- LIR ``(lowering 时消费**已发布** plan,不再回读 scope) +- 该 lambda `CallableScope` 上的 `CAPTURE` 绑定:`resetCaptureType`(镜像 + `BlockScope.resetLocalType` 的单调 `Variant → exact` 守卫与 declaration 身份校验) + +**读取路径**(禁止读物理 scope slot,禁止回落 `slotTypes()` / `VAR_TYPE_POST`): + +1. 填充时点发生在外层语句 mid-suite。此刻物理 slot 仍是 inventory `Variant`。 +2. 必须走外层当前 typed environment 上的 **declaration-anchored** 查询:按 + `sourceDeclaration` + owning `BlockScope` 对象身份匹配已 flush 的 + `LOCAL_TYPE_STABILIZATION` / `FOR_ITERATION_RESOLUTION` update。 +3. 无 matching update 时取声明类型或 inventory baseline,不得回落到 + `slotType(astNode)`。 +4. 只有上述两个 stage 能写 slot update;日后若新增会写 slot 的断言 stage, + 会先撞该校验——这就是“声明之后的断言不改 capture”的执行点。 + +| 外层绑定 | capture 类型 | +|----------|----------------| +| 显式标注 `PARAMETER` / `var x: T` / `var x: T = ...` | 声明类型 `T` | +| 未标注 `PARAMETER` | `Variant` | +| `var x := 1`(`:=` 推断) | 该声明的局部稳定化结果,此处为 `int` | +| `var x := expr` 且 initializer 未能稳定 | 保持 `Variant` | +| `var x = 1`(无标注、非 `:=`) | `Variant`(不因 initializer 是 `1` 而推断 `int`) | +| for 迭代器(`ScopeValueKind.LOCAL`,声明身份是 `ForStatement`) | `FOR_ITERATION_RESOLUTION` 在进入 for body 前已提交的精化结果 | +| 外层 lambda 的 `CAPTURE` | 该外层 capture 已冻结的声明处类型 | +| `self`(§3.5) | enclosing class 的 `GdObjectType` | + +capture 合法性以外层声明在 **lambda 语句处按 declaration-order 可见**为前提。 +`var cb := func(): return x` 后接 `var x := 1` 时,planner 可能仍登记名字, +但该条目不得进入 lowering;use-site 走既有 `DECLARATION_AFTER_USE_SITE`。 + +明确禁止: + +- 用声明**之后**的赋值、类型断言、后续 overlay、`VAR_TYPE_POST` 或函数末尾 + slot 类型回写 `CAPTURE` / `FrontendLambdaPlan` / `` +- 对 `var x = 1` 因看到 initializer `1` 而把 capture 写成 `int` +- 只更新 `LambdaCaptureEntry.type` 却让 `CallableScope` 的 CAPTURE 停在 `Variant` 占位 +- 引入 compiler-only 类型。`LirPublicAbiValidator` 与 + `FrontendPublishedFactTypeGuard.checkLambdaPlan` 继续拒绝 + +```gdscript +var a := 1 # 声明处稳定为 int → capture int +var b = 1 # 声明处 Variant → capture Variant +var cb := func(): + return a + b # a:int 副本,b:Variant 副本 +# 此后即使断言 b 为 int,或再给 a 做其它精化,都不改 capture ABI +``` + +**Godot 语义**:capture 是**构造时拷贝**。lambda 内对 capture 名赋值只改 lambda +自己的副本,不写回外层 local。对象/容器元素的可变性来自共享对象身份,不是 live-slot alias。 + +### 3.5 `self`:用 capture,不新增 opcode + +GDCC LIR 只有 `construct_lambda` + capture 列表,没有独立 self-lambda opcode。 + +- 若 lambda body 出现显式 `SelfExpression`,或 instance restriction 下的 implicit + instance member / instance method 调用需要 receiver,则把 enclosing executable 的 + `self` 收为名为 `self` 的 capture。 +- `capturesSelf == true` **当且仅当** capture 列表的**第一项**名为 `self`。 + 不得把 `self` 插在中间,也不得在没有 leading `self` 时把 flag 设为 true。 + `FrontendLambdaCapturePlan` 构造器强制该不变量。 +- 该 capture 的类型是 enclosing class 的 `GdObjectType`。 +- 合成 lambda `LirFunctionDef` **不**再注入第二份 `ensureExecutableSelfParameter`; + `self` 只出现在 `` / `_capture->self`。 +- lambda `LirFunctionDef` 一律 `setStatic(true)`(§3.7),避免 + `declareSelfSlotIfNeeded` 造 stray `self`。`Kind.LAMBDA_BODY` 的 `sourceOwner` + 是 `LambdaExpression`,`loweringRoot` 是其 body `Block`。 +- CFG / body lowering 的 self capture **operand** 不得伪造 + `IdentifierExpression + SELF`。必须用 `SelfSlotOperand.SELF_SLOT` 直接读 + enclosing executable 的 `self` 槽。 +- 静态 enclosing callable 中使用 `self`:沿用既有 restriction diagnostic,不合成非法 capture。 + +**`object_id`**: + +- `capturesSelf == true`:`GDExtensionCallableCustomInfo2.object_id` 必须填 + enclosing `self` 在 **`construct_lambda` 求值点** 的 `GDObjectInstanceID` + (fat pointer 已缓存的 `instance_id`)。实例 `free` / `queue_free` 后连接自动失效。 + 必须从缓存取值;对可能已死的 raw 调 `godot_object_get_instance_id` 会崩 + (见 `object_value_fat_pointer_implementation.md`)。 +- `capturesSelf == false`:`object_id = 0`。不得把 enclosing instance 或 class + singleton 偷偷填进去。 +- **不得**改 `construct_standalone_callable` / `gdcc_new_standalone_callable` 的 + `object_id = 0`,也不得改 `construct_callable` 的 receiver 合同。 +- `is_valid_func`:`object_id != 0` 时必须经 ObjectDB + (`godot_object_get_instance_from_id`)判定;不得只靠 `call_func` 里解引用 + 悬空 `self` 来“发现”失效。 +- `self` capture 字段仍按 ownership 合同 copy/release;`object_id` 只负责 + Godot 侧身份 / disconnect。 + +### 3.6 `CAPTURE` 与 writable / alias + +- `CallableScope.defineCapture` 的 `writable=true`:允许 lambda **内部**对 capture 名赋值(写副本)。 +- `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` 对 `CAPTURE` 继续 fail-fast。 +- lambda **body** 内读 capture 名,按该函数的 local / capture 变量降低。 +- 外层函数里,被 capture 的 local 仍按普通 `LOCAL_VAR` 读写;构造 lambda 时读一次快照。 + +### 3.7 合成函数身份 + +每个通过 compile surface 的 `LambdaExpression` 对应恰好一个 hidden `LirFunctionDef`: + +- 名字:`_lambda_`,`k` 在 owning `LirClassDef` 内从 0 递增,与 AST 遍历 / 源码出现顺序一致。 +- `setLambda(true)`、`setHidden(true)`、`setStatic(true)`。`setLambda(true)` 必须先于 + `addCapture`。**禁止**再写 `setStatic(false)`。 +- 参数表 = lambda 源码参数(含 inventory 已解析的声明类型);**不含** `self` 参数。 +- `` = `FrontendLambdaCapturePlan` 冻结列表。 +- 返回类型在 nested resolve 入口解析**一次**并随 `FrontendLambdaPlan.returnType` 首次发布; + type-check 的 return slot 与 lowering 的 shell 返回类型都消费该已发布值,禁止两处各自重复解析。 +- 不进入 `ClassScope.defineFunction` / 不进 ClassDB bind。 +- `_lambda_` 前缀视为 compiler-owned(`FrontendSyntheticPropertyHelperSupport.RESERVED_PREFIXES`); + 用户声明同名前缀时 skeleton 发 `sema.class_skeleton` 并跳过该 member。 + +Side table:`FrontendAnalysisData.lambdaPlans()`,AST-identity 键。 + +`FrontendLambdaPlan` 至少包含: + +- `LambdaExpression` AST 身份 +- 合成名 `_lambda_` +- 有序列表 `captures: List` +- `capturesSelf: boolean` +- `returnType`(声明返回类型,nested resolve 入口一次解析并冻结) +- enclosing callable AST +- owning class canonical name + +`samePlan` / `sameEntry` 比较 payload(`enclosingCallable` 按身份);first-wins merge; +同一节点不同 payload 必须 fail,不得覆盖。 + +### 3.8 LIR / C ABI + +``` +$result = construct_lambda "" $capture1 $capture2 ... +``` + +- 无 capture:userdata = `NULL`,`free_func` 仍可 no-op。 +- 有 capture:拷入 heap 上的 `${Class}_Capture_${func}`,`callable_userdata` 指向该块; + `free_func` 按字段析构后 `godot_mem_free`。 +- `object_id` 按 §3.5:`capturesSelf` → enclosing `self.instance_id`,否则 `0`。 +- helper:`gdcc_new_lambda_callable(userdata, object_id, call, is_valid, free, get_argument_count)` + → `godot_callable_custom_create2`,token 与 standalone 共享。hash / equal / less / + to_string 留空,走 Godot 默认(`call_func` + userdata 指针身份)。 +- `call_func` 把 userdata 解成 `_capture`,再调 `${Class}_${lambdaName}($arg..., _capture)`。 + 进入 `call_func` 前若 `object_id != 0` 且对象已死,应被 Godot / `is_valid_func` 挡掉。 +- 用户可见 arity = lambda 源码参数个数(**不含** capture)。 +- `captureCount > 0` 时 `func.ftl` 只发射一次 `_capture` 尾参。 +- lambda 函数 prologue:把 `_capture->name` 拷入该 capture 登记的 local `$name`。 +- `CCodegen.generateFunctionPrepareBlock` 必须排除 capture local + (`func.getCapture(name) != null`),再由 prologue 从 `_capture->name` 拷入; + 否则 destroyable 类型会泄漏默认构造值。 +- `DomLirSerializer` 必须写出真实 ``;`DomLirParser` 对称回读。 +- `ConstructInsnGen.emitConstructLambda` 必须校验:目标函数存在且 `is_lambda`、 + capture 数一致、operand 均为 `VariableOperand`、名序一致、类型 `checkAssignable`。 + 未知名 / 数量或名序不一致 / 无 self 槽却捕获 self → `InvalidInsnException`。 +- 对象 capture 的字段写入是 first-write FAT_PTR BORROWED;生命周期遵循 + `gdcc_ownership_lifecycle_spec.md`。不必复刻 Godot “已释放则变 null” 的调试打印, + 但不得 double-free。 + +Godot 把 capture 作为**前缀实参**喂给 `GDScriptFunction::call`;GDCC 把 capture 放进 +userdata 结构体。这是 ABI 差异,不是语义差异;测试应对齐用户可见 arity 与 capture 值, +而不是对齐 Godot 调用约定。 + +### 3.9 诊断 + +| 场景 | category | 策略 | +|------|----------|------| +| 仍未转正的位置出现 lambda(property init 等) | 保持 `sema.unsupported_binding_subtree` 或 inventory 对应 category | diagnostic + skip | +| lambda 内 parameter default | 与普通函数相同的 deferred default 诊断 | 不打开 default 语义 | +| capture / parameter 同名冲突 | `sema.variable_binding` 既有 duplicate/shadow 类 | diagnostic + skip define | +| 静态上下文捕获 `self` | 既有 restriction / `sema.type_check` 或 lookup | 不新增平行语义 | +| compile 时 lambda 缺 plan / 缺合成函数 | `sema.compile_check` 或 lowering fail-fast | 缺 published fact 不得重绑 | +| 用户函数名以 `_lambda_` 开头 | `sema.class_skeleton` | 跳过该 member | + +普通源码错误走 `DiagnosticManager` + skip subtree,不得当异常控制流。 +缺失/冲突的 **published** fact 在 lowering 侧 fail-fast。 +一处根因一条 diagnostic;downstream 不得重复包。 + +--- + +## 4. Compile gate 政策 + +`FrontendCompileCheckAnalyzer` 按 published plan 分流: + +- **已记录 lambda**(`lambdaPlans()` 含该节点且 body 已发布):放上 compile surface 并递归扫描 + body facts(与普通 executable block 相同)。 +- **未记录 lambda**:保持形态级 `sema.compile_check` blocker,不静默放行;若上游已在同一 + exact range 发布 unsupported 诊断,则按统一去重合同省略补发。 +- lambda body 内的 `match` 仍走上游 `sema.unsupported_binding_subtree` owner;compile gate + 不得再包一层 `sema.compile_check`。 + +`FrontendAnalysisInspectionTool` 不再把 `LambdaExpression` 当作 deferred 祖先。 + +--- + +## 5. 与 Godot 的有意识差异 + +| 点 | Godot | GDCC | +|----|-------|------| +| self-lambda 身份 | 独立 `GDScriptLambdaSelfCallable`,`get_object()=instance` | 仍用 `construct_lambda` + `self` capture;`object_id = self.instance_id` | +| 非 self-lambda 身份 | `GDScriptLambdaCallable.get_object()=script` | `object_id = 0`(无 GDScript 资源可绑) | +| capture 传递 | 调用时插到实参前的 `Variant*` | userdata 结构体 + 函数尾部 `_capture` | +| lambda 名 | 可有 debug 名 | `_lambda_` hidden | +| 相等 | 引用相等 | 引用相等 | +| 已释放的 Object capture | debug 下替换 null | 遵循 GDCC ownership;不强制复刻打印 | + +`capturesSelf` 的 `Signal.connect(func(): self.foo())` **必须**按 Godot 随实例释放自动断开。 +不捕获 `self` 的 lambda 不得假装绑到 instance 或 script。 + +--- + +## 6. 长期不变量 + +1. lambda body 一旦转正,inventory 与 suite entry **无条件**发布;typed fact 只用于 + 外层/体内普通 slot 精化、`:=` 声明处稳定化,以及 compile readiness。capture 类型取 + 外层绑定的声明处类型(含 `:=` 推断),声明之后的断言或精化不得改写。capture 类型填充 + 永远不构成 body 进入条件。 +2. `construct_callable` / `construct_standalone_callable` 永不承载 lambda。 +3. `CAPTURE` 不是 alias root;capture 是构造时拷贝。 +4. compiler-only 类型不得出现在 lambda 参数、返回、``、`expressionTypes()`。 +5. hidden lambda 函数不进 ClassDB;`is_lambda` 与 `is_hidden` 同时为真。 +6. 缺 published `FrontendLambdaPlan` 时 lowering fail-fast,禁止现场重推导。 +7. 诊断:一处根因一条 diagnostic;downstream 不得重复包。 +8. `match` / parameter default / block-local `const` / `await` 不得借这次改动进入支持面。 +9. 修改本合同时必须同步 `frontend_rules.md`、compile-check 文档、 + `gdcc_low_ir.md`(若改 insn)、`gdcc_runtime_lib.md`(若改 helper)。 + +--- + +## 7. 测试锚点 + +主要类: + +- `FrontendLambdaInventoryTest` — supported inventory happy + property-initializer skip +- `FrontendLambdaPlanSideTableTest` — 稳定 side table、compiler-only capture 拒绝 +- `FrontendLambdaCapturePlannerTest` — 纯函数推导、遮蔽、嵌套传递、`capturesSelf` 不变量 +- `FrontendLambdaSuiteResolutionTest` — nested resolve 首次发布、声明处类型、self / 嵌套 / for 迭代器、diverging republish +- `FrontendLambdaExpressionTypeTest` — 已记录 `RESOLVED(Callable)` + 未记录不污染 sibling +- `FrontendLambdaLoweringTest` — hidden shell 合成、`LAMBDA_BODY` CFG/body、外层 `construct_lambda`、缺 plan / 名冲突 / capture 数漂移 fail-fast +- `ConstructLambdaInsnGenTest` — opcode 注册、capture 块、`object_id`、prepare/prologue、名序校验 +- `ConstructLambdaInsnGenEngineTest` — Zig + 可选 `GODOT_BIN`:常量 / String capture / self `object_id` / free 后 invalid +- `FrontendCompileCheckAnalyzerTest` — 已记录放行、未记录 fail-closed、body 内 `match` 不被 `compile_check` 重复包 +- `FrontendClassSkeletonTest` — `_lambda_` reserved prefix +- `FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary` — 仍是 callable boundary +- `FrontendCfgGraphBuilderTest.buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot` — alias 仍不开放 +- `LirPublicAbiValidatorTest` / `DomLirParserTest` — compiler-only capture / `` 往返 +- e2e:`src/test/test_suite/unit_test/script/member/signal_connect_lambda.gd` + +必须保持 fail-closed 的现有锚点:全部 `FrontendScopeAnalyzerTest` 的 lambda 图形状、 +`ScopeCaptureShapeTest`、for / match / const 的 fail-closed 锚点、parameter default / +match 内嵌套 lambda 的 compile-surface skip。 + +--- + +## 8. 当前局限与后续 + +- property initializer / parameter default / class-level 表达式中的 lambda 仍未 `recordCallable`。 +- lambda 自己的 parameter default、body 内 `match` / block-local `const` / `await` 仍 deferred。 +- `CAPTURE` 不进入 direct-slot alias publication。 +- 不实现 `Callable.bind` / `unbind`。 +- silent 局部稳定化不把 `var cb := func(): ...` 的 slot 精化为 `Callable`;若未来要打开, + 只能走 nested resolve 完成后的非 silent 单调写回。 +- 表达式中的 lambda(`return func(): ...`、`foo(func(): ...)`、容器字面量元素)已由 + Interface 默认 `CONTINUE` + `supportedBodyDepth` 守卫走进 nested resolve;新增宿主表达式 + 时必须继续测这条路径。 + +--- + +## 9. 维护结论 + +1. **Plan 与 scope 分离**不可回退:inventory 只写 scope 名与 `Variant` 占位;完整 + `FrontendLambdaPlan` 只由 `LAMBDA_RESOLUTION` 在 nested resolve 入口首次发布。 +2. **声明处类型**是 capture ABI 真源;禁止读物理 slot 或 `VAR_TYPE_POST`。 +3. **`self` 只作为 leading capture**;禁止独立 opcode、禁止注入 `self` 参数、禁止 + 伪造 `IdentifierExpression + SELF`。 +4. 缺 published plan / shell 漂移必须 fail-fast,禁止现场重推导或静默跳过。 +5. 修改本合同时,同步 `frontend_rules.md`、`diagnostic_manager.md`、 + `frontend_compile_check_analyzer_implementation.md`、lowering 三份 pre-pass/CFG 文档、 + `frontend_signal_support.md` §8.4、`gdcc_low_ir.md` 与 `gdcc_runtime_lib.md` 中对应条目。 + +--- + +## 10. 参考实现位置 + +### 10.1 本仓库 + +- `src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java` +- `src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlan.java` +- `src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java` +- `src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java` +- `src/main/java/gd/script/gdcc/frontend/sema/patch/FrontendLambdaResolutionPatch.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java` +- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java` +- `src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java` +- `src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java` +- `src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java` +- `src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java` +- `src/main/c/codegen/include_451/gdcc/gdcc_callable.h` +- `src/main/c/codegen/template_451/func.ftl` +- `src/main/c/codegen/template_451/entry.h.ftl` +- `src/main/c/codegen/template_451/entry.c.ftl` + +### 10.2 Godot + +- `modules/gdscript/gdscript_parser.h`(`LambdaNode`) +- `modules/gdscript/gdscript_analyzer.cpp`(capture 沿 parent lambda 链复制) +- `modules/gdscript/gdscript_compiler.cpp`(`write_lambda`) +- `modules/gdscript/gdscript_byte_codegen.cpp`(`OPCODE_CREATE_*_LAMBDA`) +- `modules/gdscript/gdscript_lambda_callable.h/.cpp` +- `modules/gdscript/gdscript_vm.cpp`(`OPCODE_CREATE_LAMBDA` / `OPCODE_CREATE_SELF_LAMBDA`) diff --git a/doc/module_impl/frontend/frontend_lambda_plan.md b/doc/module_impl/frontend/frontend_lambda_plan.md deleted file mode 100644 index bd802575..00000000 --- a/doc/module_impl/frontend/frontend_lambda_plan.md +++ /dev/null @@ -1,1360 +0,0 @@ -# Frontend Lambda 实施计划 - -> 本文档是 GDScript `func(...)` / `LambdaExpression` 从当前结构性 deferred / unsupported -> 边界,转正到 frontend shared semantic、type-check、compile gate、CFG、body lowering -> 与 C backend 的**分阶段实施计划**。 -> 落地完成后,应将架构与冻结合同吸收为长期事实源,并**删除**本计划中的进度流水账。 -> 本文档不改变 Godot 运行时语义,也不提前开放 `match`、parameter default、 -> block-local `const` 或 `await`。 - -## 文档状态 - -- 状态:阶段 A、B、C、D、E、F、G、H 已落地;I 尚未实施。lambda body 已成为 supported - executable suite:resolver / interface / suite 解封,nested resolve 经独立 - `LAMBDA_RESOLUTION` owner 首次发布完整 `FrontendLambdaPlan`(capture 声明处类型与返回 - 类型已填充并与 scope 同源);已 record lambda 的表达式类型首次发布 - `RESOLVED(GdCallableType)`(silent 稳定化不解析 lambda initializer,`:=` slot 保持 - inventory `Variant`),type-check 经显式 re-entry walk 已 record lambda 的 body - (return slot 即 plan 返回类型);prep pass 已按 plan 合成 hidden `_lambda_` shell - (`is_lambda` + `is_hidden` + static、capture 与 plan 同源、无注入 `self` 参数), - 并新增 `Kind.LAMBDA_BODY` 打通 CFG / body insn 两个 pass 的 lambda body 分支;外层 - body 的已 record lambda 经 `LambdaConstructItem` lowering 成 - `construct_lambda "" $capture...`(self capture 走专用 `SELF_SLOT` descriptor), - 且 item 与 shell 的 capture 数/名序在 body lowering 处端到端校验。C backend 已注册 - `CONSTRUCT_LAMBDA`:heap `${Class}_Capture_${func}` + `gdcc_new_lambda_callable`, - `object_id` 来自 cached `self.instance_id`(无 self 则为 0),lambda 入口 prologue - 从 `_capture` 拷入 local,capture local 已从 `__prepare__` 默认构造排除。compile - gate 对 lambda 仍以形态级 `sema.compile_check` blocker 保持 fail-closed,解封属阶段 I。 -- 更新时间:2026-08-18(阶段 G/H 落地:`gdcc_new_lambda_callable` + `ConstructInsnGen` - `CONSTRUCT_LAMBDA` + 回归锚点;阶段 F 落地:`LambdaConstructItem` + `construct_lambda` - 发射;阶段 E 落地:hidden `LirFunctionDef` 合成 + `Kind.LAMBDA_BODY` + `_lambda_` 保留前缀) -- 适用范围: - - `src/main/java/gd/script/gdcc/frontend/sema/**` - - `src/main/java/gd/script/gdcc/frontend/scope/**` - - `src/main/java/gd/script/gdcc/frontend/lowering/**` - - `src/main/java/gd/script/gdcc/lir/**` - - `src/main/java/gd/script/gdcc/backend/c/gen/**` - - `src/main/c/codegen/include_451/gdcc/**` - - `src/main/c/codegen/template_451/**` - - `src/test/java/gd/script/gdcc/frontend/**` - - `src/test/java/gd/script/gdcc/lir/**` - - `src/test/java/gd/script/gdcc/backend/**` -- 关联文档: - - `doc/module_impl/common_rules.md` - - `doc/module_impl/frontend/frontend_rules.md` - - `doc/module_impl/frontend/diagnostic_manager.md` - - `doc/module_impl/frontend/scope_architecture_refactor_plan.md` - - `doc/module_impl/frontend/scope_analyzer_implementation.md` - - `doc/module_impl/frontend/frontend_variable_analyzer_implementation.md` - - `doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md` - - `doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md` - - `doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md` - - `doc/module_impl/frontend/frontend_compile_check_analyzer_implementation.md` - - `doc/module_impl/frontend/frontend_lowering_plan.md` - - `doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md` - - `doc/module_impl/frontend/frontend_complex_writable_target_implementation.md` - - `doc/module_impl/frontend/frontend_signal_support.md` - - `doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md`(转正顺序真源) - - `doc/module_impl/frontend/frontend_for_range_loop_implementation.md`(三层支持面 / compile-gate 解封条件的已落地参照;该文档本身已不再含阶段流水账) - - `doc/gdcc_low_ir.md`(`construct_lambda`、`is_lambda`、``) - - `doc/gdcc_runtime_lib.md` - - `doc/gdcc_c_backend.md` - - `doc/gdcc_ownership_lifecycle_spec.md` - - `doc/module_impl/frontend/frontend_gdcompiler_type_implementation.md` - - `doc/test_error/test_suite_engine_integration_known_limits.md` -- 明确非目标: - - 不在本计划转正 `match`、parameter default、block-local `const`、`await` - - 不在本计划实现 Godot `OPCODE_CREATE_SELF_LAMBDA` / `GDScriptLambdaSelfCallable` - 的独立 opcode;`self` 走既有 `construct_lambda` capture, - `capturesSelf` 时 custom Callable 的 `object_id` 绑 enclosing instance - (见 §3.5) - - 不把 `CAPTURE` 纳入 direct-slot alias publication(见 §3.6) - - 不复用 `construct_callable` / `construct_standalone_callable` 承载 lambda - (`frontend_signal_support.md` 已禁止) - - 不把 `GdCompilerType` 写入 lambda `` 或 lambda 参数/返回 ABI - - 不在 property initializer / parameter default / skipped subtree 中开放 lambda - - 不把 lambda 注册进 ClassDB / `_class_bind_methods` - - 不实现 `Callable.bind` / `unbind` 新 lowering - - 不更改 Godot 对普通局部/参数的 copy-on-capture 语义 - ---- - -## 1. 背景与当前起点 - -### 1.1 源码形态 - -GDScript lambda 是表达式级匿名函数: - -```gdscript -var cb := func(offset: int) -> int: - return seed + offset -sig.connect(func(): - pinged.emit()) -``` - -Parser AST 为 `dev.superice.gdparser.frontend.ast.LambdaExpression`,已提供 -`parameters()` / `returnType()` / `body()`。它不是 `FunctionDeclaration`, -没有源码级函数名,也不进入 class member namespace。 - -### 1.2 已落地、可复用的骨架 - -| 层 | 已有事实 | 文件 | -|----|----------|------| -| Scope 图 | `LambdaExpression` → `CallableScope(LAMBDA_EXPRESSION)` + `BlockScope(LAMBDA_BODY)` | `FrontendScopeAnalyzer.handleLambdaExpression` | -| Capture API | `CallableScope.defineCapture(...)`、`ScopeValueKind.CAPTURE`、`CaptureDef` | `CallableScope.java`、`CaptureDef.java` | -| Binding kind | `FrontendBindingKind.CAPTURE` 已存在,但生产路径几乎不产出 | `FrontendBindingKind.java` | -| Loop | lambda 是 callable boundary,重置 loop depth | `FrontendLoopControlFlowAnalyzer` | -| LIR insn | `ConstructLambdaInsn` / `CONSTRUCT_LAMBDA` / text+DOM parse | `lir/insn/ConstructLambdaInsn.java` | -| LIR function | `LirFunctionDef.isLambda`、`addCapture`(非 lambda 会抛) | `LirFunctionDef.java` | -| ABI 护栏 | `LirPublicAbiValidator` 拒绝 compiler-only capture type | `LirPublicAbiValidator.java` | -| C 模板 | `lambdaCaptureName`、`entry.h.ftl` capture typedef、`func.ftl` `_capture` 参数 | `template_451/*` | -| Custom Callable 先例 | `gdcc_new_standalone_callable` + `godot_callable_custom_create2` | `gdcc_callable.h` | - -### 1.3 当前 fail-closed 封口点 - -这些封口必须按阶段显式翻面,禁止“某一个 analyzer 单独偷开”: - -| 封口 | 位置 | 当前行为 | -|------|------|----------| -| 结构策略 | `FrontendBodySemanticSupportPolicy` | `LAMBDA_BODY` / `LAMBDA_EXPRESSION` → `LAMBDA_SUBTREE(false, false)` | -| Variable inventory | `FrontendVariableAnalyzer` | `sema.unsupported_variable_inventory_subtree`;不 bind param/local/capture | -| Interface | `FrontendInterfacePhase.handleLambdaExpression` | `SKIP_CHILDREN`;body 不进 suite entry / declaration index | -| Suite owner | `FrontendSuiteResolver.callableBody/callableParameters` | 只认 Function/Constructor;lambda 返回 `null` / empty | -| Visible value | `FrontendVisibleValueResolver` | AST 边 + current-scope backstop → `DEFERRED_UNSUPPORTED` / `LAMBDA_SUBTREE` | -| Top / chain binding | `FrontendBodyOwnerProcedures` | `sema.unsupported_binding_subtree` / `sema.unsupported_chain_route`;`walkRootBounded` 剪枝 | -| Expr typing | `FrontendExpressionSemanticSupport.resolveLambdaExpressionType` | **阶段 D 已翻面**:已 record → `RESOLVED(GdCallableType)`;未记录仍 `UNSUPPORTED` | -| Type-check | `FrontendTypeCheckAnalyzer.handleLambdaExpression` | **阶段 D 已翻面**:walk 已 record lambda 的 body(plan 闸门 + 继承 restriction/static) | -| Compile surface | `FrontendCompileCheckAnalyzer.walkExpression` | **阶段 D 起**形态级 `sema.compile_check` blocker;阶段 I 才解封 | -| Inspection | `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` | 把 `LambdaExpression` 硬编码为 deferred 祖先 | -| Skeleton | `FrontendClassSkeletonBuilder.toLirFunction` | 只从 `FunctionDeclaration` 建 `LirFunctionDef`;**阶段 E 起** `_lambda_` 前缀并入 `RESERVED_PREFIXES`,source member 复用即 `sema.class_skeleton` + skip | -| Func pre-pass | `FrontendLoweringFunctionPreparationPass.visitStatements` | **阶段 E 已翻面**:发现式 walk 可执行 body(含 lambda body 递归),按已发布 plan 合成 `_lambda_` shell + `LAMBDA_BODY` 上下文;缺 plan fail-fast | -| CFG construct | `FrontendCfgGraphBuilder.buildValue` | **阶段 F 已翻面**:已 record lambda → `LambdaConstructItem`(capture operand 按名读外层 slot,self 走 `SELF_SLOT` descriptor);缺 plan 仍 fail-fast | -| CFG alias | `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` | `CAPTURE` fail-fast | -| C codegen | `ConstructInsnGen` | **阶段 G 已翻面**:注册 `CONSTRUCT_LAMBDA`,分配 capture 块并调 `gdcc_new_lambda_callable`;未知名 / capture 数或名序不一致 fail-fast | -| DOM serialize | `DomLirSerializer` | **已修复**:`is_lambda` 函数序列化真实 `` 列表,`DomLirParser` 对称回读 | - -### 1.4 Godot 参考(语义对齐,不复制 VM) - -Godot 4 实现(`godotengine/godot`): - -- Analyzer:`modules/gdscript/gdscript_analyzer.cpp` 在解析 identifier 时, - 沿 `source_lambda -> parent_function` 链把外层 local/parameter 记入 - `LambdaNode.captures` / `captures_indices`;中间层 nested lambda 一并记录。 -- Parser 节点:`LambdaNode.use_self`、`captures`。 -- Codegen:`write_lambda(..., p_use_self)` → `OPCODE_CREATE_LAMBDA` 或 - `OPCODE_CREATE_SELF_LAMBDA`。 -- Runtime:`GDScriptLambdaCallable`(`function->call(nullptr, ...)`, - `get_object()` 返回 **script**)与 `GDScriptLambdaSelfCallable` - (`function->call(instance, ...)`,`get_object()` 返回 **instance**)。 -- Capture 在构造时拷进 `Vector`;调用时插在用户实参前面。 - 对象 capture 若已被释放则替换为 `null`。 - -GDCC 已冻结的 LIR 是 `construct_lambda "" $cap...` + userdata 结构体 + -`free_func` 析构,**没有**独立 self-lambda opcode。本计划按该 LIR 落地, -Godot self-lambda 的 object-id / disconnect-by-object 差异记入 §3.5 与 §10。 - ---- - -## 2. 目标、职责边界与转正顺序 - -### 2.1 目标 - -1. 在 supported executable body(函数 / 构造函数,以及其内部已支持 block)中, - `LambdaExpression` 成为正式表达式:结果类型为 `GdCallableType`,可赋给 - `Callable` 槽、作为 `Signal.connect` / `Callable.call` 实参。 -2. lambda 拥有完整 lexical inventory:parameter、ordinary local、capture。 -3. lambda body 无条件进入 shared semantic(禁止 typed-dependent body gate)。 -4. 外层 lowering 发射 `construct_lambda`;合成 hidden `LirFunctionDef` - (`is_lambda=true`)承接 body CFG / LIR。 -5. C backend 生成 capture 结构体、custom Callable、`free_func` 与 lambda 函数体。 -6. compile-only gate 仅在 lowering + backend + 文档 + 测试全部闭环后解封。 - -### 2.2 职责拆分 - -| 阶段 | 组件 | 职责 | -|------|------|------| -| Scope | `FrontendScopeAnalyzer` | 已建双层图;本计划不改图形状 | -| Inventory | `FrontendVariableAnalyzer` | bind lambda param/local;推导并 `defineCapture`(`Variant` 占位)。**不**写 `lambdaPlans()` | -| Capture plan | 阶段 C 独立 owner | 在 nested resolve 入口填声明处类型后**第一次**发布 `FrontendLambdaPlan` | -| Interface | `FrontendInterfacePhase` | 把 `LambdaExpression` 记为 callable owner;body 进 suite entry | -| Suite | `FrontendSuiteResolver` | 把 lambda 当 nested callable owner 解析 | -| Expr typing | `FrontendExpressionSemanticSupport` | 发布 `RESOLVED(GdCallableType)` | -| Type-check | `FrontendTypeCheckAnalyzer` | 消费 Callable 事实;遍历 lambda body | -| Loop | `FrontendLoopControlFlowAnalyzer` | 保持 callable boundary(不断开) | -| Skeleton / pre-pass | `FrontendLoweringFunctionPreparationPass` | 合成 hidden lambda `LirFunctionDef` + context | -| CFG | `FrontendCfgGraphBuilder` | 新增 lambda value item;capture 读数;`CAPTURE` 仍非 alias root | -| Body lowering | 新增 processor | 发射 `ConstructLambdaInsn` | -| Backend | `ConstructInsnGen` + `gdcc_callable.h` + 模板 | custom Callable + capture copy/free | -| Compile gate | `FrontendCompileCheckAnalyzer` | 阶段 I 才把 lambda 纳入 compile surface | - -### 2.3 强制转正顺序 - -复制 `frontend_resolution_pipeline_implementation.md` §2 与 for-in 模板: - -``` -scope graph(已完成) - → 完整 lexical inventory(param / local / capture 名) - → declaration index / typed baseline / suite entry - → SuiteResolver body entry(无条件) - → typed resolution(capture 类型取外层绑定的声明处类型,含 `:=` 推断) - → type-check - → 合成 LirFunctionDef + CFG + construct_lambda - → C backend - → compile gate 解封 -``` - -任一阶段不得靠 `PENDING` / typed readiness 决定是否进入 lambda body。 -compile gate 的 readiness 是 lowering 边界,不是 semantic body entry gate。 - -### 2.4 MVP 支持面 - -正式支持: - -- 出现在 function / constructor executable body(含 `if` / `while` / `for` body、 - 普通 block)中的 `LambdaExpression`。Interface / Suite 只在 - `supportedBodyDepth > 0` 时把 lambda 记为 callable owner;property - initializer、class-level 表达式、parameter default 中的 lambda 不得 - `recordCallable` -- 嵌套 lambda(内层 capture 必须同时写入中间层,见 §3.4) -- 显式参数类型、显式/缺省返回类型(缺省按既有 `resolveTypeOrVariant → Variant`) -- 无 capture、捕获外层 `ScopeValueKind.PARAMETER` / `LOCAL`(含 ordinary - `var` 与 for 迭代器)/ 外层 `CAPTURE` -- 捕获 enclosing instance 的 `self`(作为普通 capture 名,见 §3.5) -- lambda 值赋给 `var` / `Callable` 参数 / `Signal.connect` / `Callable.call` - -继续 fail-closed: - -- property initializer、parameter default、class-level 表达式中的 lambda -- lambda 自己的 parameter default -- lambda body 内的 `match`、block-local `const`、`await` -- `CAPTURE` 作为 direct-slot alias root -- 把 class member / global / utility / type-meta 收成 capture -- 静态函数里使用 `self` 或 instance member(沿用既有 `ResolveRestriction`) - ---- - -## 3. 冻结合同(实施前必须遵守) - -### 3.1 结构策略翻转 - -`FrontendBodySemanticSupportPolicy` 必须改为: - -- `forBlockScopeKind(LAMBDA_BODY)` → `EXECUTABLE_BODY` -- `forCallableScopeKind(LAMBDA_EXPRESSION)` → `EXECUTABLE_BODY` - -该翻转必须落在 **阶段 B**(与 inventory 同提交),不能拖到阶段 C。 -原因:`FrontendVariableAnalyzer.bindLocal` 经 -`FrontendExecutableInventorySupport.canPublishCallableLocalValueInventory(kind)` -读取 `publishesLexicalInventory()`;`LAMBDA_BODY` 在翻转前会把 body local -报成 `sema.variable_binding` “expected supported executable BlockScope”。 - -阶段 B 时 `FrontendInterfacePhase.handleLambdaExpression` 仍 `SKIP_CHILDREN`, -`FrontendSuiteResolver` 尚无 `LambdaExpression` 分支,因此 -`entersSuiteResolver=true` 没有活消费者。阶段 C 再打开 interface / suite / -resolver。不得把 policy 拆成两个 boolean 分两次翻。 - -`FrontendVisibleValueDomain.LAMBDA_SUBTREE` 枚举值保留,但生产路径从阶段 B -policy 翻转起不再映射到它。`MATCH_SUBTREE` / `PARAMETER_DEFAULT` / -`BLOCK_LOCAL_CONST_SUBTREE` 不得被这次改动打开。 - -### 3.2 Lambda 是 nested callable owner,不是 for 那种 header-only statement - -`ForStatement` 的 body 是**同一** enclosing callable 的 child suite。 -`LambdaExpression` 是**新的** callable: - -1. `FrontendInterfacePhase.handleLambdaExpression` 仅当 `supportedBodyDepth > 0` - 时调用 `recordCallable(lambda, parameters, body)`。这与 - `handleVariableDeclaration` 在 depth=0 时走 property-init 而不 bind local - 的守卫同构。depth=0(property initializer / class-level 表达式)必须 - `SKIP_CHILDREN` 且不进 `callableOwners`。 -2. `FrontendSuiteResolver.callableBody/callableParameters/restrictionForCallable/isStaticCallable` - 增加 `LambdaExpression` 分支。 -3. **解析时机**:不要把 lambda 仅丢进顶层 `callableOwners` 循环、在外层函数 - **之后**才解析。外层语句的 expr typing 会先碰到 `LambdaExpression`。 - 正确顺序: - - Interface 对 supported-body 内的 lambda 先无条件发布 inventory / - baseline / suite entry。 - - 外层 **非 silent** 的 expr-type owner 路径在发布 `GdCallableType` - **之前**,若该 lambda 已 `recordCallable` 且尚未解析,则调用 - `FrontendSuiteResolver.resolveCallableOwner(lambda)` - (独立 `FrontendCallableExportBatch`)。这是 nested resolve 的 - **唯一**生产触发点。 - - 局部稳定化的 **silent resolver** 必须把 `LambdaExpression` - initializer 列入 fail-closed(`var cb := func(): ...` 的 slot - 保持 inventory `Variant`,不写 side table、不发诊断、不触发 - nested resolve)。`:=` 推断为 `GdCallableType` 若要发生,只能由 - 非 silent 路径在 nested resolve 完成、表达式类型已发布后,按 - `frontend_local_type_stabilization` 既有单调写回决定;不得为了 - 稳定化去解析 lambda body。 - - 未 `recordCallable` 的 lambda(property-init / default / skipped) - **不得**触发 nested resolve;保持 deferred / unsupported。 - - 用 AST-identity side table 标记已解析,避免顶层循环二次解析。 -4. lambda 的 static/instance restriction **继承 enclosing callable**: - - enclosing `FunctionDeclaration.isStatic()` → `ResolveRestriction.staticContext()` - - 否则 `instanceContext()` - - 嵌套 lambda 继续继承最外层 enclosing 非 lambda callable。 - -### 3.3 表达式类型 - -`resolveLambdaExpressionType` 成功时发布: - -- `FrontendExpressionType.resolved(new GdCallableType())` -- 不在 MVP 填 `arguments` / 特化 `returnType`(与 method-as-value / - `construct_callable` 的 unparameterized `Callable` 对齐) -- 不稳定依赖(参数类型解析失败等)按既有 `propagated(...)` 传递,不得伪装成 - `UNSUPPORTED` - -仅 **已 `recordCallable`** 的 lambda 节点不再发 -`sema.unsupported_expression_route` / `sema.unsupported_binding_subtree` / -`sema.unsupported_chain_route`。未记录的 lambda(property-init 等)必须 -继续由 `FrontendBodyOwnerProcedures` 按位置发 unsupported,避免 -`resolveLambdaExpressionType` 对无 suite entry 的 body 触发 -`FrontendBodyStructuralCompleteness` fail-fast。 - -### 3.4 Capture 推导 - -新增 `FrontendLambdaCapturePlanner`(或等价 support),在 variable inventory -阶段按**名字**推导。capture **类型**取外层绑定的**声明处类型** -(显式标注,或 `:=` 在该声明上的推断/稳定化结果),不是 inventory -baseline,也不是声明之后的断言或精化。 - -**可捕获**(scope lookup 命中的是 `ScopeValueKind`,不是 -`FrontendBindingKind`): - -- 最近 enclosing supported callable 中的 `PARAMETER` -- 最近 enclosing supported executable block 中的 `LOCAL` - (ordinary `var` 与 for 迭代器都是 `LOCAL`) -- 外层 lambda 已发布的 `CAPTURE`(嵌套传递) -- enclosing instance callable 的 `self`(§3.5) - -**不可捕获**(按普通词法继续解析,不进 ``): - -- 当前 lambda 自己的 parameter / local -- class property / signal / method / 内层 class type-meta -- global / singleton / utility / enum -- 尚未转正域里的名字(match pattern、block-local const、parameter default) - -**算法(对齐 Godot innermost-first,但不依赖 Godot AST)**: - -1. 先 bind 当前 lambda 的 parameters,再 bind body ordinary locals。 -2. 扫描 lambda body 中所有 `IdentifierExpression`(含嵌套表达式;跳过 - 声明站点本身,即 `Parameter.name` / `VariableDeclaration.name`)。 -3. **从该 identifier 自己的 scope 向上**做 value lookup(innermost-first), - **禁止**一律从 lambda `CallableScope.parent` 查找。 -4. 仅当第一次命中落在**当前 lambda callable 边界之外**,且 - `ScopeValueKind` 为 `PARAMETER` / `LOCAL` / `CAPTURE` 时,才记为 - 当前 lambda 的 capture。当前 lambda 自己的 parameter / local 命中 - → 不捕获。 -5. 若该名沿 parent lambda 链仍可见,中间层也 `defineCapture`(嵌套传递)。 -6. 同一名字只记一次;顺序按**首次出现的源码顺序**冻结,供 LIR operand 顺序使用。 -7. capture 与 parameter 冲突时 `ensureCallableValueSlotAvailable` 仍 fail-fast; - 生产路径必须在 define 前做 diagnostic + skip,不要让用户源码打到 - `IllegalArgumentException`。 -8. planner 按 **post-order** 运行:先完成嵌套 lambda 的 param/local bind 与 - 其自身 capture plan,再扫描外层。外层扫描依赖内层 scope 已就位; - 否则内层尚未 bind 的 param/local 会被误判成外层 capture(H2 同类缺陷)。 -9. 中间层传递:当且仅当 source binding 位于中间层 lambda 边界之外,且 - 中间层无同名 param/local 遮蔽时,中间层 `defineCapture` 同名条目 - (所有传递层共享同一个 `sourceDeclaration`,声明处类型同源)。 - 中间层遮蔽时传递在该层终止,内层改为捕获中间层自己的 param/local。 - -反例(必须作为阶段 B negative path): - -```gdscript -func f(): - var x = 1 - var cb := func(): - var x = 2 - return x # 指 lambda local,不得捕获外层 x -``` - -**类型(已冻结:声明处类型,含 `:=` 推断;后续断言不影响)**: - -Capture 类型等于外层被捕获绑定在其**声明语句完成时**的 source-facing -类型。阶段 B 只登记名字与 `sourceDeclaration`,`defineCapture` 先写 -`Variant` 占位。真实类型在 **该 lambda 自己的 nested suite resolution -开始、其 body 语句尚未处理时** 填充,写入后立即冻结。 - -**`lambdaPlans()` 首次发布(已冻结,2026-08-17)**: - -- 阶段 B **不得**把 `Variant` 占位写入 `FrontendAnalysisData.lambdaPlans()`。 - inventory 只通过 `CallableScope.defineCapture` 暴露名字;inspection / - 阶段 B 测试读 scope 上的 `CAPTURE` 绑定,不读 side table。 -- 阶段 C 在 nested resolve 入口填完声明处类型后,**第一次** publish - 完整 `FrontendLambdaPlan`(`LambdaCaptureEntry.type` 已是声明处类型, - `capturesSelf` 已按 §3.5 与 leading `self` 对齐)。 -- `applyPatch` / `mergeSideTable` 保持 first-wins。禁止对同一 - `LambdaExpression` 先发占位再 `withType` 走 patch 覆盖。 - `LambdaCaptureEntry.withType` 只用于构造最终 plan 的本地组装,不是 - side-table 回写 API。 -- 阶段 C 必须新增**独立** semantic stage + `OverlayFacts.lambdaPlans` - 槽 + 专用 `FrontendOwnerPatch`(建议名 `LAMBDA_RESOLUTION` / - `FrontendLambdaResolutionPatch`)。**不得**把 plan 折进 - `FrontendExprTypePatch`。`FrontendPatchTransaction.order` 必须给 - 该 stage 编号;位置在 inventory 之后、且必须能在外层 EXPR_TYPE - 发布 `GdCallableType` **之前**随 nested resolve export。 -- lowering 只消费已发布的完整 plan;缺 plan 仍 fail-fast,禁止 - 现场从 scope 重推导。 - -填充必须同时更新三处,禁止只写 plan 不写 scope: - -- `LambdaCaptureEntry.type`(随第一次 publish 写入 `lambdaPlans()`) -- LIR ``(lowering 时消费 **已发布** plan,不再回读 scope) -- 该 lambda `CallableScope` 上的 `CAPTURE` 绑定:新增 - `resetCaptureType`(镜像 `BlockScope.resetLocalType` 的单调 - `Variant → exact` 守卫与 declaration 身份校验)。body type-check - 经 `effectiveScopeValue` 读 CAPTURE 时**不会**套 overlay,因此 - scope 绑定必须与 `LambdaCaptureEntry.type` 同源。 - -**读取路径**(禁止读物理 scope slot,禁止回落 `slotTypes()` / -`VAR_TYPE_POST`): - -1. 填充时点发生在外层语句 mid-suite。`BlockScope.resetLocalType` - 只在外层 callable 的 `exportBatch.applyTo(analysisData)` 时执行, - 此刻物理 slot 仍是 inventory `Variant`。 -2. 必须走外层当前 typed environment 上的 **declaration-anchored** - 查询:按 `sourceDeclaration` + owning `BlockScope` 对象身份匹配 - 已 flush 的 `LOCAL_TYPE_STABILIZATION` / `FOR_ITERATION_RESOLUTION` - update(`FrontendTypedLexicalEnvironment.localSlotType` 的 overlay - 段)。这不是“求值点最新 effective 类型”,而是该声明自己的 - 已提交稳定化事实。 -3. 不得复用 `localSlotType` 在无 update 时回落到 `slotType(astNode)` - (那会读到 `VAR_TYPE_POST` / 函数摘要)。无 matching update 时 - 取声明类型或 inventory baseline。 -4. 只有上述两个 stage 能写 slot update(`FrontendAnalysisData` - 强校验)且写回单调 `Variant → exact`,因此“该声明的最新 - update”≡“该声明自己的稳定化结果”。日后若新增会写 slot 的 - 断言 stage,会先撞该校验——这就是“声明之后的断言不改 capture” - 的执行点。 - -| 外层绑定 | capture 类型 | -|----------|----------------| -| 显式标注 `PARAMETER` / `var x: T` / `var x: T = ...` | 声明类型 `T` | -| 未标注 `PARAMETER` | `Variant` | -| `var x := 1`(`:=` 推断) | 该声明的局部稳定化结果,此处为 `int` | -| `var x := expr` 且 initializer 未能稳定 | 保持 `Variant` | -| `var x = 1`(无标注、非 `:=`) | `Variant`(不因 initializer 是 `1` 而推断 `int`) | -| for 迭代器(`ScopeValueKind.LOCAL`,声明身份是 `ForStatement`) | `FOR_ITERATION_RESOLUTION` 在进入 for body 前已提交的精化结果 | -| 外层 lambda 的 `CAPTURE` | 该外层 capture 已冻结的声明处类型 | -| `self`(§3.5) | enclosing class 的 `GdObjectType` | - -`:=` 的 `int` 来自**那条声明**已 flush 的稳定化 update,不是物理 -scope 写回,也不是函数末尾摘要。阶段 B inventory 仍会先给推断 -局部写 `Variant` baseline;若在 nested resolve 入口读物理 slot, -`var x := 1` 会错成 `Variant`。 - -capture 合法性以外层声明在 **lambda 语句处按 declaration-order -可见**为前提。`var cb := func(): return x` 后接 `var x := 1` 时, -planner 可能仍登记名字,但该条目不得进入 lowering;use-site -走既有 `DECLARATION_AFTER_USE_SITE`。不可见条目不得填进 -``。 - -明确禁止: - -- 用声明**之后**的赋值、类型断言、后续 overlay、`VAR_TYPE_POST` 或 - 函数末尾 slot 类型回写 `CAPTURE` / `FrontendLambdaPlan` / ``。 - 即使后来断言 `x` 为其他具体类型,capture 仍是声明处类型。 -- 对 `var x = 1` 因看到 initializer `1` 而把 capture 写成 `int`。 -- 只更新 `LambdaCaptureEntry.type` 却让 `CallableScope` 的 CAPTURE 停在 - `Variant` 占位。 -- 引入 compiler-only 类型。`LirPublicAbiValidator` 继续拒绝 - compiler-only capture。 - -例: - -```gdscript -var a := 1 # 声明处稳定为 int → capture int -var b = 1 # 声明处 Variant → capture Variant -var cb := func(): - return a + b # a:int 副本,b:Variant 副本 -# 此后即使断言 b 为 int,或再给 a 做其它精化,都不改 capture ABI -``` - -lambda body 的 type-check / lowering 只消费这份声明处类型。外层槽后续 -变化只影响外层语句。 - -**Godot 语义**:capture 是**构造时拷贝**。lambda 内对 capture 名赋值只改 -lambda 自己的副本,不写回外层 local。对象/容器元素的可变性来自共享对象身份, -不是 live-slot alias。 - -### 3.5 `self`:用 capture,不新增 opcode - -Godot 用 `use_self` + `GDScriptLambdaSelfCallable` 把 Callable 绑到 instance。 -GDCC LIR 只有 `construct_lambda` + capture 列表。 - -MVP 冻结: - -- 若 lambda body 出现显式 `SelfExpression`,或 instance restriction 下的 - implicit instance member / instance method 调用需要 receiver,则把 - enclosing executable 的 `self` 收为名为 `self` 的 capture。 -- `capturesSelf == true` **当且仅当** capture 列表的**第一项**名为 `self`。 - 其余 capture 仍按首次出现的源码顺序排在其后。不得把 `self` 插在中间, - 也不得在没有 leading `self` 时把 flag 设为 true。 -- 该 capture 的类型是 enclosing class 的 `GdObjectType`(与函数 `self` 参数一致)。 -- lowering 把外层 `self` slot 作为 `construct_lambda` 的一个 capture operand。 -- 合成 lambda `LirFunctionDef` **不**再注入第二份 `ensureExecutableSelfParameter`; - `self` 只出现在 `` / `_capture->self`。 -- lambda `LirFunctionDef` 一律 `setStatic(true)`(§3.7),避免 - `declareSelfSlotIfNeeded` 造 stray `self`。仅当 `capturesSelf` 时把 - `self` 放进 ``。`Kind.LAMBDA_BODY` 仍必须新增(CFG/body - pass 的 sourceOwner 门闩),但 self 槽策略以 `setStatic(true)` 为准, - 不要两套并行。 -- CFG / body lowering 的 self capture **operand** 不得伪造 - `IdentifierExpression + SELF`(该路径会 fail-fast)。必须用专用 - descriptor(例如 `SELF_SLOT`)直接读 enclosing executable 的 `self` 槽。 -- 静态 enclosing callable 中使用 `self`:沿用既有 restriction diagnostic, - 不合成非法 capture。 - -**`object_id`(已确认,对齐 Godot self-lambda)**: - -- `capturesSelf == true`:`GDExtensionCallableCustomInfo2.object_id` 必须填 - enclosing `self` 在 **`construct_lambda` 求值点** 的 - `GDObjectInstanceID`(fat pointer 已缓存的 `instance_id`,或 - `gdcc_object_id_from_raw` 仅当 raw 已保证 live)。 - 这样 `Callable.get_object()` / Signal 连接把这颗 Callable 绑到实例: - 实例 `free` / `queue_free` 后连接自动失效,后续 `emit` 不得再进 - `call_func`。 -- `capturesSelf == false`:`object_id = 0`。GDCC 没有与 Godot - `GDScript` 资源对等的 script 对象可绑;不得把 enclosing instance - 或 class singleton 偷偷填进去。 -- **不得**改 `construct_standalone_callable` / `gdcc_new_standalone_callable` - 的 `object_id = 0`,也不得改 `construct_callable` 的 receiver 合同。 -- `is_valid_func`:`object_id != 0` 时必须提供;对象已从 ObjectDB - 消失则返回 false。不得只靠 `call_func` 里解引用悬空 `self` 来“发现” - 失效。 -- `self` capture 字段仍按 ownership 合同 copy/release;`object_id` - 只负责 Godot 侧身份 / disconnect,不替代 capture 里的对象指针。 - -### 3.6 `CAPTURE` 与 writable / alias - -- `CallableScope.defineCapture` 现有 `writable=true`:允许 lambda **内部** - 对 capture 名赋值(写副本)。 -- `FrontendCfgGraphBuilder.requireDirectSlotAliasRoot` 对 `CAPTURE` 继续 - fail-fast。本计划**不**把 capture 当 live-slot alias。 -- `FrontendOpaqueExprInsnLoweringProcessors` 已把 `CAPTURE` 当符号名解析: - lambda **body** 内读 capture 名,按该函数的 local / capture 变量降低。 -- 外层函数里,被 capture 的 local 仍按普通 `LOCAL_VAR` 读写;构造 lambda 时 - 读一次快照。 - -### 3.7 合成函数身份 - -每个通过 compile surface 的 `LambdaExpression` 对应恰好一个 hidden -`LirFunctionDef`: - -- 名字:`_lambda_`,`k` 在 owning `LirClassDef` 内从 0 递增,稳定且与 - AST 遍历顺序一致(先声明顺序,再源码出现顺序)。 -- `setLambda(true)`、`setHidden(true)`、`setStatic(true)`。 - `setStatic(true)` 是为了让 `FrontendBodyLoweringSession.declareSelfSlotIfNeeded` - 不给 lambda 造 stray `self` 变量;`isLambda` 已跳过 ClassDB bind, - 无绑定副作用。`self` 只作为 §3.5 capture 存在。 - **禁止**再写 `setStatic(false)`。 -- 参数表 = lambda 源码参数(含类型);**不含** `self` 参数。 -- `` = `FrontendLambdaCapturePlan` 冻结列表。 -- 返回类型 = `FrontendDeclaredTypeSupport.resolveTypeOrVariant(lambda.returnType())` 语义, - 但在 nested resolve 入口解析**一次**并随 `FrontendLambdaPlan.returnType` 首次发布; - type-check 的 return slot 与 lowering 的 shell 返回类型都消费该已发布值,禁止两处 - 各自重复解析(避免重复 `sema.type_resolution` 告警与双源漂移)。 -- 不进入 `ClassScope.defineFunction` / 不进 ClassDB bind。 -- 命名不得与用户函数或 `_field_init_` / `_field_getter_` / `_field_setter_` 冲突; - `_lambda_` 前缀视为 compiler-owned(若用户声明同名前缀,skeleton 发 - `sema.class_skeleton` 并跳过用户声明,与 `_field_*` 合同平行)。 - -Side table(建议挂在 `FrontendAnalysisData`): - -``` -IdentityHashMap -``` - -`FrontendLambdaPlan` 至少包含: - -- `LambdaExpression` AST 身份 -- 合成名 `_lambda_` -- 有序列表 `captures: List` -- `capturesSelf: boolean` -- `returnType`(声明返回类型,nested resolve 入口一次解析并冻结) -- enclosing callable AST -- owning class canonical name - -### 3.8 LIR / C ABI - -沿用 `doc/gdcc_low_ir.md`: - -``` -$result = construct_lambda "" $capture1 $capture2 ... -``` - -- 无 capture:userdata = `NULL`,`free_func` 仍可 no-op。 -- 有 capture:拷入 heap 上的 `${Class}_Capture_${func}`, - `callable_userdata` 指向该块;`free_func` 按字段析构后 `godot_mem_free`。 -- `object_id` 按 §3.5:`capturesSelf` → enclosing `self.instance_id`, - 否则 `0`。该字段在 `godot_callable_custom_create2` 时写入,之后不改。 -- `call_func` 把 userdata 解成 `_capture`,再调 - `${Class}_${lambdaName}($arg..., _capture)`。进入 `call_func` 前若 - `object_id != 0` 且对象已死,应被 Godot / `is_valid_func` 挡掉, - 不得解引用 `_capture->self`。 -- 用户可见 arity = lambda 源码参数个数(**不含** capture)。 -- `DomLirSerializer` 必须写出真实 ``,不得再写空节点。 -- `func.ftl` 的 `_capture` 重复形参 bug **已修复**(阶段 A):`captureCount > 0` - 时只发射一次 `_capture`,由 `FuncHeaderCaptureTemplateTest` 锚定。 -- lambda 函数 prologue:把 `_capture->name` 拷入 `LirFunctionDef` 为该 - capture 登记的 local `$name`(`addCapture` 已 `variables.put(name, ...)`)。 -- `CCodegen.generateFunctionPrepareBlock` 会给每个非参数 variable - 发射默认构造(`String` → `""`,`Array` → 新数组等)。capture local - 必须从该自动初始化中排除,再由 prologue 从 `_capture->name` 拷入; - 否则 destroyable 类型会泄漏默认构造值。问题在 `CCodegen.java`, - 不是 `entry.c.ftl` 的“未初始化 local”。 - -### 3.9 诊断 - -| 场景 | category | 策略 | -|------|----------|------| -| 仍未转正的位置出现 lambda(property init 等) | 保持 `sema.unsupported_binding_subtree` 或 inventory 对应 category | diagnostic + skip | -| lambda 内 parameter default | 与普通函数相同的 deferred default 诊断 | 不打开 default 语义 | -| capture / parameter 同名冲突 | `sema.variable_binding` 既有 duplicate/shadow 类 | diagnostic + skip define | -| 静态上下文捕获 `self` | 既有 restriction / `sema.type_check` 或 lookup | 不新增平行语义 | -| compile 时 lambda 缺 plan / 缺合成函数 | `sema.compile_check` 或 lowering fail-fast | 缺 published fact 不得重绑 | -| 用户函数名以 `_lambda_` 开头 | `sema.class_skeleton` | 跳过该 member | - -普通源码错误走 `DiagnosticManager` + skip subtree,不得当异常控制流。 -缺失/冲突的 **published** fact 在 lowering 侧 fail-fast。 - ---- - -## 4. 分阶段实施步骤 - -每阶段必须可运行、可回归、可单独提交。未到阶段 I 前, -`analyzeForCompile` 对 lambda 的 compile surface 仍可保持跳过或 -“有 lambda 则 blocker”,但 **shared `analyze()`** 从阶段 C 起必须进入 body。 - -### 阶段 A — 数据面与合同测试(不翻支持面) - -**目标**:先把 plan / side table / 命名 / 模板缺陷修掉,生产封口不变。 - -**状态**:已落地(2026-08-17)。 - -**实施内容**: - -- 新增 `FrontendLambdaPlan` / `FrontendLambdaCapturePlan` / `LambdaCaptureEntry`。 -- `FrontendAnalysisData` 增加 lambda plan map 的存取 API。 -- 单测 `ScopeCaptureShapeTest` 风格的 planner 纯函数测试可先用手工 scope 图。 -- 修复 `func.ftl`:`_capture` 只发射一次(纯模板修复,不改变语义封口)。 -- `DomLirSerializer` 写出真实 ``(可先用手工 LIR fixture 测)。 - -**落地记录**: - -- 数据类:`LambdaCaptureEntry`、`FrontendLambdaCapturePlan`、`FrontendLambdaPlan` - (`frontend/sema`)。`LambdaCaptureEntry.withType` 只用于本地组装最终 plan, - 不是 side-table 回写。`capturesSelf` 当且仅当列表第一项名为 `self`。 -- Side table:`FrontendAnalysisData.lambdaPlans()` / - `updateLambdaPlans(...)`,keyed by `LambdaExpression` identity。 - 现有 owner patch 通过 `FrontendOwnerPatch.lambdaPlans()` 默认空表接入 - `applyPatch`;阶段 A 不新增 semantic stage,也不把 plan 折进 - `FrontendExprTypePatch`。完整 plan 的第一次生产发布在阶段 C。 -- Planner:`FrontendLambdaCapturePlanner` 纯函数,输入手工 scope 图 + - 有序 `IdentifierUse`;不写 scope / side table / 诊断。嵌套传递通过 - `ParentLambda(callable, body)` 从父 lambda **body** 向上查找,避免 - 漏掉中间层 local 遮蔽。 -- `func.ftl`:`captureCount > 0` 时只发射一次 `_capture`。 -- `DomLirSerializer`:lambda 按 `getCaptureList()` 写出 - ``;`LirFunctionDef.captures` 改为 `LinkedHashMap` - 以冻结插入序。 -- 测试:`FrontendLambdaCapturePlannerTest`、 - `FrontendLambdaPlanSideTableTest`、`FuncHeaderCaptureTemplateTest`、 - `DomLirSerializerTest` 往返 / ABI / 非 lambda `addCapture`。 - `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` - 保持 fail-closed 红字断言。 - -**验收细则**: - -- happy path:手工构造 `is_lambda` + 两个 capture 的 `LirFunctionDef`, - DOM 往返后 `getCaptureList()` 完整。 -- happy path:`func.ftl` 对 `captureCount > 1` 只出现一个 `_capture` 形参 - (不是“两条路径重复”,而是 list 按元素各发射一次)。 -- negative path:非 lambda `addCapture` 仍抛;compiler-only capture 仍被 - `LirPublicAbiValidator` 拒绝。 -- 现有 `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` - **仍红字不变**(本阶段不翻 inventory)。 - -### 阶段 B — Policy 翻转 + Variable inventory 解封(仍不进 SuiteResolver) - -> 状态:已落地(2026-08-17)。policy 已翻转;`bindLambdaInventory` 按 post-order 绑定 -> param / local / capture(`Variant` 占位);reporter 停止对 supported executable 内 -> lambda 报 inventory 错;`self` capture(显式 / 隐式 instance member / static 抑制) -> 已接线;`lambdaPlans()` 保持为空。 - -**目标**:lambda param / local / capture **名字**进入 scope;去掉 -`sema.unsupported_variable_inventory_subtree`。 - -**实施内容**: - -- **先做 §3.1 policy 翻转**(`LAMBDA_BODY` / `LAMBDA_EXPRESSION` → - `EXECUTABLE_BODY`),否则 `bindLocal` 会因 - `canPublishCallableLocalValueInventory(LAMBDA_BODY)==false` 发 - `sema.variable_binding`。同步更新 - `FrontendBodySemanticSupportPolicyTest` 的 lambda 行。 -- `FrontendVariableAnalyzer.handleLambdaExpression` 改为 - `bindCallableParameters(lambda, params, body)`,不再 SKIP。 -- `UnsupportedVariableBoundaryReporter.handleLambdaExpression` 停止对 - **supported executable 内**的 lambda 报 inventory 错。 -- 引入 planner:在 bind param/local 之后按 §3.4 innermost-first - `defineCapture`。本阶段只登记名字 + `sourceDeclaration`, - `defineCapture` 的类型用 `Variant` 占位。**禁止** - `updateLambdaPlans` / overlay 发布占位 plan。真实类型与 - `FrontendLambdaPlan` 的第一次 publish 按 §3.4 留到阶段 C - nested resolve 入口。 -- **本阶段不改** `FrontendInterfacePhase.handleLambdaExpression`(仍 - `SKIP_CHILDREN`),因此 `entersSuiteResolver=true` 无消费者。 -- property-init / parameter-default / skipped subtree 内的 lambda: - variable analyzer 的 boundary reporter **只扫描 callable body**, - 不会走进 property initializer。property-init lambda 的 unsupported - 仍由 `FrontendBodyOwnerProcedures` 的 binding/chain 诊断负责 - (本阶段保持这些诊断)。不得误以为 Phase B 会给 property-init - 再发一条 inventory 错。 - -**验收细则**: - -- happy path:`FrontendVariableAnalyzerTest` 原 - `analyzeWarnsForDeferredLambdaSubtreesWhileBindingOuterLocal` **翻转**: - `lambdaScope.resolveValue("item")` 为 `PARAMETER`; - `lambdaBodyScope.resolveValue("lambda_local")` 为 local; - 外层被引用的 `seed` 在 lambda `CallableScope` 上为 `CAPTURE`。 -- happy path:嵌套 lambda 引用最外层 local 时,中间 lambda 也能 - `resolveValueHere` 到 `CAPTURE`。 -- negative path:lambda local 遮蔽外层同名 local 时 **不得** capture - (`var x = 1; func(): var x = 2; return x`)。 -- negative path:嵌套 lambda 的 param/local 遮蔽外层名时,中间层不得 - 被错误插入该 capture。 -- negative path:`match` / block-local const 的 inventory 错仍在。 -- negative path:property initializer 里的 lambda 仍发 - `sema.unsupported_binding_subtree`(owner 是 BodyOwnerProcedures, - 不是 variable inventory),不得 bind、不得 `recordCallable`。 -- negative path:lambda 参数与将捕获的同名冲突 → 一条 - `sema.variable_binding`,不抛异常。 -- negative path:本阶段结束时 `lambdaPlans()` 对任何 lambda 仍为空; - capture 名只出现在 `CallableScope` 的 `CAPTURE` 绑定上。 - -### 阶段 C — Policy / resolver / interface / suite 解封 - -> 状态:已落地(2026-08-17)。resolver 已去掉 lambda AST 边与 kind 封口(callable scope -> gate 改为 policy 驱动);interface 在 `supportedBodyDepth > 0` 时 `recordCallable`; -> suite 四个 helper 已加 `LambdaExpression` 分支;nested resolve 入口填充 capture -> 声明处类型(`declarationSiteLocalSlotType` 严格 overlay 查询 + `resetCaptureType`) -> 并经 `LAMBDA_RESOLUTION` / `FrontendLambdaResolutionPatch` 首次发布完整 plan; -> `walkRootBounded` 剪枝保留;未 record 的 lambda 继续发 unsupported binding/chain。 -> `smallestContainingCallable` 已识别 `LambdaExpression`。已 record lambda 的表达式类型 -> 仍发布 UNSUPPORTED 但不发诊断(`GdCallableType` 属阶段 D)。 - -**目标**:lambda body 成为 supported executable suite。shared `analyze()` -进入 body 并发布普通表达式事实。 - -**实施内容**: - -- Policy 已在阶段 B 翻转;本阶段只打开 resolver / interface / suite。 -- `FrontendVisibleValueResolver` 去掉 lambda AST 边与 - `LAMBDA_EXPRESSION` / `LAMBDA_BODY` current-scope 的 deferred 封口。 -- `FrontendInterfacePhase.handleLambdaExpression`:仅 - `supportedBodyDepth > 0` 时 `recordCallable(...)`;depth=0 仍 - `SKIP_CHILDREN`。 -- `FrontendSuiteResolver` 四个 helper 增加 `LambdaExpression`。 - `resolveCallableOwner` 对 lambda 必须在走 body 语句之前按 §3.4 - 填充 capture 类型(declaration-anchored overlay + `resetCaptureType`), - 并经独立 `LAMBDA_RESOLUTION` owner 把完整 `FrontendLambdaPlan` - **第一次**写入 overlay → suite export → `lambdaPlans()`。 - 该填充 / publish 不是 body 进入条件。 -- 新增 `FrontendSemanticStage.LAMBDA_RESOLUTION`(或等价名)、 - `FrontendLambdaResolutionPatch`、`OverlayFacts.lambdaPlans`。 - `toOwnerPatches` / `mergeFrom` / `hasFacts` / `clear` / - `FrontendPublishedFactTypeGuard.checkOwnerPatch` 必须接线。 - 不得复用 `FrontendExprTypePatch` 或改 `mergeSideTable` 覆盖语义。 -- `FrontendBodyOwnerProcedures`:对**已 record** 的 lambda,把 - unsupported binding/chain 诊断换成 nested `resolveCallableOwner` - 触发。`walkRootBounded` 对 `LambdaExpression` 的剪枝**必须保留**, - 外层 owner 不得再 walk 进其 children 当普通表达式树。 - 对**未 record** 的 lambda,继续发 unsupported binding/chain。 -- `FrontendAnalysisInspectionTool.hasUnsupportedOrDeferredAncestor` - 中把 `LambdaExpression` 从硬编码 deferred 祖先列表移除(否则 - inspection 仍把已转正 lambda 标成 deferred)。 -- 同步检查 `smallestContainingCallable`:若它只认 - `FunctionDeclaration` / `ConstructorDeclaration`,lambda body 内诊断 - 会回退到 class/file 锚点。阶段 C 应让它识别 `LambdaExpression`, - 或在文档中明确接受该回退。 -- `FrontendBodySemanticSupportPolicyTest` 应已在阶段 B 翻转;本阶段 - 补 resolver / interface 测试。 - -**验收细则**: - -- happy path:`FrontendInterfacePhaseTest` / - `FrontendSuiteResolverTest` 中 `lambda.body()` **进入** - `suiteEntryRoots()` 与 `bodyDeclarationIndex()`。 -- happy path:`FrontendVisibleValueResolverTest.resolveSealsLambdaBody*` - **翻转**为可解析外层 capture / 当前 param / 当前 local。 -- happy path:lambda body 内 `return seed` 对 `seed` 发布 - `symbolBindings` + `CAPTURE`。 -- happy path:nested resolve 完成后 `lambdaPlans()[lambda]` 存在, - capture 类型是声明处类型(`var a := 1` → `int`,`var b = 1` → - `Variant`),且与 `CallableScope` 上对应 `CAPTURE` 同源。 - 阶段 B 结束时同一 key **仍不存在**。 -- negative path:对已发布 plan 再 patch 不同 payload 必须冲突; - 不得靠 `withType` + `applyPatch` 覆盖。 -- negative path:`match` / const / parameter default 仍 `DEFERRED_UNSUPPORTED`。 -- negative path:合成 `LAMBDA_BODY` scope 不再被 current-scope backstop 误封 - (原 `resolveRejectsSyntheticLambdaBodyCurrentScope*` 翻转或改为 - “无 AST 时仍按 EXECUTABLE_BODY 解析”)。 - -本阶段 **仍不必**让 `analyzeForCompile` 放行。 - -### 阶段 D — 表达式类型与 Callable 值 - -> 状态:已落地(2026-08-17)。`resolveLambdaExpressionType` 新增 recorded 判定参数: -> 已 record lambda 首次发布 `RESOLVED(GdCallableType)`(未参数化,与 method-as-value -> 对齐);未记录 lambda 保持 `UNSUPPORTED` + 既有 unsupported 诊断三路由。 -> `runLocalTypeStabilization` 对 lambda initializer 显式 fail-closed(slot 保持 -> inventory `Variant`,silent 路径不解析 lambda);`publishExpressionType` 的 -> recorded-lambda 静默 UNSUPPORTED 分支已随 RESOLVED 化删除。 -> `FrontendTypeCheckAnalyzer` 新增 `scanNestedLambdaBodies` 显式 re-entry, -> `handleLambdaExpression` 以 plan 存在性为闸门 walk body(继承 enclosing -> restriction/static;return slot 暂为 `GdVariantType.VARIANT`,声明返回类型的 -> 检查留待阶段 E 合成函数落地)。`FrontendCompileCheckAnalyzer.walkExpression` -> 对 lambda 改发形态级 `sema.compile_check` blocker(与 preload / get-node 同款 -> “已识别但 lowering 未落地”),analyzeForCompile 在阶段 I 前保持 -> “compile surface 上有 lambda 则 blocker”。 - -**目标**:lambda 表达式发布稳定 `RESOLVED(GdCallableType)`。 - -**实施内容**: - -- `resolveLambdaExpressionType` 按 §3.2 / §3.3 先 nested-resolve body, - 再 `resolved(new GdCallableType())`。 -- `FrontendTypeCheckAnalyzer.handleLambdaExpression` 改为 walk body - (或依赖 suite 已走完、此处只消费外层赋值兼容)。 -- 赋值 `var cb: Callable = func(): pass` 走既有 ordinary boundary。 -- `var x: int = func(): pass` 发既有 `sema.type_check` 不兼容。 - -**落地记录**: - -- 表达式类型:`FrontendExpressionSemanticSupport.resolveLambdaExpressionType` 新增 - `recordedLambda` 参数(调用点以 - `suiteEntryRoots().containsCallableOwner(lambda)` 判定)。已 record lambda - 经既有 EXPR_TYPE owner 单点首次发布 `RESOLVED(GdCallableType)`——满足 - `sameExpressionType` 严格相等约束,无"先 UNSUPPORTED 再升级"的 patch 冲突。 - `GdCallableType` 是 `GdMetaType`(非 compiler-only),通过 published-fact 类型守卫。 -- Silent 稳定化闸门:`runLocalTypeStabilization` 在 initializer 为 - `LambdaExpression` 时直接返回——`var cb := func(): ...` 的 slot 保持 - inventory `Variant`;`checkInferredLocalTypeConsistency` 对 Variant slot - 短路灯塔,无需改动。阶段 D 不实现非 silent 写回。 -- Type-check body walk:`AstWalkerTypeCheckVisitor.scanNestedLambdaBodies` - 镜像 loop-control 的 `scanNestedCallableBoundaries`,在 varDecl / return / - assert / expression statement / if / elif / while / for 八个语句 handler 上 - 扫描表达式载荷;`handleLambdaExpression` 以 - `lambdaPlans().containsKey(lambda)` 为闸门(未记录 lambda 无 plan、无 body - 事实,保持 fail-closed),经 `walkCallableBody(lambda, body, Variant, - currentRestriction, currentStaticContext)` 继承外层可调用上下文。 -- Compile gate:`walkExpression` 的 lambda 分支由静默跳过改为 - `reportExplicitCompileBlock(lambda, expressionCompileBlockedMessage("Lambda expression"))`, - 防止阶段 I 前带 lambda 的模块漏进 lowering。 -- 测试:翻转 `analyzeLeavesInferredLocalsAsVariantWhenInitializerCannotPublishStableType` - (initializer RESOLVED + slot 保持 Variant)、 - `analyzeForCompileBlocksDirectLambdaConnectArgument`(unsupported_expression_route - 清空,compile_check 恰为 lambda blocker)、 - `analyzeForCompileSkipsExplicitCompileBlocksOutsideCompileSurface` / - `analyzeSkipsGenericCompileBlocksOutsideCompileSurface` / - `analyzeForCompileSkipsBareSignalValueReferencesOutsideCompileSurface` - (compile_check 恰为 1 条 lambda blocker,off-surface 事实仍不被扫描); - `analyzeChecksOnlyExplicitOrdinaryLocalDeclaredSlotsAndSkipsUnstableInitializerFacts` - 的 lambda fixture 行改为 `Worker.VALUE`(保持"跳过不稳定 initializer"的原意图)。 - 新增 `recordedLambdaPublishesCallableExpressionTypeAlongsidePlan`、 - `analyzeChecksLambdaInitializersAgainstDeclaredSlots`、 - `analyzeWalksRecordedLambdaBodiesWithInheritedCallableContext`、 - `analyzeKeepsUnrecordedLambdaBodiesOutsideTypeCheckSurface`。 - -**验收细则**: - -- happy path:`FrontendTypeCheckAnalyzerTest` / - `FrontendBodyOwnerProceduresExprTypeTest` 中 - `var deferred_value := func(...): ...` 的 initializer 从 `UNSUPPORTED` - 变为 `RESOLVED(Callable)`。silent 稳定化不得解析该 initializer; - 该 local 是否随后精化为 `GdCallableType` 只由 nested resolve 完成后 - 的非 silent 写回决定,缺写回则保持 `Variant`。 -- happy path:`sig.connect(func(): pass)` 在 shared analyze 下不再出现 - `unsupported_binding_subtree`(compile_check 仍可暂缺或仍 blocker)。 -- negative path:类型不兼容赋值仍只由 type-check 拥有,不重复 inventory 错。 -- negative path:lambda 内仍未支持的 `match` 继续上游 owner 报错, - 不把整个 lambda 打回 `UNSUPPORTED`。 - -### 阶段 E — 合成 hidden `LirFunctionDef` - -**目标**:每个 compile-bound lambda 在 lowering 前拥有完整 shell 函数。 - -**实施内容**: - -- 在 `FrontendLoweringFunctionPreparationPass`(或紧前的专用 pass)扫描 - 已发布 `FrontendLambdaPlan` 的 `LambdaExpression`。 -- `owningClass.addFunction` 合成 `_lambda_`,`setLambda` / `setHidden` / - `setStatic(true)`(§3.7),`addCapture`,`fill` 参数与返回类型。 -- 为每个 lambda 建 `FunctionLoweringContext`。**必须新增** - `Kind.LAMBDA_BODY`(或等价),不得复用 `Kind.EXECUTABLE_BODY`: - `FrontendLoweringBuildCfgPass.publishStraightLineExecutableGraph` - 要求 `sourceOwner` 是 `FunctionDeclaration` / `ConstructorDeclaration`, - `LambdaExpression` 会直接 `IllegalStateException`。 -- 同步给 `FrontendLoweringBuildCfgPass` 增加接受 - `sourceOwner instanceof LambdaExpression` 且 `loweringRoot instanceof Block` - 的分支。 -- `FrontendLoweringBodyInsnPass` 的 Kind switch **必须**把 `LAMBDA_BODY` - 并入与 `EXECUTABLE_BODY` 相同的分支(跑 `FrontendBodyLoweringSession`)。 - 该 switch 无 `default`:漏加会静默跳过,留下 shell-only 函数再被 - `CCodegen.generateFunctionPrepareBlock` 接上 `__prepare__`。 -- 不得把 lambda 塞进 `PROPERTY_INIT`。 -- **不要**对 lambda 调 `ensureExecutableSelfParameter`;self 只走 - §3.5 capture。 -- `CGenHelper` / bind methods 已跳过 `isLambda()`,保持。 - -**验收细则**: - -- happy path:一个含两层嵌套 lambda 的函数,class 上有两个 hidden - `is_lambda` 函数,capture 列表与 plan 一致。 -- happy path:无 capture 的 lambda 无 `` 条目,且 - `getCaptureCount()==0`。 -- negative path:用户手写 `func _lambda_0():` → `sema.class_skeleton`, - 不覆盖合成函数。 -- negative path:缺 `FrontendLambdaPlan` 的 lambda 进入该 pass → fail-fast, - 不静默跳过。 - -**阶段 E 落地记录(2026-08-18)**: - -- `FunctionLoweringContext.Kind` 新增 `LAMBDA_BODY`:`sourceOwner` 为 - `LambdaExpression`,`loweringRoot` 为其 body `Block`。编译器穷举 switch 强制 - BuildCfg / BodyInsn 两个 pass 同时接线,不存在"静默跳过"窗口。 -- `FrontendLoweringFunctionPreparationPass`:每个 executable 上下文 - 建立后,经 `collectLambdaContexts` 发现式 walk 其 body(遇 lambda 则合成并递归进其 - body 找嵌套)。发现的 lambda 必须携带已发布 plan,否则 - `requireLambdaPlan` fail-fast(`IllegalStateException`)。property-initializer - 表达式不在扫描面内(其 lambda 未记录且带有上游 error 诊断,pipeline 在 AnalysisPass - 即停止)。 -- 合成(`synthesizeLambdaShell`)严格按 §3.7:`new LirFunctionDef(plan.syntheticName())` - → `setLambda(true)`(必须先于 `addCapture`)→ `setHidden(true)` → `setStatic(true)` - → 返回类型 = `plan.returnType()`(nested resolve 入口已由 - `resolveTypeOrVariant(lambda.returnType(), lambdaScope, ...)` 解析一次并随 plan 发布; - type-check 的 return slot 与 shell 共用该值,未知标注只告警一次)→ - 参数取自 lambda `CallableScope` 的 PARAMETER 绑定(inventory 已解析的声明类型, - 避免 lowering 二次解析产生重复告警;缺绑定即 fail-fast)→ 按 plan 顺序 - `addCapture(new LirCaptureDef(name, type, function))` → `owningClass.addFunction`。 - 不调 `ensureExecutableSelfParameter`;`plan.owningClassCanonicalName()` 与发现处 - class 不一致、或合成名与既有函数冲突(`_lambda_` 保留名规则的防御侧)均 fail-fast。 -- `FrontendLoweringBuildCfgPass`:`LAMBDA_BODY` 分支校验 `sourceOwner instanceof - LambdaExpression` 后,与 `EXECUTABLE_BODY` 共享新抽取的 - `publishExecutableBlockGraph`(Block loweringRoot 校验 + shell-only 校验 + - `buildExecutableBody` + graph/region/for-slot 发布)。 -- `FrontendLoweringBodyInsnPass`:`LAMBDA_BODY` 并入 - `EXECUTABLE_BODY, PROPERTY_INIT` 分支跑共享 `FrontendBodyLoweringSession`; - session 无 Kind 分支,`setStatic(true)` 使 `declareSelfSlotIfNeeded` 不产生 - stray `self`,`addCapture` 预登记的 capture 变量与 body 内 CAPTURE 符号读取 - (opaque `AssignInsn` 路由)衔接。 -- `FrontendSyntheticPropertyHelperSupport`:新增 `LAMBDA_FUNCTION_PREFIX = "_lambda_"` - 并入 `RESERVED_PREFIXES`;诊断消息按前缀分支出 "reserved synthetic lambda-function - prefix" 文案(property-helper 既有文案与测试锚点不变)。 -- 边界说明:外层 body 表达式树中的 `LambdaExpression` 仍由 `FrontendCfgGraphBuilder` - fail-fast(阶段 F 才建 `construct_lambda` item),因此阶段 E 的 CFG/body 测试只把 - LAMBDA_BODY 上下文送入后续 pass。 -- 测试:`FrontendLambdaLoweringTest`(7 例:嵌套合成/无 capture/self leading capture/ - 缺 plan fail-fast/合成名冲突 fail-fast/CFG 接线/body 接线); - `FrontendClassSkeletonTest.buildRejectsReservedLambdaFunctionPrefixButKeepsBoundaryNamesAlive`; - `FrontendLoweringFunctionPreparationPassTest` 的 Kind 全集断言补 `LAMBDA_BODY`。 - 全套 1216 frontend 测试 + 全项目 `test` 任务均通过。 - -### 阶段 F — CFG + `construct_lambda` lowering - -**目标**:外层 body 把 lambda 值 lower 成 `ConstructLambdaInsn`; -lambda body 走既有 CFG / body insn pass。 - -**实施内容**: - -- 新增 CFG item(建议名 `LambdaLoadItem` / `LambdaConstructItem`): - 持有合成名、capture 的 source value id 列表、result value id。 -- `FrontendCfgGraphBuilder` 在表达式为 `LambdaExpression` 且存在 plan 时 - 建该 item。普通 capture operand 从 enclosing - `FrontendBindingKind.LOCAL_VAR` / `PARAMETER` / 外层 `CAPTURE` - slot 读取;`self` capture 必须用 `SELF_SLOT` - descriptor,禁止伪造 `IdentifierExpression + SELF`。 -- `FrontendSequenceItemInsnLoweringProcessors` 发射 - `ConstructLambdaInsn(result, name, captures...)`。 -- lambda **body** 的 CFG 以合成 `LirFunctionDef` 为 target,复用 - `buildExecutableBody`。 -- body 内 `IdentifierExpression + CAPTURE`:按该 lambda 函数的变量名读, - **仍不得**走 `DirectSlotAliasValueItem`。 -- 外层 `CAPTURE` 出现在非 lambda body(不应发生)继续 fail-fast。 - -**验收细则**: - -- happy path:`var cb := func(x: int): return x` 外层 LIR 含 - `construct_lambda "_lambda_0"`,无 capture operand;`_lambda_0` - 有参数 `x` 与 return。 -- happy path:捕获外层 `seed` 时 insn 为 - `construct_lambda "_lambda_0" $seed`;`_lambda_0` 的 - `` 类型等于该外层绑定的**声明处类型**(§3.4), - 不是外层函数末尾 slot 类型。 -- happy path:使用 `self.foo` 的 lambda 带 `self` capture operand。 -- negative path:`FrontendCfgGraphBuilderTest` 对 **外层** - `CAPTURE` alias 的 fail-fast **保留**。 -- negative path:plan 与 CFG capture 数量不一致 → fail-fast。 - -> 状态:已落地(2026-08-18)。 -> -> - `LambdaConstructItem`(`cfg/item`):持有 `lambdaAnchor`(`LambdaExpression`,供 -> materialization 取 `RESOLVED(GdCallableType)` 结果类型)、合成名、有序 -> `CaptureOperand` 列表与 `resultValueId`;`operandValueIds()` 为空(capture 是外层 -> frame 的直接 slot 读,不经 value-id 数据流)。`CaptureOperand` 两个实现: -> `VariableSlotOperand(slotId)`(LOCAL/PARAMETER/外层 CAPTURE 按名读)与 -> `SelfSlotOperand.SELF_SLOT`(enum 单例 descriptor,slotId 即 -> `FrontendLambdaCapturePlan.SELF_CAPTURE_NAME`,不伪造 `IdentifierExpression + SELF`)。 -> - `FrontendCfgGraphBuilder.buildValue` 新增 `case LambdaExpression`: -> `buildLambdaConstructValue` 从 `lambdaPlans()` 取 plan(缺失即 fail-fast),按 plan -> 顺序生成 operand(`capturesSelf` 时防御性校验 leading 项名为 `self`), -> `preferredResultValueId` 照常穿透。 -> - `FrontendBodyLoweringSupport.requireProducedValueMaterialization` 新增 case:结果类型 -> 取 `expressionTypes()[lambdaAnchor]`,`TEMP_SLOT`。 -> - `FrontendLambdaConstructInsnLoweringProcessor`(已注册进 sequence-item registry): -> 先在 owning class 上找回 shell 并端到端校验(存在且 `is_lambda`、capture 数一致、 -> 名序一致——operand slotId 按构造即 capture 名),再发射 -> `ConstructLambdaInsn(cfg_tmp_, name, captures…)`。`ConstructLambdaInsn` -> 补了防御性 compact ctor(name 非 blank、captures 非空拷贝)。 -> - `FrontendBodyLoweringSession` 新增包级 `functionContext()` accessor 供 processor 取 -> owning class。 -> - 测试(`FrontendLambdaLoweringTest` 新增 7 例):无 capture / 本地 capture(`$seed`)/ -> self capture(`SELF_SLOT` descriptor + `$self`)/ 嵌套外层 CAPTURE slot 读(内层 insn -> 落在外层 lambda body,operand 名与外层 capture 同名)/ return 位置 preferred id 穿透; -> negative:builder 缺 plan fail-fast、item 与 shell capture 数不一致 fail-fast -> (手工给 shell 追加 phantom capture 模拟漂移)。既有 -> `buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot` 保持不变并通过。 -> - 外层 body 对 lambda 的其它表达式位置(如 call argument)走同一 `buildValue` 路径, -> 无额外分支。 - -### 阶段 G — C runtime 与 codegen - -**目标**:`construct_lambda` 可编译、可调用、可释放。 - -**实施内容**: - -- 新增 `gdcc_new_lambda_callable(...)`(或 per-lambda 生成的薄包装), - 复用 `godot_callable_custom_create2`: - - `call_func` / `free_func` / `hash` / `equal`(引用相等即可,对齐 Godot) - - `get_argument_count` = 源码参数个数 - - `object_id`:`capturesSelf` 时为 enclosing `self.instance_id`,否则 `0` - (§3.5)。helper 必须接收该 ID,不得在 C 里对可能已死的 raw 调 - `godot_object_get_instance_id`。 - - `is_valid_func`:`object_id != 0` 时按 ObjectDB 存活返回; - `object_id == 0` 时可与 standalone 一样只检查 userdata / 函数指针。 -- `ConstructInsnGen` 注册 `CONSTRUCT_LAMBDA`: - 分配 capture 结构体、逐字段 copy、调用 helper。 -- 从 `CCodegen.generateFunctionPrepareBlock` 排除 capture locals - (`func.getCapture(name) != null`),避免默认构造后再 copy 造成 - destroyable 泄漏。 -- 在 lambda 函数入口(`__prepare__` 之后或专用 prologue insn)把 - `_capture->name` 拷入对应 `$name`(destroyable 用既有 copy helper)。 -- `free_func`:对每个 destroyable 字段 `destruct`,再 free userdata。 -- 无 capture:userdata `NULL`,`free_func` no-op。 -- 对象 capture 的生命周期遵循 `gdcc_ownership_lifecycle_spec.md` 的 - copy/destruct;不必在 MVP 复刻 Godot “已释放则变 null” 的调试打印, - 但不得 double-free。 -- 同步 `doc/gdcc_runtime_lib.md`(新 helper)与 `doc/gdcc_low_ir.md` - (`_capture` 尾参 + prologue-copy 约定;当前只写了 userdata)。 - -**验收细则**: - -- happy path:targeted backend 测试(可手工 LIR)生成的 C 含 capture - typedef、`construct_lambda` 调用、`free_func`。 -- happy path:Zig 可用时 e2e:lambda 返回常量、`Callable.call` 得到预期值。 -- happy path:捕获 `int` / `String` / `Array`;String/Array 在 Callable - 释放后无泄漏(ASan/现有 destroy 约定)。 -- happy path:`capturesSelf` 的 lambda 生成的 C 里 - `GDExtensionCallableCustomInfo2.object_id` 来自 `self` 的 cached - `instance_id`,不是字面 `0`。 -- happy path(Zig + `GODOT_BIN`):`sig.connect(func(): self.flag = true)` - 后 `free`/`queue_free` 该实例,再 `sig.emit()` 不得改到已释放对象, - 也不得崩溃;连接应按 Godot 随 `object_id` 失效。 -- happy path:不捕获 `self` 的 lambda 仍 `object_id = 0`。 -- negative path:未知 lambda 名 / capture 数与函数定义不一致 → - codegen fail-fast,不生成半残 C。 - -> 状态:已落地(2026-08-18)。 -> -> - `gdcc_new_lambda_callable(...)` 加入 `gdcc_callable.h`:调用方传入 userdata / -> cached `object_id` / 四个 per-lambda 回调;hash/equal 留空,走 Godot 默认 -> `call_func + userdata` 身份。 -> - `entry.h.ftl`:仅当 `captureCount > 0` 才发 `${Class}_Capture_${func}`(字段用 -> `renderGdTypeInC`,对象为 fat pointer);并生成 `_call` / `_free` / -> `_is_valid` / `_get_argument_count`。`capturesSelf` 的 `is_valid` 用 -> `godot_object_get_instance_from_id(captures->self.instance_id)`。 -> - `ConstructInsnGen` 注册 `CONSTRUCT_LAMBDA`:按 shell 校验名序后分配 heap -> 块、逐字段 copy(destroyable 走 copy helper;对象 first-write + retain)、 -> 调 helper。未知名 / 数量或名序不一致 / 无 self 槽却捕获 self → -> `InvalidInsnException`。 -> - `CCodegen.generateFunctionPrepareBlock` 跳过 `func.getCapture(name) != null` -> 的 local;`generateFuncBody` 入口 prologue 把 `_capture->name` 拷入 `$name`。 -> - 测试:`ConstructLambdaInsnGenTest`(正反路径 + prepare/prologue + wrapper -> object_id/ObjectDB)与 `ConstructLambdaInsnGenEngineTest`(Zig + 可选 -> `GODOT_BIN`:常量 / String capture / self `object_id` / free 后 invalid)。 - -### 阶段 H — 回归锚点补齐(仍可选择暂不解封 gate) - -**目标**:focused tests 覆盖 happy / negative;test_suite 仍可暂避 lambda, -直到阶段 I。 - -**建议测试类**(新建或扩展): - -- `FrontendLambdaInventoryTest` -- `FrontendLambdaCapturePlannerTest` -- `FrontendLambdaSuiteResolverTest` -- `FrontendLambdaExpressionTypeTest` -- `FrontendLambdaLoweringTest` / 扩 `FrontendLoweringBodyInsnPassTest` -- `ConstructLambdaInsnGenTest` -- 扩 `FrontendCompileCheckAnalyzerTest`(阶段 I 翻转) -- 扩 `FrontendLoopControlFlowAnalyzerTest`(保持 boundary) - -**验收细则**: - -- 每条新恢复规则同时有 happy + negative(category、坏 subtree 跳过、 - 同模块其他 subtree 继续)。 -- `FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary` - **保持**:lambda 内 `break` 仍非法。 -- `FrontendLambdaCapturePlannerTest` / 对应 suite 测试必须锚定 §3.4 - 类型表:`var x := 1` → capture `int`;`var x = 1` → capture - `Variant`(不得因 initializer 推断);显式标注 / 参数 → 声明类型; - 未标注参数 → `Variant`;外层 `CAPTURE` 传递同源类型;`self` → - enclosing `GdObjectType`;for 迭代器 → `FOR_ITERATION_RESOLUTION` - 已提交结果。负例:lambda 之后对 source 的断言不改变已冻结 - capture 类型;`CallableScope` 的 CAPTURE 绑定与 `LambdaCaptureEntry.type` - 同源。 - -> 状态:已落地(2026-08-18)。 -> -> - 新建具名类:`FrontendLambdaInventoryTest`(supported inventory happy + -> property-initializer skip / sibling 继续)、`FrontendLambdaExpressionTypeTest` -> (recorded `RESOLVED(Callable)` + unrecorded 不污染 sibling)。 -> - 既有覆盖继续充当计划建议类:`FrontendLambdaCapturePlannerTest`、 -> `FrontendLambdaSuiteResolutionTest`、`FrontendLambdaLoweringTest`、 -> `ConstructLambdaInsnGenTest`。 -> - 未翻转:`FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` -> (阶段 I)、`FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary`。 -> - §3.4 类型表已由 suite / planner 测试锚定,本阶段不重写。 - -### 阶段 I — Compile gate 解封与文档吸收 - -**目标**:满足 `frontend_compile_check_analyzer_implementation.md` §7 五条件。 - -**实施内容**: - -- `FrontendCompileCheckAnalyzer` 将 supported executable 内的 - `LambdaExpression` 纳入 compile surface,递归扫描其 body facts。 -- 缺 `FrontendLambdaPlan` / 缺合成函数 / 缺 `construct_lambda` lowering - 合同 → `sema.compile_check` blocker 或 fail-fast,不得静默放行。 -- 更新: - - `frontend_rules.md` §MVP(删“lambda 仍 deferred”) - - `frontend_signal_support.md` 拒绝列表(其 lambda / capture 句)与 - compile-gate 句;**新增** `construct_lambda` 的 `object_id` 按 §3.5 - (`capturesSelf` 绑 `self.instance_id`,否则 `0`)。该文档现有 - “`object_id` 填 0”写的是 `construct_standalone_callable`,**不得** - 改那一句。`construct_callable` 合同也不动 - - `frontend_lowering_plan.md` §7 将 lambda 移出 post-MVP - - `frontend_variable_analyzer_implementation.md` / - `frontend_visible_value_resolver_implementation.md` / - `frontend_resolution_pipeline_implementation.md` / - `frontend_top_binding_analyzer_implementation.md` / - `scope_architecture_refactor_plan.md` 中对应 deferred 句 - - `doc/gdcc_runtime_lib.md`、`doc/gdcc_low_ir.md`(若阶段 G 已改 ABI 文本) - - `doc/test_error/test_suite_engine_integration_known_limits.md` §1 - - `doc/benchmark.md` 若仍写 “avoid lambda” -- 本计划合同吸收为 `frontend_lambda_implementation.md` 事实源, - 删除阶段流水账。 - -**验收细则**: - -- happy path:`analyzeForCompileBlocksDirectLambdaConnectArgument` **翻转** - 为 compile 成功、无 `unsupported_binding_subtree`、无多余 `compile_check`。 -- happy path:`analyzeForCompileReleasesBuiltinAndStaticMethodReferences` - 中的 `lambda_cb` 不再贡献 unsupported binding。 -- negative path:property-init / parameter-default 中的 lambda 仍不进 - compile surface。 -- negative path:lambda body 内 `match` 仍使 compile 失败,且诊断 owner - 不是误挂的 `sema.compile_check` 重复包一层。 -- Zig + `GODOT_BIN` 可用时:`sig.connect(func(): ...)` e2e 跑通。 - ---- - -## 5. 测试规则与建议命令 - -- 只跑相关测试类/方法,使用 `--no-daemon --info --console=plain`。 -- 优先: - -```text -pwsh -ExecutionPolicy Bypass -File script/run-gradle-targeted-tests.ps1 ` - -Tests FrontendLambdaInventoryTest,FrontendLambdaCapturePlannerTest,FrontendBodySemanticSupportPolicyTest -``` - -阶段 C 之后追加: - -```text -FrontendVisibleValueResolverTest,FrontendInterfacePhaseTest,FrontendSuiteResolverTest,FrontendVariableAnalyzerTest -``` - -阶段 D–F 之后追加: - -```text -FrontendTypeCheckAnalyzerTest,FrontendSemanticAnalyzerFrameworkTest,FrontendCfgGraphBuilderTest,FrontendLoweringBodyInsnPassTest -``` - -阶段 G–I 之后追加: - -```text -FrontendCompileCheckAnalyzerTest,ConstructLambdaInsnGenTest,DomLirParserTest,LirPublicAbiValidatorTest -``` - -- e2e / test_suite 仅在阶段 I 且 Zig 可用时加正向锚点;negative 留在 - frontend focused tests。 - ---- - -## 6. 必须翻转 vs 必须保持的现有测试 - -### 6.1 阶段推进后应翻转 - -| 测试 | 当前断言 | 翻转后 | -|------|----------|--------| -| `FrontendVariableAnalyzerTest.analyzeWarnsForDeferredLambdaSubtrees*` | inventory error + 空 scope | bind param/local/capture | -| `FrontendSemanticAnalyzerFrameworkTest.analyzePublishesTopBindings...` 中 lambda 段 | 无 binding + unsupported_* | `CAPTURE`/`PARAMETER` + `GdCallableType` | -| `FrontendInterfacePhaseTest.publishesForInventoryWhileUnsupported...` 中 lambda | body 不在 suite entry | body 在 suite entry | -| `FrontendSuiteResolverTest.forBodyResolvesWhileUnsupported...` 中 lambda | 非 supported block | supported + owner events | -| `FrontendVisibleValueResolverTest.resolveSealsLambdaBodyAsDeferredUnsupported` | `LAMBDA_SUBTREE` | 可解析 | -| `FrontendVisibleValueResolverTest.resolveRejectsSyntheticLambdaBody...` | current-scope 封口 | 不再因 kind 封口 | -| `FrontendBodySemanticSupportPolicyTest` lambda 行 | `LAMBDA_SUBTREE` | **阶段 B** 即翻成 `EXECUTABLE_BODY` | -| `FrontendTypeCheckAnalyzerTest` lambda initializer | `UNSUPPORTED` | `RESOLVED(Callable)` 或类型不兼容(**阶段 D 已翻转**) | -| `FrontendBodyOwnerProceduresExprTypeTest` inferred lambda local | `UNSUPPORTED` | `Callable`(**阶段 D 已翻转**,slot 保持 `Variant`) | -| `FrontendCompileCheckAnalyzerTest.analyzeForCompileBlocksDirectLambdaConnectArgument` | unsupported binding,无 compile_check | 阶段 D:unsupported 清空 + 形态级 lambda blocker;阶段 I 后 compile-ready | -| `FrontendCompileCheckAnalyzerTest.analyzeForCompileReleasesBuiltinAndStaticMethodReferences` 的 lambda_cb | unsupported binding | 无该 error | - -### 6.2 必须保持 - -| 测试 | 原因 | -|------|------| -| 全部 `FrontendScopeAnalyzerTest` 的 lambda 图测试 | 图形状不改 | -| `ScopeCaptureShapeTest` | 纯 scope API | -| `FrontendLoopControlFlowAnalyzerTest.analyzeResetsOuterLoopDepthAtLambdaBoundary` | 仍是 callable boundary | -| `FrontendCfgGraphBuilderTest.buildExecutableBodyFailsFastWhenReceiverBindingIsCaptureAliasRoot` | alias 仍不开放 | -| `LirPublicAbiValidatorTest` / `DomLirParserTest` compiler-only capture | ABI 护栏 | -| for / match / const 的 fail-closed 锚点 | 不得误开 | -| compile-surface skip:parameter default / match 内嵌套的 lambda | MVP 外 | - ---- - -## 7. 长期不变量 - -1. lambda body 一旦转正,inventory 与 suite entry **无条件**发布;typed fact - 只用于外层/体内普通 slot 精化、`:=` 声明处稳定化,以及 compile readiness。 - capture 类型取外层绑定的声明处类型(含 `:=` 推断),声明之后的断言 - 或精化不得改写。capture 类型填充永远不构成 body 进入条件。 -2. `construct_callable` / `construct_standalone_callable` 永不承载 lambda。 -3. `CAPTURE` 不是 alias root;capture 是构造时拷贝。 -4. compiler-only 类型不得出现在 lambda 参数、返回、``、 - `expressionTypes()`。 -5. hidden lambda 函数不进 ClassDB;`is_lambda` 与 `is_hidden` 同时为真。 -6. 缺 published `FrontendLambdaPlan` 时 lowering fail-fast,禁止现场重推导。 -7. 诊断:一处根因一条 diagnostic;downstream 不得重复包。 -8. `match` / parameter default / block-local `const` / `await` 不得借这次 - 改动进入支持面。 -9. 修改本合同时必须同步 `frontend_rules.md`、compile-check 文档、 - `gdcc_low_ir.md`(若改 insn)、`gdcc_runtime_lib.md`(若改 helper)。 - ---- - -## 8. 完成定义 - -全部满足才算本计划完成: - -1. 阶段 A–I 的验收细则均有 targeted 测试锚定。 -2. `analyzeForCompile` 对 MVP 支持面内的 lambda 不再因“是 lambda”而失败。 -3. 至少一条 e2e(Zig 可用时):构造 Callable、`.call()`、以及 - `Signal.connect(func(): ...)`。 -4. `func.ftl` 多 capture 只生成一个 `_capture` 参数;prologue 初始化全部 - capture local。 -5. DOM LIR 往返保留 ``。 -6. 文档:`frontend_rules.md` MVP 句、known_limits §1、lowering plan §7、 - signal 拒绝列表已更新;本计划吸收为事实源或明确标注“已落地”。 -7. `match` / default / const 回归测试仍 fail-closed。 - ---- - -## 9. 风险与未定点(不阻塞按上列 MVP 开工) - -1. **`object_id` 来源**:已在 §3.5 冻结。实施时必须从 fat pointer 缓存 - 的 `instance_id` 取值;对可能已死的 raw 调 `godot_object_get_instance_id` - 会崩(`object_value_fat_pointer_implementation.md`)。 -2. **capture 类型**:已在 §3.4 冻结为外层绑定的**声明处类型** - (`var x := 1` → `int`,`var x = 1` → `Variant`)。填充时点是 - 该 lambda nested resolve 入口;读取路径是外层 typed environment - 上按 `sourceDeclaration` 匹配的已 flush 稳定化 update,并同步 - `resetCaptureType`。不得读物理 scope slot,不得回落 - `VAR_TYPE_POST` / 函数末尾 overlay。 - **首次 publish**:阶段 B 不写 `lambdaPlans()`;阶段 C 填完后 - 第一次发布完整 plan;独立 `LAMBDA_RESOLUTION` stage + overlay - 槽 + patch;禁止 first-wins merge 上的 `withType` 回填。 -3. **嵌套 suite export**:lambda 的 `FrontendCallableExportBatch` 不得写进 - 外层函数的 statement overlay。必须独立 apply。 -4. **表达式中的 lambda 与 Interface 遍历顺序**:必须测 - `return func(): ...`、`foo(func(): ...)`、容器字面量里的 lambda - (容器字面量已 compile-ready,lambda 元素要走 nested resolve)。 - Interface 默认 `CONTINUE` 已能走进这些表达式,无需新 walker;只需 - `supportedBodyDepth` 守卫。 -5. **capture 与 `__prepare__`**:阶段 G 必须从 - `CCodegen.generateFunctionPrepareBlock` 排除 capture local,再 copy。 -6. **参数默认值**:lambda 的 default 与普通函数同一边界,不在本计划打开。 -7. Godot 把 capture 作为**前缀实参**喂给 `GDScriptFunction::call`;GDCC 把 - capture 放进 userdata 结构体。这是 ABI 差异,不是语义差异;测试应对齐 - 用户可见 arity 与 capture 值,而不是对齐 Godot 调用约定。 - ---- - -## 10. 与 Godot 的有意识差异 - -| 点 | Godot | GDCC MVP | -|----|-------|----------| -| self-lambda 身份 | 独立 `GDScriptLambdaSelfCallable`,`get_object()=instance` | 仍用 `construct_lambda` + `self` capture;`object_id = self.instance_id` | -| 非 self-lambda 身份 | `GDScriptLambdaCallable.get_object()=script` | `object_id = 0`(无 GDScript 资源可绑) | -| capture 传递 | 调用时插到实参前的 `Variant*` | userdata 结构体 + 函数尾部 `_capture` | -| lambda 名 | 可有 debug 名 | `_lambda_` hidden | -| 相等 | 引用相等 | 引用相等 | -| 已释放的 Object capture | debug 下替换 null | 遵循 GDCC ownership;不强制复刻打印 | - -`capturesSelf` 的 `Signal.connect(func(): self.foo())` **必须**按 Godot -随实例释放自动断开。不捕获 `self` 的 lambda 不得假装绑到 instance 或 script。 - ---- - -## 11. 参考事实源 - -### 11.1 本仓库 - -- `src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java` -- `src/main/java/gd/script/gdcc/frontend/scope/CallableScope.java` -- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java` -- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendInterfacePhase.java` -- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java` -- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java` -- `src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java` -- `src/main/java/gd/script/gdcc/frontend/sema/analyzer/support/FrontendExpressionSemanticSupport.java` -- `src/main/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonBuilder.java` -- `src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java` -- `src/main/java/gd/script/gdcc/lir/insn/ConstructLambdaInsn.java` -- `src/main/java/gd/script/gdcc/lir/LirFunctionDef.java` -- `src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java` -- `src/main/c/codegen/include_451/gdcc/gdcc_callable.h` -- `src/main/c/codegen/template_451/func.ftl` -- `src/main/c/codegen/template_451/entry.h.ftl` -- `src/main/c/codegen/template_451/entry.c.ftl` - -### 11.2 Godot - -- `modules/gdscript/gdscript_parser.h`(`LambdaNode`) -- `modules/gdscript/gdscript_analyzer.cpp`(capture 沿 parent lambda 链复制) -- `modules/gdscript/gdscript_compiler.cpp`(`write_lambda`) -- `modules/gdscript/gdscript_byte_codegen.cpp`(`OPCODE_CREATE_*_LAMBDA`) -- `modules/gdscript/gdscript_lambda_callable.h/.cpp` -- `modules/gdscript/gdscript_vm.cpp`(`OPCODE_CREATE_LAMBDA` / `OPCODE_CREATE_SELF_LAMBDA`) diff --git a/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md b/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md index aabdbe4b..160db349 100644 --- a/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md +++ b/doc/module_impl/frontend/frontend_local_type_stabilization_implementation.md @@ -278,7 +278,7 @@ package-private `probe(...)` 仅作为测试观察窗口存在,用来运行与 以下输入当前仍不进入 local stabilization 的正式支持面: - parameter default -- lambda initializer / capture(lambda 计划阶段 D 起,lambda 表达式自身已能发布 `RESOLVED(GdCallableType)`,但 silent 稳定化对 lambda initializer 显式 fail-closed:`var cb := func(): ...` 的 slot 保持 inventory `Variant`,不写 side table、不发诊断、不触发 nested resolve;`:=` 精化只允许来自 nested resolve 完成后的非 silent 写回) +- lambda initializer / capture(lambda 表达式自身已能发布 `RESOLVED(GdCallableType)`,但 silent 稳定化对 lambda initializer 显式 fail-closed:`var cb := func(): ...` 的 slot 保持 inventory `Variant`,不写 side table、不发诊断、不触发 nested resolve;`:=` 精化只允许来自 nested resolve 完成后的非 silent 写回) - `match` - block-local `const` - class `const` @@ -334,7 +334,7 @@ focused case 至少要继续覆盖: - forward local reference - assignment-based local type refinement - CFG branch / loop merge aware refinement -- `match` / lambda / capture local inventory +- `match` / unrecorded lambda / capture-slot silent stabilization - for iterator route-aware refinement - property `:=` metadata backfill - 跨 callable 或 whole-module 的类型收敛 diff --git a/doc/module_impl/frontend/frontend_loop_control_flow_analyzer_implementation.md b/doc/module_impl/frontend/frontend_loop_control_flow_analyzer_implementation.md index cb83c570..f526e909 100644 --- a/doc/module_impl/frontend/frontend_loop_control_flow_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_loop_control_flow_analyzer_implementation.md @@ -20,7 +20,7 @@ - 明确非目标: - 不在这里实现 `break` / `continue` 的 lowering - 不在这里修改 compile-only `FrontendCompileCheckAnalyzer` 的职责边界 - - 不在这里为 `match` / `lambda` 补齐完整 body semantic 支持,也不实现 for iteration planning 或 lowering + - 不在这里为 `match` 补齐完整 body semantic 支持,也不实现 for iteration planning 或 lowering;已记录 lambda 是 callable boundary,本 analyzer 只重置 loop depth - 不在这里新增 side table 或改写已有 side table 结构 --- diff --git a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md index 45110fc9..1dda5ff0 100644 --- a/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_cfg_pass_implementation.md @@ -61,7 +61,7 @@ - 发布 expression-rooted frontend CFG graph - 不伪造 `Block` 或 property-init-only node kind - 复用同一套 body lowering session materialize `LirBasicBlock` / instruction -- `LAMBDA_BODY`(lambda 计划阶段 E 起) +- `LAMBDA_BODY` - 复用 executable-block 构图与共享 body session materialize lambda body - 合成 shell 的 capture 变量已由 `addCapture` 预登记,body 内 CAPTURE 读取走 opaque 符号路由 - `PARAMETER_DEFAULT_INIT` @@ -199,7 +199,7 @@ fully-terminated 的 `if` / `elif` / `else` 允许把 region `mergeId` 指向 `S - `SignalLoadItem` - `CallableLoadItem` - `StandaloneCallableLoadItem` -- `LambdaConstructItem`(lambda 计划阶段 F 起) +- `LambdaConstructItem` - `SubscriptLoadItem` - `CallItem` - `MergeValueItem` @@ -219,12 +219,12 @@ fully-terminated 的 `if` / `elif` / `else` 允许把 region `mergeId` 指向 `S item 携带的 operation descriptor 直接来自 `FrontendForLoweringContract`,lowering 不重新查询 route 或硬编码 intrinsic 名称。 -`LambdaConstructItem`(lambda 计划阶段 F 起)表示外层 body 中一处已 record lambda 的构造: +`LambdaConstructItem` 表示外层 body 中一处已 record lambda 的构造: - 持有 `lambdaAnchor`(`LambdaExpression`,materialization 结果类型取其 `RESOLVED(GdCallableType)`)、合成函数名、有序 `CaptureOperand` 列表与 `resultValueId` - capture operand 是外层 frame 的直接 slot 读,不经 value-id 数据流,故 `operandValueIds()` 恒为空:`VariableSlotOperand(slotId)` 按名读外层 `LOCAL_VAR` / `PARAMETER` / 外层 `CAPTURE` slot(slot id == capture 条目名);leading `self` capture 用 `SelfSlotOperand.SELF_SLOT` 专用 descriptor,禁止伪造 `IdentifierExpression + SELF` - body lowering 发射 `ConstructLambdaInsn` 前在 owning class 上找回合成 shell 并端到端校验:shell 存在且 `is_lambda`、capture 数量一致、名序一致(operand slotId 按构造即 capture 名);任一漂移 fail-fast -- lambda body 本身的 CFG/insn 仍由阶段 E 的 `LAMBDA_BODY` context 承担,本 item 只承载外层构造点 +- lambda body 本身的 CFG/insn 仍由 `LAMBDA_BODY` context 承担,本 item 只承载外层构造点 这里的核心合同是: @@ -286,12 +286,12 @@ dynamic instance-call receiver 现也冻结为同一套 payload consumer: - direct-slot mutating receiver 已改为发布 alias-backed receiver value,而不是继续依赖 body lowering 额外解释“synthetic temp -> source slot” - 这条 direct-slot publication surface 只包含 explicit `SelfExpression`、`IdentifierExpression + LOCAL_VAR`、`IdentifierExpression + PARAMETER` -- `IdentifierExpression + CAPTURE` 当前不在 alias publication surface 内。虽然 binding/scope 模型保留了 `CAPTURE` 这一类别,但 lambda/capture lowering 与 storage semantics 仍未冻结,不能提前把它视为 alias root +- `IdentifierExpression + CAPTURE` 当前不在 alias publication surface 内。lambda/capture lowering 与 storage semantics 已落地(`construct_lambda` + capture block),但 capture-backed live-slot alias 仍未开放,不能提前把它视为 alias root - `IdentifierExpression + FrontendBindingKind.SELF` 在当前代码库中不是独立 source category:`FrontendTopBindingAnalyzer` 只会对 `SelfExpression` 发布 `SELF`,因此一旦这种 surface 泄漏到 builder 或 body lowering,二者都必须把它当作 contract violation 直接 fail-fast,而不是恢复成 `"self"` - `receiverValueIdOrNull == null` 时 fallback 到 `self` 的 implicit self receiver 不属于 payload-backed receiver publication,也不属于 direct-slot alias root - explicit `SelfExpression` 的 alias 安全性来自 `self` slot 不可被用户代码重绑定,因此不需要额外 argument no-rebinding 分类 - `IdentifierExpression + LOCAL_VAR/PARAMETER` 只有在后续 arguments 全部落在 proven no-rebinding 子集时才允许 alias publication -- `IdentifierExpression + CAPTURE` 在当前实现中必须 fail-fast,等 lambda/capture lowering 落地后再重新评估是否进入 alias eligibility +- `IdentifierExpression + CAPTURE` 在当前实现中必须 fail-fast;capture-backed live-slot alias eligibility 仍未开放 - 当前 CFG builder 已明确把 nested `CallExpression`、`AttributeCallStep` 和其它尚未证明 no-rebinding 的 effect-open expression kind 视为 snapshot fallback trigger:遇到这些参数时继续保留 ordinary `OpaqueExprValueItem(identifier)`,不再发布 live-slot alias 其中 compound assignment 的 source-order 合同固定为: @@ -479,7 +479,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 - 为 executable body 发布 `frontendCfgRegions` - 为 compile-ready `for-in` 发布 `frontendForSourceIteratorSlots` 与 `frontendForIteratorStateSlots` registry - 对 property initializer 校验 `sourceOwner == property declaration`、`loweringRoot == initializer expression` -- 对 lambda body(lambda 计划阶段 E 起)校验 `sourceOwner instanceof LambdaExpression`、`loweringRoot instanceof Block`,随后与 `EXECUTABLE_BODY` 共享同一 `publishExecutableBlockGraph`(`buildExecutableBody` + graph/region/for-slot 发布);外层 body 表达式树中的已 record `LambdaExpression` 自阶段 F 起由 CFG builder 建 `LambdaConstructItem`(缺已发布 plan 仍 fail-fast) +- 对 lambda body 校验 `sourceOwner instanceof LambdaExpression`、`loweringRoot instanceof Block`,随后与 `EXECUTABLE_BODY` 共享同一 `publishExecutableBlockGraph`(`buildExecutableBody` + graph/region/for-slot 发布);外层 body 表达式树中的已 record `LambdaExpression` 由 CFG builder 建 `LambdaConstructItem`(缺已发布 plan 仍 fail-fast) 当前不负责: @@ -505,7 +505,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 当前内部组织也已经冻结为以下形状: -- `FrontendLoweringBodyInsnPass` 本体只保留 compile-ready function context 调度;当前默认 pipeline 覆盖 executable-body、property-init 与 lambda-body(阶段 E 起并入共享 session 分支;合成 shell 的 `setStatic(true)` 保证 `declareSelfSlotIfNeeded` 不产生 stray `self`),parameter-default 继续显式 fail-fast +- `FrontendLoweringBodyInsnPass` 本体只保留 compile-ready function context 调度;当前默认 pipeline 覆盖 executable-body、property-init 与 lambda-body(并入共享 session 分支;合成 shell 的 `setStatic(true)` 保证 `declareSelfSlotIfNeeded` 不产生 stray `self`),parameter-default 继续显式 fail-fast - 真实的 per-function lowering state 收口到 `frontend.lowering.pass.body.FrontendBodyLoweringSession` - CFG node、`SequenceItem`、opaque expression root、assignment target / attribute step 都通过 `FrontendInsnLoweringProcessor` 注册表按“当前节点实际类型”动态分派 - `FrontendInsnLoweringProcessor` 现在还显式返回 lowering 结束后的当前 continuation block;`SequenceNode` 会把这个 block 一路传给后续 item,因此 writable-route runtime gate 生成的 synthetic `apply/skip/continue` blocks 不会把后续 lowering 误挂回原始 node entry block @@ -544,7 +544,7 @@ frontend CFG -> LIR body lowering 当前统一复用以下 normalization 规则 - constructor materialization - `TypeTestExpression` (`is` / `is not`) with unified `is_instance_of` or folded bool lowering - callable-local slot type published contract -- 已 record lambda 的外层构造(`LambdaConstructItem` → `construct_lambda`,lambda 计划阶段 F 起;C backend `CONSTRUCT_LAMBDA` 已于阶段 G 落地) +- 已 record lambda 的外层构造(`LambdaConstructItem` → `construct_lambda`;C backend `CONSTRUCT_LAMBDA` 已落地) plain assignment 的 compile-ready surface 明确包含 direct explicit-self property assignment: diff --git a/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md index 6bbf194f..95a54d7b 100644 --- a/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_func_pre_pass_implementation.md @@ -37,7 +37,7 @@ function pre-pass 是 `FrontendLoweringAnalysisPass` 与 `FrontendLoweringClassS - `EXECUTABLE_BODY` - `PROPERTY_INIT` -- `LAMBDA_BODY`(lambda 计划阶段 E 起:每个已发布 `FrontendLambdaPlan` 的 lambda 合成 hidden `_lambda_` shell 并发布对应 context) +- `LAMBDA_BODY`(每个已发布 `FrontendLambdaPlan` 的 lambda 合成 hidden `_lambda_` shell 并发布对应 context) `PARAMETER_DEFAULT_INIT` 当前只保留模型槽位与合同,不实际收集。 @@ -88,7 +88,7 @@ pre-pass 不新增新的 public lowering 入口,也不接受 `FrontendAnalysis - executable callable 使用 declaration-level owner,lowering root 是 body `Block` - property initializer 使用 property declaration 作为 owner,lowering root 是 initializer expression - future parameter default 使用 parameter/default declaration 作为 owner,lowering root 只指向 default-value expression -- lambda 使用 `LambdaExpression` 作为 owner,lowering root 是其 body `Block`;target function 是本 pass 按 plan 合成的 hidden shell(`is_lambda` + `is_hidden` + static),self 仅以 §3.5 capture 存在,不得注入 `self` 参数 +- lambda 使用 `LambdaExpression` 作为 owner,lowering root 是其 body `Block`;target function 是本 pass 按 plan 合成的 hidden shell(`is_lambda` + `is_hidden` + static),self 仅以 capture 存在,不得注入 `self` 参数 - 后续 pass 必须统一经由 `FunctionLoweringContext.analysisData()` 读取: - `scopesByAst()` - `symbolBindings()` @@ -227,7 +227,7 @@ function pre-pass 结束后,`LirModule` 仍必须保持 shell-only 中间态 - 在 owning class 上追加 hidden property-init synthetic shell - 对应 property 的 `initFunc` 指向该 shell -- 按已发布 `FrontendLambdaPlan` 在 owning class 上追加 hidden synthesized lambda shell(lambda 计划阶段 E 起,`is_lambda` + `is_hidden` + static,capture 列表与 plan 同源) +- 按已发布 `FrontendLambdaPlan` 在 owning class 上追加 hidden synthesized lambda shell(`is_lambda` + `is_hidden` + static,capture 列表与 plan 同源) 这条 shell-only 约束只适用于 pre-pass 产物,不得外推到默认 lowering pipeline 终态;后续 CFG/body pass 会继续把 executable body、supported property initializer 与 lambda body 写成真实函数体。 diff --git a/doc/module_impl/frontend/frontend_lowering_plan.md b/doc/module_impl/frontend/frontend_lowering_plan.md index e79146e5..fa5b18b3 100644 --- a/doc/module_impl/frontend/frontend_lowering_plan.md +++ b/doc/module_impl/frontend/frontend_lowering_plan.md @@ -223,7 +223,6 @@ - `for` 的 `OBJECT_CUSTOM`(Object `_iter_*` 精确协议)仍属 post-MVP;其余已注册 route 已接入,见 `frontend_for_range_loop_implementation.md` - `match` -- `lambda`(前端 sema/lowering 已于 lambda 计划阶段 A–F 落地至外层 `construct_lambda` + lambda body 全管线;C runtime/codegen 与 compile gate 解封仍属 post-MVP) - 参数默认值语义本身 - block-local `const` - signal coroutine use-site(`await signal`;`.emit` / `.connect` 已闭环,见 `frontend_signal_support.md`) diff --git a/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md b/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md index 8977a5cd..f1ddc22c 100644 --- a/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md +++ b/doc/module_impl/frontend/frontend_lowering_skeleton_pre_pass_implementation.md @@ -163,7 +163,7 @@ void run(FrontendLoweringContext context) - `EXECUTABLE_BODY` - `PROPERTY_INIT` -- `LAMBDA_BODY`(lambda 计划阶段 E 起:prep pass 按已发布 `FrontendLambdaPlan` 合成 hidden `_lambda_` shell) +- `LAMBDA_BODY`(prep pass 按已发布 `FrontendLambdaPlan` 合成 hidden `_lambda_` shell) `PARAMETER_DEFAULT_INIT` 当前只冻结模型,不实际收集。 @@ -255,7 +255,7 @@ void run(FrontendLoweringContext context) - `initFunc` 为空时当前兼容命名基线为 `_field_init_` - 若 `initFunc` 已存在,对应 shell 必须保持 hidden、property-compatible 且无 body,才能继续复用 - shell 仍无 basic block / `entryBlockId` -- 同一 pass 允许按已发布 `FrontendLambdaPlan` 追加 hidden synthesized lambda shell(lambda 计划阶段 E 起): +- 同一 pass 允许按已发布 `FrontendLambdaPlan` 追加 hidden synthesized lambda shell: - 名称来自 plan 的 `_lambda_`,`is_lambda` + `is_hidden` + static 同时为真 - 参数为源码参数(无注入 `self`),`` 与 plan 同源,shell 仍无 basic block / `entryBlockId` - 发现的 lambda 缺 plan、owning class 不一致或合成名冲突时 fail-fast @@ -281,14 +281,12 @@ void run(FrontendLoweringContext context) 此外,frontend MVP 仍未完整支持: -- `lambda` -- `for` - `match` - 参数默认值 - block-local `const` - signal coroutine use-site(`await signal`;`.emit` / `.connect` 已闭环,见 `frontend_signal_support.md`) -只要对应 lowering/backend 合同、实现和测试未闭环,这些边界就必须继续留在 compile gate,而不是在 lowering 中做局部放行。 +`lambda` 与 `for` 已进入 frontend shared body semantic 支持面:已记录 lambda 与已注册 for route 由 compile gate 放行,未记录 lambda / 未注册 for route 保持 fail-closed。只要对应 lowering/backend 合同、实现和测试未闭环,其余边界就必须继续留在 compile gate,而不是在 lowering 中做局部放行。 --- diff --git a/doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md b/doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md index b2fcf7e4..11e566e5 100644 --- a/doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md +++ b/doc/module_impl/frontend/frontend_resolution_pipeline_implementation.md @@ -24,7 +24,7 @@ - `doc/analysis/frontend_segmented_type_resolution_pipeline_execution_summary.md` - 明确非目标: - 不在这里定义 `for-range` lowering 或 Godot range runtime 语义 - - 不在这里转正 `lambda` / `match` / block-local `const` + - 不在这里转正 `match` / block-local `const`;已记录 `lambda` 的合同见 `frontend_lambda_implementation.md` - 不在这里定义 backend codegen 或 LIR intrinsic 合同 --- @@ -252,7 +252,8 @@ Suite 收敛后,committed overlay 构造为按 owner 有序的 patch transacti - `IfStatement` / `ElifClause` / `else` - `WhileStatement` - `ForStatement`(structural supported,header-first,body 通过 child-suite path 进入) -- `MatchStatement`、`LambdaExpression`、block-local `const` 继续 deferred / unsupported +- `MatchStatement`、block-local `const` 继续 deferred / unsupported +- 已记录 `LambdaExpression`(supported executable body 内)已转正:nested suite resolution 发布 `FrontendLambdaPlan`;未记录 lambda 继续 deferred / unsupported ### 4.4 `TypedLexicalEnvironment` overlay @@ -337,8 +338,8 @@ Per-owner patch merge 规则: 三道结构检查: 1. Request-domain hard boundary:`EXECUTABLE_BODY` 才进入 ordinary lookup。 -2. AST boundary:parameter default、lambda body、match pattern/guard/body 与 block-local `const` initializer 直接返回 structural deferred boundary;`ForStatement.body()`、iterator type 与 iterable edge 不封口。 -3. Current-scope backstop:`LAMBDA_BODY`、`MATCH_SECTION_BODY` 与 lambda callable scope 继续 fail-closed;`FOR_BODY` 是 supported executable scope。 +2. AST boundary:parameter default、match pattern/guard/body 与 block-local `const` initializer 直接返回 structural deferred boundary;`ForStatement.body()`、iterator type 与 iterable edge 不封口。已记录 lambda 的 AST 边不再封口。 +3. Current-scope backstop:`MATCH_SECTION_BODY` 继续 fail-closed;`FOR_BODY` 与已记录 lambda 的 `LAMBDA_BODY` / `LAMBDA_EXPRESSION` 是 supported executable scope。未记录 lambda 保持 fail-closed。 Overlay 不得绕过 resolver filter:resolver 先按 request-domain gate、AST boundary、current-scope gate、declaration order 与 initializer self-reference 过滤候选 declaration,再从 `TypedLexicalEnvironment` 读取该候选的 effective type / binding payload。 @@ -376,7 +377,7 @@ interface phase、body statement、suite export、diagnostics-only phase 都有 ### R8:unsupported subtree 被过早打开 -support matrix 对 lambda、match、block-local `const`、parameter default 和 unknown/skipped structure 显式返回各自 deferred domain;新增 scope/AST kind 必须通过 exhaustive mapping 显式选择 policy。 +support matrix 对 match、block-local `const`、parameter default 和 unknown/skipped structure 显式返回各自 deferred domain;已记录 lambda 走 `EXECUTABLE_BODY` policy。新增 scope/AST kind 必须通过 exhaustive mapping 显式选择 policy。 ### R9:compiler-only type 泄漏 diff --git a/doc/module_impl/frontend/frontend_rules.md b/doc/module_impl/frontend/frontend_rules.md index dfef34d0..25531496 100644 --- a/doc/module_impl/frontend/frontend_rules.md +++ b/doc/module_impl/frontend/frontend_rules.md @@ -35,10 +35,10 @@ - 普通非-virtual 方法缺失显式类型时,现有 `resolveTypeOrVariant(...) -> Variant` fallback 合同保持不变;只有命中 engine virtual 的 override 路径才额外 fail-closed。 - `break` / `continue` 的位置合法性属于 shared semantic contract;`FrontendLoopControlFlowAnalyzer` 必须在进入 compile-only gate 前就对非法 loop control 发出 `sema.loop_control_flow`,lowering 中的 loop-frame fail-fast 只能保留为实现不变量保护。 - `for-in` 的可迭代性属于 shared type-check contract:`FrontendForLoopSupport.classifyIterableSemantics(...)` 是 plan 构造与诊断的统一分类真源。静态已知不可迭代的 hard type 必须由 `FrontendTypeCheckAnalyzer` 在 iterable expression 上发 `sema.type_check` `Unable to iterate on value of type "X"`;`Variant`、`Object` 与静态未知类型保持 runtime-open,不得误报。该诊断不得阻断 plan 发布或 for body 遍历;iterable 已有 upstream 不稳定事实时不得重复发错。 -- `_field_init_`、`_field_getter_`、`_field_setter_` 是 compiler-owned synthetic property helper 前缀;`_lambda_` 是 compiler-owned synthesized lambda function 前缀(lambda 计划 §3.7,阶段 E 起并入 `RESERVED_PREFIXES`)。source class member 一旦以这些前缀开头,skeleton phase 必须发出清晰的 `sema.class_skeleton` 并跳过该 member subtree,而不是等到 lowering/backend 再因 helper 名冲突抛异常。 +- `_field_init_`、`_field_getter_`、`_field_setter_` 是 compiler-owned synthetic property helper 前缀;`_lambda_` 是 compiler-owned synthesized lambda function 前缀(并入 `RESERVED_PREFIXES`)。source class member 一旦以这些前缀开头,skeleton phase 必须发出清晰的 `sema.class_skeleton` 并跳过该 member subtree,而不是等到 lowering/backend 再因 helper 名冲突抛异常。 - GDCC class 新声明的 signal 不得覆盖 inherited engine/native signal;skeleton 必须发 `sema.class_skeleton` 并跳过该 `SignalStatement`。inherited GDCC signal 的 nearest-child shadow 保持允许,不得回退 `ScopeSignalResolver` 合同。 - `FrontendCompileCheckAnalyzer` 只能挂在 compile-only 入口上;默认共享 `FrontendSemanticAnalyzer.analyze(...)`、inspection 与未来 LSP 入口不得隐式附带 compile-only gate。 -- compile-only gate 只允许扫描未来 lowering 会消费的 compile surface:supported executable body 与 supported property initializer island;不得重新深入 parameter default、lambda、`match`、block-local `const` 或 skipped subtree。`ForStatement` 已进入 shared semantic,compile-only gate 按 route-aware policy 处理:`ForLoweringContractRegistry` 中已注册的 route 放行并进入 body 重扫 semantic facts,未注册 contract 的 route(当前仅 `OBJECT_CUSTOM`)在 statement root 发 route-not-ready blocker;已注册 route 的 CFG/body lowering 已落地。 +- compile-only gate 只允许扫描未来 lowering 会消费的 compile surface:supported executable body 与 supported property initializer island;不得重新深入 parameter default、未记录 lambda、`match`、block-local `const` 或 skipped subtree。已记录 lambda(published `FrontendLambdaPlan` + body)放上 compile surface 并递归扫描 body facts。`ForStatement` 已进入 shared semantic,compile-only gate 按 route-aware policy 处理:`ForLoweringContractRegistry` 中已注册的 route 放行并进入 body 重扫 semantic facts,未注册 contract 的 route(当前仅 `OBJECT_CUSTOM`)在 statement root 发 route-not-ready blocker;已注册 route 的 CFG/body lowering 已落地。 - compile-only gate 一旦放行 supported property initializer,默认 lowering pipeline 必须把它 materialize 为真实 `init_func` helper;backend 不得再把同名 shell-only function 当作可修补中间态消费。 - compile-only gate 对已发布 side-table 事实的最终阻断范围固定为 `BLOCKED` / `DEFERRED` / `FAILED` / `UNSUPPORTED`;`DYNAMIC` 继续保留为 frontend 已认可的 runtime-open 事实,不得在 compile gate 中误判成 blocker。 - executable-body body lowering 对 call 的 lowering-ready surface 必须与 compile gate / CFG builder 保持一致:`resolvedCalls()` 只要已发布为 `RESOLVED` 或 `DYNAMIC` 就允许进入 body pass;其中 `DYNAMIC_FALLBACK` 当前只允许 instance route,必须继续复用 `CallMethodInsn` surface,结果类型真源来自 call anchor 的 `expressionTypes()`,frontend 不得为该路由重做 callable signature 推导。runtime-open 调用本身仍由 backend dynamic dispatch 承接,但其已发布的 `Variant` 结果若继续跨越 ordinary typed boundary,则仍由 frontend ordinary boundary helper 负责后续 `(un)pack`;若同一 call site 还发布了 writable receiver access-chain payload,则 body lowering 必须整体消费这条 payload 做 receiver-side leaf selection / post-call commit,不得把同一条 chain 重新拆成额外 step item 或回头重跑 AST;同时 sequence-item lowering 必须继续线程化当前 continuation block,避免 runtime-gated writeback 把后续 lowering 错挂回原始 lexical block。长合同以 `frontend_dynamic_call_lowering_implementation.md` 为准。 @@ -64,9 +64,9 @@ ## MVP 支持约定 - 下述 MVP 约定描述的是当前 frontend 共享语义、body analyzer 与 compile surface 的正式支持面;它们不否认 parser 与 scope phase 对部分语法结构已经能识别或建图。 -- `for` 已进入 frontend shared body semantic 支持面:iterator、ordinary body local、declaration index、typed baseline 与 suite entry 在 typed resolution 前按结构无条件发布,header 解析后通过普通 child-suite path 进入 body。compile-only gate 为 route-aware policy;已注册 lowering contract 的 route 进入 CFG/body lowering,未注册 route 在 statement root 发 route-not-ready blocker。完整合同见 `frontend_for_range_loop_implementation.md`。`lambda`、`match` 仍按结构性 deferred / unsupported boundary fail-closed。 +- `for` 已进入 frontend shared body semantic 支持面:iterator、ordinary body local、declaration index、typed baseline 与 suite entry 在 typed resolution 前按结构无条件发布,header 解析后通过普通 child-suite path 进入 body。compile-only gate 为 route-aware policy;已注册 lowering contract 的 route 进入 CFG/body lowering,未注册 route 在 statement root 发 route-not-ready blocker。完整合同见 `frontend_for_range_loop_implementation.md`。`lambda` 已进入 frontend shared body semantic 支持面(已记录 lambda 放行,未记录 lambda 保持 fail-closed,合同见 `frontend_lambda_implementation.md`);`match` 仍按结构性 deferred / unsupported boundary fail-closed。 - **`ForStatement` scope 双录合同**:`scopesByAst[ForStatement]` 只表示 header 外层 scope;iterator local 与 `FOR_ITERATION_RESOLUTION` slot update 的 scope 必须是 `scopesByAst[forStatement.body()]` 的 **`FOR_BODY` `BlockScope` 对象身份**。`effectiveBinding` / `owningScopeForDeclaration` 对 iterator 声明只能查 `FOR_BODY`,因为 overlay 匹配使用 `scope ==`。细节见 `scope_analyzer_implementation.md` §6.1 与 `frontend_for_range_loop_implementation.md`。 -- 协程与 signal-based coroutine 当前不在 frontend semantic MVP 范围内;`await` 仍未闭环。`Signal.emit(...)` / `Signal.connect(...)` / `Signal.disconnect(...)` 已走既有 builtin `CallMethodInsn` 路径;Object/self、非 Dictionary builtin 实例、GDCC/engine 静态与 utility 值引用 → Callable 已闭环。builtin type-meta / `Dictionary` key / `Node.new` / lambda 仍被拒绝。长合同见 `frontend_signal_support.md`。 +- 协程与 signal-based coroutine 当前不在 frontend semantic MVP 范围内;`await` 仍未闭环。`Signal.emit(...)` / `Signal.connect(...)` / `Signal.disconnect(...)` 已走既有 builtin `CallMethodInsn` 路径;Object/self、非 Dictionary builtin 实例、GDCC/engine 静态与 utility 值引用 → Callable 已闭环;已记录 lambda → `Callable` 经 `construct_lambda` 已闭环。builtin type-meta / `Dictionary` key / `Node.new` 仍被拒绝。长合同见 `frontend_signal_support.md`。 - path-based `extends`、autoload superclass、global-script-class superclass 绑定不实施。 - 多 gdcc module 的 header superclass 绑定不在最小可行产品范围内。 - 函数参数默认值当前不在 frontend body semantic MVP 范围内;相关可见性与求值顺序继续按 deferred boundary 处理。 @@ -97,7 +97,7 @@ - direct-slot receiver alias 的安全性必须写成显式语义合同,而不是“扫描参数 AST 里有没有某个节点名”: - explicit `SelfExpression` 可直接 alias,因为 `self` slot 不可被用户代码重绑定 - `LOCAL_VAR` / `PARAMETER` root 只有在后续 argument subtree 全部落在 proven no-rebinding 子集时才允许 alias - - `CAPTURE` 当前不在 alias root 支持面内;lambda/capture lowering 与 storage semantics 仍 deferred,不能提前承诺 capture-backed live-slot alias + - `CAPTURE` 当前不在 alias root 支持面内;lambda/capture lowering 与 storage semantics 已落地(`construct_lambda` + capture block),但 capture-backed live-slot alias surface 仍未开放,不能提前承诺 - `IdentifierExpression + SELF` 不是合法 alias/input surface;当前 analyzer 只会对 explicit `SelfExpression` 发布 `SELF`,所以 builder 与 body lowering 都必须 fail-fast,不能把它静默恢复成 `"self"` - `receiverValueIdOrNull == null` 的 implicit self fallback 仍是另一条 call execution 路径,不能和 explicit `SelfExpression` 或非法 `IdentifierExpression + SELF` 混为一谈 - nested `CallExpression`、`AttributeCallStep`、以及其它 effect-open / 未分类 argument surface 必须回退 ordinary temp snapshot,避免 future rebinding form 静默穿透 alias diff --git a/doc/module_impl/frontend/frontend_signal_support.md b/doc/module_impl/frontend/frontend_signal_support.md index e57e960a..c5e590e7 100644 --- a/doc/module_impl/frontend/frontend_signal_support.md +++ b/doc/module_impl/frontend/frontend_signal_support.md @@ -51,11 +51,11 @@ Godot 对齐基线:runtime / GDExtension ABI / generated bindings 固定为 ** | 非 Dictionary builtin 实例方法:`vec.abs`、`array.clear` | `VariantCallable`(值拷贝 receiver) | 同一 `construct_callable` | | GDCC / engine 静态:`Worker.build`、`JSON.parse_string`、子类读取继承静态 | custom trampoline;owner 为声明类 | `construct_standalone_callable` | | utility:`print`、`lerp` | custom trampoline | `construct_standalone_callable` | +| 已记录 lambda:`func(...): ...`(supported executable body 内) | `Callable`(custom lambda callable + capture userdata) | `construct_lambda` | 仍拒绝: - `await signal` 及任何协程挂起 / 恢复状态机 -- lambda / capture;`ConstructCallableInsn` / `construct_standalone_callable` 都不承接 lambda - builtin type-meta 方法当值(`Vector2.abs`、`Vector2.from_angle`) - 构造器当值(`Node.new`、`Inner.new`) - `dict.clear` 当方法引用(Godot 把它当 Dictionary key) @@ -164,7 +164,7 @@ lowering 只消费 published facts。缺失 / 冲突 fact 必须 fail-fast,禁 - Dictionary 实例 method-reference:`METHOD && BUILTIN && receiverType instanceof GdDictionaryType` - builtin type-meta static method-reference:`STATIC_METHOD && ownerKind == BUILTIN` -已放行:signal 值读取、`.emit`、`.connect` / `.disconnect`、Object / self `METHOD` 值读取、非 Dictionary builtin 实例、GDCC / engine 静态、bare utility 值读取。static-context / type-meta signal 仍由 generic `UNSUPPORTED` / `BLOCKED` scan 拦截。 +已放行:signal 值读取、`.emit`、`.connect` / `.disconnect`、Object / self `METHOD` 值读取、非 Dictionary builtin 实例、GDCC / engine 静态、bare utility 值读取、已记录 lambda → `Callable`(`construct_lambda`)。static-context / type-meta signal 仍由 generic `UNSUPPORTED` / `BLOCKED` scan 拦截。 约束: @@ -173,7 +173,8 @@ lowering 只消费 published facts。缺失 / 冲突 fact 必须 fail-fast,禁 - `BARE_VALUE_REFERENCE_BINDING_KINDS` 当前为空,扫描保留为 callee-exclusion hook;不得按 `GdSignalType` / `GdCallableType` 猜测局部变量。 - `symbolBindings()` 同时键 `IdentifierExpression` / `LiteralExpression` / `SelfExpression`;bare blocker 只消费 identifier。 - `DYNAMIC` 永远不是 compile blocker。 -- lambda / `await` / `Node.new` 当值继续走既有 deferred / unsupported / FAILED 路径。 +- `await` / `Node.new` 当值继续走既有 deferred / unsupported / FAILED 路径;未记录 lambda(property initializer / parameter default / skipped subtree)保持 fail-closed。 +- 已记录 lambda 放上 compile surface 并递归扫描 body facts(合同见 `frontend_lambda_implementation.md`)。 --- @@ -235,11 +236,19 @@ operand 冻结为 `(VARIABLE, STRING)` min/max=2。旧 1-operand 非法,由 op standalone trampoline 通过 `ClassDB.class_call_static` 调 GDCC / engine 静态方法,不实现 `CALL_STATIC_METHOD` CInsnGen。 -### 8.4 注册 +### 8.4 `construct_lambda` -三个 opcode 都必须在 `ConstructInsnGen.getInsnOpcodes()` 中,否则 `CCodegen` 按 opcode 映射会抛 `Unsupported instruction opcode`。`CALL_STATIC_METHOD` 故意未注册。 +`$result = construct_lambda "" $capture...`。已记录 lambda 经 `gdcc_new_lambda_callable(userdata, object_id, call, is_valid, free, get_argument_count)` → `godot_callable_custom_create2`,与 standalone 共享 token。完整 ABI / capture / prologue 合同见 `frontend_lambda_implementation.md` 与 `doc/gdcc_low_ir.md`。 -### 8.5 emit / connect +- `object_id`:`capturesSelf == true`(capture 首项为 `self`)时绑 enclosing `self` 在 `construct_lambda` 求值点缓存的 `instance_id`(fat pointer cached,不由 raw 回推);否则填 0。不得把 enclosing instance / singleton 偷偷填进非 self-capturing lambda。 +- `object_id != 0` 时 `is_valid_func` 必须经 ObjectDB(`godot_object_get_instance_from_id`)判定,实例 free 后连接自动失效。 +- 本小节只约束 `construct_lambda`;`construct_standalone_callable` 的 `object_id` 仍按其自身合同(§8.3)填 0,`construct_callable` 的 receiver 合同(§8.2)不变。 + +### 8.5 注册 + +四个 opcode(`construct_signal`、`construct_callable`、`construct_standalone_callable`、`construct_lambda`)都必须在 `ConstructInsnGen.getInsnOpcodes()` 中,否则 `CCodegen` 按 opcode 映射会抛 `Unsupported instruction opcode`。`CALL_STATIC_METHOD` 故意未注册。 + +### 8.6 emit / connect - emit 走既有 BUILTIN vararg:`(const godot_Signal *self, const godot_Variant **argv, godot_int argc)`。零参渲染 `args[fixed + (argc > 0 ? argc : 1)]` + `(fixed + argc == 0) ? NULL : args`,避免零长度 VLA。`Signal.emit` 保留 `const self`。 - `CBodyBuilder.renderVarargArgv` 拒绝未 pack 的 Signal;frontend 必须先发 `PackVariantInsn`。 @@ -247,7 +256,7 @@ standalone trampoline 通过 `ClassDB.class_call_static` 调 GDCC / engine 静 - Callable 值上的 `.call(...)` / `.bind(...)` 仍是 builtin `CallMethodInsn`。不实施“对未物化 method-ref 直接 `.bind`”的捷径。 - `var unused: int = sig.emit()` 继续由 `sema.type_check` 按 void→int 拒绝。 -### 8.6 ClassDB 注册 +### 8.7 ClassDB 注册 `entry.c.ftl` 的 `// Signals` 只迭代 `classDef.signals`。 @@ -296,6 +305,8 @@ standalone trampoline 通过 `ClassDB.class_call_static` 调 GDCC / engine 静 - `member/signal_inherited_and_engine.gd` - `member/signal_null_receiver.gd` - `member/callable_value_refs.gd` + - `member/signal_connect_lambda.gd`(recorded lambda 无参 `connect`/`emit` 冒烟;`object_id` / free-after-invalid 以 `ConstructLambdaInsnGenEngineTest` 为准) + - `lambda/signal_and_engine.gd`(带参 lambda `connect`/`emit`、`CONNECT_ONE_SHOT`、`Array.map`/`filter`) - `member/signal_interop_compiled_to_interpreted.gd` - `member/signal_interop_interpreted_to_compiled.gd` - `member/signal_interop_bidirectional.gd` 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 1f7a4f2c..2806022b 100644 --- a/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_top_binding_analyzer_implementation.md @@ -28,7 +28,7 @@ - 不在这里发布 `resolvedMembers()`、`resolvedCalls()` 或 `expressionTypes()` - 不在这里解析显式 receiver 尾部成员或调用步骤 - 不在这里建模 read / write / call / assignable / lvalue 语义 - - 不在这里实现 parameter default、lambda、`match`、block-local `const` 的正式 binding + - 不在这里实现 parameter default、`match`、block-local `const` 的正式 binding;已记录 lambda 由 nested suite resolution 承接,合同见 `frontend_lambda_implementation.md` - 不在这里扩展 shared `Scope` 协议 - 不在这里处理 class constant binding;该能力仍延后到 MVP 之后 @@ -145,7 +145,7 @@ - `METHOD` 表示实例方法 overload set 的 function-like symbol category,可被 bare callee 与 value use-site 共同消费 - `STATIC_METHOD` 表示静态方法 overload set 的 function-like symbol category,可被 bare callee 与 value use-site 共同消费 - `CONSTANT` 仍保留在枚举中,但 class-level `const` 当前不属于本 analyzer 的正式支持面 -- `CAPTURE` 在当前支持面下通常不会正式产出,因为 lambda capture 仍 deferred +- `CAPTURE` 由已记录 lambda 的 nested suite resolution 正式产出(声明处类型写入 `FrontendLambdaPlan` 与 scope);本 analyzer 不在自己的 walk 里发明 capture binding ### 2.4 当前明确不发布的事实 @@ -381,7 +381,7 @@ builtin static namespace 当前仍 direct-only,因为 ExtensionAPI builtin met 以下位置当前都必须显式封口,不能伪装成正常 binding miss: - parameter default subtree -- lambda subtree(仅未记录的 lambda:property initializer / parameter default / skipped subtree;已 `recordCallable` 的 lambda 自 lambda 计划阶段 C 起由 top-binding 触发 nested suite resolution,不再封口) +- lambda subtree(仅未记录的 lambda:property initializer / parameter default / skipped subtree;已 `recordCallable` 的 lambda 由 top-binding 触发 nested suite resolution,不再封口) - `match` subtree - block-local `const` initializer subtree - 任何缺少稳定 `scopesByAst()` 记录的 skipped subtree @@ -465,7 +465,7 @@ builtin static namespace 当前仍 direct-only,因为 ExtensionAPI builtin met 当前 `sema.unsupported_binding_subtree` 覆盖以下语义: - parameter default subtree -- lambda subtree(仅未记录的 lambda;已 `recordCallable` 的 lambda 自阶段 C 起走 nested suite resolution,不产生此 category) +- lambda subtree(仅未记录的 lambda;已 `recordCallable` 的 lambda 走 nested suite resolution,不产生此 category) - `match` subtree - block-local `const` initializer subtree - missing-scope / skipped subtree @@ -658,7 +658,7 @@ func ping(value): return value ``` -- 已 `recordCallable` 的 lambda 自 lambda 计划阶段 C/D 起转正:top-binding 触发 nested +- 已 `recordCallable` 的 lambda 已转正:top-binding 触发 nested suite resolution,表达式类型发布 `RESOLVED(GdCallableType)`;仅未记录位置 (property initializer / parameter default / skipped subtree)的 lambda 仍 deferred @@ -699,6 +699,6 @@ func ping(): - explicit receiver 只绑定链头与 step/index arguments,不绑定尾部 segment - assignment 左右两侧都会递归进入 binding - ordinary local initializer 继续属于支持面 -- parameter default / lambda / `match` / block-local `const` 当前继续走 root-level unsupported error +- parameter default / 未记录 lambda / `match` / block-local `const` 当前继续走 root-level unsupported error - for header/body use-site 通过普通 executable-body resolver path 发布 binding,且不依赖 iterable typed result 决定 body entry - skipped executable subtree 的 warning 当前按 root-level 发布,而不是静默跳过或逐 use-site 降级 diff --git a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md index 917f75d9..cd108551 100644 --- a/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_type_check_analyzer_implementation.md @@ -34,7 +34,7 @@ - 不在这里重做表达式求值、binding、member/call 解析或 scope 构建 - 不在这里补 suite merge、missing-return、all-path return exhaustiveness 分析 - 不在这里转正 `match`、parameter default、block-local `const`、class `const` 的正式 body 语义 - - `lambda` body 遍历已于 lambda 计划阶段 D 落地:已 record lambda 经 `scanNestedLambdaBodies` 显式 re-entry 由 `handleLambdaExpression` walk body(以 `lambdaPlans()` 存在性为闸门,继承 enclosing callable 的 restriction/static context);阶段 E 起 return slot 为 `FrontendLambdaPlan.returnType()`(nested resolve 入口一次解析的声明返回类型),return 不匹配或声明非 void/Variant 下的 bare `return` 走普通 `sema.type_check` 诊断;未记录 lambda(property initializer / match / parameter default)保持 fail-closed,不进入 body + - `lambda` body 遍历已落地:已 record lambda 经 `scanNestedLambdaBodies` 显式 re-entry 由 `handleLambdaExpression` walk body(以 `lambdaPlans()` 存在性为闸门,继承 enclosing callable 的 restriction/static context);return slot 为 `FrontendLambdaPlan.returnType()`(nested resolve 入口一次解析的声明返回类型),return 不匹配或声明非 void/Variant 下的 bare `return` 走普通 `sema.type_check` 诊断;未记录 lambda(property initializer / match / parameter default)保持 fail-closed,不进入 body - 不在这里实现或发布 `FrontendForIterationPlan`(它由 for-iteration resolution owner 发布);type-check 只消费已发布 plan 做 route-aware header 校验(range arity / argument int slot / 显式 iterator element conversion)并遍历 for body,不重新推导 iterable route - 不在这里实现 property-side inference/backfill,也不在 type-check analyzer 内维护平行 implicit conversion 规则;`int -> float`、同维度 `Vector*i -> Vector*` 与 `StringName` / `String` 互转只通过 `frontend_implicit_conversion_matrix.md` 与 shared boundary helper 生效 - 不在这里实现 frontend -> LIR 的 truthiness lowering 或 `@onready` 的 runtime / ready-time 语义 diff --git a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md index ba3c0ee3..e8873168 100644 --- a/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md +++ b/doc/module_impl/frontend/frontend_variable_analyzer_implementation.md @@ -5,7 +5,7 @@ ## 文档状态 - 性质:长期事实源 -- 最后校对:2026-08-17(lambda 阶段 B:supported executable body 内的 lambda parameter / local / capture inventory 已转正,capture 类型仍为 `Variant` 占位) +- 最后校对:2026-08-18(compile gate 已按 published plan 解封;本 analyzer 仍只负责 inventory,capture 声明处类型由 nested suite resolution 填充) - 适用范围: - `src/main/java/gd/script/gdcc/frontend/sema/**` - `src/main/java/gd/script/gdcc/frontend/sema/analyzer/**` @@ -336,7 +336,6 @@ For iterator 与 parameter、外层 local 或 body local 冲突时仍发布 `sem 后续最直接的增量工作包括: -- lambda 表达式类型发布(`GdCallableType`)与 lowering 链路(阶段 D+) - `match` pattern binding 与 section inventory - block-local `const` inventory - 参数默认值的真实语义接线 diff --git a/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md b/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md index 20f69beb..cc297c71 100644 --- a/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md +++ b/doc/module_impl/frontend/frontend_visible_value_resolver_implementation.md @@ -23,7 +23,7 @@ - 不修改 shared `Scope.resolveValue(...)` 协议 - 不让 unsupported 子域伪装成正常 `NOT_FOUND` - 不在 resolver 内直接生产 diagnostics 或写入 `symbolBindings()` - - 不在当前 frontend 中为 parameter default、`match`、block-local `const` 提供正式 local inventory 解析(lambda body 已在 lambda 阶段 C 转正) + - 不在当前 frontend 中为 parameter default、`match`、block-local `const` 提供正式 local inventory 解析(lambda body 已转正) --- @@ -65,7 +65,7 @@ - `ELSE_BODY` - `WHILE_BODY` - `FOR_BODY` - - `LAMBDA_BODY`(lambda 阶段 C 起) + - `LAMBDA_BODY` - resolver 与 variable analyzer 必须共用同一 support matrix,不能各自维护名单 ### 2.2 与 shared `Scope` 的分工 @@ -113,7 +113,7 @@ resolver 当前只对 `EXECUTABLE_BODY` 域提供正常 lookup,并要求同时 - 任何 current scope 自身就是未发布 inventory 的 scope,例如: - `BlockScopeKind.MATCH_SECTION_BODY` -这些域当前不能静默回退到 outer local / class property / global,也不能伪装成普通 `NOT_FOUND`。`ForStatement` 的 iterator type、iterable 与 body edge 已转正,`FOR_BODY` current scope 直接进入 normal lookup;lambda body 自阶段 C 起同样转正:`LAMBDA_BODY` / `LAMBDA_EXPRESSION` current scope 走 policy 驱动 gate(`EXECUTABLE_BODY` 放行),lambda AST 边不再封口,body 内 use-site 直接命中 lambda 自己的 `CAPTURE` / `PARAMETER` / `LOCAL` 绑定。body 内 identifier 的精化类型仍依赖 suite overlay 的 `effectiveBinding`:iterator 声明的 owning scope 必须是 `FOR_BODY` 对象身份(`scope_analyzer_implementation.md` §6.1),否则可能回落到 Interface baseline `Variant`。 +这些域当前不能静默回退到 outer local / class property / global,也不能伪装成普通 `NOT_FOUND`。`ForStatement` 的 iterator type、iterable 与 body edge 已转正,`FOR_BODY` current scope 直接进入 normal lookup;lambda body 同样转正:`LAMBDA_BODY` / `LAMBDA_EXPRESSION` current scope 走 policy 驱动 gate(`EXECUTABLE_BODY` 放行),lambda AST 边不再封口,body 内 use-site 直接命中 lambda 自己的 `CAPTURE` / `PARAMETER` / `LOCAL` 绑定。body 内 identifier 的精化类型仍依赖 suite overlay 的 `effectiveBinding`:iterator 声明的 owning scope 必须是 `FOR_BODY` 对象身份(`scope_analyzer_implementation.md` §6.1),否则可能回落到 Interface baseline `Variant`。 ### 3.3 `ClassScope` / `ClassRegistry` 不是封口域 diff --git a/doc/module_impl/frontend/scope_architecture_refactor_plan.md b/doc/module_impl/frontend/scope_architecture_refactor_plan.md index 186dd34f..c9606fe0 100644 --- a/doc/module_impl/frontend/scope_architecture_refactor_plan.md +++ b/doc/module_impl/frontend/scope_architecture_refactor_plan.md @@ -558,7 +558,7 @@ frontend binder 侧的 declaration-order 可见性修正层,详见: - function / constructor body 与 supported nested block 中的 ordinary local `var` -> `BlockScope` - `ForStatement` iterator 与 for body ordinary local -> `FOR_BODY` scope;iterator declaration identity 与 fallback baseline 已冻结 - same-callable parameter/local shadowing 在 variable phase 直接诊断并拒绝写入 -- 参数默认值、lambda、`match`、block-local `const` 继续 deferred +- 参数默认值、`match`、block-local `const` 继续 deferred;已记录 lambda 的 param / local / capture inventory 已转正(见 `frontend_lambda_implementation.md`) 后续 binder phase 在接线前或接线过程中仍需要冻结: @@ -567,7 +567,7 @@ frontend binder 侧的 declaration-order 可见性修正层,详见: - ordinary local `var` initializer 已按 `FrontendVisibleValueResolver` 合同进入当前支持面,不再作为 binder 的前置阻塞项 - for iteration planning 与 iterator exact slot refinement - `match` pattern binding 在 guard/body 中的可见性 -- lambda capture 是同 phase 落地还是继续 deferred +- capture-backed live-slot alias 是否进入 publication surface(当前仍不开放) 同时不要把这部分职责回流到 `FrontendScopeAnalyzer`,否则会破坏当前 `scope_analyzer_implementation.md` 已冻结的 phase 边界。 diff --git a/doc/test_error/test_suite_engine_integration_known_limits.md b/doc/test_error/test_suite_engine_integration_known_limits.md index 9b562435..5d3645d3 100644 --- a/doc/test_error/test_suite_engine_integration_known_limits.md +++ b/doc/test_error/test_suite_engine_integration_known_limits.md @@ -11,21 +11,19 @@ 在设计这些正向样例时,确认了多条当前仍然成立、且会直接影响测试写法的边界,以及少量已经修复、但需要从旧测试写法中清理掉的历史回归。 -## 1. `for` / `match` / `lambda` 仍不属于 frontend body semantic MVP +## 1. `match` 仍不属于 frontend body semantic MVP;`for` / `lambda` 已转正 事实来源: - `doc/module_impl/frontend/frontend_rules.md` - - 当前明确写明 `lambda`、`match`、`for` 不在 frontend body semantic MVP 正式支持面 - - 相关子树仍按 deferred / unsupported boundary fail-closed + - `for` 已进入 frontend shared body semantic 支持面(route-aware compile gate) + - `lambda` 已进入 frontend shared body semantic 支持面(已记录 lambda 放行;property initializer / parameter default 中的未记录 lambda 仍 fail-closed) + - `match` 仍按结构性 deferred / unsupported boundary fail-closed 对 test suite 的直接影响: -- BFS / DFS、字符串扫描、数组求和等算法样例不能使用更自然的 `for item in items` -- 新增图遍历与字符串处理用例统一改写为: - - `while` 循环 - - 显式 index / queue cursor - - 递归 helper +- recorded lambda 正向样例放在 `lambda/`;未记录 lambda / lambda 内 `match` 仍不进 suite +- 历史算法样例(BFS / DFS 等)仍可继续用 `while` / 显式 index;新样例可以使用已转正的 `for` 当前处理结论: diff --git a/doc/test_suite.md b/doc/test_suite.md index b2bcdb53..98183b30 100644 --- a/doc/test_suite.md +++ b/doc/test_suite.md @@ -35,6 +35,7 @@ Rules: - `unit_test/validation` contains the Godot-side validation scripts. - Each compiled script must have a validation script with the same relative path. - Resource discovery is recursive, so nested directories are allowed and encouraged for grouping. +- Recorded-lambda e2e pairs live under `lambda/` (value call / arity, captures, self+nested+return, control-flow bodies, signal / Array consumers). Keep `object_id` / free-after-invalid in `ConstructLambdaInsnGenEngineTest`; keep unrecorded-lambda negatives in focused frontend tests, not this suite. - `GdScriptUnitTestCompileRunnerTest.EXPECTED_SCRIPT_PATHS` must be updated whenever a resource pair is added or removed, so accidental fixture drift is caught before runtime execution. diff --git a/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java b/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java index e36b7573..63b19b81 100644 --- a/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java +++ b/src/main/java/gd/script/gdcc/backend/c/gen/insn/ConstructInsnGen.java @@ -45,9 +45,9 @@ /// - construct_object /// - construct_signal /// - construct_callable -/// - construct_standalone_callable -/// - construct_lambda -public final class ConstructInsnGen implements CInsnGen { + /// - construct_standalone_callable + /// - construct_lambda + public final class ConstructInsnGen implements CInsnGen { private record ObjectConstructTarget( @NotNull GdObjectType constructedType, @NotNull ClassDef classDef, diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java b/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java index dbd62912..217781f8 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/FunctionLoweringContext.java @@ -289,7 +289,7 @@ public enum Kind { PARAMETER_DEFAULT_INIT, /// Synthesized hidden lambda shell (`_lambda_`): `sourceOwner` is the `LambdaExpression` /// and `loweringRoot` is its body `Block`. The body lowers through the same executable-body - /// pipeline, but self stays a §3.5 capture instead of an injected parameter. + /// pipeline, but self stays a capture instead of an injected parameter. LAMBDA_BODY } 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 e07bbfad..6b25e873 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 @@ -896,8 +896,8 @@ yield emitOpaqueValue( }; } - /// Builds an outer-body occurrence of a recorded lambda as one `LambdaConstructItem` (lambda - /// plan phase F). The lambda body itself is lowered separately through its own `LAMBDA_BODY` + /// Builds an outer-body occurrence of a recorded lambda as one `LambdaConstructItem`. + /// The lambda body itself is lowered separately through its own `LAMBDA_BODY` /// context; here only the enclosing-frame capture operands are collected in plan order. /// /// Operand sources follow the frozen plan entries: named slots for `LOCAL_VAR` / `PARAMETER` / diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java index 63e50ec4..05adfdee 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/cfg/item/LambdaConstructItem.java @@ -10,11 +10,11 @@ import java.util.List; import java.util.Objects; -/// Outer-body construction of a recorded lambda value (lambda plan phase F). +/// Outer-body construction of a recorded lambda value. /// /// The item carries the synthesized lambda function name plus the ordered capture operands read /// from the ENCLOSING frame; the lambda body itself lowers separately through its own -/// `LAMBDA_BODY` context (phase E). Capture operands are direct enclosing-frame slot reads +/// `LAMBDA_BODY` context. Capture operands are direct enclosing-frame slot reads /// modeled by `CaptureOperand` - a leading `self` capture uses the dedicated /// `SelfSlotOperand.SELF_SLOT` descriptor instead of a fabricated `IdentifierExpression` + SELF /// binding. diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java index 328b2fec..a4d6670f 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBodyInsnPass.java @@ -20,7 +20,7 @@ public void run(@NotNull FrontendLoweringContext context) { } switch (functionContext.kind()) { // Lambda bodies share the executable-body session: the synthetic shell is static - // (no self slot) and carries its own published CFG graph (lambda plan phase E). + // (no self slot) and carries its own published CFG graph. case EXECUTABLE_BODY, PROPERTY_INIT, LAMBDA_BODY -> new FrontendBodyLoweringSession(functionContext, context.classRegistry()).run(); case PARAMETER_DEFAULT_INIT -> throw new IllegalStateException( diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java index a25ebc2c..b9d4f32d 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringBuildCfgPass.java @@ -49,7 +49,7 @@ private void publishStraightLineExecutableGraph(@NotNull FunctionLoweringContext publishExecutableBlockGraph(functionContext); } - /// Lambda bodies reuse the executable-block CFG build verbatim (lambda plan phase E): the + /// Lambda bodies reuse the executable-block CFG build verbatim: the /// lowering root is the lambda's own `Block`, captures/parameters are ordinary published scope /// facts by this point, and the synthetic shell stays the graph's only owner. private void publishLambdaBodyGraph(@NotNull FunctionLoweringContext functionContext) { diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java index c2a7a2c9..112624ef 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/FrontendLoweringFunctionPreparationPass.java @@ -38,7 +38,7 @@ /// - executable callables reuse their published `LirFunctionDef` /// - property initializers get hidden synthetic helper scaffolds /// - recorded lambdas get hidden synthesized `_lambda_` shells from their published -/// `FrontendLambdaPlan` (lambda plan phase E / §3.7) +/// `FrontendLambdaPlan` /// - no basic blocks or instructions are emitted yet; later CFG/body passes materialize the default /// pipeline's executable callable, property-init and lambda bodies public final class FrontendLoweringFunctionPreparationPass implements FrontendLoweringPass { @@ -250,10 +250,10 @@ private FunctionLoweringContext buildPropertyInitContextOrNull( } /// Discovers every `LambdaExpression` reachable from a supported executable body and appends a - /// synthesized `LAMBDA_BODY` context for each one (lambda plan phase E). Discovered lambdas are + /// synthesized `LAMBDA_BODY` context for each one. Discovered lambdas are /// exactly the ones the interface phase records, so each must carry a published /// `FrontendLambdaPlan`; a missing plan means published-fact corruption and fails fast instead - /// of silently skipping the lambda (plan §阶段E negative / §3.9). Nested lambdas are found by + /// of silently skipping the lambda. Nested lambdas are found by /// recursing into each discovered lambda body. Property-initializer expressions stay outside /// this scan surface: their lambdas are unrecorded and already carry upstream error /// diagnostics, so the pipeline stops before preparation. @@ -361,9 +361,9 @@ private void collectLambdaContexts( return callableScope; } - /// Materializes the hidden lambda shell frozen by lambda plan §3.7: + /// Materializes the hidden lambda shell: /// `setLambda(true)` + `setHidden(true)` + `setStatic(true)`, source parameters with their - /// inventory-resolved types (no injected `self`; self only ever arrives as a §3.5 capture), + /// inventory-resolved types (no injected `self`; self only ever arrives as a capture), /// `` from the published plan, and the declared return type published on the plan. /// `setLambda(true)` must precede `addCapture` because captures are only legal on lambda /// functions. diff --git a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java index 9f6a73ca..acb9ae49 100644 --- a/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java +++ b/src/main/java/gd/script/gdcc/frontend/lowering/pass/body/FrontendSequenceItemInsnLoweringProcessors.java @@ -925,7 +925,7 @@ private static final class FrontendStandaloneCallableLoadInsnLoweringProcessor } } - /// Emits `construct_lambda` for an outer-body lambda occurrence (lambda plan phase F). + /// Emits `construct_lambda` for an outer-body lambda occurrence. /// /// The item carries only the synthetic name plus ordered enclosing-frame capture slot reads; /// the processor re-validates the synthesized shell on the owning class (existence, capture diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java index 44a95432..429985fb 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendAnalysisData.java @@ -55,8 +55,8 @@ public final class FrontendAnalysisData { /// Published container-literal construction plans keyed by `ArrayExpression` / `DictionaryExpression`. /// Freezes element boundary decisions and duplicate-key issues for type-check / CFG / lowering. private final @NotNull FrontendAstSideTable containerLiteralPlans; - /// Published lambda identity/capture plans keyed by `LambdaExpression`. Phase A only stores the - /// carrier; production inventory still stays fail-closed until Phase B. + /// Published lambda identity/capture plans keyed by `LambdaExpression`. Inventory never + /// publishes placeholder plans; the first entry is the complete `LAMBDA_RESOLUTION` payload. private final @NotNull FrontendAstSideTable lambdaPlans; private FrontendAnalysisData( diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java index 10a301a7..60b82f37 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicy.java @@ -107,8 +107,8 @@ public boolean isSupportedSuiteBodyRoot() { /// executable bodies. Match-section scopes remain in their feature-specific deferred domain. The exhaustive /// switch is intentional: a newly added block scope kind must make a compile-time-visible support choice here. /// - /// `LAMBDA_BODY` publishes lexical inventory since lambda Phase B (parameter/local/capture binding); its - /// suite-resolver entry stays unconsumed until `FrontendInterfacePhase` stops skipping lambda expressions. + /// `LAMBDA_BODY` publishes lexical inventory (parameter/local/capture binding). Suite-resolver + /// entry is consumed only after `FrontendInterfacePhase` records the lambda as a callable owner. /// /// @param kind the block scope kind whose structural support is being queried /// @return the policy that controls inventory publication, suite entry, and visible-value domain @@ -133,8 +133,8 @@ public boolean isSupportedSuiteBodyRoot() { /// Function, constructor, and lambda callables own executable bodies. As with block scopes, the exhaustive /// switch prevents new callable kinds from receiving support by default. /// - /// `LAMBDA_EXPRESSION` publishes lexical inventory since lambda Phase B; the resolver's lambda AST-edge and - /// callable-scope seals remain the fail-closed backstop until a later phase opens suite resolution. + /// `LAMBDA_EXPRESSION` publishes lexical inventory. Unrecorded lambdas stay fail-closed at the + /// resolver's AST-edge / callable-scope seals until interface records them as callable owners. /// /// @param kind the callable scope kind whose structural support is being queried /// @return the policy for the callable's body/inventory boundary diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java index 4e3753f7..fdf16c9b 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlanner.java @@ -17,8 +17,8 @@ /// Pure capture derivation over an already-built scope graph. /// /// The planner only inspects identifier uses and existing `ScopeValue` bindings. It never writes -/// scopes, never publishes side tables, and never emits diagnostics. Production inventory (Phase B) -/// will call `defineCapture` from this result; Phase A tests feed hand-built graphs. +/// scopes, never publishes side tables, and never emits diagnostics. Production inventory calls +/// `defineCapture` from this result; focused tests may feed hand-built graphs. /// /// Algorithm (innermost-first, aligned with Godot, independent of Godot AST): /// 1. Look up each use from **that identifier's own scope**, not from the lambda `CallableScope.parent`. @@ -132,8 +132,8 @@ public static boolean isCaptureFromUse(@NotNull CallableScope lambdaScope, @NotN } /// Type recorded for a capture: the declaration-site type already stored on the resolved - /// `ScopeValue`. Phase A does not read physical slot overlays; later phases replace the - /// inventory `Variant` placeholder through `LambdaCaptureEntry.withType`. + /// `ScopeValue`. The planner does not read physical slot overlays; nested resolve replaces + /// the inventory `Variant` placeholder through `LambdaCaptureEntry.withType`. public static @NotNull GdType captureTypeOf(@NotNull ScopeValue sourceBinding) { return Objects.requireNonNull(sourceBinding, "sourceBinding must not be null").type(); } diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java index e55d953e..7f2ce5e1 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlan.java @@ -10,8 +10,9 @@ /// Frozen semantic identity of one `LambdaExpression`. /// -/// Keyed in `FrontendAnalysisData.lambdaPlans()` by the lambda AST node. Phase A only publishes the -/// carrier; synthetic `_lambda_` numbering and production inventory still belong to later phases. +/// Keyed in `FrontendAnalysisData.lambdaPlans()` by the lambda AST node. The first published +/// payload is already complete: synthetic `_lambda_` name, declaration-site captures, and +/// the once-resolved return type. /// /// @param lambda AST identity of the lambda expression /// @param syntheticName compiler-owned function name, conventionally `_lambda_` diff --git a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java index b00cfc4e..b8516314 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/FrontendSyntheticPropertyHelperSupport.java @@ -15,8 +15,8 @@ public final class FrontendSyntheticPropertyHelperSupport { public static final @NotNull String PROPERTY_INIT_PREFIX = "_field_init_"; public static final @NotNull String PROPERTY_GETTER_PREFIX = "_field_getter_"; public static final @NotNull String PROPERTY_SETTER_PREFIX = "_field_setter_"; - /// Compiler-owned namespace for synthesized lambda shells (lambda plan §3.7). Source members - /// reusing it collide with the hidden `LirFunctionDef` materialized per `LambdaExpression`. + /// Compiler-owned namespace for synthesized lambda shells. Source members reusing it collide + /// with the hidden `LirFunctionDef` materialized per `LambdaExpression`. public static final @NotNull String LAMBDA_FUNCTION_PREFIX = "_lambda_"; public static final @NotNull List RESERVED_PREFIXES = List.of( PROPERTY_INIT_PREFIX, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java b/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java index fdff7012..6a262270 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/LambdaCaptureEntry.java @@ -10,8 +10,8 @@ /// One frozen lambda capture: source-facing name, declaration-site type, and the outer binding that /// produced it. /// -/// Phase A only stores the payload. Later phases fill `type` from a `Variant` inventory placeholder -/// to the declaration-site type via [`withType`](#withType); they must also call +/// Inventory first records a `Variant` placeholder. Nested resolve fills `type` from the +/// declaration-site type via [`withType`](#withType) and must also call /// `CallableScope.resetCaptureType` so the scope binding stays in sync. /// /// @param name source identifier copied into the lambda (`self` uses this same shape) diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java index 29370ffc..c63b1e7d 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProcedures.java @@ -132,7 +132,7 @@ public void runTopBinding(@NotNull FrontendSuiteContext context, @NotNull Node r } /// Recorded lambdas resolve through the nested suite trigger instead of producing unsupported - /// binding/chain diagnostics (plan §3.2/§3.3). The trigger is idempotent per lambda node: the + /// binding/chain diagnostics. The trigger is idempotent per lambda node: the /// published plan doubles as the resolved marker. Unrecorded lambdas (property initializers, /// parameter defaults, skipped subtrees) stay fail-closed. `walkRootBounded` keeps pruning at /// the lambda node, so the enclosing owner never walks the body as an ordinary expression tree. @@ -219,7 +219,7 @@ public void runLocalTypeStabilization(@NotNull FrontendSuiteContext context, @No } var initializer = variableDeclaration.value(); if (initializer instanceof LambdaExpression) { - // Silent stabilization must not resolve lambda initializers (plan §3.2): the slot + // Silent stabilization must not resolve lambda initializers: the slot // keeps its inventory Variant, and any `:=` refinement to `Callable` may only come // from a non-silent write-back after nested resolve completed — never from resolving // the lambda expression on this silent path. @@ -1025,15 +1025,6 @@ private static void publishRootExpressionTypes( return expectedType; } - private static void publishExpressionType( - @NotNull FrontendSuiteContext context, - @NotNull BodyExpressionResolver resolver, - @NotNull Expression expression, - boolean allowStatementResult - ) { - publishExpressionType(context, resolver, expression, allowStatementResult, null); - } - private static void publishExpressionType( @NotNull FrontendSuiteContext context, @NotNull BodyExpressionResolver resolver, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java index 1ab1b974..30873eff 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendCompileCheckAnalyzer.java @@ -19,6 +19,7 @@ import gd.script.gdcc.frontend.sema.FrontendExpressionTypeStatus; import gd.script.gdcc.frontend.sema.FrontendForIterationPlan; import gd.script.gdcc.frontend.sema.FrontendForIterationRoute; +import gd.script.gdcc.frontend.sema.FrontendLambdaPlan; import gd.script.gdcc.frontend.sema.FrontendMemberResolutionStatus; import gd.script.gdcc.frontend.sema.FrontendResolvedCall; import gd.script.gdcc.frontend.sema.FrontendResolvedMember; @@ -78,6 +79,9 @@ /// The gate itself stays deliberately narrow: /// - explicit AST compile blocks for the currently recognized forms whose lowering/backend support /// is not ready yet +/// - recorded lambdas (published `lambdaPlans()` entry + published body) are released onto the +/// compile surface and their bodies recursed; unrecorded lambdas (property initializer / +/// parameter default / skipped subtree) stay fail-closed behind a form-level blocker /// - generic side-table scans over published `expressionTypes()` / `resolvedMembers()` / /// `resolvedCalls()` facts that are still blocked/deferred/failed/unsupported on compile surface /// - feature-specific RESOLVED blockers for Signal.connect/disconnect and bare @@ -139,6 +143,7 @@ public void analyze( analysisData.resolvedCalls(), analysisData.slotTypes(), analysisData.forIterationPlans(), + analysisData.lambdaPlans(), diagnosticManager ).walk(sourceClassRelation.unit().ast()); } @@ -160,6 +165,14 @@ public void analyze( + "lowering support lands"; } + /// A lambda that reaches the compile surface without a published plan is unrecorded: property + /// initializer / parameter default / skipped subtrees never publish a plan or body facts. The + /// gate keeps those positions fail-closed instead of silently releasing them for lowering. + private static @NotNull String unrecordedLambdaCompileBlockedMessage() { + return "Lambda expression has no published lambda plan on this compile path and stays blocked in " + + "compile mode; only lambdas recorded inside supported executable bodies are compile-ready"; + } + /// Route-not-ready blocker for a `for-in` whose iteration route has no registered lowering contract. /// The message names the missing lowering route rather than reporting the loop as an unsupported /// subtree, so the gap is attributed to the absent contract instead of shared semantic support. @@ -269,6 +282,7 @@ private static final class AstWalkerCompileCheckVisitor implements ASTNodeHandle private final @NotNull FrontendAstSideTable resolvedCalls; private final @NotNull FrontendAstSideTable slotTypes; private final @NotNull FrontendAstSideTable forIterationPlans; + private final @NotNull FrontendAstSideTable lambdaPlans; private final @NotNull DiagnosticManager diagnosticManager; private final @NotNull ASTWalker astWalker; private final @NotNull Set compileSurfaceNodes = Collections.newSetFromMap(new IdentityHashMap<>()); @@ -287,6 +301,7 @@ private AstWalkerCompileCheckVisitor( @NotNull FrontendAstSideTable resolvedCalls, @NotNull FrontendAstSideTable slotTypes, @NotNull FrontendAstSideTable forIterationPlans, + @NotNull FrontendAstSideTable lambdaPlans, @NotNull DiagnosticManager diagnosticManager ) { this.sourcePath = Objects.requireNonNull(sourcePath, "sourcePath must not be null"); @@ -301,6 +316,7 @@ private AstWalkerCompileCheckVisitor( this.resolvedCalls = Objects.requireNonNull(resolvedCalls, "resolvedCalls must not be null"); this.slotTypes = Objects.requireNonNull(slotTypes, "slotTypes must not be null"); this.forIterationPlans = Objects.requireNonNull(forIterationPlans, "forIterationPlans must not be null"); + this.lambdaPlans = Objects.requireNonNull(lambdaPlans, "lambdaPlans must not be null"); this.diagnosticManager = Objects.requireNonNull(diagnosticManager, "diagnosticManager must not be null"); astWalker = new ASTWalker(this); } @@ -552,10 +568,7 @@ private void walkExpression(@Nullable Expression expression) { return; } switch (expression) { - case LambdaExpression lambdaExpression -> reportExplicitCompileBlock( - lambdaExpression, - expressionCompileBlockedMessage("Lambda expression") - ); + case LambdaExpression lambdaExpression -> walkLambdaExpression(lambdaExpression); case ConditionalExpression conditionalExpression -> reportExplicitCompileBlock( conditionalExpression, conditionalCompileBlockedMessage() @@ -578,6 +591,22 @@ private void walkExpression(@Nullable Expression expression) { } } + /// A recorded lambda (published plan + published body) is compile-ready: release its node onto + /// the surface and recurse into the body so its facts are scanned like any other executable + /// block. An unrecorded lambda (property initializer / parameter default / skipped subtree) + /// publishes no plan, so it stays fail-closed behind the form-level blocker; the gate never + /// silently releases a lambda that lacks the plan/synthetic-function/lowering contract facts. + private void walkLambdaExpression(@NotNull LambdaExpression lambdaExpression) { + if (isNotPublished(lambdaExpression) + || isNotPublished(lambdaExpression.body()) + || !lambdaPlans.containsKey(lambdaExpression)) { + reportExplicitCompileBlock(lambdaExpression, unrecordedLambdaCompileBlockedMessage()); + return; + } + markCompileSurfaceNode(lambdaExpression); + walkSupportedExecutableBlock(lambdaExpression.body()); + } + /// Some nodes such as `DictionaryExpression` wrap real expression payload under non-expression /// containers, so compile-surface scanning needs to recurse until it reaches nested /// expressions instead of assuming one child level is enough. diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java index b40c1759..5933f5ba 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendSuiteResolver.java @@ -51,7 +51,7 @@ public class FrontendSuiteResolver { private static final @NotNull String UNSUPPORTED_BINDING_SUBTREE_CATEGORY = "sema.unsupported_binding_subtree"; private static final @NotNull String UNSUPPORTED_CHAIN_ROUTE_CATEGORY = "sema.unsupported_chain_route"; - /// Capture kind mirrored for the synthetic `self` capture (plan §3.5: parameter-shaped). + /// Capture kind mirrored for the synthetic `self` capture (parameter-shaped). private static final @NotNull String SELF_CAPTURE_NAME = "self"; private final @NotNull FrontendStatementResolver statementResolver; @@ -157,7 +157,7 @@ private void resolveCallableOwner( throw new IllegalStateException("Suite entry callable body has no published BlockScope"); } // A lambda inherits its restriction / static context from the nearest enclosing non-lambda - // callable (plan §3.2); the same node is recorded as `enclosingCallable` in the plan. + // callable; the same node is recorded as `enclosingCallable` in the plan. var restrictionOwner = callableOwner instanceof LambdaExpression lambdaExpression ? enclosingNonLambdaCallable(lambdaExpression, analysisData) : callableOwner; @@ -200,7 +200,7 @@ private void resolveCallableOwner( exportBatch.applyTo(analysisData); } - /// Nested resolve trigger: the only production entry point for recorded lambdas (plan §3.2). + /// Nested resolve trigger: the only production entry point for recorded lambdas. /// /// The lambda resolves with its own independent export batch applied immediately at completion, /// so its plan and body facts become stable before the enclosing statement's expr-type owner @@ -223,7 +223,7 @@ private void resolveNestedLambdaOwner( /// Fills declaration-site capture types and publishes the first complete `FrontendLambdaPlan`. /// - /// Runs at nested resolve entry, before any lambda body statement is processed (plan §3.4): + /// Runs at nested resolve entry, before any lambda body statement is processed: /// each capture type is resolved from its source binding — parameters keep their declared type, /// outer captures keep their already frozen type, locals go through the enclosing typed /// environment's declaration-anchored overlay (falling back to the inventory baseline), and a @@ -256,7 +256,7 @@ private void fillAndPublishLambdaPlan( } // The declared return type resolves exactly once here, at nested-resolve entry: type-check // (return slot) and lowering (shell return type) both consume the published plan value, so - // an unknown annotation warns once and the two consumers can never drift (plan §3.7). + // an unknown annotation warns once and the two consumers can never drift. var returnType = FrontendDeclaredTypeSupport.resolveTypeOrVariant( lambda.returnType(), lambdaScope, @@ -304,7 +304,7 @@ private void fillAndPublishLambdaPlan( /// Reads the declaration-site type of a captured local: the enclosing environment's flushed /// stabilization / for-iteration update for that exact declaration, or the inventory baseline /// (explicit declared type or `Variant`) when no update exists. Never reads `VAR_TYPE_POST` or - /// post-declaration refinements (plan §3.4 read-path rules). + /// post-declaration refinements. private static @NotNull GdType declarationSiteLocalType( @NotNull CaptureSourceBinding source, @NotNull ScopeValue capture, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java index 951e24a6..b76b0a6f 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendTypeCheckAnalyzer.java @@ -1174,8 +1174,8 @@ private void reportParameterizedGdccConstructorIfNeeded(@NotNull FunctionDeclara /// Recorded lambdas re-enter here from `scanNestedLambdaBodies`: the body already resolved /// through its nested suite, so type-check walks it as an independent callable island that - /// inherits the enclosing callable's restriction/static context (plan §3.2). The return - /// slot is the declared return type published on the `FrontendLambdaPlan` (phase E), so a + /// inherits the enclosing callable's restriction/static context. The return + /// slot is the declared return type published on the `FrontendLambdaPlan`, so a /// mismatched or bare `return` gets the ordinary `sema.type_check` diagnostics instead of /// drifting against the synthesized shell's return boundary downstream. Unrecorded lambdas /// (property initializer / parameter default / skipped subtrees) publish no plan and no diff --git a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java index 22152516..e714af71 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/analyzer/FrontendVariableAnalyzer.java @@ -132,14 +132,14 @@ private static final class AstWalkerVariableBinder implements ASTNodeHandler { /// while the `self` source stays the outermost instance callable of the chain. private @Nullable Node currentSelfSourceCallable; /// Completed per-lambda capture plans, keyed by AST identity, so an enclosing lambda can transfer - /// nested captures upward. Never leaves this analyzer run: Phase B must not + /// nested captures upward. Never leaves this analyzer run: inventory must not /// publish placeholder plans into `FrontendAnalysisData.lambdaPlans()`. private final @NotNull IdentityHashMap lambdaCapturePlans = new IdentityHashMap<>(); /// Idempotency guard: both the declaration walk and the boundary reporter can reach the same lambda, /// but parameters/captures must be bound exactly once. private final @NotNull Set processedLambdas = - Collections.newSetFromMap(new IdentityHashMap<>()); + Collections.newSetFromMap(new IdentityHashMap<>()); /// Counts how many supported executable-block boundaries the walker is currently inside. /// /// The counter acts as a narrow capability flag rather than a generic nesting metric: @@ -287,8 +287,8 @@ private void walk(@NotNull SourceFile sourceFile) { @Override public @NotNull FrontendASTTraversalDirective handleLambdaExpression(@NotNull LambdaExpression lambdaExpression) { // Lambda inventory (parameters, locals, captures) is part of the supported executable - // surface since Phase B. In practice the boundary reporter routes lambdas here because - // this binder never descends into expression children; binding stays idempotent either way. + // surface. In practice the boundary reporter routes lambdas here because this binder + // never descends into expression children; binding stays idempotent either way. bindLambdaInventory(lambdaExpression); return FrontendASTTraversalDirective.SKIP_CHILDREN; } @@ -347,7 +347,7 @@ private void bindCallableParameters( /// parameters, body locals, nested-lambda inventories (via the boundary reporter), and /// finally this lambda's own `CAPTURE` bindings. /// - /// Capture planning is deliberately post-order (plan §3.4 rule 8): every nested lambda has + /// Capture planning is deliberately post-order: every nested lambda has /// finished binding and planning before this lambda scans its own uses, so nested /// parameter/local bindings can never be misread as captures of this lambda. private void bindLambdaInventory(@NotNull LambdaExpression lambdaExpression) { @@ -449,7 +449,7 @@ private void bindLambdaInventory(@NotNull LambdaExpression lambdaExpression) { /// Implicit `self` is needed when a bare identifier that was not captured resolves to an /// INSTANCE member (non-static property/signal value, or a non-static method overload) — - /// such uses need the instance receiver at runtime (plan §3.5). Static members and global + /// such uses need the instance receiver at runtime. Static members and global /// utility functions (`print`, `abs`, ...) never need a receiver and must not synthesize /// a `self` capture. private boolean usesImplicitInstanceMember( @@ -484,7 +484,7 @@ private boolean usesImplicitInstanceMember( /// Builds the leading `self` capture entry, or `null` when `self` is unavailable here: /// the enclosing non-lambda callable is static (the restriction diagnostic stays with the - /// body-typing phases, plan §3.5) or no owning class can be determined. + /// body-typing phases) or no owning class can be determined. private @Nullable LambdaCaptureEntry buildSelfCaptureEntry(@NotNull CallableScope lambdaScope) { var selfSourceCallable = currentSelfSourceCallable; var instanceSelfSource = switch (selfSourceCallable) { @@ -511,11 +511,11 @@ private boolean usesImplicitInstanceMember( } /// Registers one capture on the lambda scope without letting user source reach - /// `CallableScope`'s fail-fast duplicate guard (plan §3.4 rule 7). + /// `CallableScope`'s fail-fast duplicate guard. /// - /// Phase B registers the capture NAME with a `Variant` placeholder type plus the source - /// declaration identity; the declaration-site type replaces the placeholder during nested - /// suite resolution in a later phase. + /// Inventory registers the capture NAME with a `Variant` placeholder type plus the source + /// declaration identity; nested suite resolution later replaces the placeholder with the + /// declaration-site type. private void defineCaptureGuarded( @NotNull CallableScope lambdaScope, @NotNull LambdaCaptureEntry capture, @@ -851,11 +851,11 @@ private boolean isNotPublished(@Nullable Node astNode) { /// arbitrary expression children. Without a separate scan, lambdas nested inside expressions /// such as local initializers or return values would remain completely silent. /// - /// Since lambda Phase B, lambdas found inside supported executable bodies are no longer - /// reported; they are handed to the binder's lambda-inventory pipeline instead. The remaining - /// reported boundaries are `match` subtrees. Block-local `const` subtrees stay sealed here as - /// well: the binder already reports the declaration itself, and names inside such a subtree - /// belong to a not-yet-supported domain. + /// Lambdas found inside supported executable bodies are no longer reported; they are handed + /// to the binder's lambda-inventory pipeline instead. The remaining reported boundaries are + /// `match` subtrees. Block-local `const` subtrees stay sealed here as well: the binder already + /// reports the declaration itself, and names inside such a subtree belong to a not-yet-supported + /// domain. private static final class UnsupportedVariableBoundaryReporter implements ASTNodeHandler { private final @NotNull Path sourcePath; private final @NotNull DiagnosticManager diagnosticManager; @@ -943,7 +943,7 @@ private void report(@NotNull Block callableBody) { /// /// The scanner skips nested lambda bodies (bound and planned on their own), `match` subtrees, /// and block-local `const` subtrees: names inside not-yet-supported domains must never become - /// captures (plan §3.4 "不可捕获"). Explicit `self` expressions are flagged separately because + /// captures (those names stay ordinary lexical lookups). Explicit `self` expressions are flagged separately because /// `self` is not an identifier use. private static final class LambdaCaptureSourceScanner implements ASTNodeHandler { /// One source-ordered capture-relevant event inside a lambda body. 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 a14cfafc..d50c5745 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 @@ -525,7 +525,7 @@ private boolean shouldContinueBlockedBareCallResolution( )); } - /// Lambda typing contract (plan §3.3): a recorded lambda — one the interface phase registered + /// Lambda typing contract: a recorded lambda — one the interface phase registered /// as a callable owner and whose body already resolved through its nested suite — publishes the /// unparameterized `Callable` type, aligned with method-as-value / `construct_callable` facts. /// Lambdas outside supported executable bodies (property initializers, parameter defaults, diff --git a/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java b/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java index c8872054..e2850cf6 100644 --- a/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java +++ b/src/main/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolver.java @@ -353,7 +353,7 @@ private boolean publishesCallableLocalValueInventory(@NotNull Block block) { // The interface body inventory only indexes LOCAL bindings. A lambda CAPTURE keeps the // outer declaration identity for provenance but is not part of that inventory, so the // consistency check must not fire for it; the declaration-order visibility filter below - // still applies (plan §3.4: capture legality follows outer declaration order). + // still applies (capture legality follows outer declaration order). if (value.kind() == ScopeValueKind.LOCAL) { checkPublishedLocalInventory(variableDeclaration, value); } diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java index 463b8756..15200c4e 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenEngineTest.java @@ -41,7 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase G e2e: generated lambda Callables must call, copy captures, and bind `object_id`. +/// Generated lambda Callables must call, copy captures, and bind `object_id`. final class ConstructLambdaInsnGenEngineTest { @Test @DisplayName("construct_lambda should compile and run captureless / capture / self-object_id cases") diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java index ee86a97e..77a3b2d8 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/ConstructLambdaInsnGenTest.java @@ -35,8 +35,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase G contract: `construct_lambda` emits `gdcc_new_lambda_callable` plus a capture -/// heap block, and codegen fail-fasts when the lambda shell and operand list diverge. +/// `construct_lambda` emits `gdcc_new_lambda_callable` plus a capture heap block, and +/// codegen fail-fasts when the lambda shell and operand list diverge. final class ConstructLambdaInsnGenTest { @Test @DisplayName("construct_lambda opcode is registered on ConstructInsnGen") diff --git a/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java index f714f5a7..674ae649 100644 --- a/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java +++ b/src/test/java/gd/script/gdcc/backend/c/gen/FuncHeaderCaptureTemplateTest.java @@ -26,7 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Anchors the Phase A `func.ftl` contract: a lambda with more than one capture still emits +/// Anchors the `func.ftl` contract: a lambda with more than one capture still emits /// exactly one `_capture` parameter. The capture struct itself still lists every field. class FuncHeaderCaptureTemplateTest { @Test diff --git a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java index a046a685..585c8bf9 100644 --- a/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java +++ b/src/test/java/gd/script/gdcc/frontend/lowering/FrontendLambdaLoweringTest.java @@ -42,19 +42,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase E lambda shell-synthesis contract tests (frontend_lambda_plan.md §阶段E / §3.7). -/// Phase F adds the outer-body construction contract (§阶段F): recorded lambda occurrences -/// lower to `LambdaConstructItem` / `construct_lambda` with enclosing-frame capture operands. +/// Lambda shell-synthesis and outer-body construction contract tests. +/// Recorded lambda occurrences lower to `LambdaConstructItem` / `construct_lambda` +/// with enclosing-frame capture operands. /// -/// The compile gate still blocks lambdas in `analyzeForCompile` until phase I, so these tests run -/// the shared `analyze()` pipeline and then drive the lowering passes manually — the same recipe -/// `FrontendLoweringFunctionPreparationPassTest` uses for non-lambda modules. Assertions anchor both -/// directions: published plans materialize hidden `is_lambda` shells with faithful captures, while -/// missing plans or name collisions fail fast instead of silently skipping synthesis. +/// These tests run the shared `analyze()` pipeline and then drive the lowering passes +/// manually — the same recipe `FrontendLoweringFunctionPreparationPassTest` uses for +/// non-lambda modules. Assertions anchor both directions: published plans materialize +/// hidden `is_lambda` shells with faithful captures, while missing plans or name +/// collisions fail fast instead of silently skipping synthesis. final class FrontendLambdaLoweringTest { - /// 验收 happy path:一个含两层嵌套 lambda 的函数在 class 上得到两个 hidden `is_lambda` 函数, - /// capture 列表与已发布 plan 一致(外层按 §3.4 规则 5/9 传递 `seed`)。 + /// Nested lambdas synthesize two hidden `is_lambda` shells whose capture lists match the + /// published plans (the outer layer transit-captures `seed`). @Test void synthesizesHiddenLambdaShellsForNestedLambdas() throws Exception { var prepared = analyzeAndSkeleton("lambda_shell_nested.gd", """ @@ -97,8 +97,9 @@ func ping(): } } - /// 验收 happy path:无 capture 的 lambda 没有 `` 条目,`getCaptureCount() == 0`, - /// 参数表保持源码形状(含类型、无注入 `self`),返回类型来自声明标注。 + /// A captureless lambda has no `` entries, `getCaptureCount() == 0`, keeps the + /// source parameter shape (typed, no injected `self`), and takes its return type from the + /// declared annotation. @Test void synthesizesLambdaShellWithoutCapturesOrSelfParameter() throws Exception { var prepared = analyzeAndSkeleton("lambda_shell_plain.gd", """ @@ -128,8 +129,9 @@ func ping(): assertEquals("int", shell.getReturnType().getTypeName()); } - /// 验收 happy path(§3.5):引用外层实例方法的 lambda 把 `self` 作为 leading capture,类型为 - /// enclosing class 的 `GdObjectType`;`setStatic(true)` 保证不注入第二份 `self` 参数。 + /// A lambda that uses an enclosing instance method takes `self` as the leading capture, + /// typed as the enclosing class `GdObjectType`. `setStatic(true)` must not inject a second + /// `self` parameter. @Test void synthesizesLambdaShellWithLeadingSelfCapture() throws Exception { var prepared = analyzeAndSkeleton("lambda_shell_self.gd", """ @@ -162,7 +164,7 @@ func ping(): assertNull(shell.getParameter("self")); } - /// 验收 negative path:进入 preparation 的 lambda 缺已发布 plan → fail-fast,不静默跳过。 + /// A lambda that reaches preparation without a published plan must fail fast, not skip. @Test void preparationFailsFastWhenLambdaPlanIsMissing() throws Exception { var prepared = analyzeAndSkeleton("lambda_missing_plan.gd", """ @@ -185,8 +187,8 @@ func ping(): assertTrue(exception.getMessage().contains("LambdaMissingPlan"), exception::getMessage); } - /// 验收 negative path 的防御侧:`_lambda_` 前缀在 skeleton 阶段已被保留名规则拦截,若合成名仍 - /// 撞上既有函数则属于不变量破坏,preparation 必须 fail-fast 而不是覆盖既有函数。 + /// The `_lambda_` prefix is already reserved at skeleton time. If the synthetic name still + /// collides with an existing function, preparation must fail fast instead of overwriting it. @Test void preparationFailsFastWhenSyntheticNameIsAlreadyTaken() throws Exception { var prepared = analyzeAndSkeleton("lambda_name_collision.gd", """ @@ -211,12 +213,13 @@ func ping(): assertTrue(exception.getMessage().contains(plan.syntheticName()), exception::getMessage); } - /// 阶段 E 接线:BuildCfgPass 接受 `sourceOwner instanceof LambdaExpression` + - /// `loweringRoot instanceof Block` 的 LAMBDA_BODY 上下文,body 复用 executable-block CFG 构建 - /// (capture 读取经 opaque 符号路由),且函数仍保持 shell-only(块由 body pass 创建)。 + /// BuildCfgPass accepts a `LAMBDA_BODY` context whose `sourceOwner` is a `LambdaExpression` + /// and `loweringRoot` is its `Block`. The body reuses the executable-block CFG build + /// (capture reads go through the opaque symbol route) and the function stays shell-only + /// (blocks are created by the body pass). /// - /// 注意:外层函数 body 的 LambdaExpression 表达式 lowering 属于阶段 F,因此这里只对 lambda - /// 上下文运行 CFG pass。 + /// Outer-function `LambdaExpression` construction is a separate contract, so this test + /// only runs the CFG pass on the lambda context. @Test void buildCfgPublishesExecutableGraphForLambdaBodyContexts() throws Exception { var prepared = prepareLambdaOnlyContexts(); @@ -229,8 +232,8 @@ void buildCfgPublishesExecutableGraphForLambdaBodyContexts() throws Exception { assertTrue(lambdaContext.targetFunction().getEntryBlockId().isEmpty()); } - /// 阶段 E 接线:BodyInsnPass 把 LAMBDA_BODY 并入共享 `FrontendBodyLoweringSession` 分支, - /// lambda body 与合成 shell 的变量面(capture 变量类型与 plan 同源)真实落地。 + /// BodyInsnPass folds `LAMBDA_BODY` into the shared `FrontendBodyLoweringSession` branch, + /// so the lambda body and the synthesized shell share the same capture-variable types. @Test void bodyInsnMaterializesLambdaBodyThroughSharedSession() throws Exception { var prepared = prepareLambdaOnlyContexts(); @@ -248,9 +251,9 @@ void bodyInsnMaterializesLambdaBodyThroughSharedSession() throws Exception { assertEquals("int", captureVariable.type().getTypeName()); } - /// 阶段 F 验收 happy path:无 capture 的 lambda 在外层 body 产生一条 - /// `construct_lambda "_lambda_0"`(无 capture operand),且 CFG item 不发布任何 - /// operand value id、绝不走 `DirectSlotAliasValueItem`;`_lambda_0` shell 仍带参数与 return。 + /// A captureless lambda produces one outer-body `construct_lambda "_lambda_0"` with no + /// capture operands. The CFG item publishes no operand value ids and never uses + /// `DirectSlotAliasValueItem`; the `_lambda_0` shell still keeps its parameters and return. @Test void outerBodyConstructsCapturelessLambdaValue() throws Exception { var prepared = prepareFullPipelineContexts("lambda_construct_plain.gd", """ @@ -285,8 +288,8 @@ func ping(): assertTrue(allInstructions(shell).stream().anyMatch(ReturnInsn.class::isInstance)); } - /// 阶段 F 验收 happy path:捕获外层 `seed` 时 insn 恰好携带 `$seed` operand;capture 类型 - /// 仍是外层绑定的声明处类型(§3.4),而不是外层函数末尾的 slot 类型。 + /// Capturing the outer `seed` local emits exactly a `$seed` operand. The capture type is + /// the outer binding's declaration-site type, not the enclosing function's final slot type. @Test void outerBodyConstructInsnReadsCapturedLocalSlot() throws Exception { var prepared = prepareFullPipelineContexts("lambda_construct_capture.gd", """ @@ -315,9 +318,9 @@ func ping(): assertEquals("int", shell.getCapture("seed").type().getTypeName()); } - /// 阶段 F 验收 happy path(§3.5):使用外层实例成员的 lambda 以 leading `self` capture - /// 构造,item 侧是专用 `SelfSlotOperand.SELF_SLOT` descriptor(而非伪造的 SELF 标识符 - /// 读取),insn 侧对应 `$self` operand。 + /// A lambda that uses an enclosing instance member constructs with a leading `self` + /// capture. The item uses the dedicated `SelfSlotOperand.SELF_SLOT` descriptor (never a + /// fabricated SELF identifier) and the insn operand is `$self`. @Test void outerBodyConstructInsnUsesSelfSlotForSelfCapture() throws Exception { var prepared = prepareFullPipelineContexts("lambda_construct_self.gd", """ @@ -344,9 +347,9 @@ func ping(): assertEquals("self", shell.getCaptureList().getFirst().getName()); } - /// 阶段 F 验收 happy path:嵌套 lambda 的 capture operand 从外层 lambda 的 CAPTURE slot - /// 按名读取——内层 `construct_lambda` 出现在外层 lambda body 中且 operand 名与外层 - /// shell 的 capture 名一致。 + /// A nested lambda reads its capture operand from the enclosing lambda's CAPTURE slot + /// by name: the inner `construct_lambda` lives in the outer lambda body and the operand + /// name matches the outer shell capture. @Test void nestedLambdaConstructInsnReadsEnclosingCaptureSlot() throws Exception { var prepared = prepareFullPipelineContexts("lambda_construct_nested.gd", """ @@ -377,9 +380,9 @@ func ping(): assertEquals(List.of("seed"), captureOperandIds(innerConstruct)); } - /// 阶段 F 验收 happy path:return 位置的 lambda 把 preferred result value id 直接穿到 - /// construct item 上,stop node 的 returnValueId 与 item 的 resultValueId 一致,insn 的 - /// result slot 就是该 value id 对应的 `cfg_tmp_*`。 + /// A return-position lambda threads the preferred result value id onto the construct + /// item. The stop node's returnValueId matches the item's resultValueId, and the insn + /// result slot is the corresponding `cfg_tmp_*`. @Test void returnPositionLambdaConstructThreadsPreferredResultId() throws Exception { var prepared = prepareFullPipelineContexts("lambda_construct_return.gd", """ @@ -406,8 +409,9 @@ func ping(): assertEquals(FrontendBodyLoweringSupport.cfgTempSlotId(item.resultValueId()), insn.resultId()); } - /// 阶段 F 验收 negative path:进入 CFG 构建的 lowering-ready lambda 缺已发布 plan 时 - /// builder fail-fast(prep pass 在更早处已有同类闸门,这里锚定 builder 自身的防御)。 + /// A lowering-ready lambda that reaches CFG construction without a published plan must + /// fail fast in the builder (the prep pass already has the same gate; this anchors the + /// builder's own defense). @Test void buildCfgFailsFastWhenLambdaPlanIsMissing() throws Exception { var prepared = analyzeAndSkeleton("lambda_construct_missing_plan.gd", """ @@ -430,9 +434,9 @@ func ping(): assertTrue(exception.getMessage().contains("without a published lambda plan"), exception::getMessage); } - /// 阶段 F 验收 negative path:plan→item 与 plan→shell 任一漂移(此处通过给 shell 手工 - /// 追加 phantom capture 模拟)都会让 body lowering 在 item capture 数与 shell capture 数 - /// 不一致时 fail-fast,而不是静默发射不匹配的 `construct_lambda`。 + /// If the plan→item and plan→shell capture lists drift (simulated here by appending a + /// phantom capture to the shell), body lowering must fail fast on the count mismatch + /// instead of silently emitting a mismatched `construct_lambda`. @Test void bodyInsnFailsFastWhenItemCaptureCountDivergesFromShell() throws Exception { var prepared = analyzeAndSkeleton("lambda_construct_count_mismatch.gd", """ @@ -458,9 +462,9 @@ func ping(): assertTrue(exception.getMessage().contains("_lambda_0"), exception::getMessage); } - /// Phase F pipeline: unlike `prepareLambdaOnlyContexts`, the full context set stays published - /// so the outer executable body (lambda construction) and the lambda bodies both flow through - /// the CFG and body-insn passes. + /// Unlike `prepareLambdaOnlyContexts`, the full context set stays published so the outer + /// executable body (lambda construction) and the lambda bodies both flow through the CFG + /// and body-insn passes. private static @NotNull PreparedLambdaModule prepareFullPipelineContexts( @NotNull String fileName, @NotNull String source diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java index 118786a1..899feee0 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendBodySemanticSupportPolicyTest.java @@ -24,7 +24,7 @@ void executableBlockKindsPublishInventoryAndEnterSuiteResolver() { BlockScopeKind.ELSE_BODY, BlockScopeKind.WHILE_BODY, BlockScopeKind.FOR_BODY, - // Lambda Phase B: lambda bodies publish lexical inventory (param/local/capture binding). + // Lambda bodies publish lexical inventory (param/local/capture binding). BlockScopeKind.LAMBDA_BODY ); @@ -40,7 +40,7 @@ void executableBlockKindsPublishInventoryAndEnterSuiteResolver() { @Test void unsupportedScopesKeepPreciseStructuralDomains() { - // Lambda scopes left this deferred set in Phase B; only `match` keeps a block-scope deferred domain. + // Lambda scopes are no longer in this deferred set; only `match` keeps a block-scope deferred domain. assertEquals( FrontendBodySemanticSupportPolicy.MATCH_SUBTREE, FrontendBodySemanticSupportPolicy.forBlockScopeKind(BlockScopeKind.MATCH_SECTION_BODY) diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java index b541da28..70852f27 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendClassSkeletonTest.java @@ -1078,7 +1078,7 @@ func ok(): )); } - /// Lambda plan §3.7/§3.9: the `_lambda_` namespace is compiler-owned for synthesized lambda + /// The `_lambda_` namespace is compiler-owned for synthesized lambda /// shells, so source members reusing it are rejected like `_field_*` helpers. Boundary names /// that merely contain the prefix (without the trailing separator) stay alive. @Test diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java index a4e92f68..407377a5 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaCapturePlannerTest.java @@ -32,7 +32,7 @@ /// Pure-function capture planner tests over hand-built scope graphs. /// -/// These tests freeze §3.4 without opening production inventory: self-shadowing is not captured, +/// These tests freeze capture derivation without opening production inventory: self-shadowing is not captured, /// class properties stay lexical, first-use order is preserved, and nested transfer stops at a /// shadowing parent. class FrontendLambdaCapturePlannerTest { diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java index 34fc40f7..c2ac06be 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaExpressionTypeTest.java @@ -25,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase H expression-type anchors (frontend_lambda_plan.md §阶段H / §3.3). +/// Expression-type anchors for recorded vs unrecorded lambdas. /// /// Recorded lambdas publish `RESOLVED(Callable)`. Unrecorded ones stay fail-closed without /// poisoning a sibling recorded lambda in the same module. diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java index 8533e224..419b177b 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaInventoryTest.java @@ -33,10 +33,10 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase H inventory anchors (frontend_lambda_plan.md §阶段H / §3.2). +/// Inventory anchors for recorded lambda parameter / local / capture binding. /// -/// The existing `FrontendVariableAnalyzerTest` already covers the Phase B walk. This class -/// keeps a smaller happy/negative pair so the named Phase H surface stays explicit. +/// The existing `FrontendVariableAnalyzerTest` already covers the inventory walk. This class +/// keeps a smaller happy/negative pair so the recorded-vs-unrecorded surface stays explicit. final class FrontendLambdaInventoryTest { @Test void analyzeBindsSupportedLambdaInventoryAndLeavesPlanUnpublished() throws Exception { diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java index 59c2b423..4c0c0045 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaPlanSideTableTest.java @@ -21,8 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase A data-plane tests: lambda plans are a stable side table, compiler-only capture types -/// stay rejected, and existing owner patches do not wipe or republish the table. +/// Lambda plans are a stable side table: compiler-only capture types stay rejected, and +/// existing owner patches do not wipe or republish the table. class FrontendLambdaPlanSideTableTest { private static final Range RANGE = new Range(0, 1, new Point(0, 0), new Point(0, 1)); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java index 71887c41..9131708a 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendLambdaSuiteResolutionTest.java @@ -40,7 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -/// Phase C lambda suite-resolution contract tests (frontend_lambda_plan.md §阶段C). +/// Lambda suite-resolution contract tests. /// /// Anchors both directions: recorded lambdas resolve through the nested trigger and publish the /// first complete `FrontendLambdaPlan` with declaration-site capture types mirrored onto the @@ -114,7 +114,7 @@ func ping(): var pingFunction = findFunction(analysisData.unit().ast(), "ping"); var lambda = findNode(pingFunction.body(), LambdaExpression.class, _ -> true); - // Phase C/D contract: the nested-resolved plan (LAMBDA_RESOLUTION owner) and the callable + // The nested-resolved plan (LAMBDA_RESOLUTION owner) and the callable // expression fact (EXPR_TYPE owner) coexist for the same recorded lambda node. assertNotNull(analysisData.analysisData().lambdaPlans().get(lambda)); var expressionType = analysisData.analysisData().expressionTypes().get(lambda); @@ -123,7 +123,7 @@ func ping(): assertEquals("Callable", expressionType.publishedType().getTypeName()); // Silent stabilization never resolves the lambda initializer, so the `:=` slot keeps its - // inventory Variant; only a non-silent write-back could refine it (phase D adds none). + // inventory Variant; only a non-silent write-back after nested resolve could refine it. var bodyScope = assertInstanceOf( BlockScope.class, analysisData.analysisData().scopesByAst().get(pingFunction.body()) diff --git a/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java b/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java index bc4e7257..bb69ad56 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/FrontendVariableAnalyzerTest.java @@ -325,7 +325,7 @@ func ping(seed: int): new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); - // Lambda inventory is fully bound since Phase B, so no boundary diagnostic may fire. + // Lambda inventory is fully bound, so no boundary diagnostic may fire. var diagnosticsAfter = phaseInput.diagnostics().snapshot(); assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); @@ -340,7 +340,7 @@ func ping(seed: int): assertEquals(ScopeValueKind.LOCAL, lambdaLocalBinding.kind()); assertSame(lambdaLocalDeclaration, lambdaLocalBinding.declaration()); - // Phase B registers the capture name with a Variant placeholder type; the declaration-site + // Inventory registers the capture name with a Variant placeholder type; the declaration-site // type replaces the placeholder during nested suite resolution in a later phase. var seedCapture = lambdaScope.resolveValueHere("seed"); assertNotNull(seedCapture); @@ -349,7 +349,7 @@ func ping(seed: int): assertSame(pingFunction.parameters().getFirst(), seedCapture.declaration()); assertNotNull(pingBodyScope.resolveValue("builder")); - // Placeholder captures live only on the scope; no plan may be published in Phase B. + // Placeholder captures live only on the scope; inventory must not publish a plan. assertTrue(phaseInput.analysisData().lambdaPlans().isEmpty()); } @@ -516,7 +516,7 @@ func ping(seed: int): assertEquals(ScopeValueKind.CAPTURE, midCapture.kind()); assertSame(pingFunction.parameters().getFirst(), midCapture.declaration()); - // The intermediate lambda must re-export the same capture (nested transfer, plan §3.4 + // The intermediate lambda must re-export the same capture (nested transfer, // rule 9): same name, same source declaration identity. var outerCapture = outerLambdaScope.resolveValueHere("seed"); assertNotNull(outerCapture); @@ -567,7 +567,7 @@ func ping(): new FrontendVariableAnalyzer().analyze(phaseInput.analysisData(), phaseInput.diagnostics()); // The shadowing local lives behind the lambda callable boundary, so it is legal and the - // outer `x` must NOT be captured (plan §3.4 self-shadowing rule). + // outer `x` must NOT be captured (self-shadowing rule). var diagnosticsAfter = phaseInput.diagnostics().snapshot(); assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); assertNull(lambdaScope.resolveValueHere("x")); @@ -632,7 +632,7 @@ func ping(seed: int): assertSame(middleSeedDeclaration, midCapture.declaration()); // The intermediate lambda shadows the name with its own local, so the transfer chain - // terminates here: no capture may be inserted for the outer parameter (plan §3.4 rule 9). + // terminates here: no capture may be inserted for the outer parameter. assertNull(outerLambdaScope.resolveValueHere("seed")); } @@ -763,7 +763,7 @@ func ping(): assertEquals(0, newDiagnostics(diagnosticsBefore, diagnosticsAfter).size()); // §3.5: an explicit `self` use captures the enclosing instance under the name `self`, - // sourced at the enclosing callable. The scope binding keeps the Phase B Variant + // sourced at the enclosing callable. The scope binding keeps the inventory Variant // placeholder like every capture; the enclosing class object type is filled with all // declaration-site capture types during nested suite resolution. var selfCapture = lambdaScope.resolveValueHere("self"); diff --git a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java index 835561a6..601f426d 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/analyzer/FrontendBodyOwnerProceduresExprTypeTest.java @@ -2642,7 +2642,7 @@ static func ping(): ) ); - // Silent local stabilization never resolves lambda initializers (plan §3.2), so the + // Silent local stabilization never resolves lambda initializers, so the // `:=` slot keeps its inventory Variant even though the lambda itself resolved to // `Callable`; refinement may only come from a non-silent write-back, which phase D // deliberately does not add. 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 498419d9..7a69450e 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 @@ -25,6 +25,7 @@ import gd.script.gdcc.type.GdSignalType; import gd.script.gdcc.type.GdVariantType; import gd.script.gdcc.type.GdVoidType; +import dev.superice.gdparser.frontend.ast.AssertStatement; import dev.superice.gdparser.frontend.ast.AssignmentExpression; import dev.superice.gdparser.frontend.ast.AttributeCallStep; import dev.superice.gdparser.frontend.ast.AttributeExpression; @@ -36,10 +37,13 @@ import dev.superice.gdparser.frontend.ast.ExpressionStatement; import dev.superice.gdparser.frontend.ast.ForStatement; import dev.superice.gdparser.frontend.ast.FunctionDeclaration; +import dev.superice.gdparser.frontend.ast.GetNodeExpression; import dev.superice.gdparser.frontend.ast.IdentifierExpression; import dev.superice.gdparser.frontend.ast.LambdaExpression; import dev.superice.gdparser.frontend.ast.LiteralExpression; +import dev.superice.gdparser.frontend.ast.MatchStatement; import dev.superice.gdparser.frontend.ast.Node; +import dev.superice.gdparser.frontend.ast.PreloadExpression; import dev.superice.gdparser.frontend.ast.SelfExpression; import dev.superice.gdparser.frontend.ast.Statement; import dev.superice.gdparser.frontend.ast.TypeTestExpression; @@ -856,12 +860,7 @@ func helper(): func ping(seed = [1]): var body_local = 0 var f = func(): - {"hp": body_local} - preload("res://icon.svg") - $Camera3D - body_local as int - body_local is int - assert(body_local) + pass const answer = [body_local] match body_local: var bound when bound > 0: @@ -877,10 +876,10 @@ func ping(seed = [1]): var compiled = analyzeForCompile("compile_check_skipped_surface.gd", source); var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); - // The lambda node itself sits on the compile surface and keeps its form-level blocker - // until lowering support lands; the explicit blocks inside its body stay skipped. - assertEquals(1, compileDiagnostics.size()); - assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); + // The recorded (clean-body) lambda is released onto the compile surface, so it no + // longer carries a form-level blocker; the explicit blocks nested inside the match stay + // skipped because match itself never enters the compile surface. + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); var unsupportedBindingDiagnostics = diagnosticsByCategory( compiled.diagnostics(), "sema.unsupported_binding_subtree" @@ -1386,26 +1385,19 @@ func ping(seed = 1): """); var pingFunction = findFunction(preparedInput.unit().ast().statements(), "ping"); var defaultLiteral = assertInstanceOf(LiteralExpression.class, pingFunction.parameters().getFirst().defaultValue()); - var callbackDeclaration = findVariable(pingFunction.body().statements(), "callback"); - var lambdaExpression = assertInstanceOf(LambdaExpression.class, callbackDeclaration.value()); - var lambdaLiteral = findNode(lambdaExpression, LiteralExpression.class, ignored -> true); preparedInput.analysisData().expressionTypes().put( defaultLiteral, FrontendExpressionType.deferred("synthetic default-value deferred expression") ); - preparedInput.analysisData().expressionTypes().put( - lambdaLiteral, - FrontendExpressionType.failed("synthetic lambda failure") - ); runCompileCheck(preparedInput); - // Only the form-level lambda blocker fires; the synthetic deferred/failed facts living - // inside the lambda body stay outside the compile surface and are never scanned. + // The synthetic deferred fact sits in a parameter default, which stays outside the compile + // surface and is never scanned; phase I releases the recorded lambda, whose clean body + // contributes no blocker either. var compileDiagnostics = diagnosticsByCategory(preparedInput.analysisData().diagnostics(), "sema.compile_check"); - assertEquals(1, compileDiagnostics.size()); - assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); } @Test @@ -1842,12 +1834,17 @@ func ping(vec: Vector2, dict: Dictionary): diagnostic.message().contains("Qualified method-reference 'clear'") && !diagnostic.range().equals(FrontendRange.fromAstRange(dictStep.range())) ), compileDiagnostics::toString); - // The recorded lambda no longer reports unsupported binding/chain subtrees (plan §3.3). + // The recorded lambda no longer reports unsupported binding/chain subtrees. assertTrue(diagnosticsByCategory(compiled.diagnostics(), "sema.unsupported_binding_subtree").isEmpty()); + // The recorded lambda is released onto the compile surface, so it contributes no + // form-level lambda compile block either. + assertTrue(compileDiagnostics.stream().noneMatch(diagnostic -> + diagnostic.message().contains("Lambda expression") + ), compileDiagnostics::toString); } @Test - void analyzeForCompileBlocksDirectLambdaConnectArgument() throws Exception { + void analyzeForCompileReleasesDirectLambdaConnectArgument() throws Exception { var source = """ class_name CompileCheckDirectLambdaConnect extends Node @@ -1866,14 +1863,153 @@ func ping(sig: Signal): compiled.diagnostics(), "sema.unsupported_expression_route" ); + var unsupportedBindingDiagnostics = diagnosticsByCategory( + compiled.diagnostics(), + "sema.unsupported_binding_subtree" + ); + + // The lambda is recorded inside a supported executable body, so the gate releases + // it onto the compile surface and the connect argument compiles cleanly — no form-level + // lambda blocker, no unsupported expression route, no unsupported binding subtree. + assertFalse(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); + assertTrue(unsupportedExpressionDiagnostics.isEmpty(), unsupportedExpressionDiagnostics::toString); + assertTrue(unsupportedBindingDiagnostics.isEmpty(), unsupportedBindingDiagnostics::toString); + } + + @Test + void analyzeForCompileScansRecordedLambdaBodyExplicitBlocks() throws Exception { + var source = """ + class_name CompileCheckLambdaBodyScan + extends Node + + func ping(): + var body_local = 0 + var f = func(): + preload("res://icon.svg") + $Camera3D + assert(body_local) + """; + + var compiled = analyzeForCompile("compile_check_lambda_body_scan.gd", source); + var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); + var lambda = findNode(compiled.unit().ast(), LambdaExpression.class, ignored -> true); + var preload = findNode(compiled.unit().ast(), PreloadExpression.class, ignored -> true); + var getNode = findNode(compiled.unit().ast(), GetNodeExpression.class, ignored -> true); + var assertStatement = findNode(compiled.unit().ast(), AssertStatement.class, ignored -> true); + var lambdaRange = FrontendRange.fromAstRange(lambda.range()); + + // The gate recurses into the recorded lambda body: the body's preload / get-node / assert are + // compile-blocking facts now, while the lambda node itself carries no form-level blocker. + assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.stream().noneMatch(diagnostic -> + diagnostic.range().equals(lambdaRange) + ), compileDiagnostics::toString); + assertTrue(compileDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(FrontendRange.fromAstRange(preload.range())) + && diagnostic.message().contains("Preload expression") + ), compileDiagnostics::toString); + assertTrue(compileDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(FrontendRange.fromAstRange(getNode.range())) + && diagnostic.message().contains("Get-node expression") + ), compileDiagnostics::toString); + assertTrue(compileDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(FrontendRange.fromAstRange(assertStatement.range())) + && diagnostic.message().contains("assert statement") + ), compileDiagnostics::toString); + } + + @Test + void analyzeForCompileKeepsUnrecordedPropertyInitializerLambdaBlocked() throws Exception { + var source = """ + class_name CompileCheckPropertyInitLambda + extends Node + + var cb = func(): + pass + """; + + var compiled = analyzeForCompile("compile_check_property_init_lambda.gd", source); + var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); + var unsupportedExpressionDiagnostics = diagnosticsByCategory( + compiled.diagnostics(), + "sema.unsupported_expression_route" + ); + var lambda = findNode(compiled.unit().ast(), LambdaExpression.class, ignored -> true); + var lambdaRange = FrontendRange.fromAstRange(lambda.range()); + // A property-initializer lambda publishes no plan, so the gate must not release it onto the + // compile surface. Upstream resolution owns the failure through the unsupported + // lambda-subtree diagnostics; the compile gate adds no release and no duplicate blocker. assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); - // The recorded lambda now publishes a resolved Callable type, so the connect argument no - // longer routes through the unsupported-expression boundary; compilation stays blocked - // only by the form-level lambda compile gate until lowering support lands (phase I). - assertEquals(1, compileDiagnostics.size(), compiled.diagnostics()::toString); - assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); - assertTrue(unsupportedExpressionDiagnostics.isEmpty(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); + assertTrue(unsupportedExpressionDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(lambdaRange) + && diagnostic.message().contains("Lambda expression") + ), unsupportedExpressionDiagnostics::toString); + } + + @Test + void analyzeForCompileKeepsUnrecordedParameterDefaultLambdaBlocked() throws Exception { + var source = """ + class_name CompileCheckParamDefaultLambda + extends Node + + func ping(cb = func(): + preload("res://icon.svg") + ): + pass + """; + + var compiled = analyzeForCompile("compile_check_param_default_lambda.gd", source); + var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); + var unsupportedDefaultDiagnostics = diagnosticsByCategory( + compiled.diagnostics(), + "sema.unsupported_parameter_default_value" + ); + var lambda = findNode(compiled.unit().ast(), LambdaExpression.class, ignored -> true); + var lambdaRange = FrontendRange.fromAstRange(lambda.range()); + + // Parameter defaults are not walked by the compile gate. An unrecorded default lambda must + // stay off the surface: compile still fails through the upstream default-value owner, the + // nested preload is not scanned, and the gate does not wrap a form-level compile_check. + assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); + assertTrue(unsupportedDefaultDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(lambdaRange) + ), unsupportedDefaultDiagnostics::toString); + } + + @Test + void analyzeForCompileLambdaBodyMatchFailsWithoutCompileCheckWrap() throws Exception { + var source = """ + class_name CompileCheckLambdaBodyMatch + extends Node + + func ping(): + var x = 0 + var f = func(): + match x: + 1: + pass + """; + + var compiled = analyzeForCompile("compile_check_lambda_body_match.gd", source); + var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); + var unsupportedBindingDiagnostics = diagnosticsByCategory( + compiled.diagnostics(), + "sema.unsupported_binding_subtree" + ); + var matchStatement = findNode(compiled.unit().ast(), MatchStatement.class, ignored -> true); + + // The recorded lambda is released and its body recursed, but the match inside stays outside + // the compile surface: compilation fails through the upstream unsupported-binding owner and + // the gate does not wrap the match in an extra sema.compile_check diagnostic. + assertTrue(compiled.diagnostics().hasErrors(), compiled.diagnostics()::toString); + assertTrue(compileDiagnostics.isEmpty(), compileDiagnostics::toString); + assertTrue(unsupportedBindingDiagnostics.stream().anyMatch(diagnostic -> + diagnostic.range().equals(FrontendRange.fromAstRange(matchStatement.range())) + ), unsupportedBindingDiagnostics::toString); } @Test @@ -1942,11 +2078,11 @@ func ping(seed = pinged, handler = _handler): """; var compiled = analyzeForCompile("compile_check_signal_skipped_surface.gd", source); - // The bare signal references hidden inside the lambda body / match sections stay outside - // the compile surface; the only compile blocker is the lambda node itself (phase I gate). + // The recorded lambda body is released onto the compile surface, but the bare signal / + // self-method value reads inside it are already compile-ready; the match section stays + // skipped. None of them contributes a compile blocker. var compileDiagnostics = diagnosticsByCategory(compiled.diagnostics(), "sema.compile_check"); - assertEquals(1, compileDiagnostics.size(), () -> compiled.diagnostics().asList().toString()); - assertTrue(compileDiagnostics.getFirst().message().contains("Lambda expression")); + assertTrue(compileDiagnostics.isEmpty(), () -> compiled.diagnostics().asList().toString()); } @Test 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 c1e60025..da694a59 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 @@ -631,7 +631,7 @@ func ping(): preparedInput.diagnosticManager() ); - // Phase E: the lambda return slot is the plan-published declared type, so a mismatching + // The lambda return slot is the plan-published declared type, so a mismatching // return value gets the ordinary return diagnostic instead of a Variant pass-through. var typeCheckDiagnostics = diagnosticsByCategory( preparedInput.diagnosticManager().snapshot(), diff --git a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java index 927b27f6..f085abef 100644 --- a/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java +++ b/src/test/java/gd/script/gdcc/frontend/sema/resolver/FrontendVisibleValueResolverTest.java @@ -664,7 +664,7 @@ func ping(value): @Test void resolveAllowsSyntheticLambdaBodyCurrentScopeWithoutLambdaAstBoundary() throws Exception { - // Lambda Phase B flipped the LAMBDA_BODY policy to publish lexical inventory, so the + // LAMBDA_BODY publishes lexical inventory, so the // current-scope backstop no longer seals a synthetic LAMBDA_BODY scope. Without a real // lambda AST boundary the request resolves as an ordinary EXECUTABLE_BODY lookup. var analyzedInput = analyzedInput("synthetic_lambda_scope.gd", """ 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 986cc31a..90ceba2c 100644 --- a/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java +++ b/src/test/java/gd/script/gdcc/test_suite/GdScriptUnitTestCompileRunnerTest.java @@ -82,11 +82,17 @@ public class GdScriptUnitTestCompileRunnerTest { "initializer/local/vectori_to_vector_boundaries.gd", "initializer/property/int_to_float_boundaries.gd", "initializer/property/object_and_scalar.gd", + "lambda/captures.gd", + "lambda/control_flow_bodies.gd", + "lambda/self_nested_return.gd", + "lambda/signal_and_engine.gd", + "lambda/value_call_and_arity.gd", "member/builtin_property_access.gd", "member/builtin_property_writeback_color.gd", "member/builtin_property_writeback_vector3.gd", "member/callable_value_refs.gd", "member/compound_assignment.gd", + "member/signal_connect_lambda.gd", "member/signal_emit_connect.gd", "member/signal_inherited_and_engine.gd", "member/signal_interop_bidirectional.gd", @@ -150,6 +156,7 @@ public class GdScriptUnitTestCompileRunnerTest { private static final List CONSTRUCTOR_SCRIPT_PATHS = scriptPathsWithPrefix("constructor/"); private static final List CONTROL_FLOW_SCRIPT_PATHS = scriptPathsWithPrefix("control_flow/"); private static final List INITIALIZER_SCRIPT_PATHS = scriptPathsWithPrefix("initializer/"); + private static final List LAMBDA_SCRIPT_PATHS = scriptPathsWithPrefix("lambda/"); private static final List MEMBER_SCRIPT_PATHS = scriptPathsWithPrefix("member/"); private static final List RUNTIME_SCRIPT_PATHS = scriptPathsWithPrefix("runtime/"); private static final List SCENE_SCRIPT_PATHS = scriptPathsWithPrefix("scene/"); @@ -229,6 +236,14 @@ Stream compilesAndValidatesInitializerScripts() throws Exception { ); } + @TestFactory + Stream compilesAndValidatesLambdaScripts() throws Exception { + return compileAndValidateBundledUnitScripts( + LAMBDA_SCRIPT_PATHS, + "Zig not found; skipping lambda GDScript compile-run tests" + ); + } + @TestFactory Stream compilesAndValidatesMemberScripts() throws Exception { return compileAndValidateBundledUnitScripts( diff --git a/src/test/test_suite/unit_test/script/lambda/captures.gd b/src/test/test_suite/unit_test/script/lambda/captures.gd new file mode 100644 index 00000000..28fcae56 --- /dev/null +++ b/src/test/test_suite/unit_test/script/lambda/captures.gd @@ -0,0 +1,61 @@ +class_name LambdaCapturesSmoke +extends Node + +func local_int_capture() -> int: + var seed := 40 + var cb := func() -> int: + return seed + 2 + return int(cb.call()) + +func param_capture(seed: int) -> int: + var cb := func() -> int: + return seed + 3 + return int(cb.call()) + +func string_capture(label: String) -> String: + var cb := func() -> String: + return label + return String(cb.call()) + +func multi_capture(left: int, right: int) -> int: + var cb := func() -> int: + return left * 100 + right + return int(cb.call()) + +func copy_on_capture() -> int: + var seed := 10 + var cb := func() -> int: + seed = 99 + return seed + var inner := int(cb.call()) + return seed * 1000 + inner + +func shared_array_identity() -> int: + var values: Array = [1] + var cb := func() -> Array: + return values + var inner: Array = cb.call() + inner.push_back(2) + return values.size() + +func for_iterator_capture() -> int: + var total := 0 + for item in range(3): + var cb := func() -> int: + return item + total += int(cb.call()) + return total + +func for_iterator_snapshot() -> int: + var cbs: Array = [] + for item in range(3): + cbs.push_back(func() -> int: + return item + ) + return int(cbs[0].call()) * 100 + int(cbs[1].call()) * 10 + int(cbs[2].call()) + +func shadow_own_parameter() -> int: + var seed := 10 + var cb := func(seed: int) -> int: + return seed + return int(cb.call(3)) diff --git a/src/test/test_suite/unit_test/script/lambda/control_flow_bodies.gd b/src/test/test_suite/unit_test/script/lambda/control_flow_bodies.gd new file mode 100644 index 00000000..f9e4de61 --- /dev/null +++ b/src/test/test_suite/unit_test/script/lambda/control_flow_bodies.gd @@ -0,0 +1,60 @@ +class_name LambdaControlFlowBodiesSmoke +extends Node + +var ctor_cb: Callable + +func _init() -> void: + var seed := 41 + ctor_cb = func() -> int: + return seed + +func constructor_capture() -> int: + return int(ctor_cb.call()) + +func in_if_true() -> int: + var cb := func() -> int: + return -1 + if true: + cb = func() -> int: + return 1 + else: + cb = func() -> int: + return 2 + return int(cb.call()) + +func in_if_false() -> int: + var cb := func() -> int: + return -1 + if false: + cb = func() -> int: + return 1 + else: + cb = func() -> int: + return 2 + return int(cb.call()) + +func in_while() -> int: + var n := 0 + var total := 0 + while n < 3: + var cb := func() -> int: + return 1 + total += int(cb.call()) + n += 1 + return total + +func in_for_body() -> int: + var total := 0 + for i in range(3): + var cb := func() -> int: + return 4 + total += int(cb.call()) + return total + +func in_for_capture_iterator() -> int: + var total := 0 + for item in range(1, 4): + var cb := func() -> int: + return item + total += int(cb.call()) + return total diff --git a/src/test/test_suite/unit_test/script/lambda/self_nested_return.gd b/src/test/test_suite/unit_test/script/lambda/self_nested_return.gd new file mode 100644 index 00000000..407dbe7a --- /dev/null +++ b/src/test/test_suite/unit_test/script/lambda/self_nested_return.gd @@ -0,0 +1,57 @@ +class_name LambdaSelfNestedReturnSmoke +extends Node + +var hits: int = 0 + +func bump() -> void: + hits += 1 + +func implicit_self_member() -> int: + hits = 0 + var cb := func() -> void: + hits += 1 + cb.call() + return hits + +func implicit_self_method() -> int: + hits = 0 + var cb := func() -> void: + bump() + cb.call() + return hits + +func explicit_self() -> int: + hits = 4 + var cb := func() -> int: + return self.hits + return int(cb.call()) + +func self_get_object_is_target() -> bool: + var cb := func() -> int: + return hits + return cb.get_object() == self + +func return_lambda() -> Callable: + return func(n: int) -> int: + return n + 1 + +func call_returned(value: int) -> int: + var cb := return_lambda() + return int(cb.call(value)) + +func apply(cb: Callable, value: int) -> int: + return int(cb.call(value)) + +func pass_as_argument(value: int) -> int: + return apply(func(n: int) -> int: + return n * 3 + , value) + +func nested_capture() -> int: + var seed := 40 + var outer := func(): + var inner := func(): + return seed + 2 + return inner + var inner_cb: Callable = outer.call() + return int(inner_cb.call()) diff --git a/src/test/test_suite/unit_test/script/lambda/signal_and_engine.gd b/src/test/test_suite/unit_test/script/lambda/signal_and_engine.gd new file mode 100644 index 00000000..a39e1f9f --- /dev/null +++ b/src/test/test_suite/unit_test/script/lambda/signal_and_engine.gd @@ -0,0 +1,41 @@ +class_name LambdaSignalAndEngineSmoke +extends Node + +signal labeled(prefix: String) +signal one_shot_labeled(prefix: String) + +var last_prefix: String = "" +var one_shot_hits: int = 0 + +func connect_labeled() -> int: + return labeled.connect(func(prefix: String) -> void: + last_prefix = prefix + ) + +func fire_labeled(prefix: String) -> String: + labeled.emit(prefix) + return last_prefix + +func connect_one_shot() -> int: + return one_shot_labeled.connect(func(prefix: String) -> void: + one_shot_hits += 1 + last_prefix = prefix + , Object.CONNECT_ONE_SHOT) + +func fire_one_shot(prefix: String) -> int: + one_shot_labeled.emit(prefix) + return one_shot_hits + +func map_double() -> int: + var values: Array = [1, 2, 3] + var mapped = values.map(func(x: int) -> int: + return x * 2 + ) + return int(mapped[0]) * 100 + int(mapped[1]) * 10 + int(mapped[2]) + +func filter_even() -> int: + var values: Array = [1, 2, 3, 4] + var kept = values.filter(func(x: int) -> bool: + return x % 2 == 0 + ) + return kept.size() * 10 + int(kept[0]) diff --git a/src/test/test_suite/unit_test/script/lambda/value_call_and_arity.gd b/src/test/test_suite/unit_test/script/lambda/value_call_and_arity.gd new file mode 100644 index 00000000..28157aed --- /dev/null +++ b/src/test/test_suite/unit_test/script/lambda/value_call_and_arity.gd @@ -0,0 +1,62 @@ +class_name LambdaValueCallAndAritySmoke +extends Node + +var stored: Callable + +func captureless_constant() -> int: + var cb := func() -> int: + return 7 + return int(cb.call()) + +func captureless_argument_count() -> int: + var cb := func() -> int: + return 7 + return int(cb.get_argument_count()) + +func captured_argument_count() -> int: + var seed := 1 + var cb := func() -> int: + return seed + return int(cb.get_argument_count()) + +func typed_param_argument_count() -> int: + var cb := func(n: int) -> int: + return n + return int(cb.get_argument_count()) + +func typed_param_and_return(value: int) -> int: + var cb := func(n: int) -> int: + return n + 1 + return int(cb.call(value)) + +func omitted_return_type(value: int) -> int: + var cb := func(n: int): + return n * 2 + return int(cb.call(value)) + +func untyped_param(value: Variant) -> Variant: + var cb := func(item): + return item + return cb.call(value) + +func stored_member_call() -> int: + stored = func() -> int: + return 8 + return int(stored.call()) + +func captureless_is_valid() -> bool: + var cb := func() -> int: + return 1 + return cb.is_valid() + +func captureless_get_object_is_null() -> bool: + var cb := func() -> int: + return 1 + return cb.get_object() == null + +func distinct_identity() -> bool: + var first := func() -> int: + return 1 + var second := func() -> int: + return 1 + return first != second diff --git a/src/test/test_suite/unit_test/script/member/signal_connect_lambda.gd b/src/test/test_suite/unit_test/script/member/signal_connect_lambda.gd new file mode 100644 index 00000000..88069a24 --- /dev/null +++ b/src/test/test_suite/unit_test/script/member/signal_connect_lambda.gd @@ -0,0 +1,17 @@ +class_name SignalConnectLambdaSmoke +extends Node + +signal pinged + +var lambda_hits: int = 0 + +func connect_lambda() -> int: + return pinged.connect(func() -> void: + lambda_hits += 1 + ) + +func fire() -> void: + pinged.emit() + +func read_hits() -> int: + return lambda_hits diff --git a/src/test/test_suite/unit_test/validation/lambda/captures.gd b/src/test/test_suite/unit_test/validation/lambda/captures.gd new file mode 100644 index 00000000..01220976 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/lambda/captures.gd @@ -0,0 +1,47 @@ +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 local_value = int(target.call("local_int_capture")) + var param_value = int(target.call("param_capture", 5)) + var label = String(target.call("string_capture", "hello")) + var multi_value = int(target.call("multi_capture", 2, 7)) + var copy_value = int(target.call("copy_on_capture")) + var shared_size = int(target.call("shared_array_identity")) + var iterator_sum = int(target.call("for_iterator_capture")) + var iterator_snapshot = int(target.call("for_iterator_snapshot")) + var shadowed = int(target.call("shadow_own_parameter")) + + if local_value != 42: + push_error("local int capture failed: %s" % local_value) + return + if param_value != 8: + push_error("param capture failed: %s" % param_value) + return + if label != "hello": + push_error("string capture failed: %s" % label) + return + if multi_value != 207: + push_error("multi capture failed: %s" % multi_value) + return + if copy_value != 10099: + push_error("copy-on-capture failed: %s" % copy_value) + return + if shared_size != 2: + push_error("shared array identity failed: %s" % shared_size) + return + if iterator_sum != 3: + push_error("for-iterator capture failed: %s" % iterator_sum) + return + if iterator_snapshot != 12: + push_error("for-iterator delayed snapshot failed: %s" % iterator_snapshot) + return + if shadowed != 3: + push_error("shadowed lambda parameter failed: %s" % shadowed) + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/lambda/control_flow_bodies.gd b/src/test/test_suite/unit_test/validation/lambda/control_flow_bodies.gd new file mode 100644 index 00000000..168ae5dc --- /dev/null +++ b/src/test/test_suite/unit_test/validation/lambda/control_flow_bodies.gd @@ -0,0 +1,35 @@ +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 ctor_value = int(target.call("constructor_capture")) + var if_true = int(target.call("in_if_true")) + var if_false = int(target.call("in_if_false")) + var while_total = int(target.call("in_while")) + var for_body = int(target.call("in_for_body")) + var for_iter = int(target.call("in_for_capture_iterator")) + + if ctor_value != 41: + push_error("constructor-body lambda failed: %s" % ctor_value) + return + if if_true != 1: + push_error("if-true lambda failed: %s" % if_true) + return + if if_false != 2: + push_error("if-false lambda failed: %s" % if_false) + return + if while_total != 3: + push_error("while-body lambda failed: %s" % while_total) + return + if for_body != 12: + push_error("for-body lambda failed: %s" % for_body) + return + if for_iter != 6: + push_error("for-iterator capture in loop body failed: %s" % for_iter) + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/lambda/self_nested_return.gd b/src/test/test_suite/unit_test/validation/lambda/self_nested_return.gd new file mode 100644 index 00000000..b8f5242f --- /dev/null +++ b/src/test/test_suite/unit_test/validation/lambda/self_nested_return.gd @@ -0,0 +1,39 @@ +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 member_hits = int(target.call("implicit_self_member")) + var method_hits = int(target.call("implicit_self_method")) + var explicit_hits = int(target.call("explicit_self")) + var object_is_self = bool(target.call("self_get_object_is_target")) + var returned = int(target.call("call_returned", 6)) + var passed = int(target.call("pass_as_argument", 5)) + var nested = int(target.call("nested_capture")) + + if member_hits != 1: + push_error("implicit self member write failed: %s" % member_hits) + return + if method_hits != 1: + push_error("implicit self method call failed: %s" % method_hits) + return + if explicit_hits != 4: + push_error("explicit self capture failed: %s" % explicit_hits) + return + if not object_is_self: + push_error("self-capturing lambda get_object should be the instance") + return + if returned != 7: + push_error("returned lambda call failed: %s" % returned) + return + if passed != 15: + push_error("lambda Callable argument failed: %s" % passed) + return + if nested != 42: + push_error("nested lambda capture transfer failed: %s" % nested) + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/lambda/signal_and_engine.gd b/src/test/test_suite/unit_test/validation/lambda/signal_and_engine.gd new file mode 100644 index 00000000..b03330ae --- /dev/null +++ b/src/test/test_suite/unit_test/validation/lambda/signal_and_engine.gd @@ -0,0 +1,36 @@ +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 connect_err = int(target.call("connect_labeled")) + var labeled = String(target.call("fire_labeled", "ok")) + if connect_err != 0 or labeled != "ok": + push_error("labeled lambda connect/emit failed: err=%s value=%s" % [connect_err, labeled]) + return + + var one_shot_err = int(target.call("connect_one_shot")) + var after_first = int(target.call("fire_one_shot", "once")) + var after_second = int(target.call("fire_one_shot", "twice")) + if one_shot_err != 0 or after_first != 1 or after_second != 1: + push_error("CONNECT_ONE_SHOT lambda failed: err=%s first=%s second=%s" % [ + one_shot_err, + after_first, + after_second + ]) + return + + var mapped = int(target.call("map_double")) + if mapped != 246: + push_error("Array.map lambda failed: %s" % mapped) + return + + var filtered = int(target.call("filter_even")) + if filtered != 22: + push_error("Array.filter lambda failed: %s" % filtered) + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/lambda/value_call_and_arity.gd b/src/test/test_suite/unit_test/validation/lambda/value_call_and_arity.gd new file mode 100644 index 00000000..db4571d6 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/lambda/value_call_and_arity.gd @@ -0,0 +1,55 @@ +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 constant_value = int(target.call("captureless_constant")) + var arity = int(target.call("captureless_argument_count")) + var captured_arity = int(target.call("captured_argument_count")) + var typed_arity = int(target.call("typed_param_argument_count")) + var typed_value = int(target.call("typed_param_and_return", 4)) + var omitted_value = int(target.call("omitted_return_type", 4)) + var echoed = target.call("untyped_param", "ok") + var stored_value = int(target.call("stored_member_call")) + var valid = bool(target.call("captureless_is_valid")) + var object_is_null = bool(target.call("captureless_get_object_is_null")) + var distinct = bool(target.call("distinct_identity")) + + if constant_value != 7: + push_error("captureless constant failed: %s" % constant_value) + return + if arity != 0: + push_error("captureless arity failed: %s" % arity) + return + if captured_arity != 0: + push_error("captured lambda arity must exclude captures: %s" % captured_arity) + return + if typed_arity != 1: + push_error("typed-param arity failed: %s" % typed_arity) + return + if typed_value != 5: + push_error("typed param/return failed: %s" % typed_value) + return + if omitted_value != 8: + push_error("omitted return type failed: %s" % omitted_value) + return + if String(echoed) != "ok": + push_error("untyped param echo failed: %s" % echoed) + return + if stored_value != 8: + push_error("stored member lambda failed: %s" % stored_value) + return + if not valid: + push_error("captureless is_valid expected true") + return + if not object_is_null: + push_error("captureless get_object expected null") + return + if not distinct: + push_error("separately constructed lambdas should not compare equal") + return + + print("__UNIT_TEST_PASS_MARKER__") diff --git a/src/test/test_suite/unit_test/validation/member/signal_connect_lambda.gd b/src/test/test_suite/unit_test/validation/member/signal_connect_lambda.gd new file mode 100644 index 00000000..25f71ad4 --- /dev/null +++ b/src/test/test_suite/unit_test/validation/member/signal_connect_lambda.gd @@ -0,0 +1,20 @@ +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 connect_err = int(target.call("connect_lambda")) + if connect_err != 0: + push_error("Lambda signal connect failed: %s" % connect_err) + return + + target.call("fire") + var hits = int(target.call("read_hits")) + if hits != 1: + push_error("Lambda signal connect did not fire exactly once: hits=%s" % hits) + return + + print("__UNIT_TEST_PASS_MARKER__")