From de3725fcf4fb4f8bdfdb8733c34f78c3a506be88 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 26 Jul 2026 21:36:01 -0500 Subject: [PATCH 1/4] fix: harden gradual type checking contracts --- ...7-26-typechecker-gradual-contract-audit.md | 278 + Docs/development/compiler-internals.md | 7 +- Docs/development/index.md | 1 + Docs/development/type-system-design.md | 313 + src/analyzer/mod.rs | 1104 ++- src/analyzer/static_analyzer.rs | 2 + src/builtins.rs | 246 +- src/fixer/mod.rs | 38 +- src/fixer/tests.rs | 101 + src/interpreter/environment.rs | 73 +- src/interpreter/memory_tests.rs | 2 + src/interpreter/mod.rs | 800 +- src/interpreter/value.rs | 26 +- src/parser/ast.rs | 6 + src/parser/stmt/actions.rs | 207 +- src/parser/stmt/containers.rs | 69 +- src/stdlib/core.rs | 19 +- src/stdlib/json.rs | 18 + src/stdlib/list.rs | 15 +- src/stdlib/random.rs | 17 + src/stdlib/typechecker.rs | 1158 +-- src/typechecker/mod.rs | 7115 +++++++++++++++-- tests/action_return_type_residuals_test.rs | 469 ++ tests/container_parsing_fixes.rs | 206 + tests/fixer_return_type_roundtrip_test.rs | 74 + tests/nothing_reassign_widen_test.rs | 27 + tests/open_file_local_type_test.rs | 9 +- tests/overload_test.rs | 264 +- tests/static_container_member_test.rs | 441 + tests/stream_handle_type_test.rs | 36 + ...echecker_alias_provenance_residual_test.rs | 254 + tests/typechecker_builtin_contract_test.rs | 451 ++ tests/typechecker_container_contract_test.rs | 977 +++ tests/typechecker_definite_binding_test.rs | 152 + tests/typechecker_expression_coverage_test.rs | 91 + tests/typechecker_gradual_any_test.rs | 1262 +++ .../typechecker_legacy_list_property_test.rs | 230 + tests/typechecker_loop_runtime_parity_test.rs | 312 + tests/typechecker_response_contract_test.rs | 117 + .../typechecker_response_stream_join_test.rs | 313 +- .../typechecker_response_stream_scope_test.rs | 59 +- tests/typechecker_reuse_test.rs | 77 + tests/typechecker_runtime_binding_test.rs | 330 + ...hecker_statement_completion_parity_test.rs | 183 + ...checker_statement_operand_contract_test.rs | 1027 +++ tests/typechecker_try_finally_join_test.rs | 221 +- 46 files changed, 17355 insertions(+), 1842 deletions(-) create mode 100644 Dev diary/2026-07-26-typechecker-gradual-contract-audit.md create mode 100644 Docs/development/type-system-design.md create mode 100644 tests/fixer_return_type_roundtrip_test.rs create mode 100644 tests/static_container_member_test.rs create mode 100644 tests/typechecker_alias_provenance_residual_test.rs create mode 100644 tests/typechecker_builtin_contract_test.rs create mode 100644 tests/typechecker_container_contract_test.rs create mode 100644 tests/typechecker_definite_binding_test.rs create mode 100644 tests/typechecker_expression_coverage_test.rs create mode 100644 tests/typechecker_gradual_any_test.rs create mode 100644 tests/typechecker_legacy_list_property_test.rs create mode 100644 tests/typechecker_loop_runtime_parity_test.rs create mode 100644 tests/typechecker_response_contract_test.rs create mode 100644 tests/typechecker_reuse_test.rs create mode 100644 tests/typechecker_runtime_binding_test.rs create mode 100644 tests/typechecker_statement_completion_parity_test.rs create mode 100644 tests/typechecker_statement_operand_contract_test.rs diff --git a/Dev diary/2026-07-26-typechecker-gradual-contract-audit.md b/Dev diary/2026-07-26-typechecker-gradual-contract-audit.md new file mode 100644 index 00000000..b6a00557 --- /dev/null +++ b/Dev diary/2026-07-26-typechecker-gradual-contract-audit.md @@ -0,0 +1,278 @@ +# Dev Diary — 2026-07-26: Typechecker Gradual-Contract Audit + +## Scope + +This was a systematic audit of WFL's static type system against the parser, +analyzer, standard-library contracts, and interpreter at base commit +`62a1b302200e70797dc399d75ecc778b2ddf6af2`. The motivating symptom was the +amount of `Any` visible in type checking: some uses were legitimate gradual +boundaries, while others were masking information the runtime already knew. + +A finite audit cannot prove every possible WFL program correct. The acceptance +standard for this pass was therefore concrete and repeatable: + +- every runtime built-in has an arity and value-kind contract; +- every expression and statement operand is traversed; +- concrete information survives collections, branches, loops, actions, + containers, and runtime-created bindings; +- shared mutable state and captured scalar bindings are widened only where an + effect can actually reach them; +- `Any`, `Unknown`, `Optional`, and `Error` retain distinct meanings; +- existing valid WFL programs remain compatible; +- checker acceptance and rejection match the corresponding runtime behavior; +- all repository quality gates pass. + +## What the audit found + +### Built-in contracts were too broad + +The runtime inventory had arity information, but many static registrations +used broad `Any` parameter or return types even when the implementation +required or produced a concrete value. Some aliases and optional arities also +diverged between the two layers. + +The standard-library type registry now mirrors the installed runtime natives, +including aliases and arity ranges. The call checker applies those contracts +to explicit calls, natural-language action calls, stored references, and +zero-argument auto-calls. Result specialization retains collection element +types for operations such as `slice`, `unique`, `concat`, `pop`, `shift`, +`remove_at`, and `random_from`. `find` now returns `Optional` because its +absence result is WFL `Nothing`. + +Dedicated `Date`, `Time`, and `DateTime` types prevent an unrelated user +container with the same name from satisfying a temporal runtime contract. +Historical custom temporal annotations remain compatible where the referenced +container is not statically known. + +### Flow analysis selected syntax instead of runtime endpoints + +Several branches, loops, and error paths retained the type from whichever +source branch happened to be checked last. Loop bodies were not consistently +rechecked at a stable header, and a `try` handler could miss a mutation made +immediately before the statement that raised the error. + +The checker now joins all runtime-reachable endpoints and preserves common +outer structure (`List` joined with `List` becomes +`List`). Persistent and fresh-iteration loops use separate fixed-point +models matching the interpreter's environment behavior. Possibly empty loops +retain their zero-iteration path; a `for each` over a provably non-empty list +retains its guaranteed first-iteration effect. Nested `try` flows incrementally +join reachable intermediate binding and alias states without retaining a full +binding-by-statement snapshot matrix. Capture traversal is charged to the +execution budget. Handlers begin from the structural state at `try` entry, and +`finally` receives a newly created try-local binding only when every reachable +success/error endpoint defines it. + +The final `Any` inventory also exposed a runtime mismatch in `repeat while`: +the interpreter tracked the body's last value but discarded it on normal loop +completion, unlike `while` and `repeat until`. It now returns that value, and a +literal-`no` pre-test loop is inferred as `Nothing` instead of an unnecessary +top-level `Any`. + +Bindings created in branches escape only when every applicable runtime path +defines them. Deferred actions, methods, tests, event handlers, and WebSocket +handlers restore their outer state after definition-time checking. + +### Action results did not model WFL block values + +Return inference previously summarized source-level `return` statements after +the body and treated fallthrough as `Nothing`. That lost both the type at the +actual return program point and WFL's runtime rule that a normally completed +block evaluates to its last executed statement. + +Each reachable return is now recorded with the type and alias state at that +program point. Inference joins explicit returns with the body's normal +completion value. Annotated actions and methods validate implicit results as +well as explicit returns. Unreachable tails and literal-dead branches do not +widen the result, while a definitely returning `finally` overrides the +primary result as it does at runtime. Overloads keep independent return and +effect summaries. + +The action parser recognizes return annotations only in unambiguous +double-colon headers such as `name: List of Text:`. CodeFixer emits that syntax +recursively for list, map/binary, and optional return types. A lexer-merged +`" returns "` remains a legacy multi-word action name. + +### Shared lists and captured scalars needed effect tracking + +WFL list values use shared `Rc>` storage. Name-only inference was +therefore unsound after copying, nesting, inserting, clearing, filling, or +passing a list through a closure. + +List aliases are now keyed by stable lexical binding identity and structural +depth. Provenance survives nested list/map construction, extraction, +insertion, aggregate self-assignment, branch/error joins, loop backedges, and +fresh versus shared action returns. Projection returns rebase their full +descendant closure. Replacement and clear operations detach stale descendants +only for proven strong updates, retaining them across may-alias joins. Named +action overloads receive list and captured-scalar effect summaries, including +forward-call dependencies. + +An opaque user-code boundary cannot provide such a summary. Stored native or +method references, container construction and methods, events, dynamically +resolved calls, and WebSocket handler execution therefore invalidate affected +refinements and conservatively escape shared lists. Optional scalar guards are +restored to the original optional type rather than being trusted across a call +that may mutate the captured binding. + +### Runtime-created values and statement operands were under-typed + +The checker now reconstructs concrete types for request objects, response and +outbound streams, file and database handles, patterns, calendar values, +WebSocket server/connection values, container instances, and other implicit +runtime bindings in every relevant scope. + +HTTP response, streaming, file/process, database, header, event, and WebSocket +statements now visit and validate all operands. Static and instance container +members have separate contracts; property initialization, constructors, +inheritance, parent calls, events, and stored static method references agree +with runtime lookup. Declared property types are preserved across direct method +assignments and typed-list insertions, including statement and built-in +mutation forms on bare, inherited-instance, and static property accesses. +Method parameters and locals outrank properties through nested control-flow +scopes; properties in turn outrank true outer bindings. Instance and static +property mutations completed before a later method error are persisted instead +of being accidentally rolled back. + +Every explicit runtime result/iteration binder now creates the same local +binding the analyzer and checker model, including file, database, HTTP, +process, collection, calendar, pattern, container, and WebSocket statements. +Nested action, container, and interface declarations follow the same rule. +Ordinary `store` and `change` still target a same-named property, while a +constant declaration is rejected instead of diverging at runtime. + +Container inheritance cycles are rejected without recursive lookup. An +incompatible concrete inherited-property redeclaration emits a compatibility +warning because the runtime flattens both declarations to one mutable slot. +Static method context is now poll-local for asynchronous handlers: nested +calls and park/resume boundaries persist completed static-property writes +without allowing concurrent handlers to borrow or overwrite each other's +active context stacks. + +Static `Nothing` parity now covers both historical no-value runtime variants. +Core predicates and equality treat both variants as no value, while list +search, JSON null, and random seeding preserve their legacy `Nothing` identity +and therefore preserve existing `typeof` output. Static optionality describes +both without forcing a runtime compatibility migration. + +### Checker reuse leaked program state + +Analyzer and typechecker state now resets between programs. A failed editor or +LSP check cannot carry symbols, diagnostics, aliases, overload results, or a +budget breach into the next run. + +## What `Any` means after this pass + +`Any` remains intentional in five cases: + +1. external or dynamically shaped data such as parsed payloads, unknown + imports, and heterogeneous database records; +2. incompatible concrete results for which WFL has no general union type + (`Number | Text`, for example); +3. a heterogeneous position inside retained structure (`List` or + `Map`); +4. shared mutable data crossing an opaque effect boundary; +5. a runtime construct whose completion value is deliberately dynamic. + +An empty collection uses `Unknown` because evidence is missing. A +`T | Nothing` result uses `Optional`. A prior diagnostic propagates +`Error`. These states must not be replaced by `Any` merely to suppress a +diagnostic. + +Some boundaries remain deliberately conservative. WFL does not yet represent +arbitrary unions, and opaque container/event or stored native/method calls do +not have body-specific effect summaries. Those cases may lose precision, +including top-level `Any` when opaque code can rebind a mutable captured +variable, but they do so at an identified runtime mutation boundary rather +than through accidental fallback. + +## Compatibility work + +- Existing heterogeneous and dynamically imported programs remain gradual. +- Older temporal custom annotations and colon-style container annotations keep + their historical interpretation. +- The existing `Nothing`-then-assignment behavior inside a `for each` remains + valid when the source is provably non-empty, without making possibly empty + loops unsound. +- Existing write/flush ambiguity, zero-argument action auto-call, overload, + include, and parser compatibility suites remain green. +- Three scope-isolation fixtures that used literal text as a fake HTTP request + were updated to create a real typed request. The stricter request/event + operand checks were retained because those fake operands would fail at + runtime. + +## Compatibility-limited findings + +The review also identified four property migrations that cannot be completed +as a local checker patch without changing supported runtime behavior: + +- Concrete properties without defaults are absent on instances or `Null` in + static state, but existing programs may initialize them later or omit an + inherited property. Required-field rejection needs definite-initialization + or optional-state modeling plus the governance deprecation path. +- List properties share their runtime storage with values passed into, read + from, returned from, or projected through them. Direct property mutations + are now contract-checked, but complete protection requires property-aware + alias paths and interprocedural summaries. Deep-copying would break WFL's + existing shared-list semantics. +- Plain action and method annotations are historically static hints, not + runtime guards. Gradual or `Nothing` arguments can therefore be laundered + through a typed parameter before reaching a concrete property. A compatible + repair needs runtime casts/guards or property-effect summaries and a + migration plan; globally changing call compatibility would silently alter + established gradual behavior. +- Child containers have historically been able to redeclare inherited + properties with incompatible annotations. The analyzer now warns, but hard + invariance would reject supported programs and therefore requires the + governance deprecation path. + +These are recorded explicitly in the type-system design instead of being +hidden behind `Any` or described as solved. The practical rule for current +code is to give concrete properties compatible defaults, keep inherited +annotations compatible, and not expose a mutable typed-property list through +ordinary aliases when its element invariant matters. + +## TDD and review + +Regressions were added before their corresponding fixes for built-in +inventory and value kinds, expression traversal, collection inference, +optionality, action completion, branch/loop/try joins, alias effects, captured +scalars, static and inherited container members, runtime-created bindings, +checker reuse, runtime `Nothing` parity, static-handler concurrency, cyclic +inheritance, and method-local declaration/binder parity. + +The focused type-system matrix covers hundreds of cases across the new and +expanded integration-test binaries. Review findings about static stored +methods, property invariants, scalar effects beyond direct calls, structured +alias/return provenance, definite `try` bindings, nested error reachability, +and snapshot cost were incorporated before the final gates. + +## Verification + +All commands completed successfully on Windows: + +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features --jobs 1 -- -D warnings` +- focused type-system integration tests +- `cargo test --all --jobs 1` +- `cargo build --release --jobs 1` +- `scripts/run_integration_tests.ps1 -TestOnly` + - Rust integration preflight: passed + - WFL programs: 111 passed, 0 failed, 24 documented skips +- `scripts/run_web_tests.ps1` + - 2/2 HTTP tests passed + - TLS fixture skipped because OpenSSL was unavailable +- `python scripts/validate_docs_examples.py --ci --force` + - 18 passed, 0 failed + +The full test run still prints pre-existing unused-code warnings from several +LSP test fixtures. The integration preflight also printed a non-fatal +read-only Cargo cache bookkeeping warning. Neither affected command status or +test results. + +## Documentation + +`Docs/development/type-system-design.md` is the contributor contract for +gradual states, collections and aliases, built-ins, control-flow joins, action +results, containers, intentional `Any` boundaries, and runtime-parity review. +It is linked from the development index and compiler internals. diff --git a/Docs/development/compiler-internals.md b/Docs/development/compiler-internals.md index 74e839a2..fa9e3a94 100644 --- a/Docs/development/compiler-internals.md +++ b/Docs/development/compiler-internals.md @@ -75,6 +75,9 @@ Execution / Output **File:** `src/typechecker/mod.rs` +The complete compatibility and inference contract is documented in +[Type System Design](type-system-design.md). + **Type inference:** - Literal types from values - Variable types from assignments @@ -86,7 +89,9 @@ Execution / Output - Function call type matching - Assignment type consistency -**Built-in types:** Registered in `src/builtins.rs` with arity checking. +**Built-in contracts:** Runtime inventory and arities are registered in +`src/builtins.rs`; parameter and return contracts are defined in +`src/stdlib/typechecker.rs`. ## Interpreter diff --git a/Docs/development/index.md b/Docs/development/index.md index f245f94b..933af992 100644 --- a/Docs/development/index.md +++ b/Docs/development/index.md @@ -17,6 +17,7 @@ Development work still follows the [WFL foundation](../wfl-foundation.md): clear ## Design notes +- **[Type system design](type-system-design.md)** — Gradual typing, collection inference, control-flow joins, built-in contracts, and runtime parity - **[Route construct design](route-construct-design.md)** — HTTP routing design history - **[Stdlib higher-order functions](stdlib-higher-order-functions.md)** — Design notes for list transforms - **[Concurrency phase plan](concurrency-phase-plan.md)** — Phased TODOs for cooperative concurrency (`main loop concurrently:`, crypto off-thread, nursery) diff --git a/Docs/development/type-system-design.md b/Docs/development/type-system-design.md new file mode 100644 index 00000000..18b47a77 --- /dev/null +++ b/Docs/development/type-system-design.md @@ -0,0 +1,313 @@ +# Type System Design + +WFL uses a gradual static type system. The checker rejects operations that are +provably invalid, preserves concrete type information whenever the syntax and +runtime contracts provide it, and defers genuinely dynamic cases to runtime. + +This page documents the contract contributors must preserve when changing the +parser, analyzer, type checker, standard library, or interpreter. + +## The four inference states + +`Type` contains ordinary concrete types plus three states that have distinct +jobs: + +| State | Meaning | Checker behavior | +|---|---|---| +| A concrete type | The value is known to be a number, text, boolean, list element type, container instance, temporal value, and so on | Enforce the operation's declared contract | +| `Unknown` | Inference does not have enough evidence yet | Allow compatible evidence to refine it; otherwise defer rather than inventing a concrete type | +| `Any` | The value is intentionally dynamic or is a known union that WFL does not represent more precisely | Traverse and validate the surrounding expression, but defer checks that depend on the runtime member | +| `Error` | An earlier check already reported an error | Propagate it without producing misleading cascaded diagnostics | + +`Unknown` and `Any` are not synonyms. An empty list starts with an unknown +element type because its declaration provides no evidence; that uncertainty is +kept conservative rather than narrowed from a later statement. Parsed JSON, a +heterogeneous database row, or another dynamic boundary uses `Any` because its +runtime shape is intentionally not statically fixed. + +`Optional` is a separate inferred composite type meaning that execution +produces either `T` or `Nothing`. It is used for operations such as `find` and +for control flow whose runtime-reachable results include both cases. Unlike +`Any`, `Optional` does not satisfy an operation that requires a definite +`Text`. Optionality is preserved through collection, control-flow, return, and +overload joins instead of collapsing back to `Any` or `Unknown`. A direct +`isnothing` or `is not nothing` check on a variable narrows it inside the +corresponding branch. + +Adding `Any` to silence a checker error is not an acceptable repair. A new +`Any` must correspond to a real runtime union or dynamic boundary, and its +children must still be traversed so errors inside the expression are not lost. + +## Collection inference + +List literals join the types of all their elements: + +- a homogeneous literal retains its concrete element type; +- an empty literal is `List`; +- a heterogeneous literal is `List`; +- an element that is already `Error` keeps the expression on the error path. + +List-returning built-ins preserve or specialize the receiver's element type +where their runtime behavior permits it. Element-returning operations such as +`pop`, `shift`, `remove_at`, and `random_from` return the known element type. +They raise a runtime error rather than returning `Nothing` when no element can +be selected. `find` returns `Optional` because absence does produce +`Nothing`. Shape-preserving operations such as `slice` and `unique` keep the +list type. `concat` joins both element types. Appending mutations widen the +stored list binding only when the inserted value requires it; `fill` replaces +the element type because it overwrites every existing element. + +Maps retain key and value types when both can be inferred. Runtime records +whose fields deliberately have unrelated types, such as database rows, use a +text-keyed map with `Any` values. + +Runtime lists use shared reference-counted storage, so copying a list variable +creates an alias rather than a deep copy. The checker tracks aliases by stable +lexical binding identity. Mutating either name propagates the exact append or +replacement effect across the group, including aliases promoted from +conditionals and error handlers. Direct calls to named actions use +overload-specific summaries of captured and argument list effects. Calls +without a statically identifiable body, including stored native or method +references, container constructors, methods, events, and dynamically resolved +code, are opaque effect boundaries. At those boundaries, reachable lists are +conservatively escaped. A mutable binding that opaque code can rebind may +become top-level `Any`, while an immutable binding that can only expose shared +list storage retains its outer shape as `List`. Extracting a list through +an indexed or opaque property path conservatively escapes that source path. + +Aliases are tracked structurally through nested lists and maps, list insertion, +aggregate reassignment, and action returns. Return summaries distinguish fresh +local storage from captured or parameter-derived storage and rebase the full +descendant shape when a projection is returned. Operations that replace or +clear aggregate storage detach stale descendant paths only for a proven strong +update; may-alias descendants remain conservative. + +## Built-in function contracts + +The runtime built-in inventory and its accepted arities live in +`src/builtins.rs`. Static parameter and return contracts live in +`src/stdlib/typechecker.rs`, and `src/typechecker/mod.rs` applies those +contracts to every supported call form. + +The inventory is checked as a unit. An implemented runtime native must have a +static contract, aliases must resolve to the same contract, and reserved but +unimplemented names must not masquerade as callable functions. Generic +contracts may use `Any` for a parameter position, but result specialization +should recover the caller's concrete type when the runtime operation preserves +it. + +When adding or changing a built-in: + +1. update the runtime inventory and implementation; +2. update the static contract and aliases; +3. add positive and negative contract tests; +4. test concrete return-type propagation into a downstream operation. + +An arity-only test is not enough: the checker and interpreter must agree on +accepted value kinds and the result type. + +## Control-flow joins and definite bindings + +The checker snapshots bindings at control-flow boundaries and joins every +runtime-reachable endpoint. + +- A name becomes available after a conditional only when the runtime + guarantees that an applicable path defines it. +- Reassignments join their value types instead of blindly taking the last + syntactic branch. Joins preserve common outer structure, so two different + list element types become `List` rather than top-level `Any`. +- Loop headers are checked to a conservative fixed point so a later iteration + cannot invalidate an operation that was accepted using only first-iteration + types. Diagnostics from the first header remain valid even if a later header + widens. +- A `for each` over a list that is provably non-empty applies its first + iteration before joining later backedges. Possibly empty collections retain + the zero-iteration path. Cardinality facts are deliberately discarded after + opaque calls and mutations that can remove elements. +- Pre-test loop conditions are checked at both the first and stable headers. + A statically false condition still validates its body as source code, but the + unreachable body's type and alias effects do not reach the continuation. +- `while`, `repeat until`, and `repeat while` retain their runtime environment + across iterations. `for each`, `count`, `forever`, and `main loop` create or + clear an iteration child: their local declarations reset while mutations of + parent bindings still flow around the backedge. +- `while`, `repeat until`, and `repeat while` complete with the last executed + body value. A pre-test loop whose condition is the literal `no` completes + with `Nothing`; other loop completions remain gradual until WFL can represent + the body value plus the zero-iteration case without losing structure. +- A `try` handler starts from the structural state at entry plus the streamed + join of mutations completed before each possible error transfer. A new + try-local name is available in `finally` only when every success, handled, + otherwise, and unmatched endpoint defines it. A same-named binding that + already existed at entry keeps its clause-local shadowing semantics. + Temporary error aliases remain clause-local. +- Capturing error-transfer state is charged proportionally to the execution + budget and joined incrementally; the checker does not retain a full + binding-by-statement snapshot matrix. +- Deferred handlers, tests, and container methods use child scopes so their + declarations and refinements do not leak merely because the body was + checked. + +These rules model runtime visibility. They must be updated alongside any +interpreter change that creates, promotes, or isolates an environment. + +## Actions, overloads, and containers + +Action parameters and declared returns are checked in their lexical scope. +Unannotated returns are inferred from every runtime-reachable `return` and the +normal completion value of the action body. WFL blocks evaluate to the last +executed statement's value, so an action ending in a value expression can +infer that type without an explicit `return`. Explicit returns and normal +completion join at their exact program points rather than being selected by +source order. A body whose reachable results are `T` and `Nothing` infers +`Optional`; a body whose results are incompatible concrete types that WFL +cannot represent as a union infers `Any`. + +Named actions also record overload-specific effects on captured scalar +bindings. A call invalidates refinements that those assignments can change. +An opaque call cannot use such a summary, so it restores an optional variable +to its pre-guard type instead of trusting a stale narrowing. Guards using +`isnothing`, `is_nothing`, or direct equality/inequality with `nothing` refine +a direct variable inside the guarded branch. +Nested action definitions become visible when execution reaches their +definition, while top-level signatures remain available for forward +references. The canonical source spelling for a declared return is the framed +double-colon header `name: Type:`; recursive list, map, binary, and optional +types use the same form. Text such as `calculate returns schedule` remains a +valid legacy multi-word action name rather than being reinterpreted as an +annotation. + +Overload selection uses arity, compatibility, and specificity. Static +selection must mirror runtime selection. In particular, a dedicated temporal +type is more specific than a compatible historical custom annotation. + +Instance and static container members have separate contracts. Static defaults +are checked at definition time; static methods can access static properties but +not bare instance properties. Instance methods receive the inverse context. +Inherited members follow the same rule at typecheck time and runtime. +Initializers, direct assignments, and direct typed-list insertions preserve a +declared property contract, including when the source is gradual. The same +element check applies to statement and built-in mutation forms and to direct +instance, inherited, and static property accesses. A fresh empty list literal +is safe for any declared list element type; a shared `List` is not +equivalent evidence. Property names shadow same-named outer lexical bindings +inside methods, while parameters and method locals retain their normal +inner-scope precedence through nested control-flow scopes. Explicit +iteration, result, collection, pattern, and nested declaration binders are +method locals too; ordinary `store`/`change` statements still target a +same-named property, and a constant declaration cannot silently replace one. + +Container inheritance cycles are fatal analyzer errors and inherited member +lookup remains cycle-safe. A child that redeclares a concrete inherited +property with an incompatible concrete type receives a compatibility warning: +the runtime flattens that member to one mutable slot, so the two annotations +cannot both be enforced as independent storage contracts. + +Static method state is synchronized at nested-call boundaries and whenever an +asynchronous handler parks or resumes. Concurrent handlers keep independent +active-method context stacks while merging completed static-property writes +back to the shared container definition. + +## Compatibility-limited property work + +Four property cases require a broader language/runtime migration and are not +silently redefined by the checker: + +- A property with a concrete annotation but no default is represented as + absent on a new instance and as `Null` for static state, while older WFL + programs may populate it later or omit an inherited property deliberately. + Treating every such read as a definite `T` is not fully sound, but immediately + making it required would reject supported programs. The compatible repair + needs definite-initialization or `Optional` state, an initialization + transition, diagnostics, and the governance deprecation period (at least one + year for an unavoidable break). +- Runtime list values are shared. A list supplied to a typed property, read + back from that property, returned by a method, or reached through a nested + projection can retain the same storage under an ordinary lexical alias. + Direct mutations of the property are checked, but a complete invariant also + needs protected-alias/path constraints that follow storage in both + directions and through user-code summaries. Deep-copying at the property + boundary is not an equivalent quick fix because it would change intentional + sharing semantics. +- Plain action and method annotations are historically static hints rather + than runtime guards, and gradual or `Nothing` arguments are accepted at + those call boundaries. A typed parameter can therefore carry a runtime value + that would be rejected by a direct concrete-property assignment. Closing + that path requires either runtime casts/guards (with a compatibility + migration) or property-effect summaries that impose stricter requirements + only on callers whose arguments reach a concrete property. +- A child container can historically redeclare an inherited property using an + incompatible annotation. The analyzer now warns because runtime lookup + exposes one flattened slot, but making the override a hard error immediately + would reject supported programs. Enforcing invariance requires the + governance deprecation path and migration diagnostics. + +Until those migrations land, contributors must not describe concrete +no-default properties, aliased mutable property storage, unchecked parameter +boundaries, or incompatible inherited overrides as fully statically sound. New +code should give concrete properties compatible defaults, keep inherited +property annotations compatible, and avoid exposing mutable property lists +through aliases when a persistent element contract is required. + +## Intentional uses of `Any` + +`Any` remains part of the design, but every occurrence should fit one of these +categories: + +- data whose runtime schema is explicitly dynamic, such as parsed external + payloads, heterogeneous database records, or unknown imports; +- an unrepresentable union of incompatible concrete results, such as `Number` + on one reachable path and `Text` on another; +- the varying member position inside a preserved outer shape, such as + `List` or `Map`; +- a value that crossed an opaque mutation boundary where shared runtime state + can no longer be proven precise; +- a runtime construct whose completion value is intentionally dynamic. + +Outer structure must be retained whenever possible. For example, joining +`List` with `List` produces `List`, not top-level `Any`. +`Unknown` is used for missing evidence, and `Optional` is used for +`T | Nothing`; neither should be replaced with `Any` merely for convenience. + +## Temporal identity and compatibility + +Runtime dates, times, and date-times have dedicated `Date`, `Time`, and +`DateTime` types. This prevents a user container with a similar name from being +accepted accidentally by a temporal built-in. + +Historical source annotations that parsed as custom temporal names remain +compatible when they are not a statically known same-named container. Unknown +imports keep this decision gradual: the checker permits the call and the +runtime validates the actual value. A known `ContainerInstance` never passes a +temporal built-in contract merely because its container is named `Date`, +`Time`, or `DateTime`. + +The source spelling rules are compatibility-sensitive. Older colon-style +container annotations treated lowercase or mixed-case identifiers such as +`number` as custom type names; parser changes must not silently reinterpret +those existing programs. + +## Runtime parity checklist + +Before considering a type-system change complete, verify all of the following: + +- every expression and statement operand is traversed; +- checker reuse does not carry symbols or diagnostics between programs; +- every implicit runtime binding has an analyzer and checker type; +- static acceptance has a corresponding successful runtime path; +- a statically rejected concrete type would also fail at runtime; +- overload ranking and container inheritance agree in both layers; +- dynamic values remain gradual without hiding errors in their child + expressions; +- formatting or auto-fixing preserves type identity on reparse; +- tests cover both valid and invalid programs, plus downstream use of inferred + results. + +Run the repository's complete formatting, Clippy, Rust test, release build, +integration, web, and documentation-example gates after focused tests pass. + +--- + +**Related:** [Compiler Internals](compiler-internals.md) | +[Architecture Overview](architecture-overview.md) | +[Testing Guide](../guides/testing-guide.md) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 3ea6e06f..9b57dc22 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -1,5 +1,5 @@ use crate::parser::ast::{Expression, Literal, Parameter, Program, Statement, Type}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; #[derive(Debug, Clone, PartialEq)] @@ -78,6 +78,9 @@ pub(crate) fn format_param_type(t: &Type) -> String { Type::Boolean => "boolean".to_string(), Type::Nothing => "nothing".to_string(), Type::Pattern => "pattern".to_string(), + Type::Date => "date".to_string(), + Type::Time => "time".to_string(), + Type::DateTime => "datetime".to_string(), Type::Any => "any".to_string(), Type::Custom(name) => name.clone(), other => format!("{other:?}"), @@ -93,6 +96,7 @@ pub struct ContainerInfo { pub methods: HashMap, pub static_properties: HashMap, pub static_methods: HashMap, + pub events: HashMap, pub line: usize, pub column: usize, } @@ -116,6 +120,14 @@ pub struct MethodInfo { pub column: usize, } +#[derive(Debug, Clone)] +pub struct EventInfo { + pub name: String, + pub parameters: Vec, + pub line: usize, + pub column: usize, +} + #[derive(Debug, Clone, PartialEq)] pub struct Symbol { pub name: String, @@ -125,10 +137,20 @@ pub struct Symbol { pub column: usize, } +/// Stable identity of one lexical binding while an analyzer/type-checker run +/// is active. Names alone are insufficient because sibling scopes may reuse a +/// local name; list alias tracking uses this key to avoid conflating them. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct SymbolBindingKey { + scope_id: u64, + name: String, +} + #[derive(Debug, Clone)] pub struct Scope { pub symbols: HashMap, pub parent: Option>, + id: u64, } impl Default for Scope { @@ -142,13 +164,15 @@ impl Scope { Scope { symbols: HashMap::new(), parent: None, + id: 0, } } - pub fn with_parent(parent: Scope) -> Self { + pub fn with_parent(parent: Scope, id: u64) -> Self { Scope { symbols: HashMap::new(), parent: Some(Box::new(parent)), + id, } } @@ -215,6 +239,54 @@ impl Scope { None } } + + fn resolve_binding_key(&self, name: &str) -> Option { + if self.symbols.contains_key(name) { + Some(SymbolBindingKey { + scope_id: self.id, + name: name.to_string(), + }) + } else { + self.parent + .as_deref() + .and_then(|parent| parent.resolve_binding_key(name)) + } + } + + fn resolve_binding_key_mut(&mut self, key: &SymbolBindingKey) -> Option<&mut Symbol> { + if self.id == key.scope_id { + self.symbols.get_mut(&key.name) + } else { + self.parent + .as_deref_mut() + .and_then(|parent| parent.resolve_binding_key_mut(key)) + } + } + + fn resolve_binding_key_symbol(&self, key: &SymbolBindingKey) -> Option<&Symbol> { + if self.id == key.scope_id { + self.symbols.get(&key.name) + } else { + self.parent + .as_deref() + .and_then(|parent| parent.resolve_binding_key_symbol(key)) + } + } + + fn collect_binding_types(&self, out: &mut Vec<(SymbolBindingKey, Option)>) { + for (name, symbol) in &self.symbols { + out.push(( + SymbolBindingKey { + scope_id: self.id, + name: name.clone(), + }, + symbol.symbol_type.clone(), + )); + } + if let Some(parent) = self.parent.as_deref() { + parent.collect_binding_types(out); + } + } } #[derive(Debug, Clone)] @@ -275,6 +347,14 @@ pub fn program_has_load_module(program: &Program) -> bool { pub struct Analyzer { current_scope: Scope, + /// Root bindings supplied by the constructor (runtime constants and any + /// explicit parent variables). A reused analyzer restores this baseline + /// before every independent program so prior declarations and diagnostics + /// cannot leak across runs. + baseline_symbols: HashMap, + /// Monotone lexical-scope identity source. Scope IDs remain stable through + /// snapshots/clones and are never reused within one analyzer run. + next_scope_id: u64, errors: Vec, /// Non-fatal semantic warnings. Currently used for undefined-action calls in /// programs that use `include from`: the action may be provided by an @@ -283,7 +363,12 @@ pub struct Analyzer { warnings: Vec, action_parameters: std::collections::HashSet, containers: HashMap, + events: HashMap, current_container: Option, + /// Whether the currently analyzed container method is static. `None` + /// means analysis is outside a method body and both member categories may + /// be queried by general container-introspection helpers. + current_method_is_static: Option, /// True when the program contains `include from` statements. Included files /// are resolved dynamically at runtime, so the analyzer cannot know which /// actions/variables they expose; undefined-action errors are downgraded to @@ -310,7 +395,7 @@ pub struct Analyzer { /// reassigned to anything that is not a bare action reference; degraded to /// [`AliasState::Dynamic`] when control flow makes the binding uncertain. /// Reset per `analyze` run. - action_aliases: HashMap, + action_aliases: HashMap, /// Overload definitions *visited so far* in the PASS-2 walk, per action /// name. PASS 1 registers signatures in lexical order, so an exact count /// is a prefix length into the symbol's signature list — the overloads a @@ -328,12 +413,23 @@ pub struct Analyzer { /// intermediate state at runtime. Popping a frame merges it into its /// parent (an inner body's mutation is also the outer body's). Reset /// per `analyze` run. - alias_mutation_frames: Vec>, + alias_mutation_frames: Vec>, /// What each alias call site resolved to, keyed by (callee, line, column). /// The type checker reads this instead of the final alias map so it /// observes the alias state that held *at that statement*, not whatever /// the map ended up as. Reset per `analyze` run. alias_call_sites: HashMap<(String, usize, usize), AliasState>, + /// Captured stored-action bindings that each user action may rebind. + /// Calls conservatively degrade those bindings to `Dynamic`; the runtime + /// closure may have selected any action on a reachable body path. + action_alias_effects: HashMap>, + /// User-action calls made from within another action. Dependencies are + /// followed transitively when applying alias effects, including forward + /// definitions whose direct effects were not known when the caller body + /// was first analyzed. + action_alias_dependencies: HashMap>, + action_alias_name_stack: Vec, + action_alias_effect_stack: Vec>, } /// Statically known state of a stored-action alias variable. @@ -346,6 +442,9 @@ pub enum AliasState { action: String, visible_signatures: usize, }, + /// Bound to a native standard-library function. Its exact overload, + /// optional-arity, or variadic contract is resolved by the type checker. + Builtin { name: String }, /// Possibly an action (e.g. reassigned differently across branches): /// static validation is skipped and the call defers to runtime dispatch. Dynamic, @@ -378,7 +477,7 @@ impl OverloadCount { /// view (the round-4 review finding). #[derive(Clone)] struct FlowState { - aliases: HashMap, + aliases: HashMap, overloads: HashMap, } @@ -466,38 +565,6 @@ impl Analyzer { }; let _ = global_scope.define(undefined_symbol); - let push_symbol = Symbol { - name: "push".to_string(), - kind: SymbolKind::Function { - signatures: vec![FunctionSignature { - parameters: vec![ - Parameter { - name: "list".to_string(), - param_type: Some(Type::List(Box::new(Type::Unknown))), - default_value: None, - line: 0, - column: 0, - }, - Parameter { - name: "value".to_string(), - param_type: Some(Type::Unknown), - default_value: None, - line: 0, - column: 0, - }, - ], - return_type: Some(Type::Nothing), - }], - }, - symbol_type: Some(Type::Function { - parameters: vec![Type::List(Box::new(Type::Unknown)), Type::Unknown], - return_type: Box::new(Type::Nothing), - }), - line: 0, - column: 0, - }; - let _ = global_scope.define(push_symbol); - let loop_symbol = Symbol { name: "loop".to_string(), kind: SymbolKind::Variable { mutable: false }, @@ -574,13 +641,18 @@ impl Analyzer { }; let _ = global_scope.define(script_directory_symbol); + let baseline_symbols = global_scope.symbols.clone(); Analyzer { current_scope: global_scope, + baseline_symbols, + next_scope_id: 1, errors: Vec::new(), warnings: Vec::new(), action_parameters: std::collections::HashSet::new(), containers: HashMap::new(), + events: HashMap::new(), current_container: None, + current_method_is_static: None, has_includes: false, try_depth: 0, active_loop_variables: Vec::new(), @@ -589,6 +661,10 @@ impl Analyzer { defined_overloads: HashMap::new(), alias_mutation_frames: Vec::new(), alias_call_sites: HashMap::new(), + action_alias_effects: HashMap::new(), + action_alias_dependencies: HashMap::new(), + action_alias_name_stack: Vec::new(), + action_alias_effect_stack: Vec::new(), } } @@ -608,6 +684,7 @@ impl Analyzer { }; let _ = analyzer.current_scope.define(symbol); } + analyzer.baseline_symbols = analyzer.current_scope.symbols.clone(); analyzer } @@ -629,6 +706,7 @@ impl Analyzer { }; let _ = analyzer.current_scope.define(symbol); } + analyzer.baseline_symbols = analyzer.current_scope.symbols.clone(); analyzer } @@ -638,6 +716,22 @@ impl Analyzer { } pub fn analyze(&mut self, program: &Program) -> Result<(), Vec> { + self.current_scope = Scope { + symbols: self.baseline_symbols.clone(), + parent: None, + id: 0, + }; + self.next_scope_id = 1; + self.errors.clear(); + self.warnings.clear(); + self.action_parameters.clear(); + self.containers.clear(); + self.events.clear(); + self.current_container = None; + self.current_method_is_static = None; + self.try_depth = 0; + self.active_loop_variables.clear(); + // Reset the per-run budget breach so a reused analyzer never carries a // stale one from a previous program (matches the direct assignment of // `has_includes` below). @@ -675,6 +769,10 @@ impl Analyzer { self.defined_overloads.clear(); self.alias_mutation_frames.clear(); self.alias_call_sites.clear(); + self.action_alias_effects.clear(); + self.action_alias_dependencies.clear(); + self.action_alias_name_stack.clear(); + self.action_alias_effect_stack.clear(); // PASS 1: Register all top-level action signatures // This allows forward references between actions at the top level @@ -686,6 +784,8 @@ impl Analyzer { for statement in &program.statements { self.analyze_statement(statement); } + self.validate_container_inheritance_cycles(); + self.warn_incompatible_inherited_property_overrides(); if self.errors.is_empty() { Ok(()) @@ -722,7 +822,7 @@ impl Analyzer { /// and are not swept up. fn analyze_loop_body(&mut self, body: &[Statement]) { let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); let flow_entry = self.flow_entry(); self.push_mutation_frame(); @@ -910,8 +1010,36 @@ impl Analyzer { } } Statement::ActionDefinition { name, .. } => { - // Signature was already registered in Pass 1 - // Now analyze the body in Pass 2 + // Pass 1 only pre-registers top-level actions. Definitions in + // executable/nested blocks become visible as their statement is + // reached, so register the next local signature on demand. The + // visible-overload counter tells us how many definitions in + // this scope have already been encountered; top-level scopes + // already contain all signatures and therefore skip this path. + let registered_here = self + .current_scope + .symbols + .get(name) + .and_then(|symbol| match &symbol.kind { + SymbolKind::Function { signatures } => Some(signatures.len()), + _ => None, + }) + .unwrap_or(0); + let extends_inherited_overload = !self.current_scope.symbols.contains_key(name) + && self + .current_scope + .parent + .as_ref() + .and_then(|parent| parent.resolve(name)) + .is_some_and(|symbol| matches!(&symbol.kind, SymbolKind::Function { .. })); + let already_visible = match self.defined_overloads.get(name).copied() { + Some(OverloadCount::Exact(count)) => count, + Some(OverloadCount::Unknown) | None => 0, + }; + if !extends_inherited_overload && registered_here <= already_visible { + self.register_action_signature(statement); + } + let counter = self .defined_overloads .entry(name.clone()) @@ -929,7 +1057,7 @@ impl Analyzer { let flow_entry = self.flow_entry(); let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope.clone()); + self.enter_child_scope(outer_scope.clone()); for stmt in then_block { self.analyze_statement(stmt); @@ -952,7 +1080,7 @@ impl Analyzer { let mut defined_in_else = Vec::new(); if let Some(else_stmts) = else_block { let outer_scope_for_else = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope_for_else.clone()); + self.enter_child_scope(outer_scope_for_else.clone()); for stmt in else_stmts { self.analyze_statement(stmt); @@ -976,20 +1104,32 @@ impl Analyzer { self.join_flow_branches(&[flow_then.clone(), flow_entry.clone()]); } - // Variables defined in both branches are definitely defined - for (name, symbol) in &defined_in_then { - if (defined_in_else.iter().any(|(n, _)| n == name) || else_block.is_none()) - && let Err(error) = self.current_scope.define(symbol.clone()) - { - self.errors.push(error); - } - } - - for (name, symbol) in &defined_in_else { - if !defined_in_then.iter().any(|(n, _)| n == name) - && let Err(error) = self.current_scope.define(symbol.clone()) - { - self.errors.push(error); + // Only an explicit two-way branch can establish a new binding, + // and the name must be created on both paths. + if else_block.is_some() { + for (name, symbol) in &defined_in_then { + if let Some((_, else_symbol)) = defined_in_else + .iter() + .find(|(else_name, _)| else_name == name) + { + let mut merged = symbol.clone(); + if let ( + SymbolKind::Variable { + mutable: then_mutable, + }, + SymbolKind::Variable { + mutable: else_mutable, + }, + ) = (&symbol.kind, &else_symbol.kind) + { + merged.kind = SymbolKind::Variable { + mutable: *then_mutable && *else_mutable, + }; + } + if let Err(error) = self.current_scope.define(merged) { + self.errors.push(error); + } + } } } } @@ -1003,31 +1143,71 @@ impl Analyzer { let flow_entry = self.flow_entry(); let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope.clone()); self.analyze_statement(then_stmt); let flow_then = self.take_flow_branch(&flow_entry); let then_scope = std::mem::take(&mut self.current_scope); + let defined_in_then: Vec<_> = then_scope + .symbols + .iter() + .filter(|(name, _)| outer_scope.resolve(name).is_none()) + .map(|(name, symbol)| (name.clone(), symbol.clone())) + .collect(); if let Some(parent) = then_scope.parent { self.current_scope = *parent; } + let mut defined_in_else = Vec::new(); if let Some(else_stmt) = else_stmt { - let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + let outer_scope_for_else = std::mem::take(&mut self.current_scope); + self.enter_child_scope(outer_scope_for_else.clone()); self.analyze_statement(else_stmt); let flow_else = self.take_flow_branch(&flow_entry); self.join_flow_branches(&[flow_then, flow_else]); let else_scope = std::mem::take(&mut self.current_scope); + defined_in_else = else_scope + .symbols + .iter() + .filter(|(name, _)| outer_scope_for_else.resolve(name).is_none()) + .map(|(name, symbol)| (name.clone(), symbol.clone())) + .collect(); if let Some(parent) = else_scope.parent { self.current_scope = *parent; } } else { self.join_flow_branches(&[flow_then, flow_entry]); } + + if else_stmt.is_some() { + for (name, symbol) in defined_in_then { + if let Some((_, else_symbol)) = defined_in_else + .iter() + .find(|(else_name, _)| else_name == &name) + { + let mut merged = symbol; + if let ( + SymbolKind::Variable { + mutable: then_mutable, + }, + SymbolKind::Variable { + mutable: else_mutable, + }, + ) = (&merged.kind, &else_symbol.kind) + { + merged.kind = SymbolKind::Variable { + mutable: *then_mutable && *else_mutable, + }; + } + if let Err(error) = self.current_scope.define(merged) { + self.errors.push(error); + } + } + } + } } Statement::ForEachLoop { item_name, @@ -1038,7 +1218,7 @@ impl Analyzer { self.analyze_expression(collection); let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); let item_symbol = Symbol { name: item_name.clone(), @@ -1095,7 +1275,7 @@ impl Analyzer { } let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); // Use custom variable name if provided, otherwise default to "count" let loop_var_name = variable_name.as_deref().unwrap_or("count"); @@ -1164,7 +1344,7 @@ impl Analyzer { self.analyze_expression(condition); let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); let flow_entry = self.flow_entry(); self.push_mutation_frame(); @@ -1194,14 +1374,11 @@ impl Analyzer { // conservative, never wrong. Statement::RepeatWhileLoop { condition, body, .. - } - | Statement::RepeatUntilLoop { - condition, body, .. } => { self.analyze_expression(condition); let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); let flow_entry = self.flow_entry(); self.push_mutation_frame(); @@ -1229,6 +1406,37 @@ impl Analyzer { self.current_scope = *parent; } } + Statement::RepeatUntilLoop { + condition, body, .. + } => { + // Post-test semantics: the first body execution may establish + // bindings consumed by the condition. + let outer_scope = std::mem::take(&mut self.current_scope); + self.enter_child_scope(outer_scope); + + let flow_entry = self.flow_entry(); + self.push_mutation_frame(); + let errors_before = self.errors.len(); + for stmt in body { + self.analyze_statement(stmt); + } + if self.budget_error.is_none() { + let demoted: Vec<_> = self.errors.drain(errors_before..).collect(); + self.warnings.extend(demoted); + } + + self.analyze_expression(condition); + + let flow_body = self.take_flow_branch(&flow_entry); + let mutated = self.pop_mutation_frame(); + self.join_flow_branches(&[flow_body, flow_entry]); + self.degrade_mutated_aliases(&mutated); + + let loop_scope = std::mem::take(&mut self.current_scope); + if let Some(parent) = loop_scope.parent { + self.current_scope = *parent; + } + } Statement::ForeverLoop { body, .. } => { self.analyze_loop_body(body); } @@ -1305,7 +1513,7 @@ impl Analyzer { column: _column, } => { let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); // The inner statement's own analysis defines any variables it // introduces (file handles, read content, database handles, @@ -1338,7 +1546,13 @@ impl Analyzer { .. } => { let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); + let try_scope_entry = self.current_scope.clone(); + let try_entry_visible_names = self + .snapshot_symbol_types() + .into_iter() + .flat_map(|layer| layer.into_keys()) + .collect::>(); // Undefined names inside a try body raise catchable runtime // errors, so they are downgraded to warnings while in here. @@ -1362,16 +1576,16 @@ impl Analyzer { let mut flow_paths: Vec = vec![flow_try]; // Runtime keeps this try child environment alive through the - // selected handler/otherwise clause and finally. Snapshot the - // post-body structure so every statically possible clause is - // analyzed independently, then union its ordinary bindings - // back into the shared try scope for finally. + // selected handler/otherwise clause and finally. Analyze every + // clause independently from the structural entry: an error can + // transfer before any body-local declaration. A name is safe in + // finally only when it exists at every possible endpoint. let clause_entry_scope = self.current_scope.clone(); - let mut joined_scope_symbols = clause_entry_scope.symbols.clone(); + let mut endpoint_scope_symbols = vec![clause_entry_scope.symbols.clone()]; // Analyze each when clause for when_clause in when_clauses { - self.current_scope = clause_entry_scope.clone(); + self.current_scope = try_scope_entry.clone(); self.restore_flow(&flow_handler_entry); self.push_scope(); @@ -1405,28 +1619,20 @@ impl Analyzer { if when_clause.error_name != "error_message" { excluded_aliases.push("error_message".to_string()); } - self.pop_scope_promoting_except(&excluded_aliases); + let _ = self.pop_scope_promoting_except(&excluded_aliases); flow_paths.push(self.take_flow_branch(&flow_handler_entry)); - for (name, symbol) in &self.current_scope.symbols { - joined_scope_symbols - .entry(name.clone()) - .or_insert_with(|| symbol.clone()); - } + endpoint_scope_symbols.push(self.current_scope.symbols.clone()); } if let Some(otherwise_stmts) = otherwise_block { - self.current_scope = clause_entry_scope.clone(); + self.current_scope = try_scope_entry.clone(); self.restore_flow(&flow_handler_entry); for stmt in otherwise_stmts { self.analyze_statement(stmt); } flow_paths.push(self.take_flow_branch(&flow_handler_entry)); - for (name, symbol) in &self.current_scope.symbols { - joined_scope_symbols - .entry(name.clone()) - .or_insert_with(|| symbol.clone()); - } + endpoint_scope_symbols.push(self.current_scope.symbols.clone()); } else if !when_clauses.iter().any(|when_clause| { matches!( &when_clause.error_type, @@ -1436,12 +1642,24 @@ impl Analyzer { // Without a catch-all or otherwise block, a non-matching // error reaches finally directly from the handler entry. flow_paths.push(flow_handler_entry.clone()); + endpoint_scope_symbols.push(try_scope_entry.symbols.clone()); } self.current_scope = clause_entry_scope; - for symbol in joined_scope_symbols.into_values() { - self.define_or_replace_symbol(symbol); + for symbols in &endpoint_scope_symbols { + for (name, symbol) in symbols { + self.current_scope + .symbols + .entry(name.clone()) + .or_insert_with(|| symbol.clone()); + } } + self.current_scope.symbols.retain(|name, _| { + try_entry_visible_names.contains(name) + || endpoint_scope_symbols + .iter() + .all(|symbols| symbols.contains_key(name)) + }); // Finally can be reached from success, any selected error // clause, or an unmatched error. Join those flow endpoints @@ -1788,11 +2006,11 @@ impl Analyzer { implements, properties, methods, + events, static_properties, static_methods, line, column, - .. } => { // Create container info let mut container_info = ContainerInfo { @@ -1803,6 +2021,7 @@ impl Analyzer { methods: HashMap::new(), static_properties: HashMap::new(), static_methods: HashMap::new(), + events: HashMap::new(), line: *line, column: *column, }; @@ -1831,8 +2050,34 @@ impl Analyzer { .insert(prop.name.clone(), prop_info); } + for event in events { + container_info.events.insert( + event.name.clone(), + EventInfo { + name: event.name.clone(), + parameters: event.parameters.clone(), + line: event.line, + column: event.column, + }, + ); + } + // Register the container early so properties are available during method analysis self.register_container(container_info.clone()); + // The container value is also a lexical binding at runtime. + // Make that binding visible while method bodies are analyzed so + // a static method can call `Counter.other_method()` on its own + // container. Keep the historical ignored-duplicate behavior; + // this is the same definition that previously happened only + // after all method bodies. + let container_symbol = Symbol { + name: name.clone(), + kind: SymbolKind::Variable { mutable: false }, + symbol_type: Some(Type::Container(name.clone())), + line: *line, + column: *column, + }; + let _ = self.current_scope.define(container_symbol); // Process static properties for prop in static_properties { @@ -1858,6 +2103,12 @@ impl Analyzer { .insert(prop.name.clone(), prop_info); } + // Make property metadata available while method bodies are + // analyzed. Methods are added to the local copy below and the + // completed definition replaces this provisional entry at the + // end of the arm. + self.register_container(container_info.clone()); + // Process instance methods for method in methods { if let Statement::ActionDefinition { @@ -1899,7 +2150,9 @@ impl Analyzer { // Set current container context let previous_container = self.current_container.clone(); + let previous_method_is_static = self.current_method_is_static; self.current_container = Some(name.clone()); + self.current_method_is_static = Some(false); // Properties will be resolved through container context // Don't add them as variables to avoid conflicts with assignments @@ -1915,7 +2168,7 @@ impl Analyzer { line: param.line, column: param.column, }; - let _ = self.current_scope.define(symbol); + self.current_scope.define_or_replace(symbol); } // Same isolation as `analyze_action_body`: a method @@ -1930,6 +2183,7 @@ impl Analyzer { // Restore previous container context self.current_container = previous_container; + self.current_method_is_static = previous_method_is_static; self.pop_scope(); } } @@ -1968,24 +2222,15 @@ impl Analyzer { // Set current container context let previous_container = self.current_container.clone(); + let previous_method_is_static = self.current_method_is_static; self.current_container = Some(name.clone()); + self.current_method_is_static = Some(true); - // Add static properties as accessible variables (not instance properties) - for prop in static_properties { - let prop_type = prop - .property_type - .as_ref() - .cloned() - .unwrap_or(Type::Unknown); - let symbol = Symbol { - name: prop.name.clone(), - kind: SymbolKind::Variable { mutable: true }, - symbol_type: Some(prop_type), - line: prop.line, - column: prop.column, - }; - let _ = self.current_scope.define(symbol); - } + // Static properties are resolved through the current + // container context, just like instance properties. + // Keeping them out of the lexical symbol table lets an + // explicit method-local binder shadow a same-named + // property while parameters still outrank both. // Add method parameters for param in parameters { @@ -1998,7 +2243,7 @@ impl Analyzer { line: param.line, column: param.column, }; - let _ = self.current_scope.define(symbol); + self.current_scope.define_or_replace(symbol); } // Same isolation as `analyze_action_body`: a method @@ -2013,32 +2258,30 @@ impl Analyzer { // Restore previous container context self.current_container = previous_container; + self.current_method_is_static = previous_method_is_static; self.pop_scope(); } } // Re-register the container with all methods now that they've been processed self.register_container(container_info.clone()); - - // Also register as a type symbol - let container_symbol = Symbol { - name: name.clone(), - kind: SymbolKind::Variable { mutable: false }, - symbol_type: Some(Type::Container(name.clone())), - line: *line, - column: *column, - }; - let _ = self.current_scope.define(container_symbol); } Statement::ContainerInstantiation { container_type, instance_name, - arguments: _, - property_initializers: _, + arguments, + property_initializers, line, column, } => { + for argument in arguments { + self.analyze_expression(&argument.value); + } + for initializer in property_initializers { + self.analyze_expression(&initializer.value); + } + // Register the instance as a variable with ContainerInstance type let instance_symbol = Symbol { name: instance_name.clone(), @@ -2098,6 +2341,11 @@ impl Analyzer { } } + Statement::PushStatement { list, value, .. } => { + self.analyze_expression(list); + self.analyze_expression(value); + } + Statement::MapCreation { name, entries, @@ -2122,6 +2370,50 @@ impl Analyzer { } } + Statement::CreateDateStatement { + name, + value, + line, + column, + } => { + if let Some(value) = value { + self.analyze_expression(value); + } + let symbol = Symbol { + name: name.clone(), + kind: SymbolKind::Variable { mutable: true }, + // The explicit form is a pass-through binding at runtime; + // only the default form is guaranteed to create a Date. + symbol_type: value.is_none().then_some(Type::Date), + line: *line, + column: *column, + }; + if let Err(error) = self.current_scope.define(symbol) { + self.errors.push(error); + } + } + + Statement::CreateTimeStatement { + name, + value, + line, + column, + } => { + if let Some(value) = value { + self.analyze_expression(value); + } + let symbol = Symbol { + name: name.clone(), + kind: SymbolKind::Variable { mutable: true }, + symbol_type: value.is_none().then_some(Type::Time), + line: *line, + column: *column, + }; + if let Err(error) = self.current_scope.define(symbol) { + self.errors.push(error); + } + } + Statement::AddToListStatement { value, list_name, @@ -2129,7 +2421,7 @@ impl Analyzer { column, } => { self.analyze_expression(value); - if self.get_symbol(list_name).is_none() { + if !self.name_is_defined_for_write(list_name) { self.errors.push(SemanticError::new( format!("Variable '{list_name}' is not defined"), *line, @@ -2145,7 +2437,7 @@ impl Analyzer { column, } => { self.analyze_expression(value); - if self.get_symbol(list_name).is_none() { + if !self.name_is_defined_for_write(list_name) { self.errors.push(SemanticError::new( format!("Variable '{list_name}' is not defined"), *line, @@ -2158,7 +2450,7 @@ impl Analyzer { list_name, line, column, - } if self.get_symbol(list_name).is_none() => { + } if !self.name_is_defined_for_write(list_name) => { self.errors.push(SemanticError::new( format!("Variable '{list_name}' is not defined"), *line, @@ -2167,6 +2459,90 @@ impl Analyzer { } Statement::ClearListStatement { .. } => {} + Statement::EventDefinition { + name, + parameters, + line, + column, + } => { + for parameter in parameters { + if let Some(default_value) = ¶meter.default_value { + self.analyze_expression(default_value); + } + } + self.events.insert( + name.clone(), + EventInfo { + name: name.clone(), + parameters: parameters.clone(), + line: *line, + column: *column, + }, + ); + } + + Statement::EventTrigger { arguments, .. } => { + for argument in arguments { + self.analyze_expression(&argument.value); + } + } + + Statement::EventHandler { + event_name, + event_source, + handler_body, + .. + } => { + self.analyze_expression(event_source); + let event_info = if let Expression::Variable(source_name, _, _) = event_source { + self.current_scope + .resolve(source_name) + .and_then(|symbol| symbol.symbol_type.as_ref()) + .and_then(|symbol_type| match symbol_type { + Type::ContainerInstance(container_name) => self + .containers + .get(container_name) + .and_then(|container| container.events.get(event_name)), + _ => None, + }) + .cloned() + } else { + None + }; + + if let Some(event_info) = &event_info { + self.events + .entry(event_name.clone()) + .or_insert_with(|| event_info.clone()); + } + + self.push_scope(); + if let Some(event_info) = event_info { + for parameter in event_info.parameters { + let symbol = Symbol { + name: parameter.name, + kind: SymbolKind::Variable { mutable: false }, + symbol_type: parameter.param_type.or(Some(Type::Unknown)), + line: parameter.line, + column: parameter.column, + }; + if let Err(error) = self.current_scope.define(symbol) { + self.errors.push(error); + } + } + } + for statement in handler_body { + self.analyze_statement(statement); + } + self.pop_scope(); + } + + Statement::ParentMethodCall { arguments, .. } => { + for argument in arguments { + self.analyze_expression(&argument.value); + } + } + Statement::PatternDefinition { name, pattern, @@ -2233,12 +2609,15 @@ impl Analyzer { Statement::WaitForRequestStatement { server, request_name, - timeout: _, + timeout, line, column, } => { // Analyze the server expression self.analyze_expression(server); + if let Some(timeout) = timeout { + self.analyze_expression(timeout); + } // Define the request variable. Waiting for another request may // rebind an existing name (e.g. in a loop), so the binding is @@ -2264,7 +2643,10 @@ impl Analyzer { ("client_ip", Type::Text), ("body", Type::Text), ("body_bytes", Type::Binary), - ("headers", Type::Custom("Headers".to_string())), + ( + "headers", + Type::Map(Box::new(Type::Text), Box::new(Type::Text)), + ), ]; for (prop_name, prop_type) in request_properties.iter() { @@ -2340,7 +2722,7 @@ impl Analyzer { // scoping so the body can reference it without a false // "undefined variable" error. let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); let binding_symbol = Symbol { name: binding.clone(), @@ -2553,6 +2935,68 @@ impl Analyzer { } } + Statement::DescribeBlock { + setup, + teardown, + tests, + .. + } => { + // The runtime creates one describe-level environment. Setup + // bindings live there and are visible to every test and to + // teardown, but disappear when the describe block finishes. + self.push_scope(); + if let Some(setup) = setup { + for statement in setup { + self.analyze_statement(statement); + } + } + for test in tests { + self.analyze_statement(test); + } + if let Some(teardown) = teardown { + for statement in teardown { + self.analyze_statement(statement); + } + } + self.pop_scope(); + } + + Statement::TestBlock { body, .. } => { + // Tests run in isolated children of the describe environment: + // declarations and refinements from one test cannot affect a + // sibling or teardown. + let symbol_types = self.snapshot_symbol_types(); + let flow = self.flow_entry(); + self.push_scope(); + for statement in body { + self.analyze_statement(statement); + } + self.pop_scope(); + self.restore_symbol_types(symbol_types); + self.restore_flow(&flow); + } + + Statement::ExpectStatement { + subject, assertion, .. + } => { + use crate::parser::ast::Assertion; + + self.analyze_expression(subject); + match assertion { + Assertion::Equal(expected) + | Assertion::Be(expected) + | Assertion::GreaterThan(expected) + | Assertion::LessThan(expected) + | Assertion::Contain(expected) + | Assertion::HaveLength(expected) => self.analyze_expression(expected), + Assertion::BeYes + | Assertion::BeNo + | Assertion::Exist + | Assertion::BeEmpty + | Assertion::BeOfType(_) => {} + } + } + _ => {} } } @@ -2675,12 +3119,15 @@ impl Analyzer { fn analyze_action_body(&mut self, statement: &Statement) { if let Statement::ActionDefinition { - parameters, body, .. + name, + parameters, + body, + .. } = statement { // Create new scope for action body let outer_scope = std::mem::take(&mut self.current_scope); - self.current_scope = Scope::with_parent(outer_scope); + self.enter_child_scope(outer_scope); // Collect parameter names to remove them later let mut param_names_to_remove = Vec::new(); @@ -2701,9 +3148,7 @@ impl Analyzer { column: param.column, }; - if let Err(error) = self.current_scope.define(param_symbol) { - self.errors.push(error); - } + self.current_scope.define_or_replace(param_symbol); } // Analyze body statements. The body has not *executed* at the @@ -2714,9 +3159,18 @@ impl Analyzer { // inflate the outer visible-overload counters. let alias_entry = self.action_aliases.clone(); let overloads_entry = self.defined_overloads.clone(); + self.action_alias_name_stack.push(name.clone()); + self.action_alias_effect_stack.push(HashSet::new()); for stmt in body { self.analyze_statement(stmt); } + let action_effects = self.action_alias_effect_stack.pop().unwrap_or_default(); + let popped_name = self.action_alias_name_stack.pop(); + debug_assert_eq!(popped_name.as_deref(), Some(name.as_str())); + self.action_alias_effects + .entry(name.clone()) + .or_default() + .extend(action_effects); self.action_aliases = alias_entry; self.defined_overloads = overloads_entry; @@ -2737,6 +3191,13 @@ impl Analyzer { self.current_scope.resolve(name) } + /// Resolve only a symbol owned by the active lexical scope. Export + /// statements use this because the runtime deliberately refuses to export + /// definitions inherited from a parent environment. + pub fn get_local_symbol(&self, name: &str) -> Option<&Symbol> { + self.current_scope.symbols.get(name) + } + pub fn get_symbol_mut(&mut self, name: &str) -> Option<&mut Symbol> { // Walk parent scopes so type refinements (e.g. widening away from // Nothing on reassignment inside a loop) update the real binding and @@ -2746,6 +3207,31 @@ impl Analyzer { self.current_scope.resolve_mut(name) } + pub(crate) fn get_symbol_binding_key(&self, name: &str) -> Option { + self.current_scope.resolve_binding_key(name) + } + + pub(crate) fn get_symbol_by_binding_key_mut( + &mut self, + key: &SymbolBindingKey, + ) -> Option<&mut Symbol> { + self.current_scope.resolve_binding_key_mut(key) + } + + pub(crate) fn get_symbol_by_binding_key(&self, key: &SymbolBindingKey) -> Option<&Symbol> { + self.current_scope.resolve_binding_key_symbol(key) + } + + pub(crate) fn binding_key_is_live(&self, key: &SymbolBindingKey) -> bool { + self.current_scope.resolve_binding_key_symbol(key).is_some() + } + + pub(crate) fn live_binding_types(&self) -> Vec<(SymbolBindingKey, Option)> { + let mut bindings = Vec::new(); + self.current_scope.collect_binding_types(&mut bindings); + bindings + } + pub fn define_symbol(&mut self, symbol: Symbol) -> Result<(), SemanticError> { self.current_scope.define(symbol) } @@ -2867,24 +3353,130 @@ impl Analyzer { self.containers.insert(container.name.clone(), container); } + pub fn get_event(&self, name: &str) -> Option<&EventInfo> { + self.events.get(name) + } + fn is_container_property(&self, container_name: &str, property_name: &str) -> bool { - if let Some(container_info) = self.containers.get(container_name) { - // Check direct instance properties - if container_info.properties.contains_key(property_name) { + let mut current = Some(container_name); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name) { + return false; + } + let Some(container_info) = self.containers.get(name) else { + return false; + }; + let found = match self.current_method_is_static { + Some(true) => container_info.static_properties.contains_key(property_name), + Some(false) => container_info.properties.contains_key(property_name), + None => { + container_info.properties.contains_key(property_name) + || container_info.static_properties.contains_key(property_name) + } + }; + if found { return true; } + current = container_info.extends.as_deref(); + } + false + } - // Check direct static properties - if container_info.static_properties.contains_key(property_name) { - return true; + fn container_instance_property_type( + &self, + container_name: &str, + property_name: &str, + ) -> Option<&Type> { + let mut current = Some(container_name); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name) { + // Inheritance validation reports the cycle separately. Do not + // let this compatibility warning walk recurse forever. + return None; + } + let container = self.containers.get(name)?; + if let Some(property) = container.properties.get(property_name) { + return Some(&property.property_type); } + current = container.extends.as_deref(); + } + None + } - // Check inherited properties (both instance and static) - if let Some(parent_name) = &container_info.extends { - return self.is_container_property(parent_name, property_name); + /// Report the inherited-slot hazard without rejecting currently supported + /// programs. Instance properties are flattened into one mutable runtime + /// slot, so parent methods and child code can otherwise assume conflicting + /// concrete contracts. Making overrides invariant is a language-level + /// compatibility change and must follow the governance deprecation path. + fn warn_incompatible_inherited_property_overrides(&mut self) { + let mut warnings = Vec::new(); + for container in self.containers.values() { + let Some(parent) = container.extends.as_deref() else { + continue; + }; + for property in container.properties.values() { + if property.property_type == Type::Unknown { + continue; + } + let Some(parent_type) = self + .container_instance_property_type(parent, &property.name) + .cloned() + else { + continue; + }; + if parent_type == Type::Unknown { + continue; + } + if parent_type != property.property_type { + warnings.push(SemanticError::new( + format!( + "Property '{}' in container '{}' declares type {} but inherited \ + property from '{}' declares {}; mutable inherited property \ + overrides should keep the same contract", + property.name, + container.name, + property.property_type, + parent, + parent_type + ), + property.line, + property.column, + )); + } } } - false + self.warnings.extend(warnings); + } + + fn validate_container_inheritance_cycles(&mut self) { + let mut errors = Vec::new(); + for container in self.containers.values() { + let mut current = Some(container.name.as_str()); + let mut visited = HashSet::new(); + let mut path = Vec::new(); + while let Some(name) = current { + if !visited.insert(name) { + path.push(name); + errors.push(SemanticError::new( + format!( + "Cyclic container inheritance detected: {}", + path.join(" -> ") + ), + container.line, + container.column, + )); + break; + } + path.push(name); + current = self + .containers + .get(name) + .and_then(|info| info.extends.as_deref()); + } + } + self.errors.extend(errors); } pub fn get_container(&self, name: &str) -> Option<&ContainerInfo> { @@ -2902,9 +3494,14 @@ impl Analyzer { &self.containers } + fn enter_child_scope(&mut self, parent: Scope) { + let scope_id = self.next_scope_id; + self.next_scope_id = self.next_scope_id.saturating_add(1); + self.current_scope = Scope::with_parent(parent, scope_id); + } + pub fn push_scope(&mut self) { - let new_scope = Scope::with_parent(self.current_scope.clone()); - self.current_scope = new_scope; + self.enter_child_scope(self.current_scope.clone()); } pub fn pop_scope(&mut self) { @@ -2915,15 +3512,52 @@ impl Analyzer { /// Pop the current scope while promoting every binding except the listed /// temporary aliases into its parent. - pub fn pop_scope_promoting_except(&mut self, excluded: &[String]) { + pub(crate) fn pop_scope_promoting_except( + &mut self, + excluded: &[String], + ) -> Vec<(SymbolBindingKey, SymbolBindingKey)> { + let mut promoted_bindings = Vec::new(); + let child_scope_id = self.current_scope.id; if let Some(mut parent) = self.current_scope.parent.take() { + let parent_scope_id = parent.id; for (name, symbol) in std::mem::take(&mut self.current_scope.symbols) { if !excluded.iter().any(|excluded_name| excluded_name == &name) { + promoted_bindings.push(( + SymbolBindingKey { + scope_id: child_scope_id, + name: name.clone(), + }, + SymbolBindingKey { + scope_id: parent_scope_id, + name: name.clone(), + }, + )); parent.define_or_replace(symbol); } } self.current_scope = *parent; } + for (old_binding, new_binding) in &promoted_bindings { + if let Some(state) = self.action_aliases.remove(old_binding) { + self.action_aliases.insert(new_binding.clone(), state); + } + for frame in &mut self.alias_mutation_frames { + if frame.remove(old_binding) { + frame.insert(new_binding.clone()); + } + } + for frame in &mut self.action_alias_effect_stack { + if frame.remove(old_binding) { + frame.insert(new_binding.clone()); + } + } + for effects in self.action_alias_effects.values_mut() { + if effects.remove(old_binding) { + effects.insert(new_binding.clone()); + } + } + } + promoted_bindings } /// Validates a call against every registered signature of `name`: @@ -3256,6 +3890,13 @@ impl Analyzer { (inner, Type::Async(async_type)) => self.is_type_compatible(async_type, inner), + // Preserve compatibility with historical named annotations while + // keeping real temporal runtime values distinct from same-named + // user containers. + (Type::Custom(name), Type::Date) if name.eq_ignore_ascii_case("date") => true, + (Type::Custom(name), Type::Time) if name.eq_ignore_ascii_case("time") => true, + (Type::Custom(name), Type::DateTime) if name.eq_ignore_ascii_case("datetime") => true, + (Type::List(expected_inner), Type::List(actual_inner)) => { self.is_type_compatible(actual_inner, expected_inner) } @@ -3334,6 +3975,9 @@ impl Analyzer { /// another alias) makes `name` callable with that action's overload set; /// any other value clears a previous alias. fn update_action_alias(&mut self, name: &str, value: &Expression) { + let Some(target_binding) = self.current_scope.resolve_binding_key(name) else { + return; + }; let target = if let Expression::Variable(source, _, _) = value { match self.current_scope.resolve(source) { Some(symbol) if matches!(symbol.kind, SymbolKind::Function { .. }) => { @@ -3356,19 +4000,44 @@ impl Analyzer { } // Aliasing an alias copies its state — including the original // snapshot, not a re-read of the current definition count. - _ => self.action_aliases.get(source).cloned(), + Some(symbol) + if symbol.line == 0 + && symbol.column == 0 + && crate::builtins::is_implemented_builtin_function(source) => + { + Some(AliasState::Builtin { + name: source.clone(), + }) + } + Some(_) => self + .current_scope + .resolve_binding_key(source) + .and_then(|binding| self.action_aliases.get(&binding).cloned()), + None if crate::builtins::is_implemented_builtin_function(source) => { + Some(AliasState::Builtin { + name: source.clone(), + }) + } + None => None, } } else { None }; match target { Some(state) => { - self.action_aliases.insert(name.to_string(), state); - self.record_alias_mutation(name); + self.action_aliases.insert(target_binding.clone(), state); + self.record_alias_mutation(target_binding); } None => { - if self.action_aliases.remove(name).is_some() { - self.record_alias_mutation(name); + let removed_existing_alias = self.action_aliases.remove(&target_binding).is_some(); + // A closure can be defined before this captured binding later + // acquires an action value. Record every non-action write made + // inside an action body so a subsequent call invalidates that + // later alias instead of trusting its stale snapshot. Local + // non-alias writes remain harmless: call-site application only + // degrades binding keys that currently hold an alias. + if removed_existing_alias || !self.action_alias_effect_stack.is_empty() { + self.record_alias_mutation(target_binding); } } } @@ -3376,9 +4045,12 @@ impl Analyzer { /// Records that `name`'s alias state was written while a loop/`try` /// body frame is active (no-op at unframed depth). - fn record_alias_mutation(&mut self, name: &str) { + fn record_alias_mutation(&mut self, binding: SymbolBindingKey) { if let Some(top) = self.alias_mutation_frames.last_mut() { - top.insert(name.to_string()); + top.insert(binding.clone()); + } + if let Some(top) = self.action_alias_effect_stack.last_mut() { + top.insert(binding); } } @@ -3388,7 +4060,7 @@ impl Analyzer { /// Pops the current mutation frame, merging it into the parent frame — /// an inner body's mutation is also a mutation within the outer body. - fn pop_mutation_frame(&mut self) -> std::collections::HashSet { + fn pop_mutation_frame(&mut self) -> std::collections::HashSet { let frame = self.alias_mutation_frames.pop().unwrap_or_default(); if let Some(parent) = self.alias_mutation_frames.last_mut() { parent.extend(frame.iter().cloned()); @@ -3399,10 +4071,10 @@ impl Analyzer { /// Degrades every mutated alias to [`AliasState::Dynamic`]: an abrupt /// exit may expose an intermediate binding endpoint states cannot see, /// so calls through these names defer to runtime dispatch. - fn degrade_mutated_aliases(&mut self, mutated: &std::collections::HashSet) { - for name in mutated { + fn degrade_mutated_aliases(&mut self, mutated: &std::collections::HashSet) { + for binding in mutated { self.action_aliases - .insert(name.clone(), AliasState::Dynamic); + .insert(binding.clone(), AliasState::Dynamic); } } @@ -3448,7 +4120,8 @@ impl Analyzer { /// definition executed on one path but not another — degrades to /// [`OverloadCount::Unknown`], which makes later alias bindings Dynamic. fn join_flow_branches(&mut self, paths: &[FlowState]) { - let mut keys: std::collections::HashSet<&String> = std::collections::HashSet::new(); + let mut keys: std::collections::HashSet<&SymbolBindingKey> = + std::collections::HashSet::new(); for path in paths { keys.extend(path.aliases.keys()); } @@ -3461,7 +4134,7 @@ impl Analyzer { Some(AliasState::Dynamic) }; if let Some(state) = state { - joined.insert(key.clone(), state); + joined.insert((*key).clone(), state); } } self.action_aliases = joined; @@ -3504,8 +4177,10 @@ impl Analyzer { &self, name: &str, ) -> Option<(AliasState, Option>)> { - match self.action_aliases.get(name)? { + let binding = self.current_scope.resolve_binding_key(name)?; + match self.action_aliases.get(&binding)? { AliasState::Dynamic => Some((AliasState::Dynamic, None)), + state @ AliasState::Builtin { .. } => Some((state.clone(), None)), state @ AliasState::Bound { action, visible_signatures, @@ -3532,6 +4207,48 @@ impl Analyzer { } } + fn collect_action_alias_effects( + &self, + action: &str, + visited: &mut HashSet, + out: &mut HashSet, + ) { + if !visited.insert(action.to_string()) { + return; + } + if let Some(effects) = self.action_alias_effects.get(action) { + out.extend(effects.iter().cloned()); + } + if let Some(dependencies) = self.action_alias_dependencies.get(action) { + for dependency in dependencies { + self.collect_action_alias_effects(dependency, visited, out); + } + } + } + + /// Apply the alias-binding side effects of calling a user action. The + /// selected runtime value of every captured alias written by the closure is + /// path-dependent, so subsequent static calls through it must defer to + /// runtime dispatch. + fn apply_action_alias_effects(&mut self, action: &str) { + if let Some(caller) = self.action_alias_name_stack.last().cloned() { + self.action_alias_dependencies + .entry(caller) + .or_default() + .insert(action.to_string()); + } + + let mut affected = HashSet::new(); + self.collect_action_alias_effects(action, &mut HashSet::new(), &mut affected); + for binding in affected { + if self.action_aliases.contains_key(&binding) { + self.action_aliases + .insert(binding.clone(), AliasState::Dynamic); + self.record_alias_mutation(binding); + } + } + } + /// What the alias call at (`name`, `line`, `column`) resolved to during /// semantic analysis, for the type checker's per-statement view. pub(crate) fn alias_call_resolution( @@ -3926,6 +4643,44 @@ impl Analyzer { return; } + // Reading a zero-argument user action as a bare value invokes + // it at runtime. Record the same per-site alias resolution as + // the explicit call forms so the type checker can apply exact + // summaries without confusing a same-named inner binding. + if let Some((state, signatures)) = self.alias_call_target(name) { + let auto_calls = signatures.as_ref().is_some_and(|signatures| { + signatures + .iter() + .any(|signature| signature.parameters.is_empty()) + }); + if auto_calls { + let called_action = match &state { + AliasState::Bound { action, .. } => Some(action.clone()), + _ => None, + }; + self.alias_call_sites + .insert((name.clone(), *line, *column), state); + if let Some(action) = called_action { + self.apply_action_alias_effects(&action); + } + } + } else { + let auto_called_action = self.current_scope.resolve(name).and_then(|symbol| { + if let SymbolKind::Function { signatures } = &symbol.kind + && signatures + .iter() + .any(|signature| signature.parameters.is_empty()) + { + Some(name.clone()) + } else { + None + } + }); + if let Some(action) = auto_called_action { + self.apply_action_alias_effects(&action); + } + } + if self.current_scope.resolve(name).is_none() { // Check if it's a container property (including inherited) let is_container_property = @@ -3999,7 +4754,7 @@ impl Analyzer { self.analyze_expression(&arg.value); } - if Self::is_builtin_function(name) { + if crate::builtins::is_implemented_builtin_function(name) { // Builtins keep the historical arity-only // check; their full validation lives in the // stdlib layer. @@ -4029,6 +4784,7 @@ impl Analyzer { *line, *column, ); + self.apply_action_alias_effects(name); } } None => { @@ -4056,6 +4812,10 @@ impl Analyzer { // overload set. let alias_target = self.alias_call_target(name); if let Some((state, signatures)) = alias_target { + let called_action = match &state { + AliasState::Bound { action, .. } => Some(action.clone()), + _ => None, + }; self.alias_call_sites .insert((name.clone(), *line, *column), state); for arg in arguments { @@ -4073,6 +4833,9 @@ impl Analyzer { *column, ); } + if let Some(action) = called_action { + self.apply_action_alias_effects(&action); + } } else if is_injected_builtin || self.action_parameters.contains(name) { @@ -4207,8 +4970,13 @@ impl Analyzer { self.analyze_expression(&arg.value); } - // Skip validation for builtin functions - they have their own validation - if Self::is_builtin_function(name) { + // Implemented natives validate in the type checker. Reserved + // future names also defer there only when no real user symbol + // shadows the reservation. + if crate::builtins::is_implemented_builtin_function(name) + || (Self::is_builtin_function(name) + && self.current_scope.resolve(name).is_none()) + { return; } @@ -4235,11 +5003,16 @@ impl Analyzer { *line, *column, ); + self.apply_action_alias_effects(name); } None => { // A stored action reference (`store h as f`) is // callable with the aliased action's snapshot. if let Some((state, signatures)) = self.alias_call_target(name) { + let called_action = match &state { + AliasState::Bound { action, .. } => Some(action.clone()), + _ => None, + }; self.alias_call_sites .insert((name.clone(), *line, *column), state); if let Some(signatures) = signatures { @@ -4252,6 +5025,9 @@ impl Analyzer { *column, ); } + if let Some(action) = called_action { + self.apply_action_alias_effects(&action); + } } else { // Symbol exists but is not a function/action self.errors.push(SemanticError::new( @@ -4274,6 +5050,11 @@ impl Analyzer { )); } } + Expression::Literal(Literal::List(elements), _, _) => { + for element in elements { + self.analyze_expression(element); + } + } Expression::Literal(_, _, _) => {} // Container-related expressions Expression::StaticMemberAccess { @@ -4689,6 +5470,7 @@ mod tests { static_properties, methods: HashMap::new(), static_methods: HashMap::new(), + events: HashMap::new(), extends: None, implements: Vec::new(), line: 1, @@ -4730,6 +5512,7 @@ mod tests { static_properties: base_static_properties, methods: HashMap::new(), static_methods: HashMap::new(), + events: HashMap::new(), extends: None, implements: Vec::new(), line: 1, @@ -4757,6 +5540,7 @@ mod tests { static_properties: derived_static_properties, methods: HashMap::new(), static_methods: HashMap::new(), + events: HashMap::new(), extends: Some("BaseContainer".to_string()), implements: Vec::new(), line: 1, diff --git a/src/analyzer/static_analyzer.rs b/src/analyzer/static_analyzer.rs index 747faf23..c6d02deb 100644 --- a/src/analyzer/static_analyzer.rs +++ b/src/analyzer/static_analyzer.rs @@ -483,6 +483,8 @@ impl StaticAnalyzer for Analyzer { "This name is not defined at this point; if it is still undefined at runtime, the resulting error can be handled by the surrounding try/catch block." } else if warning.message.starts_with("Undefined signal handler") { "No action with this name is defined; define the handler action so it can run when the signal is received." + } else if warning.message.starts_with("Property '") { + "Inherited instance properties share one mutable runtime slot. Keep the parent and child annotations identical; invariant enforcement requires the compatibility deprecation process." } else { "This action is not defined in this file; it may be provided by an included module at runtime, otherwise this is likely a typo." }; diff --git a/src/builtins.rs b/src/builtins.rs index 5fe36950..95deae62 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -4,10 +4,10 @@ use std::collections::HashSet; use std::sync::OnceLock; -/// Complete list of all builtin function names in WFL +/// Complete list of all builtin function names recognized by WFL. /// This list includes: /// 1. Functions actually implemented in stdlib modules -/// 2. Functions recognized by TypeChecker (for future compatibility) +/// 2. Names reserved by the parser/analyzer for clear diagnostics and future compatibility /// 3. Special test functions used in test programs const BUILTIN_FUNCTIONS: &[&str] = &[ // Core functions (implemented in stdlib/core.rs) @@ -166,6 +166,8 @@ const BUILTIN_FUNCTIONS: &[&str] = &[ "timestamp", "datetime_from_timestamp", "time_diff", + "isleapyear", + "is_leap_year", // Time functions recognized by TypeChecker but not yet implemented "sleep", "time", @@ -181,11 +183,9 @@ const BUILTIN_FUNCTIONS: &[&str] = &[ "add_minutes", "addseconds", "add_seconds", - "formatdate", // Duplicate of format_date - "formattime", // Duplicate of format_time - "parsedate", // Duplicate of parse_date - "isleapyear", - "is_leap_year", + "formatdate", // Duplicate of format_date + "formattime", // Duplicate of format_time + "parsedate", // Duplicate of parse_date "daysbetween", // Duplicate of days_between "monthsbetween", "months_between", @@ -226,12 +226,12 @@ const BUILTIN_FUNCTIONS: &[&str] = &[ "copy_file", "move_file", "remove_file", + "delete_file", "remove_dir", // File system functions recognized by TypeChecker but not yet implemented "read_file", "write_file", "file_exists", - "delete_file", "create_directory", "list_directory", "is_directory", @@ -240,8 +240,188 @@ const BUILTIN_FUNCTIONS: &[&str] = &[ "nested_function", ]; +/// Native functions actually installed by [`crate::stdlib::register_stdlib`]. +/// +/// Keep this inventory distinct from [`BUILTIN_FUNCTIONS`]: the latter also +/// reserves future names so the parser can recognize their call syntax. Static +/// checking must only assign callable contracts to names in this runtime list. +const IMPLEMENTED_BUILTIN_FUNCTIONS: &[&str] = &[ + // Core + "print", + "typeof", + "type_of", + "isnothing", + "is_nothing", + // Crypto + "wflhash256", + "wflhash512", + "wflhash256_with_salt", + "wflmac256", + "sha256", + "hmac_sha256", + "generate_csrf_token", + "pbkdf2_hmac_sha256", + "constant_time_equals", + "secure_random_bytes", + "hash_password", + "verify_password", + "argon2_hash", + "argon2_verify", + "bcrypt_hash", + "bcrypt_verify", + "scrypt_hash", + "scrypt_verify", + "pbkdf2_hash", + "pbkdf2_verify", + // Filesystem + "list_dir", + "glob", + "rglob", + "path_join", + "path_basename", + "path_dirname", + "makedirs", + "file_mtime", + "path_exists", + "is_file", + "is_dir", + "count_lines", + "path_extension", + "path_stem", + "file_size", + "copy_file", + "move_file", + "remove_file", + "delete_file", + "remove_dir", + // JSON + "parse_json", + "stringify_json", + "stringify_json_pretty", + // Math + "abs", + "round", + "floor", + "ceil", + "clamp", + "min", + "max", + "power", + "sqrt", + "sin", + "cos", + "tan", + // Random + "random", + "random_between", + "random_int", + "random_boolean", + "random_from", + "random_seed", + "generate_uuid", + // Text + "touppercase", + "tolowercase", + "substring", + "string_split", + "to_uppercase", + "to_lowercase", + "trim", + "starts_with", + "ends_with", + "split", + "startswith", + "endswith", + "replace", + "last_index_of", + "lastindexof", + "padleft", + "padright", + "format_number", + "capitalize", + "reverse", + "reverse_text", + "parse_query_string", + "parse_cookies", + "parse_form_urlencoded", + // Lists (including text/binary overloads implemented by this module) + "length", + "push", + "pop", + "contains", + "indexof", + "index_of", + "shift", + "unshift", + "remove_at", + "removeat", + "insert_at", + "insertat", + "clear", + "slice", + "concat", + "includes", + "join", + "unique", + "count", + "size", + "fill", + "sort", + "reverse_list", + "find", + "find_index", + "every", + "some", + // Pattern + "pattern_matches", + "pattern_find", + "pattern_find_all", + // Time + "today", + "now", + "datetime_now", + "format_date", + "format_time", + "format_datetime", + "parse_date", + "parse_time", + "create_time", + "create_date", + "create_datetime", + "add_days", + "subtract_days", + "days_between", + "current_date", + "date_part", + "time_part", + "utc_now", + "year", + "month", + "day", + "dayofweek", + "day_of_week", + "dayofyear", + "day_of_year", + "hour", + "minute", + "second", + "is_leap_year", + "isleapyear", + "days_in_month", + "week_of_year", + "timestamp", + "datetime_from_timestamp", + "time_diff", + // Web + "path_params", + "path_matches", + "mime_type", + "parse_multipart", +]; + /// Cached HashSet for O(1) lookup performance static BUILTIN_SET: OnceLock> = OnceLock::new(); +static IMPLEMENTED_BUILTIN_SET: OnceLock> = OnceLock::new(); /// Initialize the builtin function set fn get_builtin_set() -> &'static HashSet<&'static str> { @@ -258,6 +438,18 @@ pub fn builtin_functions() -> impl Iterator { BUILTIN_FUNCTIONS.iter().copied() } +/// Check whether a recognized builtin name has a native runtime implementation. +pub fn is_implemented_builtin_function(name: &str) -> bool { + IMPLEMENTED_BUILTIN_SET + .get_or_init(|| IMPLEMENTED_BUILTIN_FUNCTIONS.iter().copied().collect()) + .contains(name) +} + +/// Iterate over every native builtin installed by the standard library. +pub fn implemented_builtin_functions() -> impl Iterator { + IMPLEMENTED_BUILTIN_FUNCTIONS.iter().copied() +} + /// Get the parameter count (arity) for a builtin function /// Returns the correct number of parameters each function expects pub fn get_function_arity(name: &str) -> usize { @@ -376,10 +568,10 @@ pub fn get_function_arity(name: &str) -> usize { // Single argument functions "compile_pattern" => 1, // Two argument functions - "pattern_matches" | "pattern_find" | "match_pattern" | "pattern" | "match" | "test" - | "extract" | "ismatch" | "is_match" => 2, + "pattern_matches" | "pattern_find" | "pattern_find_all" | "match_pattern" | "pattern" + | "match" | "test" | "extract" | "ismatch" | "is_match" => 2, // Three argument functions - "pattern_find_all" | "replace_pattern" | "findall" | "find_all" => 3, + "replace_pattern" | "findall" | "find_all" => 3, // === FILE SYSTEM FUNCTIONS === // Single argument functions (remove_dir also here as it can take 1 or 2 args) @@ -405,6 +597,27 @@ pub fn get_function_arity(name: &str) -> usize { } } +/// Return the inclusive argument-count range accepted by a builtin at runtime. +/// +/// `get_function_arity` remains the canonical arity used by zero-argument +/// auto-call detection and by legacy callers that can represent only one +/// number. Static call validation must use this range so optional and variadic +/// builtins are not rejected merely because their preferred arity differs. +pub fn get_function_arity_range(name: &str) -> (usize, Option) { + match name { + "print" => (0, None), + "path_join" => (1, None), + "create_time" => (2, Some(3)), + "create_datetime" => (3, Some(6)), + "timestamp" => (0, Some(1)), + "remove_dir" => (1, Some(2)), + _ => { + let arity = get_function_arity(name); + (arity, Some(arity)) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -457,6 +670,17 @@ mod tests { BUILTIN_FUNCTIONS.len(), "Duplicate builtin function names detected" ); + + let implemented: HashSet<_> = IMPLEMENTED_BUILTIN_FUNCTIONS.iter().copied().collect(); + assert_eq!( + implemented.len(), + IMPLEMENTED_BUILTIN_FUNCTIONS.len(), + "Duplicate implemented builtin function names detected" + ); + assert!( + implemented.iter().all(|name| set.contains(name)), + "Every implemented builtin must also be present in the recognized catalog" + ); } #[test] diff --git a/src/fixer/mod.rs b/src/fixer/mod.rs index fda95f6b..76ab5298 100644 --- a/src/fixer/mod.rs +++ b/src/fixer/mod.rs @@ -258,14 +258,14 @@ impl CodeFixer { output.push_str(" with parameters "); for (i, param) in parameters.iter().enumerate() { if i > 0 { - output.push_str(", "); + output.push_str(" and "); } let fixed_param_name = self.fix_identifier_name(¶m.name, summary); output.push_str(&fixed_param_name); if let Some(param_type) = ¶m.param_type { output.push_str(" as "); - output.push_str(&format!("{param_type:?}")); + output.push_str(&self.format_type(param_type)); } if let Some(default_value) = ¶m.default_value { @@ -281,8 +281,8 @@ impl CodeFixer { } if let Some(ret_type) = return_type { - output.push_str(" returning "); - output.push_str(&format!("{ret_type:?}")); + output.push_str(": "); + output.push_str(&self.format_action_return_type(ret_type)); } output.push_str(":\n"); @@ -1139,6 +1139,30 @@ impl CodeFixer { } } + /// Format the recursively representable action-return surface types. + /// + /// This is intentionally separate from `format_type`: changing the shared + /// spellings would also change parameters and properties. Only list, + /// map/binary, and optional nesting are added to the action-header + /// round-trip contract; other internal type spellings retain their existing + /// behavior. + fn format_action_return_type(&self, type_val: &Type) -> String { + match type_val { + Type::List(inner) => { + format!("List of {}", self.format_action_return_type(inner)) + } + Type::Map(key, value) => format!( + "Map of {} to {}", + self.format_action_return_type(key), + self.format_action_return_type(value) + ), + Type::Optional(inner) => { + format!("Optional of {}", self.format_action_return_type(inner)) + } + _ => self.format_type(type_val), + } + } + #[allow(clippy::only_used_in_recursion)] fn format_type(&self, type_val: &Type) -> String { match type_val { @@ -1147,6 +1171,9 @@ impl CodeFixer { Type::Boolean => "Boolean".to_string(), Type::Nothing => "Nothing".to_string(), Type::Pattern => "Pattern".to_string(), + Type::Date => "date".to_string(), + Type::Time => "time".to_string(), + Type::DateTime => "datetime".to_string(), Type::Binary => "Binary".to_string(), Type::Custom(name) => name.clone(), Type::List(inner) => format!("List of {}", self.format_type(inner)), @@ -1171,6 +1198,9 @@ impl CodeFixer { Type::Interface(name) => name.clone(), Type::Async(inner) => format!("Async {}", self.format_type(inner)), Type::Any => "Any".to_string(), + Type::Optional(inner) => { + format!("{} or Nothing", self.format_type(inner)) + } Type::Unknown => "Unknown".to_string(), Type::Error => "Error".to_string(), } diff --git a/src/fixer/tests.rs b/src/fixer/tests.rs index 01b4c2a3..dad144fb 100644 --- a/src/fixer/tests.rs +++ b/src/fixer/tests.rs @@ -114,3 +114,104 @@ fn test_concatenation_chain_length() { assert_eq!(fixer.count_concatenation_chain(expression), 2); } } + +#[test] +fn temporal_type_identity_survives_fix_and_reparse() { + let input = "define action called inspect with parameters day as date and clock as time and instant as datetime:\n display day\nend action"; + let tokens = lex_wfl_with_positions(input); + let program = Parser::new(&tokens).parse().unwrap(); + + let fixer = CodeFixer::new(); + let (fixed_code, _) = fixer.fix(&program, input); + let reparsed_tokens = lex_wfl_with_positions(&fixed_code); + let reparsed = Parser::new(&reparsed_tokens).parse().unwrap(); + + let parameter_types = |program: &Program| { + let Statement::ActionDefinition { parameters, .. } = &program.statements[0] else { + panic!("expected action definition"); + }; + parameters + .iter() + .map(|parameter| parameter.param_type.clone()) + .collect::>() + }; + assert_eq!(parameter_types(&program), parameter_types(&reparsed)); + assert!(fixed_code.contains("day as date")); + assert!(fixed_code.contains("clock as time")); + assert!(fixed_code.contains("instant as datetime")); + assert!(fixed_code.contains("date and clock")); +} + +#[test] +fn action_return_type_survives_fix_and_reparse() { + let input = "define action called current_day: date:\n return today\nend action"; + let tokens = lex_wfl_with_positions(input); + let program = Parser::new(&tokens).parse().unwrap(); + + let fixer = CodeFixer::new(); + let (fixed_code, _) = fixer.fix(&program, input); + assert!( + fixed_code.contains("current_day: date:"), + "the fixer must emit parser-canonical return syntax: {fixed_code}" + ); + + let reparsed = Parser::new(&lex_wfl_with_positions(&fixed_code)) + .parse() + .expect("fixed action must reparse"); + let Statement::ActionDefinition { + name, return_type, .. + } = &reparsed.statements[0] + else { + panic!("expected an action definition after fixing"); + }; + assert_eq!(name, "current_day"); + assert_eq!(return_type, &Some(Type::Date)); +} + +#[test] +fn compound_action_return_types_survive_fix_and_reparse() { + let cases = [ + (Type::List(Box::new(Type::Text)), "produce: List of Text:"), + ( + Type::Map(Box::new(Type::Text), Box::new(Type::Binary)), + "produce: Map of Text to Binary:", + ), + ( + Type::Optional(Box::new(Type::List(Box::new(Type::Number)))), + "produce: Optional of List of Number:", + ), + ]; + + for (expected_type, expected_source) in cases { + let program = Program { + statements: vec![Statement::ActionDefinition { + name: "produce".to_string(), + parameters: vec![], + body: vec![], + return_type: Some(expected_type.clone()), + line: 1, + column: 1, + }], + }; + + let (fixed_code, _) = CodeFixer::new().fix(&program, ""); + assert!( + fixed_code.contains(expected_source), + "fixer emitted an unexpected compound return type: {fixed_code}" + ); + + let reparsed = Parser::new(&lex_wfl_with_positions(&fixed_code)) + .parse() + .unwrap_or_else(|error| { + panic!("fixed compound return type must reparse: {fixed_code}\n{error:?}") + }); + let Statement::ActionDefinition { return_type, .. } = &reparsed.statements[0] else { + panic!("expected an action definition after fixing"); + }; + assert_eq!( + return_type, + &Some(expected_type), + "fixed return annotation changed type: {fixed_code}" + ); + } +} diff --git a/src/interpreter/environment.rs b/src/interpreter/environment.rs index 57ece223..98c4cdbd 100644 --- a/src/interpreter/environment.rs +++ b/src/interpreter/environment.rs @@ -100,6 +100,27 @@ impl Environment { &mut self, name: &str, func: Rc, + ) -> Result { + self.define_or_merge_action_impl(name, func, false) + } + + /// Defines a method-body action in the current call scope. This has the + /// same overload behavior as [`Self::define_or_merge_action`], but permits + /// the declaration to shadow a container property held in an outer method + /// environment, matching the analyzer's lexical binding model. + pub fn define_or_merge_action_direct( + &mut self, + name: &str, + func: Rc, + ) -> Result { + self.define_or_merge_action_impl(name, func, true) + } + + fn define_or_merge_action_impl( + &mut self, + name: &str, + func: Rc, + define_directly: bool, ) -> Result { use super::value::OverloadedFunction; @@ -140,7 +161,11 @@ impl Environment { } let value = Value::Function(func); - self.define(name, value.clone())?; + if define_directly { + self.define_direct(name, value.clone())?; + } else { + self.define(name, value.clone())?; + } Ok(value) } @@ -230,6 +255,30 @@ impl Environment { Ok(()) } + /// Returns the nearest parent scope that owns `name` directly. + /// + /// Method bodies use this to distinguish a container property's synthetic + /// runtime binding from an ordinary outer lexical binding. The former may + /// be shadowed by an explicit method-local binder; the latter retains WFL's + /// historical no-shadowing rule. + pub fn parent_scope_defining(&self, name: &str) -> Option>> { + let mut candidate = self.parent.as_ref().and_then(Weak::upgrade); + while let Some(scope) = candidate { + let (defines_name, next) = { + let borrowed = scope.borrow(); + ( + borrowed.values.contains_key(name), + borrowed.parent.as_ref().and_then(Weak::upgrade), + ) + }; + if defines_name { + return Some(scope); + } + candidate = next; + } + None + } + /// Handles variable declarations and re-assignments in a single scope chain traversal. /// /// This method optimizes variable declaration (`store x as y`) by consolidating what was @@ -410,6 +459,28 @@ impl Environment { self.values.get(name).cloned() } + /// Remove and return a binding from this scope only. Used for temporary + /// clause-local aliases that must reveal any outer/local binding again + /// after a handler finishes. + pub fn take_local_binding(&mut self, name: &str) -> Option<(Value, bool)> { + let value = self.values.remove(name)?; + let was_constant = self.constants.remove(name); + Some((value, was_constant)) + } + + /// Replace the current local binding with a previously saved one, or remove + /// it when no binding existed before the temporary override. + pub fn restore_local_binding(&mut self, name: &str, saved: Option<(Value, bool)>) { + self.values.remove(name); + self.constants.remove(name); + if let Some((value, was_constant)) = saved { + self.values.insert(name.to_string(), value); + if was_constant { + self.constants.insert(name.to_string()); + } + } + } + pub fn get(&self, name: &str) -> Option { // Check local scope first if let Some(value) = self.values.get(name) { diff --git a/src/interpreter/memory_tests.rs b/src/interpreter/memory_tests.rs index 57c3c6ef..570505d1 100644 --- a/src/interpreter/memory_tests.rs +++ b/src/interpreter/memory_tests.rs @@ -25,6 +25,7 @@ mod tests { line: 1, column: 1, enforce_param_types: std::cell::Cell::new(false), + static_method_context: None, }; // Store in environment @@ -119,6 +120,7 @@ mod tests { line: 1, column: 1, enforce_param_types: std::cell::Cell::new(false), + static_method_context: None, }; let function_value = Value::Function(Rc::new(function)); diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 535c8de0..c99151c7 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -23,7 +23,8 @@ use self::environment::Environment; use self::error::{ErrorKind, RuntimeError}; use self::value::{ ContainerDefinitionValue, ContainerEventValue, ContainerInstanceValue, ContainerMethodValue, - EventHandler, FunctionValue, InterfaceDefinitionValue, OverloadedFunction, Value, + EventHandler, FunctionValue, InterfaceDefinitionValue, OverloadedFunction, StaticMethodContext, + Value, }; use crate::builtins::get_function_arity; use crate::config::WflConfig; @@ -839,6 +840,10 @@ struct RunState { in_count_loop: bool, call_depth: usize, call_stack: Vec, + /// Static-method environments active in this handler. Like `call_stack`, + /// these contexts may remain live across an `.await`, so concurrent + /// handlers must park them independently between polls. + active_static_method_contexts: Vec>, block_overload_dups: Option>>, /// Server response streams opened by this handler and not yet explicitly /// closed. Closed automatically when the handler ends on any path (see @@ -885,6 +890,35 @@ struct RunState { loading_stack: Vec, } +/// Installs one handler's parked [`RunState`] in the interpreter until drop. +/// +/// Besides reducing duplicated swap calls, the guard makes restoration +/// unwind-safe if polling or dropping an inner future panics. +struct InstalledRunState<'a> { + interp: &'a Interpreter, + parked: &'a mut RunState, +} + +impl<'a> InstalledRunState<'a> { + fn new(interp: &'a Interpreter, parked: &'a mut RunState) -> Self { + interp.swap_run_state(parked); + interp.refresh_active_static_method_context(); + Self { interp, parked } + } +} + +impl Drop for InstalledRunState<'_> { + fn drop(&mut self) { + // A static method may suspend after mutating its lexical property + // mirror. Publish those changes before another handler is polled, then + // refresh from shared state when this handler is installed again. This + // prevents two interleaved handlers from later writing back stale, + // full-property snapshots over each other. + self.interp.persist_active_static_method_context(); + self.interp.swap_run_state(self.parked); + } +} + /// Wraps a handler future so its [`RunState`] is swapped into the interpreter /// for the duration of each `poll` and swapped back out again the instant the /// poll returns (ready **or** pending). This makes the interpreter's run-state @@ -902,9 +936,9 @@ struct RunState { struct IsolatedHandler<'a, T> { interp: &'a Interpreter, state: RunState, - /// `Some` until `Drop` takes it: the handler future must be dropped with - /// this handler's run state installed (see `Drop`), which plain field - /// drop order cannot provide. + /// Kept in an `Option` so cancellation can explicitly drop the suspended + /// future while this handler's state is installed. Any RAII guards inside + /// the future then unwind against their own call/static-context stacks. inner: Option + 'a>>>, } @@ -918,14 +952,14 @@ impl<'a, T> std::future::Future for IsolatedHandler<'a, T> { // Every field is `Unpin` (`&`, `RunState`, and `Pin>`), so the // wrapper itself is `Unpin` and `get_mut` is sound. let this = self.get_mut(); - this.interp.swap_run_state(&mut this.state); - let result = this - .inner - .as_mut() - .expect("IsolatedHandler polled after drop") - .as_mut() - .poll(cx); - this.interp.swap_run_state(&mut this.state); + let result = { + let _installed = InstalledRunState::new(this.interp, &mut this.state); + this.inner + .as_mut() + .expect("isolated handler must not be polled after its inner future is dropped") + .as_mut() + .poll(cx) + }; match result { std::task::Poll::Ready(value) => { std::task::Poll::Ready((value, this.state.accepted_request)) @@ -938,16 +972,17 @@ impl<'a, T> std::future::Future for IsolatedHandler<'a, T> { impl<'a, T> Drop for IsolatedHandler<'a, T> { fn drop(&mut self) { // Drop the handler future FIRST, with this handler's run state - // installed: a future dropped while suspended still runs the `Drop` - // of every live RAII guard inside it (call-depth, capture, - // module-load), and those guards must unwind against the handler's - // own state — not the ambient context that happens to be installed - // at teardown (#642). + // installed: a future dropped while suspended still runs the `Drop` of + // every live RAII guard inside it (call-depth, capture, module-load, + // and `StaticMethodCallScope`), and those guards must unwind against + // the handler's own state, not the ambient context installed at + // teardown (#642). The `InstalledRunState` guard also persists this + // handler's static-method contexts on swap-out. if let Some(inner) = self.inner.take() { - self.interp.swap_run_state(&mut self.state); + let _installed = InstalledRunState::new(self.interp, &mut self.state); drop(inner); - self.interp.swap_run_state(&mut self.state); } + // The handler is finished (normal return, error, panic contained by // `catch_unwind`, or cancellation as the loop tears down). After the // final swap-out, `state` holds any streams it opened but never @@ -1403,6 +1438,11 @@ pub struct Interpreter { /// dedicated RAII counter means a caught `ResourceLimit` can never leave the /// enforcement depth under-counted, so catch-and-recurse stays bounded. call_depth: Cell, + /// Static methods execute against lexical environments that mirror shared + /// container properties. This stack synchronizes those mirrors at nested + /// call boundaries so a re-entrant static call sees mutations made by its + /// caller and the caller resumes with mutations made by its callee. + active_static_method_contexts: RefCell>>, /// The depth `call_depth` resets to at the start of a run. Normally 0, but a /// child interpreter spawned by `execute file` inherits the parent's live /// depth here, so recursion accounting *spans* the execute-file boundary: a @@ -1469,6 +1509,54 @@ pub struct Interpreter { current_test_name: RefCell>, } +/// Synchronizes one static-method property environment with the shared +/// container definitions for the full lifetime of its call. +/// +/// Keeping this as an RAII scope is important: dropping an in-flight +/// interpretation future must not strand an active context on the stack or +/// discard mutations that completed before cancellation. +struct StaticMethodCallScope<'a> { + active_contexts: &'a RefCell>>, + context: Rc, +} + +impl<'a> StaticMethodCallScope<'a> { + fn enter(interpreter: &'a Interpreter, context: Rc) -> Self { + interpreter.persist_active_static_method_context(); + Interpreter::refresh_static_method_context(&context); + interpreter + .active_static_method_contexts + .borrow_mut() + .push(Rc::clone(&context)); + Self { + active_contexts: &interpreter.active_static_method_contexts, + context, + } + } +} + +impl Drop for StaticMethodCallScope<'_> { + fn drop(&mut self) { + Interpreter::persist_static_method_context(&self.context); + + let parent_context = { + let mut active_contexts = self.active_contexts.borrow_mut(); + let popped = active_contexts.pop(); + debug_assert!( + popped + .as_ref() + .is_some_and(|context| Rc::ptr_eq(context, &self.context)), + "static method contexts must unwind in call order" + ); + active_contexts.last().cloned() + }; + + if let Some(parent_context) = parent_context { + Interpreter::refresh_static_method_context(&parent_context); + } + } +} + // Test framework data structures #[derive(Debug, Default, Clone)] pub struct TestResults { @@ -4131,6 +4219,7 @@ impl Interpreter { current_block_overload_dups: RefCell::new(None), call_stack: RefCell::new(Vec::new()), call_depth: Cell::new(0), + active_static_method_contexts: RefCell::new(Vec::new()), base_call_depth: 0, io_client: Rc::new(IoClient::new(Arc::clone(&config))), step_mode: false, // Default to non-step mode @@ -4265,6 +4354,9 @@ impl Interpreter { return_type: Box::new(Type::Unknown), }, Value::Pattern(_) => Type::Pattern, + Value::Date(_) => Type::Date, + Value::Time(_) => Type::Time, + Value::DateTime(_) => Type::DateTime, Value::ContainerDefinition(def) => Type::Container(def.name.clone()), Value::ContainerInstance(inst) => { Type::ContainerInstance(inst.borrow().container_type.clone()) @@ -4630,6 +4722,10 @@ impl Interpreter { let depth = self.call_depth.replace(state.call_depth); state.call_depth = depth; std::mem::swap(&mut *self.call_stack.borrow_mut(), &mut state.call_stack); + std::mem::swap( + &mut *self.active_static_method_contexts.borrow_mut(), + &mut state.active_static_method_contexts, + ); std::mem::swap( &mut *self.current_block_overload_dups.borrow_mut(), &mut state.block_overload_dups, @@ -6138,15 +6234,23 @@ impl Interpreter { .as_ref() .is_some_and(|dups| dups.contains(name)), ), + static_method_context: None, }; // A same-scope redefinition of an action name merges into an - // overload set instead of erroring; every other collision - // keeps its existing error. - match env - .borrow_mut() - .define_or_merge_action(name, Rc::new(function)) - { + // overload set instead of erroring. An explicit method-local + // declaration may shadow a synthetic property binding, while + // ordinary outer lexical bindings retain the historical + // no-shadowing rule. + let shadows_property = self.definition_shadows_container_property(&env, name); + let result = if shadows_property { + env.borrow_mut() + .define_or_merge_action_direct(name, Rc::new(function)) + } else { + env.borrow_mut() + .define_or_merge_action(name, Rc::new(function)) + }; + match result { Ok(defined_value) => Ok((defined_value, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -6389,7 +6493,7 @@ impl Interpreter { // OPTIMIZATION: Recycle environment if possible let loop_env = self.get_recycled_env(loop_env_recycle.take(), &env); - match loop_env.borrow_mut().define(item_name, item) { + match loop_env.borrow_mut().define_direct(item_name, item) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -6437,7 +6541,7 @@ impl Interpreter { // OPTIMIZATION: Recycle environment if possible let loop_env = self.get_recycled_env(loop_env_recycle.take(), &env); - match loop_env.borrow_mut().define(item_name, value) { + match loop_env.borrow_mut().define_direct(item_name, value) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -6754,7 +6858,7 @@ impl Interpreter { Ok(handle) => { match env .borrow_mut() - .define(variable_name, Value::Text(handle.into())) + .define_direct(variable_name, Value::Text(handle.into())) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), @@ -6785,7 +6889,7 @@ impl Interpreter { Ok(handle) => { let define_result = env .borrow_mut() - .define(variable_name, Value::Text(handle.as_str().into())); + .define_direct(variable_name, Value::Text(handle.as_str().into())); match define_result { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => { @@ -6820,7 +6924,7 @@ impl Interpreter { ) .await?; - match env.borrow_mut().define(variable_name, result) { + match env.borrow_mut().define_direct(variable_name, result) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -6873,7 +6977,7 @@ impl Interpreter { // borrow before the `close_file` await below. let define_result = env .borrow_mut() - .define(variable_name, Value::Text(content.into())); + .define_direct(variable_name, Value::Text(content.into())); match define_result { Ok(_) => { let _ = self.io_client.close_file(&handle).await; @@ -6897,7 +7001,7 @@ impl Interpreter { Ok(content) => { match env .borrow_mut() - .define(variable_name, Value::Text(content.into())) + .define_direct(variable_name, Value::Text(content.into())) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), @@ -7851,9 +7955,10 @@ impl Interpreter { Ok(content) => { // Capture the define result and drop the // env borrow before the `close_file` await. - let define_result = env - .borrow_mut() - .define(variable_name, Value::Text(content.into())); + let define_result = env.borrow_mut().define_direct( + variable_name, + Value::Text(content.into()), + ); match define_result { Ok(_) => { let _ = @@ -7880,7 +7985,7 @@ impl Interpreter { Ok(content) => { match env .borrow_mut() - .define(variable_name, Value::Text(content.into())) + .define_direct(variable_name, Value::Text(content.into())) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), @@ -7970,22 +8075,46 @@ impl Interpreter { }; if matches { - // Bind the error under the clause's name and the - // `error_message` alias, which is always available - // in error-handling clauses. + // Bind the error under clause-local aliases. + // Ordinary handler-created bindings remain in + // the shared try environment for `finally`, but + // these aliases must reveal any previous local + // or outer bindings again when the clause ends. let error_text = Value::Text(err.message.into()); - { + let (saved_error_name, saved_error_message) = { let mut env_mut = child_env.borrow_mut(); + let saved_error_name = + env_mut.take_local_binding(&when_clause.error_name); + let saved_error_message = + if when_clause.error_name == "error_message" { + None + } else { + env_mut.take_local_binding("error_message") + }; env_mut.define_or_replace( &when_clause.error_name, error_text.clone(), ); env_mut.define_or_replace("error_message", error_text); - } + (saved_error_name, saved_error_message) + }; result = self .execute_block(&when_clause.body, Rc::clone(&child_env)) .await; + { + let mut env_mut = child_env.borrow_mut(); + if when_clause.error_name != "error_message" { + env_mut.restore_local_binding( + "error_message", + saved_error_message, + ); + } + env_mut.restore_local_binding( + &when_clause.error_name, + saved_error_name, + ); + } executed = true; break; } @@ -8007,11 +8136,13 @@ impl Interpreter { // A `finally:` block runs on both the success and error paths, // after any matching when/otherwise clause. If it raises its own - // error, that error wins; otherwise the primary result (the - // success value or the still-unhandled error) propagates. + // error, that error wins. Abrupt control flow from finally + // (`return`, `break`, `continue`, `exit`) also wins; an ordinary + // fallthrough preserves the primary result. if let Some(finally_stmts) = finally_block { match self.execute_block(finally_stmts, child_env).await { - Ok(_) => primary_result, + Ok((_, ControlFlow::None)) => primary_result, + Ok(finally_result) => Ok(finally_result), Err(finally_err) => Err(finally_err), } } else { @@ -8044,7 +8175,7 @@ impl Interpreter { Ok(body) => { match env .borrow_mut() - .define(variable_name, Value::Text(body.into())) + .define_direct(variable_name, Value::Text(body.into())) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), @@ -8093,7 +8224,7 @@ impl Interpreter { Ok(body) => { match env .borrow_mut() - .define(variable_name, Value::Text(body.into())) + .define_direct(variable_name, Value::Text(body.into())) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), @@ -8240,7 +8371,7 @@ impl Interpreter { Value::Text(response_body.into()) }; - match env.borrow_mut().define(variable_name, value) { + match env.borrow_mut().define_direct(variable_name, value) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -8559,7 +8690,7 @@ impl Interpreter { } } - Ok((Value::Null, ControlFlow::None)) + Ok((_last_value, ControlFlow::None)) } Statement::PushStatement { list, @@ -8598,7 +8729,7 @@ impl Interpreter { } let list_value = Value::List(Rc::new(RefCell::new(list_items))); - match env.borrow_mut().define(name, list_value) { + match env.borrow_mut().define_direct(name, list_value) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -8621,7 +8752,7 @@ impl Interpreter { } let map_value = Value::Object(Rc::new(RefCell::new(map))); - match env.borrow_mut().define(name, map_value) { + match env.borrow_mut().define_direct(name, map_value) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -8643,7 +8774,7 @@ impl Interpreter { Value::Date(Rc::new(today)) }; - match env.borrow_mut().define(name, date_value) { + match env.borrow_mut().define_direct(name, date_value) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -8664,7 +8795,7 @@ impl Interpreter { Value::Time(Rc::new(now)) }; - match env.borrow_mut().define(name, time_value) { + match env.borrow_mut().define_direct(name, time_value) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -8774,14 +8905,16 @@ impl Interpreter { properties, methods, events, - static_properties: _static_properties, - static_methods: _static_methods, + static_properties, + static_methods, line, column, } => { // Create a new container definition let mut container_properties = HashMap::new(); let mut container_methods = HashMap::new(); + let mut container_static_properties = HashMap::new(); + let mut container_static_methods = HashMap::new(); for prop in properties { let property_type_str = prop @@ -8840,6 +8973,42 @@ impl Interpreter { } } + for prop in static_properties { + let value = match &prop.default_value { + Some(expression) => { + self._evaluate_expression(expression, env.clone()).await? + } + None => Value::Null, + }; + container_static_properties.insert(prop.name.clone(), value); + } + + for method in static_methods { + if let Statement::ActionDefinition { + name, + parameters, + body, + line, + column, + .. + } = method + { + container_static_methods.insert( + name.clone(), + ContainerMethodValue { + name: name.clone(), + params: parameters.iter().map(|p| p.name.clone()).collect(), + body: body.clone(), + is_static: true, + is_public: true, + env: Rc::downgrade(&env), + line: *line, + column: *column, + }, + ); + } + } + // Process events let mut container_events = HashMap::new(); for event in events { @@ -8860,8 +9029,8 @@ impl Interpreter { properties: container_properties, methods: container_methods, events: container_events, - static_properties: HashMap::new(), // Future feature - static_methods: HashMap::new(), // Future feature + static_properties: Rc::new(RefCell::new(container_static_properties)), + static_methods: container_static_methods, line: *line, column: *column, }; @@ -8869,8 +9038,16 @@ impl Interpreter { // Create the container definition value let container_value = Value::ContainerDefinition(Rc::new(container_def)); - // Store the container definition in the environment - match env.borrow_mut().define(name, container_value.clone()) { + // A nested type declaration follows the same method-local + // property-shadowing rule as action declarations. + let shadows_property = self.definition_shadows_container_property(&env, name); + let result = if shadows_property { + env.borrow_mut() + .define_direct(name, container_value.clone()) + } else { + env.borrow_mut().define(name, container_value.clone()) + }; + match result { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -8908,7 +9085,7 @@ impl Interpreter { // Store the instance in the environment match env .borrow_mut() - .define(instance_name, instance_value.clone()) + .define_direct(instance_name, instance_value.clone()) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), @@ -8940,6 +9117,7 @@ impl Interpreter { line: init_method.line, column: init_method.column, enforce_param_types: std::cell::Cell::new(false), + static_method_context: None, }; // Create a new environment for the constructor execution @@ -9001,8 +9179,16 @@ impl Interpreter { let interface_value = Value::InterfaceDefinition(Rc::new(interface_def)); - // Store the interface definition in the environment - match env.borrow_mut().define(name, interface_value.clone()) { + // Match the analyzer's lexical binding model without relaxing + // ordinary outer-scope shadowing. + let shadows_property = self.definition_shadows_container_property(&env, name); + let result = if shadows_property { + env.borrow_mut() + .define_direct(name, interface_value.clone()) + } else { + env.borrow_mut().define(name, interface_value.clone()) + }; + match result { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *_line, *_column)), } @@ -9217,6 +9403,7 @@ impl Interpreter { line: method_val.line, column: method_val.column, enforce_param_types: std::cell::Cell::new(false), + static_method_context: None, }; // Create a new environment for the method execution @@ -9280,7 +9467,7 @@ impl Interpreter { Ok(compiled_pattern) => { // Store the compiled pattern in the environment let pattern_value = Value::Pattern(Rc::new(compiled_pattern)); - match env.borrow_mut().define(name, pattern_value.clone()) { + match env.borrow_mut().define_direct(name, pattern_value.clone()) { Ok(_) => {} Err(msg) => return Err(RuntimeError::new(msg, *line, *column)), } @@ -9724,7 +9911,7 @@ impl Interpreter { target_port ); - match env.borrow_mut().define(server_name, server_value) { + match env.borrow_mut().define_direct(server_name, server_value) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -9834,7 +10021,7 @@ impl Interpreter { println!("Secure server is listening on port {}", addr.port()); - match env.borrow_mut().define(server_name, server_value) { + match env.borrow_mut().define_direct(server_name, server_value) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -9878,7 +10065,7 @@ impl Interpreter { println!("Server is listening on port {}", addr.port()); - match env.borrow_mut().define(server_name, server_value) { + match env.borrow_mut().define_direct(server_name, server_value) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -11217,7 +11404,7 @@ impl Interpreter { let server_value = Value::Text(Arc::from(key)); println!("WebSocket server is listening on port {}", addr.port()); - match env.borrow_mut().define(server_name, server_value) { + match env.borrow_mut().define_direct(server_name, server_value) { Ok(_) => Ok((Value::Null, ControlFlow::None)), Err(msg) => Err(RuntimeError::new(msg, *line, *column)), } @@ -11484,7 +11671,7 @@ impl Interpreter { // Store result if variable name provided if let Some(var_name) = variable_name { env.borrow_mut() - .define(var_name, result_obj) + .define_direct(var_name, result_obj) .map_err(|e| RuntimeError::new(e, *line, *column))?; } @@ -11738,7 +11925,7 @@ impl Interpreter { if let (Some(var_name), Some(buffer)) = (variable_name, capture_buffer) { let output = buffer.borrow(); env.borrow_mut() - .define(var_name, Value::Text(Arc::from(output.as_str()))) + .define_direct(var_name, Value::Text(Arc::from(output.as_str()))) .map_err(|e| RuntimeError::new(e, *line, *column))?; } @@ -11815,7 +12002,7 @@ impl Interpreter { // Store process ID in variable env.borrow_mut() - .define(variable_name, Value::Text(Arc::from(process_id.as_str()))) + .define_direct(variable_name, Value::Text(Arc::from(process_id.as_str()))) .map_err(|e| RuntimeError::new(e, *line, *column))?; Ok((Value::Null, ControlFlow::None)) @@ -11857,7 +12044,7 @@ impl Interpreter { // Store output in variable env.borrow_mut() - .define(variable_name, Value::Text(Arc::from(output.as_str()))) + .define_direct(variable_name, Value::Text(Arc::from(output.as_str()))) .map_err(|e| RuntimeError::new(e, *line, *column))?; Ok((Value::Null, ControlFlow::None)) @@ -11932,7 +12119,7 @@ impl Interpreter { // Store exit code in variable if provided if let Some(var_name) = variable_name { env.borrow_mut() - .define(var_name, Value::Number(exit_code as f64)) + .define_direct(var_name, Value::Number(exit_code as f64)) .map_err(|e| RuntimeError::new(e, *line, *column))?; } @@ -12829,23 +13016,18 @@ impl Interpreter { } }; - // Look up the static member - if let Some(value) = container_def.static_properties.get(member) { - Ok(value.clone()) - } else if let Some(method) = container_def.static_methods.get(member) { - // Create a function value from the method - let function = FunctionValue { - name: Some(method.name.clone()), - params: method.params.clone(), - param_types: vec![None; method.params.len()], - body: method.body.clone(), - env: method.env.clone(), - line: method.line, - column: method.column, - enforce_param_types: std::cell::Cell::new(false), - }; + self.persist_active_static_method_context(); - Ok(Value::Function(Rc::new(function))) + // Look up the static member, following the same inheritance + // chain the static checker accepts. + if let Some(value) = + Self::resolve_static_property(&env, Rc::clone(&container_def), member) + { + Ok(value) + } else if let Some((method_owner, method)) = + Self::resolve_static_method(&env, Rc::clone(&container_def), member) + { + Ok(Self::static_method_reference(&env, method_owner, method)) } else { Err(RuntimeError::new( format!("Static member '{member}' not found in container '{container}'"), @@ -12868,8 +13050,40 @@ impl Interpreter { // Clone the object value to avoid borrow issues let object_val_clone = object_val.clone(); - // Check if the object is a container instance - if let Value::ContainerInstance(instance_rc) = &object_val_clone { + // Static methods are called on the container definition value. + if let Value::ContainerDefinition(container_def) = &object_val_clone { + self.persist_active_static_method_context(); + + let (method_owner, method_val) = + Self::resolve_static_method(&env, Rc::clone(container_def), method) + .ok_or_else(|| { + RuntimeError::new( + format!( + "Static method '{method}' not found in container '{}'", + container_def.name + ), + line, + column, + ) + })?; + + let Value::Function(function) = + Self::static_method_reference(&env, method_owner, method_val) + else { + unreachable!("static method references are always functions"); + }; + + let mut argument_values = Vec::with_capacity(arguments.len()); + for argument in arguments { + argument_values.push( + self.evaluate_expression(&argument.value, Rc::clone(&env)) + .await?, + ); + } + + self.call_function(&function, argument_values, line, column) + .await + } else if let Value::ContainerInstance(instance_rc) = &object_val_clone { // Clone instance_rc for later property write-back let instance_rc_for_writeback = instance_rc.clone(); @@ -12929,6 +13143,7 @@ impl Interpreter { line: method_val.line, column: method_val.column, enforce_param_types: std::cell::Cell::new(false), + static_method_context: None, }; // Create a new environment for the method execution @@ -12981,12 +13196,13 @@ impl Interpreter { line: function.line, column: function.column, enforce_param_types: function.enforce_param_types.clone(), + static_method_context: None, }; // Call the function with the method environment let result = self .call_function(&method_function, arg_values, line, column) - .await?; + .await; // WRITE BACK MODIFIED PROPERTIES TO CONTAINER // This fixes the property mutation issue where properties modified @@ -13000,7 +13216,7 @@ impl Interpreter { } } - Ok(result) + result } else { Err(RuntimeError::new( format!("Method '{method}' not found in container '{container_type}'"), @@ -13639,6 +13855,28 @@ impl Interpreter { )) } } + Value::ContainerDefinition(definition) => { + self.persist_active_static_method_context(); + + if let Some(value) = + Self::resolve_static_property(&env, Rc::clone(&definition), property) + { + Ok(value) + } else if let Some((method_owner, method)) = + Self::resolve_static_method(&env, Rc::clone(&definition), property) + { + Ok(Self::static_method_reference(&env, method_owner, method)) + } else { + Err(RuntimeError::new( + format!( + "Static member '{property}' not found in container '{}'", + definition.name + ), + *line, + *column, + )) + } + } Value::Object(obj_rc) => { let obj = obj_rc.borrow(); if let Some(prop_value) = obj.get(property) { @@ -14134,8 +14372,10 @@ impl Interpreter { /// Picks the overload whose parameter count and declared parameter types /// match the actual argument values: filter by count, drop candidates /// whose concrete annotations reject an argument, then prefer the - /// candidate with the most concretely-matched parameters (ties resolve to - /// definition order). + /// candidate with the most concretely-matched parameters. When that count + /// ties, an exact branded temporal annotation (`date`, `time`, or + /// `datetime`) outranks its broader historical custom-name annotation; + /// remaining ties resolve to definition order. fn select_overload( overloaded: &OverloadedFunction, args: &[Value], @@ -14173,9 +14413,10 @@ impl Interpreter { )); } - let mut best: Option<(&Rc, usize)> = None; + let mut best: Option<(&Rc, (usize, usize))> = None; for func in &arity_matches { let mut concrete_matches = 0usize; + let mut exact_temporal_matches = 0usize; let mut accepts = true; for (param_type, arg) in func.param_types.iter().zip(args) { if let Some(expected) = param_type { @@ -14194,6 +14435,14 @@ impl Interpreter { || matches!(expected, Type::Nothing) { concrete_matches += 1; + if matches!( + (expected, arg), + (Type::Date, Value::Date(_)) + | (Type::Time, Value::Time(_)) + | (Type::DateTime, Value::DateTime(_)) + ) { + exact_temporal_matches += 1; + } } } else { accepts = false; @@ -14201,8 +14450,9 @@ impl Interpreter { } } } - if accepts && best.is_none_or(|(_, count)| concrete_matches > count) { - best = Some((func, concrete_matches)); + let specificity = (concrete_matches, exact_temporal_matches); + if accepts && best.is_none_or(|(_, score)| specificity > score) { + best = Some((func, specificity)); } } @@ -14224,6 +14474,151 @@ impl Interpreter { } } + fn definition_shadows_container_property( + &self, + env: &Rc>, + name: &str, + ) -> bool { + let Some(defining_scope) = env.borrow().parent_scope_defining(name) else { + return false; + }; + + let this_value = defining_scope.borrow().values.get("this").cloned(); + if let Some(Value::ContainerInstance(instance)) = this_value + && instance.borrow().properties.contains_key(name) + { + return true; + } + + self.active_static_method_contexts + .borrow() + .last() + .is_some_and(|context| { + Rc::ptr_eq(&context.env, &defining_scope) + && context.property_owners.contains_key(name) + }) + } + + fn resolve_static_property( + env: &Rc>, + mut definition: Rc, + member: &str, + ) -> Option { + loop { + let value = definition.static_properties.borrow().get(member).cloned(); + if value.is_some() { + return value; + } + let parent_name = definition.extends.as_ref()?.clone(); + definition = match env.borrow().get(&parent_name) { + Some(Value::ContainerDefinition(parent)) => parent, + _ => return None, + }; + } + } + + fn resolve_static_method( + env: &Rc>, + mut definition: Rc, + member: &str, + ) -> Option<(Rc, ContainerMethodValue)> { + loop { + if let Some(method) = definition.static_methods.get(member).cloned() { + return Some((definition, method)); + } + let parent_name = definition.extends.as_ref()?.clone(); + definition = match env.borrow().get(&parent_name) { + Some(Value::ContainerDefinition(parent)) => parent, + _ => return None, + }; + } + } + + fn static_definition_chain( + env: &Rc>, + mut definition: Rc, + ) -> Vec> { + let mut chain = vec![Rc::clone(&definition)]; + while let Some(parent_name) = definition.extends.as_ref() { + let Some(Value::ContainerDefinition(parent)) = env.borrow().get(parent_name) else { + break; + }; + definition = parent; + chain.push(Rc::clone(&definition)); + } + chain.reverse(); + chain + } + + fn static_method_reference( + env: &Rc>, + method_owner: Rc, + method: ContainerMethodValue, + ) -> Value { + let static_env = Environment::new_child_env(env); + let mut property_owners = HashMap::new(); + for definition in Self::static_definition_chain(env, method_owner) { + for (property_name, property_value) in definition.static_properties.borrow().iter() { + static_env + .borrow_mut() + .define_or_replace(property_name, property_value.clone()); + property_owners.insert(property_name.clone(), Rc::clone(&definition)); + } + } + let static_method_context = Rc::new(StaticMethodContext { + env: Rc::clone(&static_env), + property_owners, + }); + Value::Function(Rc::new(FunctionValue { + name: Some(method.name.clone()), + params: method.params.clone(), + param_types: vec![None; method.params.len()], + body: method.body, + env: Rc::downgrade(&static_env), + line: method.line, + column: method.column, + enforce_param_types: std::cell::Cell::new(false), + static_method_context: Some(static_method_context), + })) + } + + fn refresh_static_method_context(context: &StaticMethodContext) { + for (property_name, owner) in &context.property_owners { + let current_value = owner.static_properties.borrow().get(property_name).cloned(); + if let Some(current_value) = current_value { + context + .env + .borrow_mut() + .define_or_replace(property_name, current_value); + } + } + } + + fn persist_static_method_context(context: &StaticMethodContext) { + for (property_name, owner) in &context.property_owners { + if let Some(updated_value) = context.env.borrow().get_local(property_name) { + owner + .static_properties + .borrow_mut() + .insert(property_name.clone(), updated_value); + } + } + } + + fn persist_active_static_method_context(&self) { + let active_context = self.active_static_method_contexts.borrow().last().cloned(); + if let Some(active_context) = active_context { + Self::persist_static_method_context(&active_context); + } + } + + fn refresh_active_static_method_context(&self) { + let active_context = self.active_static_method_contexts.borrow().last().cloned(); + if let Some(active_context) = active_context { + Self::refresh_static_method_context(&active_context); + } + } + /// Whether a runtime value satisfies a declared parameter type. Untyped /// and unknown annotations accept everything; `Custom` types match a /// container instance of that type or of a descendant (via the parent @@ -14241,12 +14636,24 @@ impl Interpreter { Type::Boolean => matches!(value, Value::Bool(_)), Type::Nothing => matches!(value, Value::Null | Value::Nothing), Type::Pattern => matches!(value, Value::Pattern(_)), + Type::Date => matches!(value, Value::Date(_)), + Type::Time => matches!(value, Value::Time(_)), + Type::DateTime => matches!(value, Value::DateTime(_)), Type::List(_) => matches!(value, Value::List(_)), Type::Map(_, _) => matches!(value, Value::Object(_)), Type::Custom(name) => { if name.eq_ignore_ascii_case("any") { return true; } + if name.eq_ignore_ascii_case("date") && matches!(value, Value::Date(_)) { + return true; + } + if name.eq_ignore_ascii_case("time") && matches!(value, Value::Time(_)) { + return true; + } + if name.eq_ignore_ascii_case("datetime") && matches!(value, Value::DateTime(_)) { + return true; + } if let Value::ContainerInstance(instance) = value { let mut current = Some(Rc::clone(instance)); while let Some(inst) = current { @@ -14389,6 +14796,10 @@ impl Interpreter { return Err(self.budget_error(exceeded, line, column)); } let _depth_guard = CallDepthGuard::enter(&self.call_depth); + let _static_method_scope = func + .static_method_context + .as_ref() + .map(|context| StaticMethodCallScope::enter(self, Rc::clone(context))); let frame = CallFrame::new( func.name @@ -14854,11 +15265,76 @@ impl Interpreter { #[cfg(test)] mod concurrent_handler_classification_tests { use super::*; + use std::future::{Future, poll_fn}; + use std::pin::Pin; + use std::task::{Context, Poll}; fn error(kind: ErrorKind, message: &str) -> RuntimeError { RuntimeError::with_kind(message.to_string(), 1, 1, kind) } + fn one_yield_static_scope<'a>( + interpreter: &'a Interpreter, + context: Rc, + ) -> impl Future + 'a { + let yielded = Rc::new(Cell::new(false)); + async move { + let _depth = CallDepthGuard::enter(&interpreter.call_depth); + let _scope = StaticMethodCallScope::enter(interpreter, context); + poll_fn(move |cx| { + if yielded.replace(true) { + Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + } + + fn isolated_static_handler<'a>( + interpreter: &'a Interpreter, + context: Rc, + ) -> Pin>> { + Box::pin(IsolatedHandler { + interp: interpreter, + state: RunState::fresh(interpreter.base_call_depth), + inner: Some(Box::pin(one_yield_static_scope(interpreter, context))), + }) + } + + fn shared_static_owner() -> Rc { + let mut static_properties = HashMap::new(); + static_properties.insert("x".to_string(), Value::Number(0.0)); + static_properties.insert("y".to_string(), Value::Number(0.0)); + Rc::new(ContainerDefinitionValue { + name: "Shared".to_string(), + extends: None, + implements: Vec::new(), + properties: HashMap::new(), + methods: HashMap::new(), + events: HashMap::new(), + static_properties: Rc::new(RefCell::new(static_properties)), + static_methods: HashMap::new(), + line: 1, + column: 1, + }) + } + + fn static_context_for(owner: &Rc) -> Rc { + let env = Environment::new_global(); + let mut property_owners = HashMap::new(); + for (name, value) in owner.static_properties.borrow().iter() { + env.borrow_mut().define_or_replace(name, value.clone()); + property_owners.insert(name.clone(), Rc::clone(owner)); + } + Rc::new(StaticMethodContext { + env, + property_owners, + }) + } + #[test] fn more_than_the_breaker_threshold_of_request_wait_timeouts_stays_request_local() { let timeout = error( @@ -14898,6 +15374,138 @@ mod concurrent_handler_classification_tests { ); } + #[test] + fn concurrent_handlers_park_and_cancel_static_contexts_independently() { + let interpreter = Interpreter::new(); + let context_a = Rc::new(StaticMethodContext { + env: Environment::new_global(), + property_owners: HashMap::new(), + }); + let context_b = Rc::new(StaticMethodContext { + env: Environment::new_global(), + property_owners: HashMap::new(), + }); + let mut handler_a = isolated_static_handler(&interpreter, Rc::clone(&context_a)); + let mut handler_b = isolated_static_handler(&interpreter, Rc::clone(&context_b)); + let mut cx = Context::from_waker(std::task::Waker::noop()); + + assert!(handler_a.as_mut().poll(&mut cx).is_pending()); + assert!( + interpreter + .active_static_method_contexts + .borrow() + .is_empty() + ); + assert_eq!(interpreter.call_depth.get(), interpreter.base_call_depth); + assert!( + handler_a + .as_ref() + .get_ref() + .state + .active_static_method_contexts + .first() + .is_some_and(|context| Rc::ptr_eq(context, &context_a)) + ); + + assert!(handler_b.as_mut().poll(&mut cx).is_pending()); + assert!( + interpreter + .active_static_method_contexts + .borrow() + .is_empty() + ); + assert_eq!(interpreter.call_depth.get(), interpreter.base_call_depth); + assert!( + handler_b + .as_ref() + .get_ref() + .state + .active_static_method_contexts + .first() + .is_some_and(|context| Rc::ptr_eq(context, &context_b)) + ); + + assert!(handler_a.as_mut().poll(&mut cx).is_ready()); + assert!( + handler_a + .as_ref() + .get_ref() + .state + .active_static_method_contexts + .is_empty() + ); + assert!( + interpreter + .active_static_method_contexts + .borrow() + .is_empty() + ); + + // Handler B is still suspended with its own scope. Cancellation must + // unwind B against B's parked state without leaking into interpreter + // scratch state or reviving A's completed context. + drop(handler_b); + assert!( + interpreter + .active_static_method_contexts + .borrow() + .is_empty() + ); + assert_eq!(interpreter.call_depth.get(), interpreter.base_call_depth); + } + + #[test] + fn poll_boundaries_merge_interleaved_static_property_updates() { + let interpreter = Interpreter::new(); + let owner = shared_static_owner(); + let context_a = static_context_for(&owner); + let context_b = static_context_for(&owner); + let mut state_a = RunState::fresh(interpreter.base_call_depth); + let mut state_b = RunState::fresh(interpreter.base_call_depth); + state_a + .active_static_method_contexts + .push(Rc::clone(&context_a)); + state_b + .active_static_method_contexts + .push(Rc::clone(&context_b)); + + { + let _installed = InstalledRunState::new(&interpreter, &mut state_a); + context_a + .env + .borrow_mut() + .define_or_replace("x", Value::Number(1.0)); + } + { + let _installed = InstalledRunState::new(&interpreter, &mut state_b); + assert!(matches!( + context_b.env.borrow().get_local("x"), + Some(Value::Number(1.0)) + )); + context_b + .env + .borrow_mut() + .define_or_replace("y", Value::Number(2.0)); + } + { + let _installed = InstalledRunState::new(&interpreter, &mut state_a); + assert!(matches!( + context_a.env.borrow().get_local("y"), + Some(Value::Number(2.0)) + )); + } + + let properties = owner.static_properties.borrow(); + assert!(matches!(properties.get("x"), Some(Value::Number(1.0)))); + assert!(matches!(properties.get("y"), Some(Value::Number(2.0)))); + assert!( + interpreter + .active_static_method_contexts + .borrow() + .is_empty() + ); + } + #[tokio::test] async fn missing_pending_entry_is_cancelled_only_while_the_handler_owns_it() { let interpreter = Interpreter::new(); diff --git a/src/interpreter/value.rs b/src/interpreter/value.rs index 739be039..68fb8e73 100644 --- a/src/interpreter/value.rs +++ b/src/interpreter/value.rs @@ -40,6 +40,17 @@ pub enum Value { pub type NativeFunction = fn(Vec) -> Result; +/// Durable state needed by a first-class reference to a static container method. +/// +/// Static properties are copied into the method environment for ordinary WFL +/// name lookup. The owners map lets each call refresh those copies from the +/// live container definitions and persist successful mutations afterward. +#[derive(Clone)] +pub struct StaticMethodContext { + pub env: Rc>, + pub property_owners: HashMap>, +} + #[derive(Clone)] pub struct FunctionValue { pub name: Option, @@ -60,6 +71,10 @@ pub struct FunctionValue { /// hints, not runtime guards. A `Cell` so merging can flip existing /// members already shared behind `Rc` (including captured snapshots). pub enforce_param_types: std::cell::Cell, + /// Present only for a first-class static-method reference. Besides keeping + /// its otherwise-ephemeral closure alive, this synchronizes copied static + /// properties with their shared container state at call boundaries. + pub static_method_context: Option>, } /// The runtime value of an action name defined more than once in a scope: @@ -92,7 +107,9 @@ pub struct ContainerDefinitionValue { /// arity/type-aware inheritance and interface-conformance matching. pub methods: HashMap, pub events: HashMap, - pub static_properties: HashMap, + /// Static state is shared by every reference to the container definition + /// and can be updated by static methods. + pub static_properties: Rc>>, pub static_methods: HashMap, pub line: usize, pub column: usize, @@ -582,6 +599,12 @@ impl fmt::Display for Value { impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { + if matches!( + (self, other), + (Value::Null, Value::Nothing) | (Value::Nothing, Value::Null) + ) { + return true; + } // Optimization: Mismatched types are never equal. // This avoids allocating the cycle-detection HashSet for mismatched types (e.g. List == Number). if std::mem::discriminant(self) != std::mem::discriminant(other) { @@ -660,6 +683,7 @@ fn eq_with_visited( (Value::DateTime(a), Value::DateTime(b)) => a == b, (Value::Null, Value::Null) => true, (Value::Nothing, Value::Nothing) => true, + (Value::Null, Value::Nothing) | (Value::Nothing, Value::Null) => true, (Value::List(a), Value::List(b)) => { if Rc::ptr_eq(a, b) { diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 112d1da0..f2ee8790 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -1056,6 +1056,9 @@ pub enum Type { Boolean, Nothing, Pattern, + Date, + Time, + DateTime, Custom(String), List(Box), Map(Box, Box), @@ -1068,6 +1071,9 @@ pub enum Type { Error, // Used to mark expressions that have already failed type checking Async(Box), // For asynchronous operations returning a value of Type Any, // Used for generic types like lists of any type + /// An inferred value that may be `Nothing` because an action can fall + /// through without executing a value-returning `return`. + Optional(Box), // Container-related types Container(String), ContainerInstance(String), diff --git a/src/parser/stmt/actions.rs b/src/parser/stmt/actions.rs index b83ac8f2..0c82b85e 100644 --- a/src/parser/stmt/actions.rs +++ b/src/parser/stmt/actions.rs @@ -10,11 +10,13 @@ use crate::parser::expr::ExprParser; /// Maps a token in type position (`x as `, `returns `) to its /// `Type`. Some type names lex as keywords rather than identifiers (`text`, /// `pattern`), so matching on `Identifier` alone would reject them. -fn type_from_token(token: &Token) -> Option { +pub(crate) fn type_from_token(token: &Token) -> Option { match token { Token::KeywordText => Some(Type::Text), Token::KeywordPattern => Some(Type::Pattern), Token::KeywordAny => Some(Type::Any), + Token::KeywordDate => Some(Type::Date), + Token::KeywordTime => Some(Type::Time), // `nothing` lexes as its own literal token, never as an identifier. Token::NothingLiteral => Some(Type::Nothing), // Primitive names match case-insensitively (`as Text` == `as text`): @@ -34,6 +36,103 @@ fn type_from_token(token: &Token) -> Option { } } +/// Maps the older colon-style container syntax (`property x: T`, +/// `action f needs x: T`) without changing its historical custom-type +/// interpretation. Before keyword-backed types were supported here, only the +/// exact spellings below were primitives; lowercase or mixed-case identifiers +/// such as a user container named `number` remained custom types. +pub(crate) fn colon_type_from_token(token: &Token) -> Option { + match token { + Token::KeywordText => Some(Type::Text), + Token::KeywordPattern => Some(Type::Pattern), + Token::KeywordAny => Some(Type::Any), + Token::KeywordDate => Some(Type::Date), + Token::KeywordTime => Some(Type::Time), + Token::NothingLiteral => Some(Type::Nothing), + Token::Identifier(type_name) => Some(match type_name.as_str() { + "Text" => Type::Text, + "Number" => Type::Number, + "Boolean" => Type::Boolean, + "Nothing" => Type::Nothing, + "Pattern" => Type::Pattern, + _ => Type::Custom(type_name.clone()), + }), + _ => None, + } +} + +fn is_action_return_type_marker(token: &Token, keyword: Token, spelling: &str) -> bool { + token == &keyword + || matches!(token, Token::Identifier(name) if name.eq_ignore_ascii_case(spelling)) +} + +impl<'a> Parser<'a> { + /// Parse the recursive type syntax used between the two colons in a typed + /// action header (`name: List of Optional of Number:`). + /// + /// This deliberately returns `None` without recording an error. The caller + /// parses speculatively after the normal action-body colon and rewinds when + /// there is no second colon, preserving legacy same-line action bodies. + fn parse_action_return_type(&mut self) -> Option { + let token = self.cursor.peek()?.token.clone(); + + // CodeFixer capitalizes these contextual keywords, so accept both the + // keyword token and its identifier spelling inside the framed header. + if is_action_return_type_marker(&token, Token::KeywordList, "list") { + self.bump_sync(); + return self + .parse_action_return_type_after_of() + .map(|inner| Type::List(Box::new(inner))); + } + if is_action_return_type_marker(&token, Token::KeywordMap, "map") { + self.bump_sync(); + if !self.consume_action_type_token(Token::KeywordOf) { + return None; + } + let key = self.parse_action_return_type()?; + if !self.consume_action_type_token(Token::KeywordTo) { + return None; + } + let value = self.parse_action_return_type()?; + return Some(Type::Map(Box::new(key), Box::new(value))); + } + if is_action_return_type_marker(&token, Token::KeywordOptional, "optional") { + self.bump_sync(); + return self + .parse_action_return_type_after_of() + .map(|inner| Type::Optional(Box::new(inner))); + } + if is_action_return_type_marker(&token, Token::KeywordBinary, "binary") { + self.bump_sync(); + return Some(Type::Binary); + } + + let parsed = type_from_token(&token)?; + self.bump_sync(); + Some(parsed) + } + + fn parse_action_return_type_after_of(&mut self) -> Option { + if !self.consume_action_type_token(Token::KeywordOf) { + return None; + } + self.parse_action_return_type() + } + + fn consume_action_type_token(&mut self, expected: Token) -> bool { + if self + .cursor + .peek() + .is_some_and(|token| token.token == expected) + { + self.bump_sync(); + true + } else { + false + } + } +} + pub(crate) trait ActionParser<'a>: ExprParser<'a> { fn parse_action_definition(&mut self) -> Result where @@ -207,43 +306,6 @@ impl<'a> ActionParser<'a> for Parser<'a> { } } - let return_type = if let Some(token) = self.cursor.peek() { - if let Token::Identifier(id) = &token.token { - if id.to_lowercase() == "returns" { - self.bump_sync(); // Consume "returns" - - if let Some(type_token) = self.cursor.peek() { - if let Some(typ) = type_from_token(&type_token.token) { - self.bump_sync(); - Some(typ) - } else { - let err_token = type_token.clone(); - return Err(ParseError::from_token( - format!( - "Expected type name after 'returns', found {:?}", - err_token.token - ), - &err_token, - )); - } - } else { - return Err(ParseError::from_span( - "Unexpected end of input after 'returns'".to_string(), - crate::diagnostics::Span { start: 0, end: 0 }, - 0, - 0, - )); - } - } else { - None - } - } else { - None - } - } else { - None - }; - // Check for KeywordAnd that might be mistakenly present after the last parameter if let Some(token) = self.cursor.peek() && let Token::Identifier(id) = &token.token @@ -258,6 +320,22 @@ impl<'a> ActionParser<'a> for Parser<'a> { } self.expect_token(Token::Colon, "Expected ':' after action definition")?; + let return_type_start = self.cursor.checkpoint(); + let return_type = match self.parse_action_return_type() { + Some(parsed_type) + if self + .cursor + .peek() + .is_some_and(|token| token.token == Token::Colon) => + { + self.bump_sync(); // Consume the typed header's closing colon. + Some(parsed_type) + } + _ => { + self.cursor.rewind(return_type_start); + None + } + }; // Skip any Eol tokens after the colon self.skip_eol(); @@ -392,30 +470,26 @@ impl<'a> ActionParser<'a> for Parser<'a> { // Check if the next token is actually a type identifier // If it's not, this colon just marks the start of the action body (no return type) if let Some(type_token) = self.cursor.peek() { - if let Token::Identifier(type_name) = &type_token.token { - // Check if this identifier is a valid type name - let is_type = matches!( - type_name.as_str(), - "Text" | "Number" | "Boolean" | "Nothing" | "Pattern" - ) || type_name.chars().next().is_some_and(|c| c.is_uppercase()); - - if is_type { - let name_str = type_name.clone(); - self.bump_sync(); // Consume type name - Some(match name_str.as_str() { - "Text" => Type::Text, - "Number" => Type::Number, - "Boolean" => Type::Boolean, - "Nothing" => Type::Nothing, - "Pattern" => Type::Pattern, - _ => Type::Custom(name_str), - }) - } else { - // This identifier is not a type, so no return type specified - None + let is_explicit_type = match &type_token.token { + Token::KeywordText + | Token::KeywordPattern + | Token::KeywordAny + | Token::KeywordDate + | Token::KeywordTime + | Token::NothingLiteral => true, + Token::Identifier(type_name) => { + matches!( + type_name.as_str(), + "Text" | "Number" | "Boolean" | "Nothing" | "Pattern" + ) || type_name.chars().next().is_some_and(|c| c.is_uppercase()) } + _ => false, + }; + if is_explicit_type { + let parsed = colon_type_from_token(&type_token.token); + self.bump_sync(); + parsed } else { - // Next token after ':' is not an identifier, so no return type None } } else { @@ -478,16 +552,11 @@ impl<'a> ActionParser<'a> for Parser<'a> { self.bump_sync(); // Consume ':' if let Some(type_name_token) = self.cursor.peek() { - if let Token::Identifier(type_name) = &type_name_token.token { + if let Some(parameter_type) = + colon_type_from_token(&type_name_token.token) + { self.bump_sync(); // Consume type name - Some(match type_name.as_str() { - "Text" => Type::Text, - "Number" => Type::Number, - "Boolean" => Type::Boolean, - "Nothing" => Type::Nothing, - "Pattern" => Type::Pattern, - _ => Type::Custom(type_name.clone()), - }) + Some(parameter_type) } else { let err_token = type_name_token.clone(); return Err(ParseError::from_token( diff --git a/src/parser/stmt/containers.rs b/src/parser/stmt/containers.rs index 8387a42a..79eedc44 100644 --- a/src/parser/stmt/containers.rs +++ b/src/parser/stmt/containers.rs @@ -4,6 +4,7 @@ use super::super::{ Argument, EventDefinition, ParseError, Parser, PropertyDefinition, PropertyInitializer, Statement, Type, Visibility, }; +use super::actions::colon_type_from_token; use super::{ActionParser, StmtParser}; use crate::lexer::token::Token; use crate::parser::expr::ExprParser; @@ -49,6 +50,50 @@ pub(crate) trait ContainerParser<'a>: ExprParser<'a> + ActionParser<'a> { ) -> Result<(Vec, Vec), ParseError>; } +impl<'a> Parser<'a> { + /// Parse the active colon-style property type grammar. `List` without an + /// element annotation is explicitly dynamic; `List of T` retains `T`, and + /// nesting is recursive. Other spellings keep the historical colon-style + /// case rules in `colon_type_from_token`. + fn parse_property_type_annotation( + &mut self, + property_token: &crate::lexer::token::TokenWithPosition, + ) -> Result { + let Some(type_token) = self.cursor.peek().cloned() else { + return Err(ParseError::from_token( + "Expected type name after ':'".to_string(), + property_token, + )); + }; + + let is_list = matches!(type_token.token, Token::KeywordList) + || matches!(&type_token.token, Token::Identifier(name) if name == "List"); + if is_list { + self.bump_sync(); + if self + .cursor + .peek() + .is_some_and(|token| token.token == Token::KeywordOf) + { + self.bump_sync(); + let element_type = self.parse_property_type_annotation(&type_token)?; + return Ok(Type::List(Box::new(element_type))); + } + return Ok(Type::List(Box::new(Type::Any))); + } + + if let Some(property_type) = colon_type_from_token(&type_token.token) { + self.bump_sync(); + Ok(property_type) + } else { + Err(ParseError::from_token( + "Expected type name after ':'".to_string(), + &type_token, + )) + } + } +} + impl<'a> ContainerParser<'a> for Parser<'a> { fn parse_container_definition(&mut self) -> Result where @@ -548,29 +593,7 @@ impl<'a> ContainerParser<'a> for Parser<'a> { if token.token == Token::Colon { self.bump_sync(); // Consume ':' - if let Some(type_token) = self.cursor.peek() { - if let Token::Identifier(type_name) = &type_token.token { - self.bump_sync(); // Consume type name - Some(match type_name.as_str() { - "Text" => Type::Text, - "Number" => Type::Number, - "Boolean" => Type::Boolean, - "Nothing" => Type::Nothing, - "Pattern" => Type::Pattern, - _ => Type::Custom(type_name.clone()), - }) - } else { - return Err(ParseError::from_token( - "Expected type name after ':'".to_string(), - type_token, - )); - } - } else { - return Err(ParseError::from_token( - "Expected type name after ':'".to_string(), - start_token, - )); - } + Some(self.parse_property_type_annotation(start_token)?) } else { None } diff --git a/src/stdlib/core.rs b/src/stdlib/core.rs index f8699143..424ad6e4 100644 --- a/src/stdlib/core.rs +++ b/src/stdlib/core.rs @@ -27,7 +27,7 @@ pub fn native_isnothing(args: Vec) -> Result { check_arg_count("isnothing", &args, 1)?; match &args[0] { - Value::Null => Ok(Value::Bool(true)), + Value::Null | Value::Nothing => Ok(Value::Bool(true)), _ => Ok(Value::Bool(false)), } } @@ -50,3 +50,20 @@ pub fn register_core(env: &mut Environment) { // interpreter's version without shelling out to `wfl --version`. let _ = env.define("wfl_version", Value::Text(crate::version::VERSION.into())); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isnothing_accepts_both_legacy_no_value_variants() { + assert_eq!( + native_isnothing(vec![Value::Null]).unwrap(), + Value::Bool(true) + ); + assert_eq!( + native_isnothing(vec![Value::Nothing]).unwrap(), + Value::Bool(true) + ); + } +} diff --git a/src/stdlib/json.rs b/src/stdlib/json.rs index 65d60059..13643d81 100644 --- a/src/stdlib/json.rs +++ b/src/stdlib/json.rs @@ -167,6 +167,24 @@ mod tests { } } + #[test] + fn test_parse_json_null_preserves_legacy_nothing_identity() { + let result = native_parse_json(vec![Value::Text(Arc::from("null"))]) + .expect("JSON null should parse"); + assert!( + matches!(result, Value::Nothing), + "JSON null must preserve the legacy Nothing variant for typeof" + ); + assert_eq!( + crate::stdlib::core::native_typeof(vec![result.clone()]).unwrap(), + Value::Text(Arc::from("Nothing")) + ); + assert_eq!( + crate::stdlib::core::native_isnothing(vec![result]).unwrap(), + Value::Bool(true) + ); + } + #[test] fn test_stringify_json() { let mut obj = HashMap::new(); diff --git a/src/stdlib/list.rs b/src/stdlib/list.rs index 8b1cc0c7..956eb72a 100644 --- a/src/stdlib/list.rs +++ b/src/stdlib/list.rs @@ -727,10 +727,21 @@ mod tests { } #[test] - fn test_find_not_found() { + fn test_find_not_found_preserves_legacy_nothing_identity() { let list = make_list(vec![Value::Number(1.0)]); let result = native_find(vec![list, Value::Number(99.0)]).unwrap(); - assert_eq!(result, Value::Nothing); + assert!( + matches!(result, Value::Nothing), + "a failed find must preserve the legacy Nothing variant for typeof" + ); + assert_eq!( + crate::stdlib::core::native_typeof(vec![result.clone()]).unwrap(), + Value::Text(Arc::from("Nothing")) + ); + assert_eq!( + crate::stdlib::core::native_isnothing(vec![result]).unwrap(), + Value::Bool(true) + ); } #[test] diff --git a/src/stdlib/random.rs b/src/stdlib/random.rs index dae2905b..911a1e30 100644 --- a/src/stdlib/random.rs +++ b/src/stdlib/random.rs @@ -261,4 +261,21 @@ mod tests { assert_eq!(result1, result2, "Same seed should produce same values"); } + + #[test] + fn test_random_seed_preserves_legacy_nothing_identity() { + let result = native_random_seed(vec![Value::Number(42.0)]).unwrap(); + assert!( + matches!(result, Value::Nothing), + "random_seed must preserve its legacy Nothing variant for typeof" + ); + assert_eq!( + crate::stdlib::core::native_typeof(vec![result.clone()]).unwrap(), + Value::Text(Arc::from("Nothing")) + ); + assert_eq!( + crate::stdlib::core::native_isnothing(vec![result]).unwrap(), + Value::Bool(true) + ); + } } diff --git a/src/stdlib/typechecker.rs b/src/stdlib/typechecker.rs index f83c65bd..8241bc9a 100644 --- a/src/stdlib/typechecker.rs +++ b/src/stdlib/typechecker.rs @@ -1,569 +1,753 @@ use crate::analyzer::Analyzer; use crate::parser::ast::Type; +/// Register the static contracts for every native function installed by +/// `stdlib::register_stdlib`. +/// +/// `Type::Any` is used only for values that are known to be dynamically typed +/// at runtime (for example parsed JSON or an element from a heterogeneous +/// list). `Type::Unknown` is intentionally absent from these contracts because +/// it represents incomplete inference, not a runtime union. pub fn register_stdlib_types(analyzer: &mut Analyzer) { - register_print(analyzer); - register_typeof(analyzer); - register_isnothing(analyzer); - - register_abs(analyzer); - register_round(analyzer); - register_floor(analyzer); - register_ceil(analyzer); + register_core(analyzer); + register_math(analyzer); register_random(analyzer); - register_clamp(analyzer); - - register_text_length(analyzer); - register_touppercase(analyzer); - register_tolowercase(analyzer); - register_substring(analyzer); - register_starts_with(analyzer); - register_ends_with(analyzer); - - register_list_length(analyzer); - register_push(analyzer); - register_pop(analyzer); - register_contains(analyzer); - register_indexof(analyzer); - - register_pattern_matches(analyzer); - register_pattern_find(analyzer); - register_pattern_replace(analyzer); - register_pattern_split(analyzer); - - register_parse_json(analyzer); - register_stringify_json(analyzer); - register_stringify_json_pretty(analyzer); - - register_parse_query_string(analyzer); - register_parse_cookies(analyzer); - register_parse_form_urlencoded(analyzer); - - register_path_params(analyzer); - register_path_matches(analyzer); - register_parse_multipart(analyzer); - - register_generate_uuid(analyzer); - register_generate_csrf_token(analyzer); - - register_wflhash256(analyzer); - register_wflhash512(analyzer); - register_wflhash256_with_salt(analyzer); - register_wflmac256(analyzer); - register_sha256(analyzer); - register_hmac_sha256(analyzer); - register_auth_primitives(analyzer); - register_password_hashing(analyzer); - register_count_lines(analyzer); - register_path_extension(analyzer); - register_path_stem(analyzer); - register_file_size(analyzer); - register_copy_file(analyzer); - register_move_file(analyzer); - register_remove_file(analyzer); - register_remove_dir(analyzer); + register_text(analyzer); + register_list(analyzer); + register_pattern(analyzer); + register_json(analyzer); + register_web(analyzer); + register_crypto(analyzer); + register_filesystem(analyzer); + register_time(analyzer); +} + +/// The repeated parameter contract for the two variadic native functions. +pub(crate) fn variadic_builtin_parameter_type(name: &str) -> Option { + match name { + "print" => Some(Type::Any), + "path_join" => Some(Type::Text), + _ => None, + } } -fn register_print(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - let param_types = vec![]; // Variadic, accepts any number of arguments - - analyzer.register_builtin_function("print", param_types, return_type); +fn register(analyzer: &mut Analyzer, names: &[&str], parameters: Vec, result: Type) { + for name in names { + analyzer.register_builtin_function(name, parameters.clone(), result.clone()); + } } -fn register_typeof(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Unknown]; // Accepts any type - - analyzer.register_builtin_function("typeof", param_types.clone(), return_type.clone()); - - analyzer.register_builtin_function("type_of", param_types, return_type); +fn register_same_result_overloads( + analyzer: &mut Analyzer, + names: &[&str], + parameter_sets: impl IntoIterator>, + result: Type, +) { + for parameters in parameter_sets { + register(analyzer, names, parameters, result.clone()); + } } -fn register_isnothing(analyzer: &mut Analyzer) { - let return_type = Type::Boolean; - let param_types = vec![Type::Unknown]; // Accepts any type - - analyzer.register_builtin_function("isnothing", param_types.clone(), return_type.clone()); - - analyzer.register_builtin_function("is_nothing", param_types, return_type); +fn list(element: Type) -> Type { + Type::List(Box::new(element)) } -fn register_abs(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Number]; - - analyzer.register_builtin_function("abs", param_types, return_type); +fn map(key: Type, value: Type) -> Type { + Type::Map(Box::new(key), Box::new(value)) } -fn register_round(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Number]; - - analyzer.register_builtin_function("round", param_types, return_type); +fn repeated(value: Type, count: usize) -> Vec { + vec![value; count] } -fn register_floor(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Number]; - - analyzer.register_builtin_function("floor", param_types, return_type); +fn register_core(analyzer: &mut Analyzer) { + // `print` is variadic; its repeated Any contract is enforced separately. + register(analyzer, &["print"], vec![], Type::Nothing); + register( + analyzer, + &["typeof", "type_of"], + vec![Type::Any], + Type::Text, + ); + register( + analyzer, + &["isnothing", "is_nothing"], + vec![Type::Any], + Type::Boolean, + ); } -fn register_ceil(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Number]; - - analyzer.register_builtin_function("ceil", param_types, return_type); +fn register_math(analyzer: &mut Analyzer) { + register( + analyzer, + &["abs", "round", "floor", "ceil", "sqrt", "sin", "cos", "tan"], + vec![Type::Number], + Type::Number, + ); + register( + analyzer, + &["min", "max", "power"], + vec![Type::Number, Type::Number], + Type::Number, + ); + register( + analyzer, + &["clamp"], + vec![Type::Number, Type::Number, Type::Number], + Type::Number, + ); } fn register_random(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![]; // No parameters - - analyzer.register_builtin_function("random", param_types, return_type); -} - -fn register_clamp(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Number, Type::Number, Type::Number]; - - analyzer.register_builtin_function("clamp", param_types, return_type); -} - -fn register_text_length(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("length", param_types, return_type); -} - -fn register_touppercase(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("touppercase", param_types.clone(), return_type.clone()); - - analyzer.register_builtin_function("to_uppercase", param_types, return_type); -} - -fn register_tolowercase(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("tolowercase", param_types.clone(), return_type.clone()); - - analyzer.register_builtin_function("to_lowercase", param_types, return_type); -} - -fn register_substring(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text, Type::Number, Type::Number]; - - analyzer.register_builtin_function("substring", param_types, return_type); -} - -fn register_starts_with(analyzer: &mut Analyzer) { - let return_type = Type::Boolean; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("starts_with", param_types.clone(), return_type.clone()); - analyzer.register_builtin_function("startswith", param_types, return_type); -} - -fn register_ends_with(analyzer: &mut Analyzer) { - let return_type = Type::Boolean; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("ends_with", param_types.clone(), return_type.clone()); - analyzer.register_builtin_function("endswith", param_types, return_type); -} - -fn register_list_length(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::List(Box::new(Type::Unknown))]; - - analyzer.register_builtin_function("length", param_types, return_type); -} - -fn register_push(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - let param_types = vec![Type::List(Box::new(Type::Unknown)), Type::Unknown]; - - analyzer.register_builtin_function("push", param_types, return_type); -} - -fn register_pop(analyzer: &mut Analyzer) { - let return_type = Type::Unknown; - let param_types = vec![Type::List(Box::new(Type::Unknown))]; - - analyzer.register_builtin_function("pop", param_types, return_type); -} - -fn register_contains(analyzer: &mut Analyzer) { - let return_type = Type::Boolean; - - // Register list version: contains(list, item) - let list_params = vec![Type::List(Box::new(Type::Unknown)), Type::Unknown]; - analyzer.register_builtin_function("contains", list_params, return_type.clone()); - - // Register text version: contains(text, substring) - let text_params = vec![Type::Text, Type::Text]; - analyzer.register_builtin_function("contains", text_params, return_type); -} - -fn register_indexof(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::List(Box::new(Type::Unknown)), Type::Unknown]; - - analyzer.register_builtin_function("indexof", param_types.clone(), return_type.clone()); - - analyzer.register_builtin_function("index_of", param_types, return_type); -} - -fn register_pattern_matches(analyzer: &mut Analyzer) { - let return_type = Type::Boolean; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("matches_pattern", param_types, return_type); -} - -fn register_pattern_find(analyzer: &mut Analyzer) { - let return_type = Type::Map(Box::new(Type::Text), Box::new(Type::Text)); - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("find_pattern", param_types, return_type); -} - -fn register_pattern_replace(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text, Type::Text, Type::Text]; - - analyzer.register_builtin_function("replace_pattern", param_types, return_type); + register(analyzer, &["random"], vec![], Type::Number); + register(analyzer, &["random_boolean"], vec![], Type::Boolean); + register(analyzer, &["generate_uuid"], vec![], Type::Text); + register( + analyzer, + &["random_between", "random_int"], + vec![Type::Number, Type::Number], + Type::Number, + ); + register(analyzer, &["random_from"], vec![list(Type::Any)], Type::Any); + register( + analyzer, + &["random_seed"], + vec![Type::Number], + Type::Nothing, + ); } -fn register_pattern_split(analyzer: &mut Analyzer) { - let return_type = Type::List(Box::new(Type::Text)); - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("split_by_pattern", param_types, return_type); +fn register_text(analyzer: &mut Analyzer) { + register( + analyzer, + &[ + "touppercase", + "to_uppercase", + "tolowercase", + "to_lowercase", + "trim", + "capitalize", + "reverse", + "reverse_text", + ], + vec![Type::Text], + Type::Text, + ); + register( + analyzer, + &["substring"], + vec![Type::Text, Type::Number, Type::Number], + Type::Text, + ); + register( + analyzer, + &["string_split", "split"], + vec![Type::Text, Type::Text], + list(Type::Text), + ); + register( + analyzer, + &["starts_with", "startswith", "ends_with", "endswith"], + vec![Type::Text, Type::Text], + Type::Boolean, + ); + register( + analyzer, + &["replace"], + vec![Type::Text, Type::Text, Type::Text], + Type::Text, + ); + register( + analyzer, + &["last_index_of", "lastindexof"], + vec![Type::Text, Type::Text], + Type::Number, + ); + register( + analyzer, + &["padleft", "padright"], + vec![Type::Text, Type::Number], + Type::Text, + ); + register( + analyzer, + &["format_number"], + vec![Type::Number, Type::Number], + Type::Text, + ); + register( + analyzer, + &[ + "parse_query_string", + "parse_cookies", + "parse_form_urlencoded", + ], + vec![Type::Text], + map(Type::Text, Type::Text), + ); } -fn register_wflhash256(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; +fn register_list(analyzer: &mut Analyzer) { + let dynamic_list = list(Type::Any); - analyzer.register_builtin_function("wflhash256", param_types, return_type); -} + register_same_result_overloads( + analyzer, + &["length", "size"], + [ + vec![dynamic_list.clone()], + vec![Type::Text], + vec![Type::Binary], + ], + Type::Number, + ); + register_same_result_overloads( + analyzer, + &["contains", "includes"], + [ + vec![dynamic_list.clone(), Type::Any], + vec![Type::Text, Type::Text], + ], + Type::Boolean, + ); + register_same_result_overloads( + analyzer, + &["indexof", "index_of", "find_index"], + [ + vec![dynamic_list.clone(), Type::Any], + vec![Type::Text, Type::Text], + ], + Type::Number, + ); -fn register_wflhash512(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; + register( + analyzer, + &["push", "unshift"], + vec![dynamic_list.clone(), Type::Any], + Type::Nothing, + ); + register( + analyzer, + &["insert_at", "insertat"], + vec![dynamic_list.clone(), Type::Number, Type::Any], + Type::Nothing, + ); + register( + analyzer, + &["clear", "sort", "reverse_list"], + vec![dynamic_list.clone()], + Type::Nothing, + ); + register( + analyzer, + &["fill"], + vec![dynamic_list.clone(), Type::Any], + Type::Nothing, + ); - analyzer.register_builtin_function("wflhash512", param_types, return_type); -} + register( + analyzer, + &["pop", "shift"], + vec![dynamic_list.clone()], + Type::Any, + ); + register( + analyzer, + &["remove_at", "removeat"], + vec![dynamic_list.clone(), Type::Number], + Type::Any, + ); -fn register_wflhash256_with_salt(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text, Type::Text]; + register( + analyzer, + &["slice"], + vec![dynamic_list.clone(), Type::Number, Type::Number], + dynamic_list.clone(), + ); + register( + analyzer, + &["concat"], + vec![dynamic_list.clone(), dynamic_list.clone()], + dynamic_list.clone(), + ); + register( + analyzer, + &["unique"], + vec![dynamic_list.clone()], + dynamic_list.clone(), + ); - analyzer.register_builtin_function("wflhash256_with_salt", param_types, return_type); + register( + analyzer, + &["join"], + vec![dynamic_list.clone(), Type::Text], + Type::Text, + ); + register( + analyzer, + &["count"], + vec![dynamic_list.clone(), Type::Any], + Type::Number, + ); + register( + analyzer, + &["find"], + vec![dynamic_list.clone(), Type::Any], + Type::Optional(Box::new(Type::Any)), + ); + register( + analyzer, + &["every", "some"], + vec![dynamic_list, Type::Any], + Type::Boolean, + ); } -fn register_wflmac256(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("wflmac256", param_types, return_type); +fn register_pattern(analyzer: &mut Analyzer) { + let parameters = vec![Type::Text, Type::Pattern]; + register( + analyzer, + &["pattern_matches"], + parameters.clone(), + Type::Boolean, + ); + register( + analyzer, + &["pattern_find"], + parameters.clone(), + Type::Optional(Box::new(map(Type::Text, Type::Any))), + ); + register( + analyzer, + &["pattern_find_all"], + parameters, + list(map(Type::Text, Type::Any)), + ); } -fn register_sha256(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; +fn register_json(analyzer: &mut Analyzer) { + register(analyzer, &["parse_json"], vec![Type::Text], Type::Any); - analyzer.register_builtin_function("sha256", param_types, return_type); + let json_values = [ + Type::Nothing, + Type::Boolean, + Type::Number, + Type::Text, + list(Type::Any), + map(Type::Text, Type::Any), + ]; + for value_type in json_values { + register( + analyzer, + &["stringify_json", "stringify_json_pretty"], + vec![value_type], + Type::Text, + ); + } } -fn register_hmac_sha256(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("hmac_sha256", param_types, return_type); +fn register_web(analyzer: &mut Analyzer) { + register( + analyzer, + &["path_params"], + vec![Type::Text, Type::Text], + Type::Optional(Box::new(map(Type::Text, Type::Text))), + ); + register( + analyzer, + &["path_matches"], + vec![Type::Text, Type::Text], + Type::Boolean, + ); + register(analyzer, &["mime_type"], vec![Type::Text], Type::Text); + + let parts = list(map(Type::Text, Type::Any)); + register_same_result_overloads( + analyzer, + &["parse_multipart"], + [vec![Type::Text, Type::Text], vec![Type::Binary, Type::Text]], + parts, + ); } -fn register_auth_primitives(analyzer: &mut Analyzer) { - // pbkdf2_hmac_sha256 of password and salt and iterations and length -> Text - analyzer.register_builtin_function( - "pbkdf2_hmac_sha256", +fn register_crypto(analyzer: &mut Analyzer) { + register( + analyzer, + &["wflhash256", "wflhash512", "sha256"], + vec![Type::Text], + Type::Text, + ); + register( + analyzer, + &["wflhash256_with_salt", "wflmac256", "hmac_sha256"], + vec![Type::Text, Type::Text], + Type::Text, + ); + register(analyzer, &["generate_csrf_token"], vec![], Type::Text); + register( + analyzer, + &["pbkdf2_hmac_sha256"], vec![Type::Text, Type::Text, Type::Number, Type::Number], Type::Text, ); - // constant_time_equals of a and b -> Boolean - analyzer.register_builtin_function( - "constant_time_equals", + register( + analyzer, + &["constant_time_equals"], + vec![Type::Text, Type::Text], + Type::Boolean, + ); + register( + analyzer, + &["secure_random_bytes"], + vec![Type::Number], + Type::Text, + ); + register( + analyzer, + &[ + "hash_password", + "argon2_hash", + "bcrypt_hash", + "scrypt_hash", + "pbkdf2_hash", + ], + vec![Type::Text], + Type::Text, + ); + register( + analyzer, + &[ + "verify_password", + "argon2_verify", + "bcrypt_verify", + "scrypt_verify", + "pbkdf2_verify", + ], vec![Type::Text, Type::Text], Type::Boolean, ); - // secure_random_bytes of n -> Text (hex) - analyzer.register_builtin_function("secure_random_bytes", vec![Type::Number], Type::Text); -} - -fn register_password_hashing(analyzer: &mut Analyzer) { - // `*_hash of password` -> Text (a PHC/MCF hash string) - for name in [ - "hash_password", - "argon2_hash", - "bcrypt_hash", - "scrypt_hash", - "pbkdf2_hash", - ] { - analyzer.register_builtin_function(name, vec![Type::Text], Type::Text); - } - // `*_verify of password and stored_hash` -> Boolean - for name in [ - "verify_password", - "argon2_verify", - "bcrypt_verify", - "scrypt_verify", - "pbkdf2_verify", - ] { - analyzer.register_builtin_function(name, vec![Type::Text, Type::Text], Type::Boolean); - } -} - -fn register_count_lines(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Text]; // Takes a file path as text - - analyzer.register_builtin_function("count_lines", param_types, return_type); -} - -fn register_path_extension(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("path_extension", param_types, return_type); -} - -fn register_path_stem(analyzer: &mut Analyzer) { - let return_type = Type::Text; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("path_stem", param_types, return_type); -} - -fn register_file_size(analyzer: &mut Analyzer) { - let return_type = Type::Number; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("file_size", param_types, return_type); -} - -fn register_copy_file(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("copy_file", param_types, return_type); -} - -fn register_move_file(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - let param_types = vec![Type::Text, Type::Text]; - - analyzer.register_builtin_function("move_file", param_types, return_type); -} - -fn register_remove_file(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - let param_types = vec![Type::Text]; - - analyzer.register_builtin_function("remove_file", param_types, return_type); -} - -fn register_remove_dir(analyzer: &mut Analyzer) { - let return_type = Type::Nothing; - - // Register 1-arg version (non-recursive) - let param_types = vec![Type::Text]; - analyzer.register_builtin_function("remove_dir", param_types.clone(), return_type.clone()); - - // Register 2-arg version (with recursive flag) - let param_types_with_recursive = vec![Type::Text, Type::Boolean]; - analyzer.register_builtin_function("remove_dir", param_types_with_recursive, return_type); -} - -fn register_parse_json(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text]; // JSON string - let return_type = Type::Unknown; // Can return object, list, text, number, boolean, or nothing - - analyzer.register_builtin_function("parse_json", param_types, return_type); -} - -fn register_stringify_json(analyzer: &mut Analyzer) { - let param_types = vec![Type::Unknown]; // Accepts any value - let return_type = Type::Text; // Returns JSON string - - analyzer.register_builtin_function("stringify_json", param_types, return_type); -} - -fn register_stringify_json_pretty(analyzer: &mut Analyzer) { - let param_types = vec![Type::Unknown]; // Accepts any value - let return_type = Type::Text; // Returns pretty-printed JSON string - - analyzer.register_builtin_function("stringify_json_pretty", param_types, return_type); -} - -fn register_parse_query_string(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text]; // Query string - let return_type = Type::Unknown; // Returns object with string values - - analyzer.register_builtin_function("parse_query_string", param_types, return_type); -} - -fn register_parse_cookies(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text]; // Cookie header - let return_type = Type::Unknown; // Returns object with cookie values - - analyzer.register_builtin_function("parse_cookies", param_types, return_type); -} - -fn register_parse_form_urlencoded(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text]; // Form data - let return_type = Type::Unknown; // Returns object with form values - - analyzer.register_builtin_function("parse_form_urlencoded", param_types, return_type); } -fn register_path_params(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text, Type::Text]; // Request path, route template - // Returns an object of text captures, or nothing on no match; the map - // typing lets `params["id"]` typecheck cleanly. - let return_type = Type::Map(Box::new(Type::Text), Box::new(Type::Text)); +fn register_filesystem(analyzer: &mut Analyzer) { + register(analyzer, &["list_dir"], vec![Type::Text], list(Type::Text)); + register( + analyzer, + &["glob", "rglob"], + vec![Type::Text, Type::Text], + list(Type::Text), + ); - analyzer.register_builtin_function("path_params", param_types, return_type); + // `path_join` is variadic; this one-argument signature supplies its result + // type while the repeated Text contract is enforced separately. + register(analyzer, &["path_join"], vec![Type::Text], Type::Text); + register( + analyzer, + &[ + "path_basename", + "path_dirname", + "path_extension", + "path_stem", + ], + vec![Type::Text], + Type::Text, + ); + register( + analyzer, + &["makedirs", "remove_file", "delete_file"], + vec![Type::Text], + Type::Nothing, + ); + register( + analyzer, + &["file_mtime", "count_lines", "file_size"], + vec![Type::Text], + Type::Number, + ); + register( + analyzer, + &["path_exists", "is_file", "is_dir"], + vec![Type::Text], + Type::Boolean, + ); + register( + analyzer, + &["copy_file", "move_file"], + vec![Type::Text, Type::Text], + Type::Nothing, + ); + register_same_result_overloads( + analyzer, + &["remove_dir"], + [vec![Type::Text], vec![Type::Text, Type::Boolean]], + Type::Nothing, + ); } -fn register_path_matches(analyzer: &mut Analyzer) { - let param_types = vec![Type::Text, Type::Text]; // Request path, route template - let return_type = Type::Boolean; - - analyzer.register_builtin_function("path_matches", param_types, return_type); -} +fn register_time(analyzer: &mut Analyzer) { + let date = Type::Date; + let time = Type::Time; + let datetime = Type::DateTime; -fn register_parse_multipart(analyzer: &mut Analyzer) { - // body (text or binary) + Content-Type header value - let param_types = vec![Type::Any, Type::Text]; - let return_type = Type::List(Box::new(Type::Unknown)); // list of part objects + register(analyzer, &["today"], vec![], date.clone()); + register(analyzer, &["now"], vec![], time.clone()); + register( + analyzer, + &["datetime_now", "utc_now"], + vec![], + datetime.clone(), + ); + register(analyzer, &["current_date"], vec![], Type::Text); - analyzer.register_builtin_function("parse_multipart", param_types, return_type); -} + register( + analyzer, + &["format_date"], + vec![date.clone(), Type::Text], + Type::Text, + ); + register( + analyzer, + &["format_time"], + vec![time.clone(), Type::Text], + Type::Text, + ); + register( + analyzer, + &["format_datetime"], + vec![datetime.clone(), Type::Text], + Type::Text, + ); + register( + analyzer, + &["parse_date"], + vec![Type::Text, Type::Text], + date.clone(), + ); + register( + analyzer, + &["parse_time"], + vec![Type::Text, Type::Text], + time.clone(), + ); -fn register_generate_uuid(analyzer: &mut Analyzer) { - let param_types = vec![]; // No arguments - let return_type = Type::Text; // Returns UUID string + register_same_result_overloads( + analyzer, + &["create_time"], + [repeated(Type::Number, 2), repeated(Type::Number, 3)], + time.clone(), + ); + register( + analyzer, + &["create_date"], + repeated(Type::Number, 3), + date.clone(), + ); + register_same_result_overloads( + analyzer, + &["create_datetime"], + (3..=6).map(|count| repeated(Type::Number, count)), + datetime.clone(), + ); - analyzer.register_builtin_function("generate_uuid", param_types, return_type); -} + register( + analyzer, + &["add_days", "subtract_days"], + vec![date.clone(), Type::Number], + date.clone(), + ); + register( + analyzer, + &["days_between"], + vec![date.clone(), date.clone()], + Type::Number, + ); + register( + analyzer, + &["date_part"], + vec![datetime.clone()], + date.clone(), + ); + register( + analyzer, + &["time_part"], + vec![datetime.clone()], + time.clone(), + ); -fn register_generate_csrf_token(analyzer: &mut Analyzer) { - let param_types = vec![]; // No arguments - let return_type = Type::Text; // Returns random token string + register_same_result_overloads( + analyzer, + &[ + "year", + "month", + "day", + "dayofweek", + "day_of_week", + "dayofyear", + "day_of_year", + "week_of_year", + ], + [vec![date.clone()], vec![datetime.clone()]], + Type::Number, + ); + register_same_result_overloads( + analyzer, + &["hour", "minute", "second"], + [vec![time.clone()], vec![datetime.clone()]], + Type::Number, + ); + register_same_result_overloads( + analyzer, + &["is_leap_year", "isleapyear"], + [ + vec![Type::Number], + vec![date.clone()], + vec![datetime.clone()], + ], + Type::Boolean, + ); + register( + analyzer, + &["days_in_month"], + vec![Type::Number, Type::Number], + Type::Number, + ); - analyzer.register_builtin_function("generate_csrf_token", param_types, return_type); + register_same_result_overloads( + analyzer, + &["timestamp"], + [ + vec![], + vec![date.clone()], + vec![time.clone()], + vec![datetime.clone()], + ], + Type::Number, + ); + register( + analyzer, + &["datetime_from_timestamp"], + vec![Type::Number], + datetime.clone(), + ); + register_same_result_overloads( + analyzer, + &["time_diff"], + [ + vec![time.clone(), time.clone()], + vec![time.clone(), datetime.clone()], + vec![datetime.clone(), time], + vec![datetime.clone(), datetime], + ], + Type::Number, + ); } #[cfg(test)] mod tests { use super::*; - use crate::analyzer::Analyzer; + use crate::analyzer::SymbolKind; + use crate::builtins; + use crate::interpreter::environment::Environment; + use crate::interpreter::value::Value; + use std::collections::BTreeSet; + + fn contains_unknown(value_type: &Type) -> bool { + match value_type { + Type::Unknown => true, + Type::List(inner) | Type::Async(inner) => contains_unknown(inner), + Type::Map(key, value) => contains_unknown(key) || contains_unknown(value), + Type::Function { + parameters, + return_type, + } => parameters.iter().any(contains_unknown) || contains_unknown(return_type), + _ => false, + } + } #[test] - fn test_remove_dir_overload_registration() { - let mut analyzer = Analyzer::new(); - - // Register the overloaded remove_dir function - register_remove_dir(&mut analyzer); - - // This should succeed - 1-arg version - let one_arg_result = analyzer.get_symbol("remove_dir"); - assert!(one_arg_result.is_some(), "remove_dir should be registered"); - - // Check that we can find the function with appropriate signatures - let symbol = one_arg_result.unwrap(); - if let crate::analyzer::SymbolKind::Function { signatures } = &symbol.kind { - // After the fix, we should have both signatures - println!("Function has {} signatures", signatures.len()); + fn runtime_inventory_matches_installed_native_functions() { + let environment = Environment::new_global(); + crate::stdlib::register_stdlib(&mut environment.borrow_mut()); + + let actual: BTreeSet = environment + .borrow() + .values + .iter() + .filter_map(|(name, value)| { + matches!(value, Value::NativeFunction(_, _)).then_some(name.clone()) + }) + .collect(); + let catalogued: BTreeSet = builtins::implemented_builtin_functions() + .map(str::to_string) + .collect(); + + assert_eq!( + actual, catalogued, + "the native runtime and implemented-builtin catalog must stay synchronized" + ); + } - // Test that we have both 1-arg and 2-arg versions - assert_eq!( - signatures.len(), - 2, - "remove_dir should have both 1-arg and 2-arg versions" + #[test] + fn every_runtime_builtin_has_a_precise_static_contract() { + let mut analyzer = Analyzer::new(); + register_stdlib_types(&mut analyzer); + + for name in builtins::implemented_builtin_functions() { + let symbol = analyzer + .get_symbol(name) + .unwrap_or_else(|| panic!("runtime builtin '{name}' has no static contract")); + let SymbolKind::Function { signatures } = &symbol.kind else { + panic!("runtime builtin '{name}' was not registered as a function"); + }; + assert!( + !signatures.is_empty(), + "runtime builtin '{name}' has no callable signature" ); - - let has_one_param = signatures.iter().any(|sig| sig.parameters.len() == 1); - let has_two_param = signatures.iter().any(|sig| sig.parameters.len() == 2); - - assert!(has_one_param, "Should have 1-arg signature"); - assert!(has_two_param, "Should have 2-arg signature"); + for signature in signatures { + assert!( + signature + .parameters + .iter() + .filter_map(|parameter| parameter.param_type.as_ref()) + .all(|parameter| !contains_unknown(parameter)), + "runtime builtin '{name}' uses Unknown as a parameter wildcard" + ); + assert!( + signature + .return_type + .as_ref() + .is_some_and(|result| !contains_unknown(result)), + "runtime builtin '{name}' has an absent or Unknown result" + ); + } } } #[test] - fn test_function_overloading_issue() { + fn future_reserved_names_do_not_receive_fake_runtime_contracts() { let mut analyzer = Analyzer::new(); + register_stdlib_types(&mut analyzer); - // This test demonstrates the core issue: duplicate function registration fails - let return_type = Type::Nothing; - - // Register first version - should succeed - let param_types_1 = vec![Type::Text]; - analyzer.register_builtin_function("test_overload", param_types_1, return_type.clone()); - - // Register second version - currently fails silently - let param_types_2 = vec![Type::Text, Type::Boolean]; - analyzer.register_builtin_function("test_overload", param_types_2, return_type); - - // Lookup the function - let symbol = analyzer.get_symbol("test_overload"); - assert!(symbol.is_some(), "Function should be registered"); - - // This test will now pass after we fix the overloading mechanism - if let Some(sym) = symbol - && let crate::analyzer::SymbolKind::Function { signatures } = &sym.kind + for name in builtins::builtin_functions() + .filter(|name| !builtins::is_implemented_builtin_function(name)) { - // After the fix, should have multiple signatures - println!("test_overload function has {} signatures", signatures.len()); - // This assertion now tests that overloading works - assert_eq!( - signatures.len(), - 2, - "Should have both 1-arg and 2-arg signatures" + assert!( + analyzer.get_symbol(name).is_none(), + "reserved but unimplemented name '{name}' must not have a fake contract" ); } } #[test] - fn test_remove_dir_should_support_both_arities() { + fn remove_dir_registers_both_runtime_arities() { let mut analyzer = Analyzer::new(); - - // Register the overloaded remove_dir function - register_remove_dir(&mut analyzer); - - // Get the registered function + register_stdlib_types(&mut analyzer); let symbol = analyzer.get_symbol("remove_dir").unwrap(); - if let crate::analyzer::SymbolKind::Function { signatures } = &symbol.kind { - // After fixing the overloading, this should pass - assert_eq!( - signatures.len(), - 2, - "Both 1-arg and 2-arg versions should be supported" - ); - - // Check we have the right arities - let arities: Vec = signatures.iter().map(|sig| sig.parameters.len()).collect(); - assert!(arities.contains(&1), "Should have 1-arg version"); - assert!(arities.contains(&2), "Should have 2-arg version"); - } + let SymbolKind::Function { signatures } = &symbol.kind else { + panic!("remove_dir should be a function"); + }; + let arities: BTreeSet<_> = signatures + .iter() + .map(|signature| signature.parameters.len()) + .collect(); + assert_eq!(arities, BTreeSet::from([1, 2])); } } diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index 76574187..b232aaf2 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -1,10 +1,10 @@ -use crate::analyzer::{Analyzer, Symbol, SymbolKind}; +use crate::analyzer::{Analyzer, Symbol, SymbolBindingKey, SymbolKind}; use crate::builtins; use crate::parser::ast::{ Expression, Literal, Operator, Parameter, PatternExpression, Program, Statement, Type, - UnaryOperator, + UnaryOperator, WsHandlerEvent, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; #[derive(Debug, Clone)] @@ -102,6 +102,9 @@ impl fmt::Display for Type { Type::Boolean => write!(f, "Boolean"), Type::Nothing => write!(f, "Nothing"), Type::Pattern => write!(f, "Pattern"), + Type::Date => write!(f, "Date"), + Type::Time => write!(f, "Time"), + Type::DateTime => write!(f, "DateTime"), Type::Binary => write!(f, "Binary"), Type::Custom(name) => write!(f, "{name}"), Type::List(item_type) => write!(f, "List of {item_type}"), @@ -123,6 +126,7 @@ impl fmt::Display for Type { Type::Error => write!(f, "Error"), Type::Async(t) => write!(f, "Async<{t}>"), Type::Any => write!(f, "Any"), + Type::Optional(inner) => write!(f, "{inner} or Nothing"), Type::Container(name) => write!(f, "Container<{name}>"), Type::ContainerInstance(name) => write!(f, "Instance<{name}>"), Type::Interface(name) => write!(f, "Interface<{name}>"), @@ -130,11 +134,64 @@ impl fmt::Display for Type { } } +#[derive(Clone)] +enum ListMutationEffect { + Join(Type), + Replace(Type), + Escape, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ListAliasPath { + binding: SymbolBindingKey, + index_depth: usize, +} + +#[derive(Debug, Clone)] +struct RecordedReturn { + return_type: Type, + line: usize, + column: usize, + has_value: bool, + list_sources: Vec<(usize, HashSet)>, +} + +#[derive(Clone)] +struct DeferredSummarySnapshot { + list_effects: Option>, + binding_effects: Option>, + returns: Option>, + dependencies: Option>, +} + +type ActionSummaryKey = (String, usize); +type SharedListReturnProvenance = HashMap>; +type SymbolTypeSnapshot = Vec>>; +type BindingTypeSnapshot = HashMap>; +type ListAliasSnapshot = HashMap>; +type BlockFlowResult = (bool, Type); + +#[derive(Clone, Default)] +struct TryFlowAccumulator { + binding_types: BindingTypeSnapshot, + list_aliases: ListAliasSnapshot, +} + pub struct TypeChecker { analyzer: Analyzer, + /// Canonical stdlib signatures are kept separate from program symbols so + /// constructor choice (`new` versus production's `with_analyzer`) and user + /// declarations cannot silently remove or overwrite builtin contracts. + builtin_contracts: Analyzer, errors: Vec, analyzer_already_run: bool, current_container: Option, + current_method_is_static: Option, + /// For each property visible to the active method, records the lexical + /// binding (if any) that existed outside the method. Comparing the live + /// binding key with this baseline distinguishes a method parameter/local + /// from a same-named true outer binding even inside nested try/loop scopes. + current_method_outer_property_bindings: Option>>, /// True when the program contains `include from` statements. Included files /// expose their actions dynamically at runtime, so undefined-action errors /// are suppressed to match the analyzer (see issue #548). @@ -153,6 +210,49 @@ pub struct TypeChecker { /// definition checked), so overloaded call sites resolve their return /// type through this table instead. overload_returns: HashMap<(String, usize), Type>, + /// True only while checking a runtime-reachable later iteration of a loop + /// whose environment persists across iterations. A constant declaration + /// succeeds on the first iteration but would be a runtime redeclaration on + /// every reachable backedge. + checking_persistent_loop_backedge: bool, + /// Flow-sensitive may-alias groups for runtime list allocations. Bindings + /// are keyed by lexical identity rather than name so sibling/local scopes + /// can safely reuse names. Reassignment detaches a binding, while + /// control-flow joins union every alias relation reachable at that point. + list_alias_groups: HashMap>, + /// List paths a user action may mutate or expose from its captured + /// environment. Definition-time checking records these effects, then + /// restores the outer state; call sites apply the summary. + user_action_list_effects: HashMap>, + /// Scalar bindings that a user action may reassign. The value is the join + /// of every assigned type on a runtime-reachable path. Call sites join this + /// with the current flow type, preserving soundness when a closure mutates + /// an outer Optional value inside a narrowed branch. + user_action_binding_effects: HashMap>, + user_action_shared_list_returns: HashMap, + user_action_dependencies: HashMap>, + deferred_action_key_stack: Vec, + deferred_list_effect_stack: Vec>, + deferred_binding_effect_stack: Vec>, + deferred_return_type_stack: Vec>, + /// Streaming joins of every reachable intermediate state in each active + /// try body. Retaining one accumulator per nesting level avoids keeping a + /// full symbol/alias snapshot for every statement prefix. + try_flow_states: Vec, + try_flow_capture_suspended: usize, + /// The runtime value produced by the statement currently being checked. + /// Most statements produce Nothing; expression and control-flow statements + /// replace this slot. The wrapper saves/restores it across recursion. + current_statement_completion: Type, + /// Original Optional types for bindings narrowed by active guards. Opaque + /// user-code calls restore these instead of silently retaining a stale + /// present-value refinement. + optional_refinement_origins: HashMap, + has_websocket_handlers: bool, + /// Bindings whose current list value is statically known to contain at + /// least one element. This small cardinality fact lets a for-each over a + /// non-empty literal retain its guaranteed first-iteration effects. + definitely_nonempty_lists: HashSet, } impl Default for TypeChecker { @@ -162,19 +262,40 @@ impl Default for TypeChecker { } impl TypeChecker { - pub fn new() -> Self { + fn builtin_contract_analyzer() -> Analyzer { let mut analyzer = Analyzer::new(); - crate::stdlib::typechecker::register_stdlib_types(&mut analyzer); + analyzer + } + pub fn new() -> Self { TypeChecker { - analyzer, + analyzer: Analyzer::new(), + builtin_contracts: Self::builtin_contract_analyzer(), errors: Vec::new(), analyzer_already_run: false, current_container: None, + current_method_is_static: None, + current_method_outer_property_bindings: None, has_includes: false, budget_error: None, overload_returns: HashMap::new(), + checking_persistent_loop_backedge: false, + list_alias_groups: HashMap::new(), + user_action_list_effects: HashMap::new(), + user_action_binding_effects: HashMap::new(), + user_action_shared_list_returns: HashMap::new(), + user_action_dependencies: HashMap::new(), + deferred_action_key_stack: Vec::new(), + deferred_list_effect_stack: Vec::new(), + deferred_binding_effect_stack: Vec::new(), + deferred_return_type_stack: Vec::new(), + try_flow_states: Vec::new(), + try_flow_capture_suspended: 0, + current_statement_completion: Type::Nothing, + optional_refinement_origins: HashMap::new(), + has_websocket_handlers: false, + definitely_nonempty_lists: HashSet::new(), } } @@ -183,12 +304,31 @@ impl TypeChecker { pub fn with_analyzer(analyzer: Analyzer) -> Self { TypeChecker { analyzer, + builtin_contracts: Self::builtin_contract_analyzer(), errors: Vec::new(), analyzer_already_run: true, // Analyzer has already been run when passed in current_container: None, + current_method_is_static: None, + current_method_outer_property_bindings: None, has_includes: false, budget_error: None, overload_returns: HashMap::new(), + checking_persistent_loop_backedge: false, + list_alias_groups: HashMap::new(), + user_action_list_effects: HashMap::new(), + user_action_binding_effects: HashMap::new(), + user_action_shared_list_returns: HashMap::new(), + user_action_dependencies: HashMap::new(), + deferred_action_key_stack: Vec::new(), + deferred_list_effect_stack: Vec::new(), + deferred_binding_effect_stack: Vec::new(), + deferred_return_type_stack: Vec::new(), + try_flow_states: Vec::new(), + try_flow_capture_suspended: 0, + current_statement_completion: Type::Nothing, + optional_refinement_origins: HashMap::new(), + has_websocket_handlers: false, + definitely_nonempty_lists: HashSet::new(), } } @@ -235,52 +375,2072 @@ impl TypeChecker { let first_value = values.first().cloned().unwrap_or(None); let merged = if values.iter().all(|value| value == &first_value) { first_value - } else if values + } else if values.iter().any(Option::is_none) { + Some(Type::Unknown) + } else { + values + .into_iter() + .flatten() + .reduce(Self::join_inferred_types) + }; + joined_layer.insert(name, merged); + } + } + + joined + } + + /// Join values stored in a heterogeneous collection. `Any` is a real + /// known-dynamic type and therefore dominates; `Unknown` means inference + /// is incomplete and must never be narrowed merely by visiting a later + /// concrete value. + fn join_collection_value_type(current: Option, next: Type) -> Type { + let Some(current) = current else { + return next; + }; + Self::join_inferred_types(current, next) + } + + fn optionalize(ty: Type) -> Type { + match ty { + Type::Optional(_) | Type::Nothing => ty, + other => Type::Optional(Box::new(other)), + } + } + + /// Join two runtime-reachable types without discarding structure they are + /// guaranteed to share. A list remains a list when only its element types + /// differ; likewise for maps and async values. `Unknown` remains the + /// conservative "insufficient evidence" state, while `Any` represents a + /// genuine union at the exact position where types diverge. + fn join_inferred_types(left: Type, right: Type) -> Type { + if left == right { + return left; + } + match (left, right) { + (Type::Error, _) | (_, Type::Error) => Type::Error, + (Type::Optional(left), Type::Optional(right)) => { + Self::optionalize(Self::join_inferred_types(*left, *right)) + } + (Type::Optional(inner), Type::Nothing) | (Type::Nothing, Type::Optional(inner)) => { + Type::Optional(inner) + } + (Type::Optional(inner), other) | (other, Type::Optional(inner)) => { + Self::optionalize(Self::join_inferred_types(*inner, other)) + } + (Type::Unknown, _) | (_, Type::Unknown) => Type::Unknown, + (Type::Any, _) | (_, Type::Any) => Type::Any, + (Type::Nothing, other) | (other, Type::Nothing) => Self::optionalize(other), + (Type::List(left), Type::List(right)) => { + Type::List(Box::new(Self::join_inferred_types(*left, *right))) + } + (Type::Map(left_key, left_value), Type::Map(right_key, right_value)) => Type::Map( + Box::new(Self::join_inferred_types(*left_key, *right_key)), + Box::new(Self::join_inferred_types(*left_value, *right_value)), + ), + (Type::Async(left), Type::Async(right)) => { + Type::Async(Box::new(Self::join_inferred_types(*left, *right))) + } + _ => Type::Any, + } + } + + fn union_list_alias_bindings_in( + groups: &mut HashMap>, + left: ListAliasPath, + right: ListAliasPath, + ) { + let left_neighbors = groups + .get(&left) + .cloned() + .unwrap_or_else(|| HashSet::from([left.clone()])); + let right_neighbors = groups + .get(&right) + .cloned() + .unwrap_or_else(|| HashSet::from([right.clone()])); + + for member in &left_neighbors { + groups + .entry(member.clone()) + .or_insert_with(|| HashSet::from([member.clone()])) + .insert(right.clone()); + } + for member in &right_neighbors { + groups + .entry(member.clone()) + .or_insert_with(|| HashSet::from([member.clone()])) + .insert(left.clone()); + } + groups.entry(left).or_default().extend(right_neighbors); + groups.entry(right).or_default().extend(left_neighbors); + } + + fn union_list_alias_bindings(&mut self, left: ListAliasPath, right: ListAliasPath) { + Self::union_list_alias_bindings_in(&mut self.list_alias_groups, left, right); + } + + fn add_list_may_alias_edge(&mut self, left: ListAliasPath, right: ListAliasPath) { + self.list_alias_groups + .entry(left.clone()) + .or_insert_with(|| HashSet::from([left.clone()])) + .insert(right.clone()); + self.list_alias_groups + .entry(right.clone()) + .or_insert_with(|| HashSet::from([right.clone()])) + .insert(left); + } + + /// Record an alias reached through an aggregate path and materialize any + /// already-known descendants at the translated target depth. The alias + /// graph deliberately is not transitively closed because a branch join can + /// mean “A aliases B or C” without B ever aliasing C. Materializing only + /// structural descendants gives deep aggregate paths their real Rc + /// provenance without inventing that disjunctive B/C edge. + fn add_structural_list_alias(&mut self, source: ListAliasPath, target: ListAliasPath) { + let mut edges = vec![(source.clone(), target.clone())]; + for alias in self.list_alias_members_for_path(&source) { + if alias != source { + edges.push((alias, target.clone())); + } + } + + let descendants = self + .list_alias_groups + .keys() + .filter(|path| path.binding == source.binding && path.index_depth > source.index_depth) + .cloned() + .collect::>(); + for descendant in descendants { + let translated = ListAliasPath { + binding: target.binding.clone(), + index_depth: target.index_depth + descendant.index_depth - source.index_depth, + }; + for alias in self.list_alias_members_for_path(&descendant) { + if alias != descendant { + edges.push((alias, translated.clone())); + } + } + } + + for (left, right) in edges { + self.add_list_may_alias_edge(left, right); + } + } + + fn join_list_alias_snapshots( + states: &[HashMap>], + ) -> HashMap> { + let mut joined = HashMap::new(); + for state in states { + Self::merge_list_alias_snapshot_into(&mut joined, state); + } + joined + } + + fn merge_list_alias_snapshot_into(joined: &mut ListAliasSnapshot, state: &ListAliasSnapshot) { + for (left, members) in state { + for right in members { + joined + .entry(left.clone()) + .or_insert_with(|| HashSet::from([left.clone()])) + .insert(right.clone()); + joined + .entry(right.clone()) + .or_insert_with(|| HashSet::from([right.clone()])) + .insert(left.clone()); + } + } + } + + fn detach_list_alias_binding(&mut self, name: &str) { + let Some(binding) = self.analyzer.get_symbol_binding_key(name) else { + return; + }; + let detached = self + .list_alias_groups + .keys() + .filter(|path| path.binding == binding) + .cloned() + .collect::>(); + if detached.is_empty() { + return; + } + for path in &detached { + self.list_alias_groups.remove(path); + } + for group in self.list_alias_groups.values_mut() { + group.retain(|member| !detached.contains(member)); + } + self.list_alias_groups + .retain(|_, members| members.len() > 1); + } + + fn expression_is_definitely_nonempty_list(&self, expression: &Expression) -> bool { + match expression { + Expression::Literal(Literal::List(elements), ..) => !elements.is_empty(), + Expression::Variable(name, ..) => self + .analyzer + .get_symbol_binding_key(name) + .is_some_and(|binding| self.definitely_nonempty_lists.contains(&binding)), + _ => false, + } + } + + fn update_binding_nonempty_fact(&mut self, name: &str, is_nonempty: bool) { + let Some(binding) = self.analyzer.get_symbol_binding_key(name) else { + return; + }; + if is_nonempty { + self.definitely_nonempty_lists.insert(binding); + } else { + self.definitely_nonempty_lists.remove(&binding); + } + } + + fn mark_list_target_nonempty(&mut self, target: &Expression) { + let Some(path) = self.list_target_binding_path(target) else { + return; + }; + for member in self.list_alias_members_for_path(&path) { + if member.index_depth == 0 { + self.definitely_nonempty_lists.insert(member.binding); + } + } + } + + fn snapshot_deferred_summary(&self) -> DeferredSummarySnapshot { + DeferredSummarySnapshot { + list_effects: self.deferred_list_effect_stack.last().cloned(), + binding_effects: self.deferred_binding_effect_stack.last().cloned(), + returns: self.deferred_return_type_stack.last().cloned(), + dependencies: self.deferred_action_key_stack.last().map(|key| { + self.user_action_dependencies + .get(key) + .cloned() + .unwrap_or_default() + }), + } + } + + fn join_binding_effect( + effects: &mut HashMap, + binding: SymbolBindingKey, + effect_type: Type, + ) { + effects + .entry(binding) + .and_modify(|current| { + *current = Self::join_inferred_types(current.clone(), effect_type.clone()); + }) + .or_insert(effect_type); + } + + fn restore_deferred_summary(&mut self, snapshot: DeferredSummarySnapshot) { + if let (Some(current), Some(saved)) = ( + self.deferred_list_effect_stack.last_mut(), + snapshot.list_effects, + ) { + *current = saved; + } + if let (Some(current), Some(saved)) = ( + self.deferred_binding_effect_stack.last_mut(), + snapshot.binding_effects, + ) { + *current = saved; + } + if let (Some(current), Some(saved)) = + (self.deferred_return_type_stack.last_mut(), snapshot.returns) + { + *current = saved; + } + if let (Some(key), Some(saved)) = ( + self.deferred_action_key_stack.last().cloned(), + snapshot.dependencies, + ) { + self.user_action_dependencies.insert(key, saved); + } + } + + fn join_deferred_summaries( + &mut self, + entry: &DeferredSummarySnapshot, + endpoints: &[DeferredSummarySnapshot], + ) { + if let Some(current) = self.deferred_list_effect_stack.last_mut() { + *current = entry.list_effects.clone().unwrap_or_default(); + for endpoint in endpoints { + if let Some(effects) = &endpoint.list_effects { + current.extend(effects.iter().cloned()); + } + } + } + + if let Some(current) = self.deferred_binding_effect_stack.last_mut() { + *current = entry.binding_effects.clone().unwrap_or_default(); + for endpoint in endpoints { + if let Some(effects) = &endpoint.binding_effects { + for (binding, effect_type) in effects { + Self::join_binding_effect(current, binding.clone(), effect_type.clone()); + } + } + } + } + + if let Some(current) = self.deferred_return_type_stack.last_mut() { + *current = entry.returns.clone().unwrap_or_default(); + let entry_len = current.len(); + for endpoint in endpoints { + if let Some(returns) = &endpoint.returns { + current.extend(returns.iter().skip(entry_len).cloned()); + } + } + } + if let Some(key) = self.deferred_action_key_stack.last().cloned() { + let mut joined = entry.dependencies.clone().unwrap_or_default(); + for endpoint in endpoints { + if let Some(dependencies) = &endpoint.dependencies { + joined.extend(dependencies.iter().cloned()); + } + } + self.user_action_dependencies.insert(key, joined); + } + } + + /// Check every statement for diagnostics while retaining state only from + /// the runtime-reachable prefix. + fn check_statement_block_impl(&mut self, statements: &[Statement]) -> BlockFlowResult { + let mut can_continue = true; + let mut completion_type = Type::Nothing; + let mut terminal_state = None; + + for (index, statement) in statements.iter().enumerate() { + let was_reachable = can_continue; + if !was_reachable { + self.try_flow_capture_suspended += 1; + } + let statement_completion = self.check_statement_types(statement); + if !was_reachable { + self.try_flow_capture_suspended -= 1; + } + if self.budget_error.is_some() { + return (false, Type::Error); + } + + if was_reachable { + completion_type = statement_completion; + } + if was_reachable && Self::statement_definitely_stops_current_block(statement) { + can_continue = false; + if index + 1 < statements.len() { + terminal_state = Some(( + self.analyzer.snapshot_current_scope_symbols(), + self.analyzer.snapshot_symbol_types(), + self.list_alias_groups.clone(), + self.snapshot_deferred_summary(), + self.user_action_list_effects.clone(), + self.user_action_binding_effects.clone(), + self.user_action_shared_list_returns.clone(), + self.user_action_dependencies.clone(), + self.overload_returns.clone(), + self.optional_refinement_origins.clone(), + self.definitely_nonempty_lists.clone(), + )); + } + } + } + + if let Some(( + scope_symbols, + symbol_types, + aliases, + deferred, + action_effects, + action_binding_effects, + shared_returns, + dependencies, + overload_returns, + refinement_origins, + nonempty_lists, + )) = terminal_state + { + self.analyzer.restore_current_scope_symbols(scope_symbols); + self.analyzer.restore_symbol_types(symbol_types); + self.list_alias_groups = aliases; + self.restore_deferred_summary(deferred); + self.user_action_list_effects = action_effects; + self.user_action_binding_effects = action_binding_effects; + self.user_action_shared_list_returns = shared_returns; + self.user_action_dependencies = dependencies; + self.overload_returns = overload_returns; + self.optional_refinement_origins = refinement_origins; + self.definitely_nonempty_lists = nonempty_lists; + } + + (can_continue, completion_type) + } + + fn check_statement_block(&mut self, statements: &[Statement]) -> bool { + self.check_statement_block_impl(statements).0 + } + + fn check_statement_block_with_completion(&mut self, statements: &[Statement]) -> (bool, Type) { + self.check_statement_block_impl(statements) + } + + fn capture_active_try_flow_state(&mut self) { + if self.try_flow_states.is_empty() || self.try_flow_capture_suspended > 0 { + return; + } + let types = self + .analyzer + .live_binding_types() + .into_iter() + .collect::(); + let live_bindings = types.keys().cloned().collect::>(); + let alias_edges = self + .list_alias_groups + .values() + .fold(0usize, |count, members| count.saturating_add(members.len())); + let work_per_accumulator = types.len().saturating_add(alias_edges).max(1); + let work_units = work_per_accumulator.saturating_mul(self.try_flow_states.len()); + if !self.charge_try_flow_work(work_units) { + return; + } + + for state in &mut self.try_flow_states { + for (binding, accumulated_type) in &mut state.binding_types { + let current_type = types.get(binding).cloned().unwrap_or(None); + *accumulated_type = match (accumulated_type.take(), current_type) { + (Some(left), Some(right)) => Some(Self::join_inferred_types(left, right)), + _ => Some(Type::Unknown), + }; + } + Self::merge_list_alias_snapshot_into(&mut state.list_aliases, &self.list_alias_groups); + state.list_aliases.retain(|path, members| { + if !live_bindings.contains(&path.binding) { + return false; + } + members.retain(|member| live_bindings.contains(&member.binding)); + !members.is_empty() + }); + } + } + + fn charge_try_flow_work(&mut self, work_units: usize) -> bool { + let Some(budget) = crate::exec::budget::ExecutionBudget::current() else { + return true; + }; + for _ in 0..work_units { + if let Err(exceeded) = budget.charge_operation(!budget.is_deadline_exempt()) { + self.errors + .push(TypeError::new(exceeded.message(), None, None, 0, 0)); + self.budget_error = Some(exceeded); + return false; + } + } + true + } + + fn apply_try_binding_accumulator( + &mut self, + entry: SymbolTypeSnapshot, + state: &BindingTypeSnapshot, + ) -> SymbolTypeSnapshot { + self.analyzer.restore_symbol_types(entry); + for (binding, merged) in state { + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(binding) { + symbol.symbol_type = merged.clone(); + } + } + self.analyzer.snapshot_symbol_types() + } + + fn retain_live_alias_paths(&self, snapshot: &mut ListAliasSnapshot) { + snapshot.retain(|path, members| { + if !self.analyzer.binding_key_is_live(&path.binding) { + return false; + } + members.retain(|member| self.analyzer.binding_key_is_live(&member.binding)); + !members.is_empty() + }); + } + + fn prune_dead_list_alias_paths(&mut self) { + let dead = self + .list_alias_groups + .keys() + .filter(|path| !self.analyzer.binding_key_is_live(&path.binding)) + .cloned() + .collect::>(); + if dead.is_empty() { + return; + } + for path in &dead { + self.list_alias_groups.remove(path); + } + for members in self.list_alias_groups.values_mut() { + members.retain(|member| !dead.contains(member)); + } + self.list_alias_groups + .retain(|_, members| members.len() > 1); + } + + fn record_direct_list_alias( + &mut self, + target_name: &str, + value: &Expression, + value_type: &Type, + ) { + if !Self::type_may_be_list(value_type) { + return; + } + let target_is_list = self + .analyzer + .get_symbol(target_name) + .and_then(|symbol| symbol.symbol_type.as_ref()) + .is_some_and(|ty| { + matches!( + ty, + Type::List(_) | Type::Unknown | Type::Any | Type::Error + ) || matches!(ty, Type::Optional(inner) if matches!(inner.as_ref(), Type::List(_))) + }); + if !target_is_list { + return; + } + + if let Some(source) = self.list_target_binding_path(value) + && self.alias_path_may_be_list(&source) + { + if let Some(target) = self.analyzer.get_symbol_binding_key(target_name) { + self.union_list_alias_bindings( + source, + ListAliasPath { + binding: target, + index_depth: 0, + }, + ); + } + } else if matches!( + value, + Expression::MemberAccess { .. } + | Expression::PropertyAccess { .. } + | Expression::MethodCall { .. } + ) { + // The target now holds a shared list reached through a structural + // opaque path that the current AST/type model cannot name. + self.apply_list_mutation_effect(value, ListMutationEffect::Escape); + if let Some(target) = self.analyzer.get_symbol_mut(target_name) + && let Some(current_type) = target.symbol_type.clone() + && let Some(updated_type) = + Self::apply_effect_at_list_path(¤t_type, 0, &ListMutationEffect::Escape) + { + target.symbol_type = Some(updated_type); + } + } + } + + fn type_may_be_list(ty: &Type) -> bool { + matches!(ty, Type::List(_) | Type::Unknown | Type::Any | Type::Error) + || matches!(ty, Type::Optional(inner) if Self::type_may_be_list(inner)) + } + + fn type_at_alias_path(ty: &Type, index_depth: usize) -> Option { + if index_depth == 0 { + return Some(ty.clone()); + } + match ty { + Type::List(element) => Self::type_at_alias_path(element, index_depth - 1), + Type::Map(_, value) => Self::type_at_alias_path(value, index_depth - 1), + Type::Optional(inner) => Self::type_at_alias_path(inner, index_depth), + Type::Unknown | Type::Any | Type::Error => Some(ty.clone()), + _ => None, + } + } + + fn alias_path_may_be_list(&self, path: &ListAliasPath) -> bool { + self.analyzer + .get_symbol_by_binding_key(&path.binding) + .and_then(|symbol| symbol.symbol_type.as_ref()) + .and_then(|ty| Self::type_at_alias_path(ty, path.index_depth)) + .is_some_and(|ty| Self::type_may_be_list(&ty)) + } + + fn alias_path_may_contain_list(&self, path: &ListAliasPath) -> bool { + self.analyzer + .get_symbol_by_binding_key(&path.binding) + .and_then(|symbol| symbol.symbol_type.as_ref()) + .and_then(|ty| Self::type_at_alias_path(ty, path.index_depth)) + .is_some_and(|ty| Self::type_may_contain_list(&ty)) + } + + fn record_nested_list_alias_expression( + &mut self, + target_binding: &SymbolBindingKey, + target_depth: usize, + value: &Expression, + ) { + let mut captured = Vec::new(); + self.capture_nested_list_alias_sources(value, target_depth, &mut captured); + for (captured_depth, sources) in captured { + let target = ListAliasPath { + binding: target_binding.clone(), + index_depth: captured_depth, + }; + for source in sources { + self.add_structural_list_alias(source, target.clone()); + } + } + } + + fn record_nested_list_aliases(&mut self, target_name: &str, value: &Expression) { + let Some(target_binding) = self.analyzer.get_symbol_binding_key(target_name) else { + return; + }; + self.record_nested_list_alias_expression(&target_binding, 0, value); + } + + fn detach_list_alias_descendants(&mut self, target: &Expression) { + let Some(target_path) = self.list_target_binding_path(target) else { + return; + }; + let roots = self.list_alias_members_for_path(&target_path); + // Alias groups are may-alias sets after a control-flow join. Clearing + // or filling through one member only replaces the descendants of the + // allocation selected at runtime; deleting every member's descendants + // would lose provenance for every unselected allocation. Without a + // separate must-alias relation, retain those edges as a sound weak + // update and strong-update only an unaliased root. + if roots.len() > 1 { + return; + } + let detached = self + .list_alias_groups + .keys() + .filter(|path| { + roots + .iter() + .any(|root| path.binding == root.binding && path.index_depth > root.index_depth) + }) + .cloned() + .collect::>(); + for path in &detached { + self.list_alias_groups.remove(path); + } + for members in self.list_alias_groups.values_mut() { + members.retain(|member| !detached.contains(member)); + } + self.list_alias_groups + .retain(|_, members| members.len() > 1); + } + + fn record_list_insertion_aliases(&mut self, target: &Expression, value: &Expression) { + let Some(target_path) = self.list_target_binding_path(target) else { + return; + }; + for root in self.list_alias_members_for_path(&target_path) { + self.record_nested_list_alias_expression(&root.binding, root.index_depth + 1, value); + } + } + + fn capture_nested_list_alias_sources( + &self, + value: &Expression, + target_depth: usize, + out: &mut Vec<(usize, HashSet)>, + ) { + if let Some(source_path) = self.list_target_binding_path(value) + && self.alias_path_may_contain_list(&source_path) + { + self.capture_alias_path_and_descendants(&source_path, target_depth, out); + return; + } + match value { + Expression::Literal(Literal::List(values), ..) => { + for item in values { + self.capture_nested_list_alias_sources(item, target_depth + 1, out); + } + } + Expression::FunctionCall { + function, + arguments, + line, + column, + } => { + let Expression::Variable(name, ..) = function.as_ref() else { + return; + }; + let action_keys = self.action_summary_keys_for_call(name, *line, *column); + if !action_keys.is_empty() { + out.extend( + self.shared_list_return_sources_for_action_keys(&action_keys) + .into_iter() + .map(|(depth, sources)| (target_depth + depth, sources)), + ); + return; + } + let builtin_name = self + .builtin_name_for_call(name, *line, *column) + .unwrap_or_default(); + let shape_result = matches!(builtin_name.as_str(), "slice" | "unique" | "concat"); + let element_result = matches!( + builtin_name.as_str(), + "find" | "random_from" | "pop" | "shift" | "remove_at" | "removeat" + ); + if !shape_result && !element_result { + return; + } + for argument in arguments .iter() - .any(|value| value.is_none() || matches!(value.as_ref(), Some(Type::Unknown))) + .take(if builtin_name == "concat" { 2 } else { 1 }) { - Some(Type::Unknown) + if let Some(mut source_path) = self.list_target_binding_path(&argument.value) { + source_path.index_depth += 1; + self.capture_alias_path_and_descendants( + &source_path, + target_depth + usize::from(shape_result), + out, + ); + } + } + } + Expression::ActionCall { + name, line, column, .. + } => { + let action_keys = self.action_summary_keys_for_call(name, *line, *column); + out.extend( + self.shared_list_return_sources_for_action_keys(&action_keys) + .into_iter() + .map(|(depth, sources)| (target_depth + depth, sources)), + ); + } + Expression::Variable(name, line, column) => { + let action_keys = self.action_summary_keys_for_call(name, *line, *column); + let auto_calls = action_keys.iter().any(|(action, index)| { + self.action_signatures(action) + .and_then(|signatures| signatures.get(*index).cloned()) + .is_some_and(|signature| signature.parameters.is_empty()) + }); + if auto_calls { + out.extend( + self.shared_list_return_sources_for_action_keys(&action_keys) + .into_iter() + .map(|(depth, sources)| (target_depth + depth, sources)), + ); + } + } + _ => {} + } + } + + fn capture_alias_path_and_descendants( + &self, + source_path: &ListAliasPath, + target_depth: usize, + out: &mut Vec<(usize, HashSet)>, + ) { + let mut relative_depths = HashSet::new(); + if let Some(source_type) = self + .analyzer + .get_symbol_by_binding_key(&source_path.binding) + .and_then(|symbol| symbol.symbol_type.as_ref()) + .and_then(|ty| Self::type_at_alias_path(ty, source_path.index_depth)) + { + Self::collect_list_depths(&source_type, 0, &mut relative_depths); + } + relative_depths.extend( + self.list_alias_groups + .keys() + .filter(|path| { + path.binding == source_path.binding + && path.index_depth >= source_path.index_depth + }) + .map(|path| path.index_depth - source_path.index_depth), + ); + + let mut relative_depths = relative_depths.into_iter().collect::>(); + relative_depths.sort_unstable(); + for relative_depth in relative_depths { + let candidate = ListAliasPath { + binding: source_path.binding.clone(), + index_depth: source_path.index_depth + relative_depth, + }; + if self.alias_path_may_be_list(&candidate) { + out.push(( + target_depth + relative_depth, + self.list_alias_members_for_path(&candidate), + )); + } + } + } + + fn action_summary_keys_for_call( + &self, + name: &str, + line: usize, + column: usize, + ) -> Vec { + if let Some(resolution) = self.analyzer.alias_call_resolution(name, line, column) { + return match resolution { + crate::analyzer::AliasState::Bound { + action, + visible_signatures, + } => (0..*visible_signatures) + .map(|index| (action.clone(), index)) + .collect(), + crate::analyzer::AliasState::Builtin { .. } + | crate::analyzer::AliasState::Dynamic => Vec::new(), + }; + } + self.action_signatures(name) + .map(|signatures| { + (0..signatures.len()) + .map(|index| (name.to_string(), index)) + .collect() + }) + .unwrap_or_default() + } + + fn builtin_name_for_call(&self, name: &str, line: usize, column: usize) -> Option { + match self.analyzer.alias_call_resolution(name, line, column) { + Some(crate::analyzer::AliasState::Builtin { name }) => Some(name.clone()), + Some( + crate::analyzer::AliasState::Bound { .. } | crate::analyzer::AliasState::Dynamic, + ) => None, + None if builtins::is_implemented_builtin_function(name) => Some(name.to_string()), + None => None, + } + } + + fn shared_list_return_sources_for_action_keys( + &self, + action_keys: &[ActionSummaryKey], + ) -> Vec<(usize, HashSet)> { + let mut merged = SharedListReturnProvenance::new(); + for provenance in action_keys + .iter() + .filter_map(|key| self.user_action_shared_list_returns.get(key)) + { + for (depth, sources) in provenance { + merged + .entry(*depth) + .or_default() + .extend(sources.iter().cloned()); + } + } + merged.into_iter().collect() + } + + fn capture_block_completion_list_sources( + &self, + statements: &[Statement], + target_depth: usize, + out: &mut Vec<(usize, HashSet)>, + ) { + let Some(last) = statements.last() else { + return; + }; + self.capture_statement_completion_list_sources(last, target_depth, out); + } + + fn capture_statement_completion_list_sources( + &self, + statement: &Statement, + target_depth: usize, + out: &mut Vec<(usize, HashSet)>, + ) { + match statement { + Statement::ExpressionStatement { expression, .. } => { + self.capture_nested_list_alias_sources(expression, target_depth, out); + } + Statement::IfStatement { + then_block, + else_block, + .. + } => { + self.capture_block_completion_list_sources(then_block, target_depth, out); + if let Some(else_block) = else_block { + self.capture_block_completion_list_sources(else_block, target_depth, out); + } + } + Statement::SingleLineIf { + then_stmt, + else_stmt, + .. + } => { + self.capture_statement_completion_list_sources(then_stmt, target_depth, out); + if let Some(else_stmt) = else_stmt { + self.capture_statement_completion_list_sources(else_stmt, target_depth, out); + } + } + Statement::TryStatement { + body, + when_clauses, + otherwise_block, + .. + } => { + self.capture_block_completion_list_sources(body, target_depth, out); + for clause in when_clauses { + self.capture_block_completion_list_sources(&clause.body, target_depth, out); + } + if let Some(otherwise_block) = otherwise_block { + self.capture_block_completion_list_sources(otherwise_block, target_depth, out); + } + } + Statement::WaitForStatement { inner, .. } => { + self.capture_statement_completion_list_sources(inner, target_depth, out); + } + // These loops return the final body value at runtime. A + // repeat-until body executes at least once; the others may not, + // but retaining a may-alias edge is conservative for every + // continuation where a body value is produced. + Statement::WhileLoop { body, .. } + | Statement::RepeatWhileLoop { body, .. } + | Statement::RepeatUntilLoop { body, .. } + | Statement::ForeverLoop { body, .. } + | Statement::MainLoop { body, .. } => { + self.capture_block_completion_list_sources(body, target_depth, out); + } + _ => {} + } + } + + fn restore_captured_list_alias_sources( + &mut self, + target_name: &str, + captured: Vec<(usize, HashSet)>, + ) { + let Some(target_binding) = self.analyzer.get_symbol_binding_key(target_name) else { + return; + }; + for (target_depth, sources) in captured { + let target = ListAliasPath { + binding: target_binding.clone(), + index_depth: target_depth, + }; + for source in sources { + // When the RHS mentions the assigned binding, that path names + // the pre-assignment allocation. It has no independent live + // binding after the strong update; any other captured alias + // still links the retained allocation to the new target path. + if source.binding != target_binding { + self.add_structural_list_alias(source, target.clone()); + } + } + } + } + + fn list_target_binding_path(&self, expression: &Expression) -> Option { + match expression { + Expression::Variable(name, ..) => { + self.analyzer + .get_symbol_binding_key(name) + .map(|binding| ListAliasPath { + binding, + index_depth: 0, + }) + } + Expression::IndexAccess { collection, .. } => { + let mut path = self.list_target_binding_path(collection)?; + path.index_depth += 1; + Some(path) + } + _ => None, + } + } + + fn expression_root_binding_key(&self, expression: &Expression) -> Option { + match expression { + Expression::Variable(name, ..) => self.analyzer.get_symbol_binding_key(name), + Expression::IndexAccess { collection, .. } => { + self.expression_root_binding_key(collection) + } + Expression::MemberAccess { object, .. } + | Expression::PropertyAccess { object, .. } + | Expression::MethodCall { object, .. } => self.expression_root_binding_key(object), + _ => None, + } + } + + fn apply_effect_at_list_path( + ty: &Type, + index_depth: usize, + effect: &ListMutationEffect, + ) -> Option { + if index_depth == 0 { + return match ty { + Type::List(element_type) => { + let next_element = match effect { + ListMutationEffect::Join(value_type) => Self::join_collection_value_type( + Some((**element_type).clone()), + value_type.clone(), + ), + ListMutationEffect::Replace(value_type) => value_type.clone(), + ListMutationEffect::Escape => Type::Any, + }; + Some(Type::List(Box::new(next_element))) + } + Type::Optional(inner) => { + Self::apply_effect_at_list_path(inner, index_depth, effect) + .map(|updated| Type::Optional(Box::new(updated))) + } + Type::Unknown | Type::Any | Type::Error => Some(ty.clone()), + _ => None, + }; + } + + match ty { + Type::List(element_type) => { + Self::apply_effect_at_list_path(element_type, index_depth - 1, effect) + .map(|updated| Type::List(Box::new(updated))) + } + Type::Map(key_type, value_type) => { + Self::apply_effect_at_list_path(value_type, index_depth - 1, effect) + .map(|updated| Type::Map(Box::new((**key_type).clone()), Box::new(updated))) + } + Type::Optional(inner) => Self::apply_effect_at_list_path(inner, index_depth, effect) + .map(|updated| Type::Optional(Box::new(updated))), + Type::Unknown | Type::Any | Type::Error => Some(ty.clone()), + _ => None, + } + } + + fn list_alias_members_for_path(&self, path: &ListAliasPath) -> HashSet { + let mut members = self + .list_alias_groups + .get(path) + .cloned() + .unwrap_or_else(|| HashSet::from([path.clone()])); + + // A relation can connect paths at different depths + // (`inner@0 <-> nested@1`). A mutation below either endpoint keeps the + // same relative offset, so `inner@1` corresponds to `nested@2`. + for (ancestor, aliases) in &self.list_alias_groups { + if ancestor.binding == path.binding && ancestor.index_depth <= path.index_depth { + let offset = path.index_depth - ancestor.index_depth; + for alias in aliases { + members.insert(ListAliasPath { + binding: alias.binding.clone(), + index_depth: alias.index_depth + offset, + }); + } + } + } + members + } + + fn apply_list_mutation_effect_at_path( + &mut self, + path: &ListAliasPath, + effect: &ListMutationEffect, + ) { + let members = self.list_alias_members_for_path(path); + if let Some(active_effects) = self.deferred_list_effect_stack.last_mut() { + active_effects.extend(members.iter().cloned()); + } + for member in members { + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&member.binding) + && let Some(current_type) = symbol.symbol_type.clone() + && let Some(updated_type) = + Self::apply_effect_at_list_path(¤t_type, member.index_depth, effect) + { + symbol.symbol_type = Some(updated_type); + } + } + } + + fn apply_list_mutation_effect(&mut self, target: &Expression, effect: ListMutationEffect) { + if let Some(path) = self.list_target_binding_path(target) { + self.apply_list_mutation_effect_at_path(&path, &effect); + return; + } + + // Property/method-derived list values can still share mutable runtime + // storage, but the current Type model does not retain a property path. + // Widen only that expression's root rather than every list in scope. + if let Some(root) = self.expression_root_binding_key(target) { + let root = ListAliasPath { + binding: root, + index_depth: 0, + }; + let members = self + .list_alias_groups + .get(&root) + .cloned() + .unwrap_or_else(|| HashSet::from([root])); + if let Some(active_effects) = self.deferred_list_effect_stack.last_mut() { + active_effects.extend(members.iter().cloned()); + } + for member in members { + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&member.binding) { + symbol.symbol_type = Some(Type::Any); + } + } + } + } + + fn apply_user_action_list_effects(&mut self, action_keys: &[ActionSummaryKey]) { + self.definitely_nonempty_lists.clear(); + if let Some(caller) = self.deferred_action_key_stack.last().cloned() { + self.user_action_dependencies + .entry(caller) + .or_default() + .extend(action_keys.iter().cloned()); + } + + let effects = action_keys + .iter() + .flat_map(|key| { + self.user_action_list_effects + .get(key) + .into_iter() + .flat_map(|effects| effects.iter().cloned()) + }) + .collect::>(); + for path in effects { + if self.analyzer.binding_key_is_live(&path.binding) { + self.apply_list_mutation_effect_at_path(&path, &ListMutationEffect::Escape); + } + } + + let mut binding_effects = HashMap::new(); + for key in action_keys { + if let Some(effects) = self.user_action_binding_effects.get(key) { + for (binding, effect_type) in effects { + Self::join_binding_effect( + &mut binding_effects, + binding.clone(), + effect_type.clone(), + ); + } + } + } + for (binding, effect_type) in binding_effects { + if !self.analyzer.binding_key_is_live(&binding) { + continue; + } + let current_type = self + .analyzer + .get_symbol_by_binding_key(&binding) + .and_then(|symbol| symbol.symbol_type.clone()); + let updated_type = current_type + .map(|current| Self::join_inferred_types(current, effect_type.clone())) + .unwrap_or(effect_type); + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&binding) { + symbol.symbol_type = Some(updated_type); + } + } + } + + fn escape_shared_list_return_type(&self, action_keys: &[ActionSummaryKey], ty: Type) -> Type { + let mut shared_depths = action_keys + .iter() + .filter_map(|key| self.user_action_shared_list_returns.get(key)) + .flat_map(|provenance| provenance.keys().copied()) + .collect::>(); + shared_depths.sort_unstable(); + shared_depths.dedup(); + shared_depths.reverse(); + + let mut escaped = ty; + for depth in shared_depths { + if let Some(updated) = + Self::apply_effect_at_list_path(&escaped, depth, &ListMutationEffect::Escape) + { + escaped = updated; + } + } + escaped + } + + fn propagate_user_action_summaries(&mut self) { + loop { + let mut changed = false; + let dependencies = self.user_action_dependencies.clone(); + for (caller, callees) in dependencies { + let inherited_effects = callees + .iter() + .flat_map(|callee| { + self.user_action_list_effects + .get(callee) + .into_iter() + .flat_map(|effects| effects.iter().cloned()) + }) + .collect::>(); + let effects = self + .user_action_list_effects + .entry(caller.clone()) + .or_default(); + let previous_len = effects.len(); + effects.extend(inherited_effects); + changed |= effects.len() != previous_len; + + let inherited_binding_effects = callees + .iter() + .filter_map(|callee| self.user_action_binding_effects.get(callee)) + .flat_map(|effects| effects.iter()) + .map(|(binding, effect_type)| (binding.clone(), effect_type.clone())) + .collect::>(); + let binding_effects = self + .user_action_binding_effects + .entry(caller.clone()) + .or_default(); + let previous_binding_effects = binding_effects.clone(); + for (binding, effect_type) in inherited_binding_effects { + Self::join_binding_effect(binding_effects, binding, effect_type); + } + changed |= *binding_effects != previous_binding_effects; + } + if !changed { + break; + } + } + } + + fn escape_possible_shared_list_return_type(ty: Type) -> Type { + match ty { + Type::List(_) => Type::List(Box::new(Type::Any)), + Type::Map(key, value) => Type::Map( + Box::new(Self::escape_possible_shared_list_return_type(*key)), + Box::new(Self::escape_possible_shared_list_return_type(*value)), + ), + Type::Optional(inner) => Type::Optional(Box::new( + Self::escape_possible_shared_list_return_type(*inner), + )), + Type::Async(inner) => Type::Async(Box::new( + Self::escape_possible_shared_list_return_type(*inner), + )), + other => other, + } + } + + fn type_may_contain_list(ty: &Type) -> bool { + match ty { + Type::List(_) | Type::Unknown | Type::Any | Type::Error => true, + // Runtime maps index values; keys are text and are never mutable + // list paths. Alias depth therefore follows only the value side. + Type::Map(_, value) => Self::type_may_contain_list(value), + Type::Optional(inner) | Type::Async(inner) => Self::type_may_contain_list(inner), + _ => false, + } + } + + fn collect_list_depths(ty: &Type, depth: usize, out: &mut HashSet) { + match ty { + Type::List(element) => { + out.insert(depth); + Self::collect_list_depths(element, depth + 1, out); + } + Type::Map(_, value) => { + Self::collect_list_depths(value, depth + 1, out); + } + Type::Optional(inner) | Type::Async(inner) => { + Self::collect_list_depths(inner, depth, out); + } + Type::Unknown | Type::Any | Type::Error => { + out.insert(depth); + } + _ => {} + } + } + + fn escape_lists_in_type(ty: Type) -> Type { + match ty { + Type::List(_) => Type::List(Box::new(Type::Any)), + Type::Map(key, value) => Type::Map( + Box::new(Self::escape_lists_in_type(*key)), + Box::new(Self::escape_lists_in_type(*value)), + ), + Type::Optional(inner) => Type::Optional(Box::new(Self::escape_lists_in_type(*inner))), + Type::Async(inner) => Type::Async(Box::new(Self::escape_lists_in_type(*inner))), + other => other, + } + } + + fn escape_all_visible_mutable_state(&mut self) { + // A live Optional refinement retains a sound upper bound even when an + // opaque closure may rebind the value. Preserve that original type + // instead of erasing it to Any; the closure can select either the + // present or Nothing branch, but cannot justify values outside the + // statically checked Optional contract. + let optional_origins = self.optional_refinement_origins.clone(); + self.invalidate_optional_refinements(); + self.definitely_nonempty_lists.clear(); + let bindings = self + .analyzer + .live_binding_types() + .into_iter() + .collect::>(); + for (binding, ty) in bindings { + let Some(ty) = ty else { + continue; + }; + let is_mutable = self + .analyzer + .get_symbol_by_binding_key(&binding) + .is_some_and(|symbol| { + matches!(symbol.kind, SymbolKind::Variable { mutable: true }) + }); + if is_mutable { + let escaped_type = optional_origins.get(&binding).cloned().unwrap_or(Type::Any); + if let Some(active_effects) = self.deferred_binding_effect_stack.last_mut() { + Self::join_binding_effect( + active_effects, + binding.clone(), + escaped_type.clone(), + ); + } + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&binding) { + // An opaque closure can rebind any mutable captured value, + // not merely mutate storage reachable through a list. Keep + // a known Optional origin when one bounds the possibilities. + symbol.symbol_type = Some(escaped_type); + } + continue; + } + if !Self::type_may_contain_list(&ty) { + continue; + } + let mut depths = HashSet::new(); + Self::collect_list_depths(&ty, 0, &mut depths); + if let Some(active_effects) = self.deferred_list_effect_stack.last_mut() { + active_effects.extend(depths.iter().map(|index_depth| ListAliasPath { + binding: binding.clone(), + index_depth: *index_depth, + })); + } + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&binding) { + symbol.symbol_type = Some(Self::escape_lists_in_type(ty)); + } + } + } + + fn invalidate_optional_refinements(&mut self) { + let origins = self + .optional_refinement_origins + .iter() + .map(|(binding, origin)| (binding.clone(), origin.clone())) + .collect::>(); + for (binding, origin) in origins { + if let Some(symbol) = self.analyzer.get_symbol_by_binding_key_mut(&binding) { + symbol.symbol_type = Some(origin); + } + } + } + + fn escape_user_action_list_arguments( + &mut self, + arguments: &[crate::parser::ast::Argument], + argument_types: &[Type], + ) { + for (argument, argument_type) in arguments.iter().zip(argument_types) { + if !Self::type_may_contain_list(argument_type) { + continue; + } + if let Some(root) = self.list_target_binding_path(&argument.value) { + let mut depths = HashSet::new(); + Self::collect_list_depths(argument_type, 0, &mut depths); + let mut depths = depths.into_iter().collect::>(); + depths.sort_unstable_by(|left, right| right.cmp(left)); + for depth in depths { + self.apply_list_mutation_effect_at_path( + &ListAliasPath { + binding: root.binding.clone(), + index_depth: root.index_depth + depth, + }, + &ListMutationEffect::Escape, + ); + } + } else { + self.apply_list_mutation_effect(&argument.value, ListMutationEffect::Escape); + } + } + } + + fn record_deferred_list_rebind( + &mut self, + target_name: &str, + value: &Expression, + value_type: &Type, + ) { + if self.deferred_list_effect_stack.is_empty() || !Self::type_may_be_list(value_type) { + return; + } + let mut affected = HashSet::new(); + if let Some(binding) = self.analyzer.get_symbol_binding_key(target_name) { + affected.extend(self.list_alias_members_for_path(&ListAliasPath { + binding, + index_depth: 0, + })); + } + if let Some(source) = self.list_target_binding_path(value) { + affected.extend(self.list_alias_members_for_path(&source)); + } + if let Some(active_effects) = self.deferred_list_effect_stack.last_mut() { + active_effects.extend(affected); + } + } + + fn record_deferred_binding_assignment(&mut self, name: &str) { + let Some(binding) = self.analyzer.get_symbol_binding_key(name) else { + return; + }; + let Some(effect_type) = self + .analyzer + .get_symbol_by_binding_key(&binding) + .and_then(|symbol| symbol.symbol_type.clone()) + else { + return; + }; + if let Some(active_effects) = self.deferred_binding_effect_stack.last_mut() { + Self::join_binding_effect(active_effects, binding, effect_type); + } + } + + fn merge_promoted_list_alias_bindings( + &mut self, + promoted: Vec<(SymbolBindingKey, SymbolBindingKey)>, + ) { + for (old_binding, new_binding) in promoted { + let old_paths = self + .list_alias_groups + .keys() + .filter(|path| path.binding == old_binding) + .cloned() + .collect::>(); + if old_paths.is_empty() { + self.union_list_alias_bindings( + ListAliasPath { + binding: old_binding, + index_depth: 0, + }, + ListAliasPath { + binding: new_binding, + index_depth: 0, + }, + ); + } else { + for old_path in old_paths { + self.union_list_alias_bindings( + old_path.clone(), + ListAliasPath { + binding: new_binding.clone(), + index_depth: old_path.index_depth, + }, + ); + } + } + } + } + + fn same_type_error(left: &TypeError, right: &TypeError) -> bool { + left.message == right.message + && left.expected == right.expected + && left.found == right.found + && left.line == right.line + && left.column == right.column + } + + fn deduplicate_errors_from(&mut self, start: usize) { + let mut index = start; + while index < self.errors.len() { + if self.errors[..index] + .iter() + .any(|earlier| Self::same_type_error(earlier, &self.errors[index])) + { + self.errors.remove(index); + } else { + index += 1; + } + } + } + + /// Recognize the direct variable forms users write to guard a value that + /// may be Nothing. The boolean says whether the condition's true branch is + /// the Nothing branch. + fn nothing_tested_variable(condition: &Expression) -> Option<(&str, bool)> { + match condition { + Expression::BinaryOperation { + left, + operator: operator @ (Operator::Equals | Operator::NotEquals), + right, + .. + } => { + let name = match (left.as_ref(), right.as_ref()) { + (Expression::Variable(name, ..), Expression::Literal(Literal::Nothing, ..)) + | (Expression::Literal(Literal::Nothing, ..), Expression::Variable(name, ..)) => { + name.as_str() + } + _ => return None, + }; + Some((name, matches!(operator, Operator::Equals))) + } + Expression::FunctionCall { + function, + arguments, + .. + } if arguments.len() == 1 + && matches!( + function.as_ref(), + Expression::Variable(name, ..) + if name == "isnothing" || name == "is_nothing" + ) => + { + match &arguments[0].value { + Expression::Variable(name, ..) => Some((name, true)), + _ => None, + } + } + Expression::ActionCall { + name, arguments, .. + } if (name == "isnothing" || name == "is_nothing") && arguments.len() == 1 => { + match &arguments[0].value { + Expression::Variable(name, ..) => Some((name, true)), + _ => None, + } + } + Expression::UnaryOperation { + operator: UnaryOperator::Not, + expression, + .. + } => Self::nothing_tested_variable(expression) + .map(|(name, true_is_nothing)| (name, !true_is_nothing)), + _ => None, + } + } + + fn optional_condition_refinement( + &self, + condition: &Expression, + ) -> Option<(String, Type, Type)> { + let (name, true_is_nothing) = Self::nothing_tested_variable(condition)?; + let Type::Optional(inner) = self + .analyzer + .get_symbol(name) + .and_then(|symbol| symbol.symbol_type.clone())? + else { + return None; + }; + let present_type = *inner; + let (then_type, else_type) = if true_is_nothing { + (Type::Nothing, present_type) + } else { + (present_type, Type::Nothing) + }; + Some((name.to_string(), then_type, else_type)) + } + + fn refine_symbol_type(&mut self, name: &str, ty: &Type) { + if let Some(binding) = self.analyzer.get_symbol_binding_key(name) + && let Some(origin @ Type::Optional(_)) = self + .analyzer + .get_symbol_by_binding_key(&binding) + .and_then(|symbol| symbol.symbol_type.clone()) + { + self.optional_refinement_origins + .entry(binding) + .or_insert(origin); + } + if let Some(symbol) = self.analyzer.get_symbol_mut(name) { + symbol.symbol_type = Some(ty.clone()); + } + } + + /// Whether execution can reach the current loop's next iteration. A + /// definitely terminating statement suppresses the backedge; nested loops + /// do not, because their `break` applies to the nested loop. + fn loop_body_can_reach_backedge(statements: &[Statement]) -> bool { + !statements + .iter() + .any(Self::statement_definitely_stops_current_loop) + } + + fn statement_definitely_stops_current_loop(statement: &Statement) -> bool { + match statement { + Statement::BreakStatement { .. } + | Statement::ExitStatement { .. } + | Statement::ReturnStatement { .. } => true, + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_block, + else_block: None, + .. + } => !Self::loop_body_can_reach_backedge(then_block), + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_block: Some(else_block), + .. + } => !Self::loop_body_can_reach_backedge(else_block), + Statement::IfStatement { + then_block, + else_block: Some(else_block), + .. + } => { + !Self::loop_body_can_reach_backedge(then_block) + && !Self::loop_body_can_reach_backedge(else_block) + } + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_stmt, + .. + } => Self::statement_definitely_stops_current_loop(then_stmt), + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_stmt, + .. + } => else_stmt + .as_ref() + .is_some_and(|statement| Self::statement_definitely_stops_current_loop(statement)), + Statement::SingleLineIf { + then_stmt, + else_stmt: Some(else_stmt), + .. + } => { + Self::statement_definitely_stops_current_loop(then_stmt) + && Self::statement_definitely_stops_current_loop(else_stmt) + } + Statement::WaitForStatement { inner, .. } => { + Self::statement_definitely_stops_current_loop(inner) + } + Statement::TryStatement { + body, + when_clauses, + otherwise_block, + finally_block, + .. + } => { + finally_block + .as_ref() + .is_some_and(|block| !Self::loop_body_can_reach_backedge(block)) + || (!Self::loop_body_can_reach_backedge(body) + && when_clauses + .iter() + .all(|clause| !Self::loop_body_can_reach_backedge(&clause.body)) + && otherwise_block + .as_ref() + .is_none_or(|block| !Self::loop_body_can_reach_backedge(block))) + } + _ => false, + } + } + + fn block_can_continue(statements: &[Statement]) -> bool { + !statements + .iter() + .any(Self::statement_definitely_stops_current_block) + } + + fn statement_definitely_stops_current_block(statement: &Statement) -> bool { + match statement { + Statement::BreakStatement { .. } + | Statement::ContinueStatement { .. } + | Statement::ExitStatement { .. } + | Statement::ReturnStatement { .. } => true, + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_block, + else_block: None, + .. + } => !Self::block_can_continue(then_block), + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_block: Some(else_block), + .. + } => !Self::block_can_continue(else_block), + Statement::IfStatement { + then_block, + else_block: Some(else_block), + .. + } => !Self::block_can_continue(then_block) && !Self::block_can_continue(else_block), + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_stmt, + .. + } => Self::statement_definitely_stops_current_block(then_stmt), + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_stmt, + .. + } => else_stmt + .as_ref() + .is_some_and(|statement| Self::statement_definitely_stops_current_block(statement)), + Statement::SingleLineIf { + then_stmt, + else_stmt: Some(else_stmt), + .. + } => { + Self::statement_definitely_stops_current_block(then_stmt) + && Self::statement_definitely_stops_current_block(else_stmt) + } + Statement::WaitForStatement { inner, .. } => { + Self::statement_definitely_stops_current_block(inner) + } + Statement::TryStatement { + body, + when_clauses, + otherwise_block, + finally_block, + .. + } => { + finally_block + .as_ref() + .is_some_and(|block| !Self::block_can_continue(block)) + || (!Self::block_can_continue(body) + && when_clauses + .iter() + .all(|clause| !Self::block_can_continue(&clause.body)) + && otherwise_block + .as_ref() + .is_none_or(|block| !Self::block_can_continue(block))) + } + Statement::WhileLoop { + condition: Expression::Literal(Literal::Boolean(true), ..), + body, + .. + } + | Statement::RepeatWhileLoop { + condition: Expression::Literal(Literal::Boolean(true), ..), + body, + .. + } + | Statement::RepeatUntilLoop { + condition: Expression::Literal(Literal::Boolean(false), ..), + body, + .. + } + | Statement::ForeverLoop { body, .. } + | Statement::MainLoop { body, .. } => !Self::block_may_break_current_loop(body), + _ => false, + } + } + + /// Check a loop whose body executes in one persistent environment (`while`, + /// `repeat until`, and the child environment of `repeat while`). The first + /// iteration and the stable later-iteration header are both real runtime + /// states, so diagnostics from either must survive. + fn check_persistent_loop_body_fixed_point( + &mut self, + body: &[Statement], + condition_can_repeat: bool, + ) { + let previous_backedge_mode = self.checking_persistent_loop_backedge; + // A literal-false pre-test condition makes the body unreachable. We + // still validate its statements, but none of its type or alias effects + // may flow to the post-loop state. + let unreachable_alias_state = + (!condition_can_repeat).then(|| self.list_alias_groups.clone()); + let entry = self.analyzer.snapshot_symbol_types(); + let entry_aliases = self.list_alias_groups.clone(); + let summary_entry = self.snapshot_deferred_summary(); + self.checking_persistent_loop_backedge = previous_backedge_mode; + self.analyzer.restore_symbol_types(entry.clone()); + self.list_alias_groups = entry_aliases.clone(); + self.check_statement_block(body); + if self.budget_error.is_some() { + if let Some(alias_state) = unreachable_alias_state { + self.list_alias_groups = alias_state; + } + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } + + self.prune_dead_list_alias_paths(); + let first_backedge = self.analyzer.snapshot_symbol_types(); + let first_backedge_aliases = self.list_alias_groups.clone(); + let first_error_end = self.errors.len(); + if !condition_can_repeat { + self.restore_deferred_summary(summary_entry.clone()); + } + let first_summary = self.snapshot_deferred_summary(); + let mut header = if condition_can_repeat { + Self::join_type_snapshots(&[entry.clone(), first_backedge]) + } else { + entry.clone() + }; + let mut header_aliases = if condition_can_repeat { + Self::join_list_alias_snapshots(&[ + entry_aliases.clone(), + first_backedge_aliases.clone(), + ]) + } else { + entry_aliases.clone() + }; + + if condition_can_repeat && Self::loop_body_can_reach_backedge(body) { + loop { + self.analyzer.restore_symbol_types(header.clone()); + self.list_alias_groups = header_aliases.clone(); + let error_count = self.errors.len(); + self.checking_persistent_loop_backedge = true; + self.restore_deferred_summary(first_summary.clone()); + self.check_statement_block(body); + if self.budget_error.is_some() { + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } + self.restore_deferred_summary(first_summary.clone()); + self.errors.truncate(error_count); + + self.prune_dead_list_alias_paths(); + let backedge = self.analyzer.snapshot_symbol_types(); + let backedge_aliases = self.list_alias_groups.clone(); + let next = Self::join_type_snapshots(&[entry.clone(), header.clone(), backedge]); + let next_aliases = Self::join_list_alias_snapshots(&[ + entry_aliases.clone(), + header_aliases.clone(), + backedge_aliases, + ]); + if next == header && next_aliases == header_aliases { + break; + } + header = next; + header_aliases = next_aliases; + } + + self.analyzer.restore_symbol_types(header.clone()); + self.list_alias_groups = header_aliases.clone(); + self.checking_persistent_loop_backedge = true; + self.restore_deferred_summary(first_summary); + self.check_statement_block(body); + self.prune_dead_list_alias_paths(); + header_aliases = + Self::join_list_alias_snapshots(&[header_aliases, self.list_alias_groups.clone()]); + self.deduplicate_errors_from(first_error_end); + } + + self.analyzer.restore_symbol_types(header); + self.list_alias_groups = header_aliases; + if let Some(alias_state) = unreachable_alias_state { + self.list_alias_groups = alias_state; + } + self.checking_persistent_loop_backedge = previous_backedge_mode; + } + + fn restore_fresh_iteration_state( + &mut self, + local_symbols: &HashMap, + parent_types: &[HashMap>], + ) { + self.analyzer + .restore_current_scope_symbols(local_symbols.clone()); + let local_types = local_symbols + .iter() + .map(|(name, symbol)| (name.clone(), symbol.symbol_type.clone())) + .collect(); + let mut state = Vec::with_capacity(parent_types.len() + 1); + state.push(local_types); + state.extend_from_slice(parent_types); + self.analyzer.restore_symbol_types(state); + } + + /// Check a loop whose runtime creates or clears a child environment before + /// every iteration (`for each`, `count`, `forever`, and `main loop`). + /// Iteration-local declarations reset; only mutations resolved into parent + /// scopes contribute to the next header. + fn check_fresh_iteration_loop_body(&mut self, body: &[Statement], guaranteed_iteration: bool) { + let previous_backedge_mode = self.checking_persistent_loop_backedge; + let local_symbols = self.analyzer.snapshot_current_scope_symbols(); + let entry = self.analyzer.snapshot_symbol_types(); + let entry_aliases = self.list_alias_groups.clone(); + let parent_entry = entry.get(1..).unwrap_or_default().to_vec(); + let summary_entry = self.snapshot_deferred_summary(); + + self.checking_persistent_loop_backedge = false; + self.restore_fresh_iteration_state(&local_symbols, &parent_entry); + self.list_alias_groups = entry_aliases.clone(); + self.check_statement_block(body); + if self.budget_error.is_some() { + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } + + self.prune_dead_list_alias_paths(); + let first_snapshot = self.analyzer.snapshot_symbol_types(); + let first_backedge_aliases = self.list_alias_groups.clone(); + let first_backedge = first_snapshot.get(1..).unwrap_or_default().to_vec(); + let first_error_end = self.errors.len(); + let first_summary = self.snapshot_deferred_summary(); + let mut parent_header = if guaranteed_iteration { + first_backedge + } else { + Self::join_type_snapshots(&[parent_entry.clone(), first_backedge]) + }; + let mut header_aliases = if guaranteed_iteration { + first_backedge_aliases + } else { + Self::join_list_alias_snapshots(&[entry_aliases.clone(), first_backedge_aliases]) + }; + + if Self::loop_body_can_reach_backedge(body) { + loop { + self.restore_fresh_iteration_state(&local_symbols, &parent_header); + self.list_alias_groups = header_aliases.clone(); + let error_count = self.errors.len(); + self.checking_persistent_loop_backedge = false; + self.restore_deferred_summary(first_summary.clone()); + self.check_statement_block(body); + if self.budget_error.is_some() { + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } + self.restore_deferred_summary(first_summary.clone()); + self.errors.truncate(error_count); + + self.prune_dead_list_alias_paths(); + let snapshot = self.analyzer.snapshot_symbol_types(); + let backedge_aliases = self.list_alias_groups.clone(); + let backedge = snapshot.get(1..).unwrap_or_default().to_vec(); + let next = if guaranteed_iteration { + Self::join_type_snapshots(&[parent_header.clone(), backedge]) } else { - Some(Type::Any) + Self::join_type_snapshots(&[ + parent_entry.clone(), + parent_header.clone(), + backedge, + ]) }; - joined_layer.insert(name, merged); + let next_aliases = if guaranteed_iteration { + Self::join_list_alias_snapshots(&[header_aliases.clone(), backedge_aliases]) + } else { + Self::join_list_alias_snapshots(&[ + entry_aliases.clone(), + header_aliases.clone(), + backedge_aliases, + ]) + }; + if next == parent_header && next_aliases == header_aliases { + break; + } + parent_header = next; + header_aliases = next_aliases; } + + self.restore_fresh_iteration_state(&local_symbols, &parent_header); + self.list_alias_groups = header_aliases.clone(); + self.checking_persistent_loop_backedge = false; + self.restore_deferred_summary(first_summary); + self.check_statement_block(body); + self.prune_dead_list_alias_paths(); + header_aliases = + Self::join_list_alias_snapshots(&[header_aliases, self.list_alias_groups.clone()]); + self.deduplicate_errors_from(first_error_end); } - joined + self.restore_fresh_iteration_state(&local_symbols, &parent_header); + self.list_alias_groups = header_aliases; + let _ = summary_entry; + self.checking_persistent_loop_backedge = previous_backedge_mode; } - /// Check a loop body under the conservative type state seen at the top of - /// every iteration. Exploratory passes contribute only their backedge - /// state; diagnostics are emitted once after the header stabilizes. - fn check_loop_body_fixed_point(&mut self, body: &[Statement]) { + /// Type-check a post-test loop. The body runs before the condition on the + /// first iteration, and later iterations re-enter under the joined + /// entry/backedge state. + fn check_repeat_until_fixed_point( + &mut self, + condition: &Expression, + body: &[Statement], + line: usize, + column: usize, + ) { + let previous_backedge_mode = self.checking_persistent_loop_backedge; let entry = self.analyzer.snapshot_symbol_types(); - let mut header = entry.clone(); + let entry_aliases = self.list_alias_groups.clone(); + let summary_entry = self.snapshot_deferred_summary(); + + self.checking_persistent_loop_backedge = previous_backedge_mode; + self.analyzer.restore_symbol_types(entry.clone()); + self.list_alias_groups = entry_aliases; + self.check_statement_block(body); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { + self.type_error( + "Condition in repeat-until loop must be a boolean expression".to_string(), + Some(Type::Boolean), + Some(condition_type), + line, + column, + ); + } + if self.budget_error.is_some() { + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } - loop { - self.analyzer.restore_symbol_types(header.clone()); - let error_count = self.errors.len(); - for statement in body { - self.check_statement_types(statement); - } - if self.budget_error.is_some() { - return; + self.prune_dead_list_alias_paths(); + let first_backedge = self.analyzer.snapshot_symbol_types(); + let first_backedge_aliases = self.list_alias_groups.clone(); + let first_error_end = self.errors.len(); + let first_summary = self.snapshot_deferred_summary(); + let mut header = Self::join_type_snapshots(&[entry.clone(), first_backedge]); + let mut header_aliases = first_backedge_aliases.clone(); + + let condition_can_repeat = + !matches!(condition, Expression::Literal(Literal::Boolean(true), ..)); + if condition_can_repeat && Self::loop_body_can_reach_backedge(body) { + loop { + self.analyzer.restore_symbol_types(header.clone()); + self.list_alias_groups = header_aliases.clone(); + let error_count = self.errors.len(); + self.checking_persistent_loop_backedge = true; + self.restore_deferred_summary(first_summary.clone()); + self.check_statement_block(body); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { + self.type_error( + "Condition in repeat-until loop must be a boolean expression".to_string(), + Some(Type::Boolean), + Some(condition_type), + line, + column, + ); + } + if self.budget_error.is_some() { + self.checking_persistent_loop_backedge = previous_backedge_mode; + return; + } + self.restore_deferred_summary(first_summary.clone()); + self.errors.truncate(error_count); + + self.prune_dead_list_alias_paths(); + let backedge = self.analyzer.snapshot_symbol_types(); + let backedge_aliases = self.list_alias_groups.clone(); + let next = Self::join_type_snapshots(&[entry.clone(), header.clone(), backedge]); + let next_aliases = Self::join_list_alias_snapshots(&[ + first_backedge_aliases.clone(), + header_aliases.clone(), + backedge_aliases, + ]); + if next == header && next_aliases == header_aliases { + break; + } + header = next; + header_aliases = next_aliases; } - self.errors.truncate(error_count); - let backedge = self.analyzer.snapshot_symbol_types(); - let next = Self::join_type_snapshots(&[entry.clone(), header.clone(), backedge]); - if next == header { - break; + self.analyzer.restore_symbol_types(header.clone()); + self.list_alias_groups = header_aliases.clone(); + self.checking_persistent_loop_backedge = true; + self.restore_deferred_summary(first_summary); + self.check_statement_block(body); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { + self.type_error( + "Condition in repeat-until loop must be a boolean expression".to_string(), + Some(Type::Boolean), + Some(condition_type), + line, + column, + ); } - header = next; + self.prune_dead_list_alias_paths(); + header_aliases = + Self::join_list_alias_snapshots(&[header_aliases, self.list_alias_groups.clone()]); + self.deduplicate_errors_from(first_error_end); } - self.analyzer.restore_symbol_types(header.clone()); - for statement in body { - self.check_statement_types(statement); - } self.analyzer.restore_symbol_types(header); + self.list_alias_groups = header_aliases; + let _ = summary_entry; + self.checking_persistent_loop_backedge = previous_backedge_mode; } /// Like [`check_loop_body_fixed_point`], but leaves the POST-BODY type @@ -482,23 +2642,37 @@ impl TypeChecker { "join" => Type::Text, // List functions - "push" | "pop" | "shift" | "unshift" | "removeat" | "remove_at" | "insertat" - | "insert_at" | "slice" | "concat" | "unique" | "sort" | "reverse_list" | "clear" - | "filter" | "map" => Type::List(Box::new(Type::Any)), - "find" | "reduce" => Type::Any, + "push" | "sort" | "reverse_list" | "clear" | "unshift" | "insertat" | "insert_at" + | "fill" => Type::Nothing, + "slice" | "concat" | "unique" | "filter" | "map" => Type::List(Box::new(Type::Any)), + "find" => Type::Optional(Box::new(Type::Any)), + "pop" | "shift" | "removeat" | "remove_at" | "reduce" => Type::Any, "count" | "size" | "find_index" => Type::Number, "includes" | "every" | "some" => Type::Boolean, - // Time functions - "now" | "today" | "time" | "date" | "year" | "month" | "day" | "hour" | "minute" - | "second" | "dayofweek" | "day_of_week" | "adddays" | "add_days" | "addmonths" - | "add_months" | "addyears" | "add_years" | "addhours" | "add_hours" | "addminutes" - | "add_minutes" | "addseconds" | "add_seconds" => Type::Number, + // Time functions. Date/Time/DateTime are runtime value types, so + // preserve them as named static types rather than erasing them to + // Any (or, historically, misclassifying them as Number). + "today" | "date" | "parsedate" | "parse_date" | "create_date" | "date_part" + | "adddays" | "add_days" | "subtract_days" | "addmonths" | "add_months" + | "addyears" | "add_years" => Type::Date, + "now" | "time" | "parse_time" | "create_time" | "time_part" => Type::Time, + "datetime_now" + | "create_datetime" + | "utc_now" + | "datetime_from_timestamp" + | "addhours" + | "add_hours" + | "addminutes" + | "add_minutes" + | "addseconds" + | "add_seconds" => Type::DateTime, + "year" | "month" | "day" | "hour" | "minute" | "second" | "dayofweek" + | "day_of_week" | "dayofyear" | "day_of_year" | "days_in_month" | "week_of_year" + | "timestamp" | "time_diff" => Type::Number, "formatdate" | "format_date" | "formattime" | "format_time" | "format_datetime" | "current_date" => Type::Text, - "parsedate" | "parse_date" | "isleapyear" | "is_leap_year" => Type::Number, - // DateTime-valued functions: no static type exists for DateTime - "datetime_now" | "create_time" | "create_date" | "parse_time" => Type::Any, + "isleapyear" | "is_leap_year" => Type::Boolean, "daysbetween" | "days_between" | "monthsbetween" | "months_between" | "yearsbetween" | "years_between" => Type::Number, @@ -506,8 +2680,14 @@ impl TypeChecker { "pattern" | "match" | "test" | "replace_pattern" | "extract" => Type::Text, "ismatch" | "is_match" | "pattern_matches" => Type::Boolean, "findall" | "find_all" => Type::List(Box::new(Type::Text)), - "pattern_find" => Type::Any, // Match object or nothing - "pattern_find_all" => Type::List(Box::new(Type::Any)), + "pattern_find" => Type::Optional(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Any), + ))), + "pattern_find_all" => Type::List(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Any), + ))), // Crypto functions "wflhash256" @@ -536,13 +2716,22 @@ impl TypeChecker { "stringify_json" | "stringify_json_pretty" => Type::Text, // Query and form parsing (objects with string values) - "parse_query_string" | "parse_cookies" | "parse_form_urlencoded" => Type::Any, + "parse_query_string" | "parse_cookies" | "parse_form_urlencoded" => { + Type::Map(Box::new(Type::Text), Box::new(Type::Text)) + } // Web routing helpers - "path_params" => Type::Map(Box::new(Type::Text), Box::new(Type::Text)), + // A capture map on success, Nothing when the route does not match. + "path_params" => Type::Optional(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Text), + ))), "path_matches" => Type::Boolean, "mime_type" => Type::Text, - "parse_multipart" => Type::List(Box::new(Type::Any)), + "parse_multipart" => Type::List(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Any), + ))), // Text functions registered under stdlib-specific names "string_split" => Type::List(Box::new(Type::Text)), @@ -565,6 +2754,473 @@ impl TypeChecker { } } + /// Runtime variable lookup auto-invokes native builtins whose canonical + /// arity is zero. Mirror that here; nonzero-arity builtins remain callable + /// function values. + fn get_bare_builtin_type(&self, name: &str) -> Type { + let parameter_count = builtins::get_function_arity(name); + let return_type = self.get_builtin_function_type(name, parameter_count); + if parameter_count == 0 { + return_type + } else { + let fixed_signatures = self.builtin_signatures(name).map(|signatures| { + signatures + .into_iter() + .filter(|signature| signature.parameters.len() == parameter_count) + .collect::>() + }); + if crate::stdlib::typechecker::variadic_builtin_parameter_type(name).is_none() + && let Some(signatures) = fixed_signatures + && signatures.len() == 1 + { + let signature = &signatures[0]; + return Type::Function { + parameters: signature + .parameters + .iter() + .map(|parameter| { + parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown) + }) + .collect(), + return_type: Box::new( + signature + .return_type + .as_ref() + .cloned() + .unwrap_or(return_type), + ), + }; + } + Type::Function { + parameters: vec![Type::Any; parameter_count], + return_type: Box::new(return_type), + } + } + } + + /// Infer and validate a builtin call through one path for both + /// `function of ...` and `call function with ...` syntax. + fn infer_builtin_call_type( + &mut self, + name: &str, + arguments: &[crate::parser::ast::Argument], + line: usize, + column: usize, + ) -> Type { + // Always visit arguments, even when the call has the wrong arity. A + // nested type error must not disappear merely because the callee is a + // native function. + let mut declared_property_target = None; + let mut arg_types = Vec::with_capacity(arguments.len()); + for (index, argument) in arguments.iter().enumerate() { + if index == 0 + && matches!( + name, + "push" | "unshift" | "insert_at" | "insertat" | "fill" | "clear" + ) + { + let (target_type, property_contract) = + self.infer_list_mutation_target(&argument.value); + arg_types.push(target_type); + declared_property_target = property_contract; + } else { + arg_types.push(self.infer_expression_type(&argument.value)); + } + } + + if !builtins::is_implemented_builtin_function(name) { + self.type_error( + format!("Builtin '{name}' is recognized but not implemented by the runtime"), + None, + None, + line, + column, + ); + return Type::Error; + } + + let (minimum, maximum) = builtins::get_function_arity_range(name); + let arity_is_valid = + arguments.len() >= minimum && maximum.is_none_or(|maximum| arguments.len() <= maximum); + if !arity_is_valid { + let expected = match maximum { + None => format!("at least {minimum}"), + Some(maximum) if minimum == maximum => minimum.to_string(), + Some(maximum) => format!("{minimum} to {maximum}"), + }; + self.type_error( + format!( + "Builtin '{name}' expects {expected} arguments, but {} were provided", + arguments.len() + ), + None, + None, + line, + column, + ); + return Type::Error; + } + + if arg_types.contains(&Type::Error) { + return Type::Error; + } + + // Variadic builtins have one repeated runtime parameter contract that + // cannot be represented by the fixed-vector FunctionSignature type. + let variadic_parameter = crate::stdlib::typechecker::variadic_builtin_parameter_type(name); + if let Some(parameter_type) = &variadic_parameter + && let Some((index, arg_type)) = arg_types + .iter() + .enumerate() + .find(|(_, arg_type)| !self.are_builtin_types_compatible(parameter_type, arg_type)) + { + self.type_error( + format!( + "Argument {} of builtin '{}' expected {}, but found {}", + index + 1, + name, + parameter_type, + arg_type + ), + Some(parameter_type.clone()), + Some(arg_type.clone()), + line, + column, + ); + return Type::Error; + } + + // Every implemented native has at least one registered static + // signature. Resolve fixed-arity overloads here and return the result + // from the contract itself, avoiding a second source of truth. + if let Some(signatures) = self.builtin_signatures(name) { + let arity_matches: Vec<_> = signatures + .iter() + .filter(|signature| signature.parameters.len() == arguments.len()) + .collect(); + if let Some(signature) = + arity_matches.iter().find(|signature| { + signature.parameters.iter().zip(arg_types.iter()).all( + |(parameter, arg_type)| { + let parameter_type = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + self.are_builtin_types_compatible(¶meter_type, arg_type) + }, + ) + }) + { + let declared_return = signature + .return_type + .as_ref() + .cloned() + .unwrap_or(Type::Error); + if let Some((property_name, Type::List(element_type))) = &declared_property_target { + let value_index = match name { + "push" | "unshift" | "fill" => Some(1), + "insert_at" | "insertat" => Some(2), + _ => None, + }; + if let Some(value_index) = value_index + && let Some(value_type) = arg_types.get(value_index) + && !self.are_declared_property_values_compatible( + element_type, + value_type, + &arguments[value_index].value, + ) + { + self.type_error( + format!( + "Builtin '{name}' cannot put {value_type} into property \ + '{property_name}' because its declared element type is \ + {element_type}" + ), + Some((**element_type).clone()), + Some(value_type.clone()), + line, + column, + ); + return Type::Error; + } + } + self.apply_builtin_type_effects( + name, + arguments, + &arg_types, + declared_property_target.is_some(), + ); + return Self::specialize_builtin_return_type(name, &arg_types, declared_return); + } + + if !arity_matches.is_empty() { + let signature = arity_matches[0]; + if let Some((index, (parameter, arg_type))) = signature + .parameters + .iter() + .zip(arg_types.iter()) + .enumerate() + .find(|(_, (parameter, arg_type))| { + let parameter_type = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + !self.are_builtin_types_compatible(¶meter_type, arg_type) + }) + { + let parameter_type = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + let temporal_hint = match (¶meter_type, arg_type) { + (Type::Date, Type::Custom(custom_name)) + if custom_name.eq_ignore_ascii_case("date") + && self.analyzer.get_containers().contains_key(custom_name) => + { + Some(format!( + "'{custom_name}' is a custom/container annotation in this \ + program; use lowercase 'date' for the temporal type" + )) + } + (Type::Time, Type::Custom(custom_name)) + if custom_name.eq_ignore_ascii_case("time") + && self.analyzer.get_containers().contains_key(custom_name) => + { + Some(format!( + "'{custom_name}' is a custom/container annotation in this \ + program; use lowercase 'time' for the temporal type" + )) + } + (Type::DateTime, Type::Custom(custom_name)) + if custom_name.eq_ignore_ascii_case("datetime") + && self.analyzer.get_containers().contains_key(custom_name) => + { + Some(format!( + "'{custom_name}' is a custom/container annotation in this \ + program, and WFL has no unambiguous DateTime spelling while \ + that container exists; rename the container or leave the \ + parameter gradual" + )) + } + _ => None, + }; + if let Some(hint) = temporal_hint { + self.type_error( + format!( + "Argument {} of builtin '{}' requires a runtime temporal value, \ + but {}", + index + 1, + name, + hint + ), + None, + None, + line, + column, + ); + } else { + self.type_error( + format!( + "Argument {} of builtin '{}' expected {}, but found {}", + index + 1, + name, + parameter_type, + arg_type + ), + Some(parameter_type), + Some(arg_type.clone()), + line, + column, + ); + } + } else { + self.type_error( + format!("No signature of builtin '{name}' matches this call"), + None, + None, + line, + column, + ); + } + return Type::Error; + } + + // A variadic call beyond its single registered seed signature was + // validated by the repeated parameter contract above. + if variadic_parameter.is_some() { + return self.get_builtin_function_type(name, arguments.len()); + } + } else { + self.type_error( + format!("Builtin '{name}' has no registered static contract"), + None, + None, + line, + column, + ); + return Type::Error; + } + + // A fixed-arity native with no matching signature indicates a broken + // checker/runtime contract table rather than a dynamic call. + self.type_error( + format!( + "Builtin '{name}' has no static signature for {} arguments", + arguments.len() + ), + None, + None, + line, + column, + ); + Type::Error + } + + fn specialize_builtin_return_type( + name: &str, + argument_types: &[Type], + declared_return: Type, + ) -> Type { + let first_element = || match argument_types.first() { + Some(Type::List(element)) => Some((**element).clone()), + _ => None, + }; + match name { + "random_from" | "pop" | "shift" | "remove_at" | "removeat" => { + first_element().unwrap_or(declared_return) + } + "find" => first_element() + .map(Self::optionalize) + .unwrap_or(declared_return), + "slice" | "unique" => match argument_types.first() { + Some(list_type @ Type::List(_)) => list_type.clone(), + _ => declared_return, + }, + "concat" => match (argument_types.first(), argument_types.get(1)) { + (Some(Type::List(left)), Some(Type::List(right))) => Type::List(Box::new( + Self::join_collection_value_type(Some((**left).clone()), (**right).clone()), + )), + _ => declared_return, + }, + _ => declared_return, + } + } + + fn apply_builtin_type_effects( + &mut self, + name: &str, + arguments: &[crate::parser::ast::Argument], + argument_types: &[Type], + target_is_declared_property: bool, + ) { + if matches!(name, "clear" | "pop" | "shift" | "remove_at" | "removeat") { + self.definitely_nonempty_lists.clear(); + } + if name == "clear" { + if !target_is_declared_property + && let Some(target) = arguments.first().map(|argument| &argument.value) + { + self.detach_list_alias_descendants(target); + } + return; + } + let value_index = match name { + "push" | "unshift" | "fill" => Some(1), + "insert_at" | "insertat" => Some(2), + _ => None, + }; + let Some(value_index) = value_index else { + return; + }; + let Some(target) = arguments.first().map(|argument| &argument.value) else { + return; + }; + if target_is_declared_property { + // Container properties are not analyzer bindings. Applying the + // lexical alias effect here would instead widen an unrelated + // same-named outer variable (for a bare property) or the instance + // binding itself (for `box.items`). + return; + } + let (Some(target_type), Some(value_type)) = + (argument_types.first(), argument_types.get(value_index)) + else { + return; + }; + if !matches!( + target_type, + Type::List(_) | Type::Unknown | Type::Any | Type::Error + ) { + return; + } + let effect = if name == "fill" { + self.detach_list_alias_descendants(target); + ListMutationEffect::Replace(value_type.clone()) + } else { + ListMutationEffect::Join(value_type.clone()) + }; + self.apply_list_mutation_effect(target, effect); + self.record_list_insertion_aliases(target, &arguments[value_index].value); + if name != "fill" { + self.mark_list_target_nonempty(target); + } + } + + /// Infer a list mutation target once while retaining whether its type came + /// from a declared container property. The ordinary `Type::List` alone + /// is insufficient: lexical lists may widen under gradual typing, whereas + /// a property annotation is a persistent contract. + fn infer_list_mutation_target( + &mut self, + target: &Expression, + ) -> (Type, Option<(String, Type)>) { + match target { + Expression::Variable(name, ..) => { + let target_type = self.infer_expression_type(target); + let (declared_type, is_property) = self.resolve_bare_mutation_target_type(name); + ( + target_type, + is_property + .then_some(declared_type) + .flatten() + .map(|declared| (name.clone(), declared)), + ) + } + Expression::PropertyAccess { + object, + property, + line, + column, + } => { + let object_type = self.infer_expression_type(object); + let (target_type, is_declared_property) = + self.infer_property_access_type(object_type, property, *line, *column); + ( + target_type.clone(), + is_declared_property.then_some((property.clone(), target_type)), + ) + } + Expression::StaticMemberAccess { + container, member, .. + } => { + let target_type = self.infer_expression_type(target); + let declared_type = self.container_static_property_type(container, member); + ( + target_type, + declared_type.map(|declared| (member.clone(), declared)), + ) + } + _ => (self.infer_expression_type(target), None), + } + } + /// The registered signatures for `name` when it resolves to a function /// symbol, cloned so callers don't hold a borrow of the analyzer. fn action_signatures(&self, name: &str) -> Option> { @@ -646,6 +3302,39 @@ impl TypeChecker { }) .collect(); + let specificity_of = |index: usize| -> (usize, usize) { + signatures[index].parameters.iter().zip(&arg_types).fold( + (0, 0), + |(concrete, exact_temporal), (param, arg)| { + let Some(param_type) = param.param_type.as_ref() else { + return (concrete, exact_temporal); + }; + if matches!(param_type, Type::Any | Type::Unknown) + || matches!(arg, Type::Any | Type::Unknown | Type::Error) + || (matches!(arg, Type::Nothing) && !matches!(param_type, Type::Nothing)) + { + return (concrete, exact_temporal); + } + let exact_temporal_match = matches!( + (param_type, arg), + (Type::Date, Type::Date) + | (Type::Time, Type::Time) + | (Type::DateTime, Type::DateTime) + ); + ( + concrete + 1, + exact_temporal + usize::from(exact_temporal_match), + ) + }, + ) + }; + let best_specificity = compatible.iter().map(|&index| specificity_of(index)).max(); + let most_specific: Vec = compatible + .iter() + .copied() + .filter(|&index| Some(specificity_of(index)) == best_specificity) + .collect(); + let return_type_of = |checker: &Self, index: usize| -> Type { checker .overload_returns @@ -655,7 +3344,7 @@ impl TypeChecker { .unwrap_or(Type::Unknown) }; - match compatible.len() { + let result = match most_specific.len() { 0 => { let provided: Vec = arg_types.iter().map(|t| t.to_string()).collect(); let mut message = format!( @@ -671,24 +3360,172 @@ impl TypeChecker { self.type_error(message, None, None, line, column); Type::Error } - 1 => return_type_of(self, compatible[0]), + 1 => return_type_of(self, most_specific[0]), _ => { // Statically ambiguous — dynamic argument types, `nothing` - // arguments, or container-inheritance overlap: the runtime - // dispatches on the actual values. If every surviving overload - // agrees on the return type, the call still has that type. - let mut returns = compatible.iter().map(|&i| return_type_of(self, i)); - let first = returns.next().unwrap_or(Type::Unknown); - if returns.all(|t| t == first) { - first - } else { - Type::Unknown - } + // arguments, or equally-specific container-inheritance overlap: + // the runtime dispatches on the actual values. Join all + // reachable results so common structure and a known Nothing + // path are preserved. + most_specific + .iter() + .map(|&i| return_type_of(self, i)) + .reduce(Self::join_inferred_types) + .unwrap_or(Type::Unknown) } + }; + let selected_keys = most_specific + .iter() + .map(|index| (name.to_string(), *index)) + .collect::>(); + if result != Type::Error { + self.escape_user_action_list_arguments(arguments, &arg_types); + self.apply_user_action_list_effects(&selected_keys); + } + self.escape_shared_list_return_type(&selected_keys, result) + } + + fn infer_bare_action_statement( + &mut self, + name: &str, + signatures: &[crate::analyzer::FunctionSignature], + line: usize, + column: usize, + ) -> Type { + self.infer_overloaded_call_type(name, signatures, &[], line, column) + } + + fn infer_bare_variable_statement( + &mut self, + name: &str, + line: usize, + column: usize, + ) -> Option { + if let Some(resolution) = self + .analyzer + .alias_call_resolution(name, line, column) + .cloned() + { + return match resolution { + crate::analyzer::AliasState::Bound { + action, + visible_signatures, + } => self.action_signatures(&action).map(|signatures| { + let visible = visible_signatures.min(signatures.len()); + self.infer_bare_action_statement(&action, &signatures[..visible], line, column) + }), + crate::analyzer::AliasState::Builtin { .. } => None, + crate::analyzer::AliasState::Dynamic => { + self.escape_all_visible_mutable_state(); + Some(Type::Unknown) + } + }; + } + + self.action_signatures(name).and_then(|signatures| { + (signatures.len() == 1 + || signatures + .iter() + .any(|signature| signature.parameters.is_empty())) + .then(|| self.infer_bare_action_statement(name, &signatures, line, column)) + }) + } + + fn infer_zero_arg_variable_expression( + &mut self, + name: &str, + line: usize, + column: usize, + ) -> Option { + if let Some(resolution) = self + .analyzer + .alias_call_resolution(name, line, column) + .cloned() + { + return match resolution { + crate::analyzer::AliasState::Bound { + action, + visible_signatures, + } => self.action_signatures(&action).and_then(|signatures| { + let visible = visible_signatures.min(signatures.len()); + signatures[..visible] + .iter() + .any(|signature| signature.parameters.is_empty()) + .then(|| { + self.infer_overloaded_call_type( + &action, + &signatures[..visible], + &[], + line, + column, + ) + }) + }), + crate::analyzer::AliasState::Builtin { .. } => None, + crate::analyzer::AliasState::Dynamic => { + self.escape_all_visible_mutable_state(); + Some(Type::Unknown) + } + }; + } + + if let Some(signatures) = self.action_signatures(name) + && signatures + .iter() + .any(|signature| signature.parameters.is_empty()) + { + return Some(self.infer_overloaded_call_type(name, &signatures, &[], line, column)); + } + + let symbol_type = self + .analyzer + .get_symbol(name) + .and_then(|symbol| symbol.symbol_type.clone()); + if let Some(Type::Function { + parameters, + return_type, + }) = symbol_type + && parameters.is_empty() + { + // A stored method/native reference is auto-called by the runtime + // when read as a variable. Its closure may touch captured state, + // but unlike a named WFL action it has no keyed effect summary. + self.escape_all_visible_mutable_state(); + return Some(*return_type); } + None } pub fn check_types(&mut self, program: &Program) -> Result<(), TypeCheckError> { + // A checker may be reused by an editor or other long-lived caller. + // Diagnostics and program symbols belong to one run only. + self.errors.clear(); + self.current_container = None; + self.current_method_is_static = None; + self.current_method_outer_property_bindings = None; + self.checking_persistent_loop_backedge = false; + self.list_alias_groups.clear(); + self.user_action_list_effects.clear(); + self.user_action_binding_effects.clear(); + self.user_action_shared_list_returns.clear(); + self.user_action_dependencies.clear(); + self.deferred_action_key_stack.clear(); + self.deferred_list_effect_stack.clear(); + self.deferred_binding_effect_stack.clear(); + self.deferred_return_type_stack.clear(); + self.try_flow_states.clear(); + self.try_flow_capture_suspended = 0; + self.current_statement_completion = Type::Nothing; + self.optional_refinement_origins.clear(); + self.has_websocket_handlers = false; + self.definitely_nonempty_lists.clear(); + // A supplied, already-run analyzer is valid only for this first call. + // Reuse must create and run a fresh analyzer for the next Program. + let analyzer_was_pre_run = std::mem::take(&mut self.analyzer_already_run); + if !analyzer_was_pre_run { + self.analyzer = Analyzer::new(); + } + // Reset the per-run budget breach so a reused TypeChecker (e.g. an editor // session) neither carries a stale breach nor lets the recursive // `check_statement_types` short-circuit fire against a previous run's @@ -710,9 +3547,7 @@ impl TypeChecker { // Only run the analyzer if it hasn't been run already // When created with with_analyzer(), the analyzer has already been run, // so we don't need to analyze again. This prevents duplicate symbol registration. - if !self.analyzer_already_run - && let Err(semantic_errors) = self.analyzer.analyze(program) - { + if !analyzer_was_pre_run && let Err(semantic_errors) = self.analyzer.analyze(program) { // Propagate the analyzer's *typed* breach so an analysis-phase // deadline/cancellation/resource failure stays fatal and is never // mistaken for an ordinary semantic diagnostic. @@ -760,36 +3595,81 @@ impl TypeChecker { pattern: &PatternExpression, line: usize, column: usize, + ) { + let mut captures = std::collections::HashSet::new(); + self.check_pattern_expression_types_with_captures(pattern, line, column, &mut captures); + } + + fn check_pattern_expression_types_with_captures( + &mut self, + pattern: &PatternExpression, + line: usize, + column: usize, + captures: &mut std::collections::HashSet, ) { match pattern { PatternExpression::Literal(_) | PatternExpression::CharacterClass(_) - | PatternExpression::Anchor(_) - | PatternExpression::Backreference(_) => { + | PatternExpression::Anchor(_) => { // Leaf nodes are always valid } + PatternExpression::Backreference(name) => { + if !captures.contains(name) { + self.type_error( + format!("Backreference to undefined capture group: '{name}'"), + None, + None, + line, + column, + ); + } + } PatternExpression::Quantified { pattern: inner_pattern, .. } => { - self.check_pattern_expression_types(inner_pattern, line, column); + self.check_pattern_expression_types_with_captures( + inner_pattern, + line, + column, + captures, + ); } PatternExpression::Sequence(patterns) | PatternExpression::Alternative(patterns) => { for inner_pattern in patterns { - self.check_pattern_expression_types(inner_pattern, line, column); + self.check_pattern_expression_types_with_captures( + inner_pattern, + line, + column, + captures, + ); } } PatternExpression::Capture { + name, pattern: inner_pattern, - .. } => { - self.check_pattern_expression_types(inner_pattern, line, column); + // The compiler registers a capture before compiling its inner + // expression, so a recursive/self backreference follows the + // same name-resolution order here. + captures.insert(name.clone()); + self.check_pattern_expression_types_with_captures( + inner_pattern, + line, + column, + captures, + ); } PatternExpression::Lookahead(inner_pattern) | PatternExpression::NegativeLookahead(inner_pattern) | PatternExpression::Lookbehind(inner_pattern) | PatternExpression::NegativeLookbehind(inner_pattern) => { - self.check_pattern_expression_types(inner_pattern, line, column); + self.check_pattern_expression_types_with_captures( + inner_pattern, + line, + column, + captures, + ); } PatternExpression::ListReference(name) => { // TypeChecker delegates undefined variable checks to Analyzer. @@ -798,7 +3678,7 @@ impl TypeChecker { self.infer_expression_type(&Expression::Variable(name.clone(), line, column)); match var_type { Type::List(ref item_type) => { - if **item_type != Type::Text { + if **item_type != Type::Text && !self.is_gradual_type(item_type) { self.type_error( format!("Pattern list reference '{name}' must contain Text, got List of {item_type}"), Some(Type::List(Box::new(Type::Text))), @@ -808,6 +3688,7 @@ impl TypeChecker { ); } } + Type::Unknown | Type::Any | Type::Error => {} _ => { self.type_error( format!( @@ -831,7 +3712,7 @@ impl TypeChecker { column: usize, ) { let server_type = self.infer_expression_type(server_expr); - if server_type != Type::Text { + if server_type != Type::Text && !self.is_gradual_type(&server_type) { self.type_error( "Server must be a text string".to_string(), Some(Type::Text), @@ -842,7 +3723,19 @@ impl TypeChecker { } } - fn check_statement_types(&mut self, statement: &Statement) { + fn check_statement_types(&mut self, statement: &Statement) -> Type { + let previous_completion = + std::mem::replace(&mut self.current_statement_completion, Type::Nothing); + self.check_statement_types_inner(statement); + let completion = + std::mem::replace(&mut self.current_statement_completion, previous_completion); + if self.budget_error.is_none() { + self.capture_active_try_flow_state(); + } + completion + } + + fn check_statement_types_inner(&mut self, statement: &Statement) { // Recursive front-end checkpoint. This method recurses into `if`/loop/ // `try`/action/container-method bodies, so polling the run budget here // (mirroring the parser's per-`parse_statement` placement) keeps deeply @@ -871,9 +3764,48 @@ impl TypeChecker { line: _line, column: _column, } => { - let list_type = self.infer_expression_type(list); - match list_type { - Type::List(_) | Type::Unknown => {} + let (list_type, declared_property) = self.infer_list_mutation_target(list); + let value_type = self.infer_expression_type(value); + let declared_property_element = match declared_property.as_ref() { + Some((name, Type::List(element))) => Some((name.as_str(), (**element).clone())), + _ => None, + }; + match &list_type { + Type::List(_) => { + let property_violation = + declared_property_element.as_ref().filter(|(_, expected)| { + !self.are_declared_property_values_compatible( + expected, + &value_type, + value, + ) + }); + if let Some((name, expected)) = property_violation { + self.type_error( + format!( + "Cannot push {value_type} into property '{name}' because its \ + declared element type is {expected}" + ), + Some(expected.clone()), + Some(value_type), + *_line, + *_column, + ); + } else if declared_property_element.is_none() { + self.apply_list_mutation_effect( + list, + ListMutationEffect::Join(value_type), + ); + self.record_list_insertion_aliases(list, value); + } + } + Type::Unknown | Type::Any | Type::Error => { + // A control-flow join may make the promoted alias + // itself gradual while its may-alias group still + // contains a precisely typed outer list. + self.apply_list_mutation_effect(list, ListMutationEffect::Join(value_type)); + self.record_list_insertion_aliases(list, value); + } _ => { self.errors.push(TypeError::new( format!("Expected list type for push operation, got {list_type:?}"), @@ -884,7 +3816,13 @@ impl TypeChecker { )); } } - self.infer_expression_type(value); + if matches!( + list_type, + Type::List(_) | Type::Unknown | Type::Any | Type::Error + ) && declared_property.is_none() + { + self.mark_list_target_nonempty(list); + } } Statement::RepeatWhileLoop { condition, @@ -896,14 +3834,30 @@ impl TypeChecker { // iteration, so bindings from a backedge are visible at the // next header but remain local after the loop. self.analyzer.push_scope(); - self.check_loop_body_fixed_point(body); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { + self.errors.push(TypeError::new( + format!( + "Expected boolean condition in repeat-while loop, got {condition_type:?}" + ), + Some(Type::Boolean), + Some(condition_type), + *_line, + *_column, + )); + } + let first_condition_error_end = self.errors.len(); + self.check_persistent_loop_body_fixed_point( + body, + !matches!(condition, Expression::Literal(Literal::Boolean(false), ..)), + ); if self.budget_error.is_some() { self.analyzer.pop_scope(); return; } let condition_type = self.infer_expression_type(condition); - if condition_type != Type::Boolean && condition_type != Type::Unknown { + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { self.errors.push(TypeError::new( format!( "Expected boolean condition in repeat-while loop, got {condition_type:?}" @@ -914,7 +3868,14 @@ impl TypeChecker { *_column, )); } + self.deduplicate_errors_from(first_condition_error_end); self.analyzer.pop_scope(); + self.current_statement_completion = + if matches!(condition, Expression::Literal(Literal::Boolean(false), ..)) { + Type::Nothing + } else { + Type::Any + }; } Statement::ExitStatement { line: _, column: _ } => {} Statement::WaitForStatement { @@ -922,7 +3883,7 @@ impl TypeChecker { line: _line, column: _column, } => { - self.check_statement_types(inner); + self.current_statement_completion = self.check_statement_types(inner); } Statement::WaitForDurationStatement { duration, @@ -931,10 +3892,7 @@ impl TypeChecker { .. } => { let duration_type = self.infer_expression_type(duration); - if duration_type != Type::Number - && duration_type != Type::Unknown - && duration_type != Type::Error - { + if duration_type != Type::Number && !self.is_gradual_type(&duration_type) { self.type_error( "Expected a number for wait duration".to_string(), Some(Type::Number), @@ -943,6 +3901,13 @@ impl TypeChecker { *_column, ); } + if self.has_websocket_handlers { + // Runtime pumps registered WebSocket handlers throughout + // this wait. Their deferred bodies are an opaque captured- + // environment boundary until handler-specific summaries + // become part of the public type model. + self.escape_all_visible_mutable_state(); + } } Statement::TryStatement { body, @@ -952,30 +3917,51 @@ impl TypeChecker { line: _line, column: _column, } => { + self.definitely_nonempty_lists.clear(); // Runtime evaluates the try body, handlers, otherwise, and // finally block inside one shared child environment. self.analyzer.push_scope(); + let try_scope_entry_symbols = self.analyzer.snapshot_current_scope_symbols(); let entry_types = self.analyzer.snapshot_symbol_types(); - for stmt in body { - self.check_statement_types(stmt); - } + let try_entry_visible_names = entry_types + .iter() + .flat_map(|layer| layer.keys().cloned()) + .collect::>(); + let entry_aliases = self.list_alias_groups.clone(); + let summary_entry = self.snapshot_deferred_summary(); + self.try_flow_states.push(TryFlowAccumulator { + binding_types: self.analyzer.live_binding_types().into_iter().collect(), + list_aliases: entry_aliases.clone(), + }); + let (success_can_continue, success_completion) = + self.check_statement_block_with_completion(body); + let mut body_flow = self.try_flow_states.pop().unwrap_or_default(); if self.budget_error.is_some() { self.analyzer.pop_scope(); return; } let success_endpoint = self.analyzer.snapshot_symbol_types(); - - // An error can leave the body from any statement, so handlers - // start from the conservative entry/success join. Keep the - // success scope's symbol set as the structural baseline: - // success-only bindings remain resolvable as gradual types, - // while exact restoration prevents one handler's new symbols - // from contaminating the next handler. - let handler_entry = - Self::join_type_snapshots(&[entry_types, success_endpoint.clone()]); - let handler_scope_symbols = self.analyzer.snapshot_current_scope_symbols(); - let mut joined_scope_symbols = handler_scope_symbols.clone(); + let success_aliases = self.list_alias_groups.clone(); + + // An error can leave the body after any reachable nested + // statement. The streaming accumulator joins every such state + // without retaining one full snapshot per prefix. + let handler_entry = self + .apply_try_binding_accumulator(entry_types.clone(), &body_flow.binding_types); + self.retain_live_alias_paths(&mut body_flow.list_aliases); + let handler_entry_aliases = body_flow.list_aliases; + let success_scope_symbols = self.analyzer.snapshot_current_scope_symbols(); + let mut endpoint_scope_symbols = vec![success_scope_symbols.clone()]; let mut endpoints = vec![success_endpoint]; + let mut endpoint_aliases = vec![success_aliases]; + let mut continuation_endpoints = Vec::new(); + let mut continuation_aliases = Vec::new(); + let mut continuation_completion_types = Vec::new(); + if success_can_continue { + continuation_endpoints.push(endpoints[0].clone()); + continuation_aliases.push(endpoint_aliases[0].clone()); + continuation_completion_types.push(success_completion); + } // Type check each when clause in its own scope so the bound // error name cannot clobber an outer variable of the same @@ -985,8 +3971,9 @@ impl TypeChecker { // same via Environment::define_or_replace). for when_clause in when_clauses { self.analyzer - .restore_current_scope_symbols(handler_scope_symbols.clone()); + .restore_current_scope_symbols(try_scope_entry_symbols.clone()); self.analyzer.restore_symbol_types(handler_entry.clone()); + self.list_alias_groups = handler_entry_aliases.clone(); self.analyzer.push_scope(); self.analyzer.define_or_replace_symbol(Symbol { name: when_clause.error_name.clone(), @@ -1007,42 +3994,54 @@ impl TypeChecker { }); } - for stmt in &when_clause.body { - self.check_statement_types(stmt); - } + let (handler_can_continue, handler_completion) = + self.check_statement_block_with_completion(&when_clause.body); let mut excluded_aliases = vec![when_clause.error_name.clone()]; if when_clause.error_name != "error_message" { excluded_aliases.push("error_message".to_string()); } - self.analyzer.pop_scope_promoting_except(&excluded_aliases); + let promoted = self.analyzer.pop_scope_promoting_except(&excluded_aliases); + self.merge_promoted_list_alias_bindings(promoted); if self.budget_error.is_some() { self.analyzer.pop_scope(); return; } - endpoints.push(self.analyzer.snapshot_symbol_types()); - for (name, symbol) in self.analyzer.snapshot_current_scope_symbols() { - joined_scope_symbols.entry(name).or_insert(symbol); + let endpoint = self.analyzer.snapshot_symbol_types(); + let aliases = self.list_alias_groups.clone(); + endpoints.push(endpoint.clone()); + endpoint_aliases.push(aliases.clone()); + if handler_can_continue { + continuation_endpoints.push(endpoint); + continuation_aliases.push(aliases); + continuation_completion_types.push(handler_completion); } + endpoint_scope_symbols.push(self.analyzer.snapshot_current_scope_symbols()); } if let Some(otherwise_stmts) = otherwise_block { self.analyzer - .restore_current_scope_symbols(handler_scope_symbols.clone()); + .restore_current_scope_symbols(try_scope_entry_symbols.clone()); self.analyzer.restore_symbol_types(handler_entry.clone()); - for stmt in otherwise_stmts { - self.check_statement_types(stmt); - } + self.list_alias_groups = handler_entry_aliases.clone(); + let (otherwise_can_continue, otherwise_completion) = + self.check_statement_block_with_completion(otherwise_stmts); if self.budget_error.is_some() { self.analyzer.pop_scope(); return; } - endpoints.push(self.analyzer.snapshot_symbol_types()); - for (name, symbol) in self.analyzer.snapshot_current_scope_symbols() { - joined_scope_symbols.entry(name).or_insert(symbol); + let endpoint = self.analyzer.snapshot_symbol_types(); + let aliases = self.list_alias_groups.clone(); + endpoints.push(endpoint.clone()); + endpoint_aliases.push(aliases.clone()); + if otherwise_can_continue { + continuation_endpoints.push(endpoint); + continuation_aliases.push(aliases); + continuation_completion_types.push(otherwise_completion); } + endpoint_scope_symbols.push(self.analyzer.snapshot_current_scope_symbols()); } else if !when_clauses.iter().any(|when_clause| { matches!( &when_clause.error_type, @@ -1052,21 +4051,88 @@ impl TypeChecker { // A non-matching error reaches finally without running a // handler when there is no catch-all or otherwise block. endpoints.push(handler_entry.clone()); + endpoint_aliases.push(handler_entry_aliases.clone()); + endpoint_scope_symbols.push(try_scope_entry_symbols.clone()); } - self.analyzer - .restore_current_scope_symbols(handler_scope_symbols); - for symbol in joined_scope_symbols.into_values() { - self.analyzer.define_or_replace_symbol(symbol); + let mut definite_scope_symbols = success_scope_symbols; + for symbols in &endpoint_scope_symbols { + for (name, symbol) in symbols { + definite_scope_symbols + .entry(name.clone()) + .or_insert_with(|| symbol.clone()); + } } + definite_scope_symbols.retain(|name, _| { + try_entry_visible_names.contains(name) + || endpoint_scope_symbols + .iter() + .all(|symbols| symbols.contains_key(name)) + }); + self.analyzer + .restore_current_scope_symbols(definite_scope_symbols); let joined_endpoint = Self::join_type_snapshots(&endpoints); self.analyzer.restore_symbol_types(joined_endpoint); + self.list_alias_groups = Self::join_list_alias_snapshots(&endpoint_aliases); if let Some(finally_stmts) = finally_block { - for stmt in finally_stmts { - self.check_statement_types(stmt); + let pre_finally_scope_symbols = self.analyzer.snapshot_current_scope_symbols(); + let primary_summary = self.snapshot_deferred_summary(); + let primary_return_len = primary_summary.returns.as_ref().map_or(0, Vec::len); + let finally_error_start = self.errors.len(); + let finally_can_continue = self.check_statement_block(finally_stmts); + let mut final_summary = self.snapshot_deferred_summary(); + + if !finally_can_continue { + // A return/exit/break/continue from finally overrides + // the primary control flow. Preserve effects that + // happened before finally, but discard primary return + // values in favor of the returns produced by finally. + if let Some(all_returns) = &final_summary.returns { + let mut selected = summary_entry.returns.clone().unwrap_or_default(); + selected.extend(all_returns.iter().skip(primary_return_len).cloned()); + if let Some(active) = self.deferred_return_type_stack.last_mut() { + *active = selected.clone(); + } + final_summary.returns = Some(selected); + } + } + + if finally_can_continue && !continuation_endpoints.is_empty() { + // Ordinary finally preserves the primary endpoint's + // control flow. Re-apply its state transform to only + // the endpoints that can actually reach the statement + // after this try; abrupt Return/Exit/error endpoints + // must still be considered while validating finally, + // but cannot pollute the post-try state. + self.analyzer + .restore_current_scope_symbols(pre_finally_scope_symbols); + self.analyzer + .restore_symbol_types(Self::join_type_snapshots( + &continuation_endpoints, + )); + self.list_alias_groups = + Self::join_list_alias_snapshots(&continuation_aliases); + self.restore_deferred_summary(primary_summary); + self.check_statement_block(finally_stmts); + self.restore_deferred_summary(final_summary); + self.deduplicate_errors_from(finally_error_start); + } else if finally_can_continue { + self.analyzer.restore_symbol_types(entry_types); + self.list_alias_groups = entry_aliases; } + } else if !continuation_endpoints.is_empty() { + self.analyzer + .restore_symbol_types(Self::join_type_snapshots(&continuation_endpoints)); + self.list_alias_groups = Self::join_list_alias_snapshots(&continuation_aliases); + } else { + self.analyzer.restore_symbol_types(entry_types); + self.list_alias_groups = entry_aliases; } + self.current_statement_completion = continuation_completion_types + .into_iter() + .reduce(Self::join_inferred_types) + .unwrap_or(Type::Nothing); self.analyzer.pop_scope(); } Statement::HttpGetStatement { @@ -1076,7 +4142,7 @@ impl TypeChecker { column: _column, } => { let url_type = self.infer_expression_type(url); - if url_type != Type::Text && url_type != Type::Unknown && url_type != Type::Error { + if url_type != Type::Text && !self.is_gradual_type(&url_type) { self.type_error( "URL must be a text string".to_string(), Some(Type::Text), @@ -1086,11 +4152,7 @@ impl TypeChecker { ); } - if !variable_name.is_empty() - && let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) - { - symbol.symbol_type = Some(Type::Text); - } + self.bind_runtime_value(variable_name, Type::Text, true, *_line, *_column); } Statement::HttpPostStatement { url, @@ -1100,7 +4162,7 @@ impl TypeChecker { column: _column, } => { let url_type = self.infer_expression_type(url); - if url_type != Type::Text && url_type != Type::Unknown && url_type != Type::Error { + if url_type != Type::Text && !self.is_gradual_type(&url_type) { self.type_error( "URL must be a text string".to_string(), Some(Type::Text), @@ -1110,13 +4172,18 @@ impl TypeChecker { ); } - self.infer_expression_type(data); - - if !variable_name.is_empty() - && let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) - { - symbol.symbol_type = Some(Type::Text); + let data_type = self.infer_expression_type(data); + if data_type != Type::Text && !self.is_gradual_type(&data_type) { + self.type_error( + "HTTP POST data must be text".to_string(), + Some(Type::Text), + Some(data_type), + *_line, + *_column, + ); } + + self.bind_runtime_value(variable_name, Type::Text, true, *_line, *_column); } Statement::HttpRequestStatement { url, @@ -1129,7 +4196,7 @@ impl TypeChecker { column: _column, } => { let url_type = self.infer_expression_type(url); - if url_type != Type::Text && url_type != Type::Unknown && url_type != Type::Error { + if url_type != Type::Text && !self.is_gradual_type(&url_type) { self.type_error( "URL must be a text string".to_string(), Some(Type::Text), @@ -1141,10 +4208,7 @@ impl TypeChecker { if let Some(method) = method { let method_type = self.infer_expression_type(method); - if method_type != Type::Text - && method_type != Type::Unknown - && method_type != Type::Error - { + if method_type != Type::Text && !self.is_gradual_type(&method_type) { self.type_error( "HTTP method must be a text string".to_string(), Some(Type::Text), @@ -1192,15 +4256,17 @@ impl TypeChecker { } } - if !variable_name.is_empty() - && let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) - { - symbol.symbol_type = Some(if *full_response { - Type::Map(Box::new(Type::Text), Box::new(Type::Unknown)) + self.bind_runtime_value( + variable_name, + if *full_response { + Type::Map(Box::new(Type::Text), Box::new(Type::Any)) } else { Type::Text - }); - } + }, + true, + *_line, + *_column, + ); } Statement::HttpStreamStatement { url, @@ -1212,7 +4278,7 @@ impl TypeChecker { column: _column, } => { let url_type = self.infer_expression_type(url); - if url_type != Type::Text && url_type != Type::Unknown && url_type != Type::Error { + if url_type != Type::Text && !self.is_gradual_type(&url_type) { self.type_error( "URL must be a text string".to_string(), Some(Type::Text), @@ -1223,10 +4289,7 @@ impl TypeChecker { } if let Some(method) = method { let method_type = self.infer_expression_type(method); - if method_type != Type::Text - && method_type != Type::Unknown - && method_type != Type::Error - { + if method_type != Type::Text && !self.is_gradual_type(&method_type) { self.type_error( "HTTP method must be a text string".to_string(), Some(Type::Text), @@ -1276,11 +4339,13 @@ impl TypeChecker { // status/ok/headers via index/member access, and is closeable). // A distinct handle type — not a bare `Map` — so `close` accepts // it without also accepting an ordinary user map. - if !variable_name.is_empty() - && let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) - { - symbol.symbol_type = Some(Type::Custom("HttpStream".to_string())); - } + self.bind_runtime_value( + variable_name, + Type::Custom("HttpStream".to_string()), + true, + *_line, + *_column, + ); } Statement::WaitForNextChunkStatement { source, @@ -1309,14 +4374,19 @@ impl TypeChecker { *column, ); } - // The binding may be a chunk/line value or `nothing` at end of - // stream, so leave the bound variable's type open (Any) to avoid - // false errors on the `check if is nothing` termination. - if !variable_name.is_empty() - && let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) + let value_type = if matches!(statement, Statement::WaitForNextChunkStatement { .. }) { - symbol.symbol_type = Some(Type::Any); - } + Type::Binary + } else { + Type::Text + }; + self.bind_runtime_value( + variable_name, + Type::Optional(Box::new(value_type)), + true, + *line, + *column, + ); } Statement::StartStreamingResponseStatement { request, @@ -1327,7 +4397,16 @@ impl TypeChecker { line: _line, column: _column, } => { - let _ = self.infer_expression_type(request); + let request_type = self.infer_expression_type(request); + if !self.is_pending_request_type(&request_type) { + self.type_error( + "Streaming response target must be a request object".to_string(), + Some(Type::Custom("Request".to_string())), + Some(request_type.clone()), + *_line, + *_column, + ); + } // Enforce the clause types (like RespondStatement) so obvious // mistakes fail at typecheck rather than at runtime. if let Some(status) = status { @@ -1497,10 +4576,11 @@ impl TypeChecker { Statement::VariableDeclaration { name, value, - is_constant: _, + is_constant, line: _line, column: _column, } => { + let is_definitely_nonempty = self.expression_is_definitely_nonempty_list(value); let inferred_type = self.infer_expression_type(value); // Special case for loopcounter variable @@ -1518,13 +4598,17 @@ impl TypeChecker { // references. The type-compatibility and symbol-recording paths // below still record the more specific type when one is available. - let symbol_type_option = if let Some(symbol) = self.analyzer.get_symbol(name) { - symbol.symbol_type.clone() - } else { - None - }; + let (resolved_type, is_property) = self.resolve_bare_mutation_target_type(name); + let declared_property_type = is_property.then_some(resolved_type.clone()).flatten(); + let symbol_type_option = (!is_property).then_some(resolved_type).flatten(); - let need_type_error = if let Some(declared_type) = &symbol_type_option { + let need_type_error = if let Some(declared_type) = &declared_property_type { + !self.are_declared_property_values_compatible( + declared_type, + &inferred_type, + value, + ) + } else if let Some(declared_type) = &symbol_type_option { !self.are_types_compatible(declared_type, &inferred_type) } else { false @@ -1532,20 +4616,39 @@ impl TypeChecker { if need_type_error { self.type_error( - format!("Cannot initialize variable '{name}' with incompatible type"), - symbol_type_option.clone(), + if let Some(expected) = &declared_property_type { + format!( + "Cannot initialize property '{name}' with {inferred_type} because \ + its declared type is {expected}" + ) + } else { + format!("Cannot initialize variable '{name}' with incompatible type") + }, + declared_property_type + .clone() + .or(symbol_type_option.clone()), Some(inferred_type.clone()), *_line, *_column, ); } - - if inferred_type != Type::Error - && inferred_type != Type::Unknown - && let Some(symbol) = self.analyzer.get_symbol_mut(name) - && symbol.symbol_type.is_none() - { - symbol.symbol_type = Some(inferred_type.clone()); + if declared_property_type.is_some() { + // Runtime container properties live in the method + // environment and shadow outer lexical bindings. Their + // declared registry type remains the source of truth. + if *is_constant { + self.type_error( + format!( + "Cannot redeclare container property '{name}' as a constant; \ + the property binding already exists" + ), + declared_property_type, + Some(inferred_type), + *_line, + *_column, + ); + } + return; } // Locals declared inside an action body have no symbol left @@ -1557,20 +4660,48 @@ impl TypeChecker { // outer variable's name is a fatal semantic error ("Use // 'change x to '"), so a resolved outer symbol can // only mean the store refers to that same variable. - if self.analyzer.get_symbol(name).is_none() { - let recorded_type = if inferred_type == Type::Error { - Type::Unknown - } else { - inferred_type - }; + let recorded_type = if inferred_type == Type::Error { + Type::Unknown + } else { + inferred_type + }; + let alias_value_type = recorded_type.clone(); + if self.analyzer.get_local_symbol(name).is_some() { + if *is_constant && self.checking_persistent_loop_backedge { + self.type_error( + format!( + "Constant '{name}' is redeclared when the persistent loop \ + reaches another iteration" + ), + None, + None, + *_line, + *_column, + ); + } + // Fixed-point loop checking revisits the same declaration + // under a widened header. The declaration executes before + // its later uses on every iteration, so it replaces that + // header type with the freshly inferred value just as the + // runtime replaces the binding. + if let Some(symbol) = self.analyzer.get_symbol_mut(name) { + symbol.symbol_type = Some(recorded_type); + } + } else { let _ = self.analyzer.define_symbol(Symbol { name: name.clone(), - kind: SymbolKind::Variable { mutable: true }, + kind: SymbolKind::Variable { + mutable: !is_constant, + }, symbol_type: Some(recorded_type), line: *_line, column: *_column, }); } + self.detach_list_alias_binding(name); + self.record_direct_list_alias(name, value, &alias_value_type); + self.record_nested_list_aliases(name, value); + self.update_binding_nonempty_fact(name, is_definitely_nonempty); } Statement::Assignment { name, @@ -1578,14 +4709,18 @@ impl TypeChecker { line, column, } => { + let is_definitely_nonempty = self.expression_is_definitely_nonempty_list(value); let inferred_type = self.infer_expression_type(value); + let alias_value_type = inferred_type.clone(); + let mut captured_alias_sources = Vec::new(); + self.capture_nested_list_alias_sources(value, 0, &mut captured_alias_sources); // Clone the existing type first so we can re-borrow mutably below // when widening away from Nothing (issue #605). - let existing_type = self - .analyzer - .get_symbol(name) - .and_then(|s| s.symbol_type.clone()); + let (resolved_type, is_property) = self.resolve_bare_mutation_target_type(name); + let declared_property_type = is_property.then_some(resolved_type.clone()).flatten(); + let symbol_type = (!is_property).then_some(resolved_type).flatten(); + let existing_type = symbol_type.or_else(|| declared_property_type.clone()); match existing_type { // `store x as nothing` is the idiomatic "uninitialized" @@ -1593,7 +4728,7 @@ impl TypeChecker { // new value's type; otherwise later indexing/use stays // pinned to Nothing and raises false // "Cannot index into Nothing" diagnostics (issue #605). - Some(Type::Nothing) => { + Some(Type::Nothing) if declared_property_type.is_none() => { if inferred_type != Type::Nothing && inferred_type != Type::Error && let Some(symbol) = self.analyzer.get_symbol_mut(name) @@ -1602,16 +4737,41 @@ impl TypeChecker { } } Some(variable_type) => { - if !self.are_types_compatible(&variable_type, &inferred_type) { + let is_compatible = if declared_property_type.is_some() { + self.are_declared_property_values_compatible( + &variable_type, + &inferred_type, + value, + ) + } else { + self.are_types_compatible(&variable_type, &inferred_type) + }; + if !is_compatible { self.type_error( - format!( - "Cannot assign value of incompatible type to variable '{name}'" - ), + if declared_property_type.is_some() { + format!( + "Cannot assign {inferred_type} to property '{name}' because \ + its declared type is {variable_type}" + ) + } else { + format!( + "Cannot assign value of incompatible type to variable \ + '{name}'" + ) + }, Some(variable_type), Some(inferred_type), *line, *column, ); + } else if inferred_type != Type::Error + && let Some(symbol) = self.analyzer.get_symbol_mut(name) + { + // `change` replaces the current runtime value. + // Flow-sensitive state must therefore record the + // assigned value itself, including Nothing, rather + // than retaining a stale pre-assignment type. + symbol.symbol_type = Some(inferred_type); } } None => { @@ -1627,6 +4787,25 @@ impl TypeChecker { } } } + if declared_property_type.is_some() { + // Runtime container properties shadow outer lexical + // bindings. Do not accidentally detach aliases or record + // deferred effects against a same-named outer variable. + return; + } + self.record_deferred_binding_assignment(name); + self.record_deferred_list_rebind(name, value, &alias_value_type); + self.detach_list_alias_binding(name); + self.restore_captured_list_alias_sources(name, captured_alias_sources); + if matches!( + value, + Expression::MemberAccess { .. } + | Expression::PropertyAccess { .. } + | Expression::MethodCall { .. } + ) { + self.record_direct_list_alias(name, value, &alias_value_type); + } + self.update_binding_nonempty_fact(name, is_definitely_nonempty); } Statement::ActionDefinition { name, @@ -1659,6 +4838,27 @@ impl TypeChecker { // in builtin positions (e.g. `respond to req with ...`, #569). let return_type_value = return_type.as_ref().cloned().unwrap_or(Type::Unknown); + // Nested action symbols lived only in the analyzer's discarded + // body scope. Re-create their ownership in the active scope so + // calls and local-only exports observe runtime scope rules. + if self.analyzer.get_local_symbol(name).is_none() { + let _ = self.analyzer.define_symbol(Symbol { + name: name.clone(), + kind: SymbolKind::Function { + signatures: vec![crate::analyzer::FunctionSignature { + parameters: parameters.clone(), + return_type: return_type.clone(), + }], + }, + symbol_type: Some(Type::Function { + parameters: param_types.clone(), + return_type: Box::new(return_type_value.clone()), + }), + line: *_line, + column: *_column, + }); + } + if let Some(symbol) = self.analyzer.get_symbol_mut(name) { symbol.symbol_type = Some(Type::Function { parameters: param_types.clone(), @@ -1684,7 +4884,7 @@ impl TypeChecker { line: param.line, column: param.column, }; - let _ = self.analyzer.define_symbol(param_symbol); + self.analyzer.define_or_replace_symbol(param_symbol); } // Snapshot before the body so Nothing-widening (and other @@ -1692,25 +4892,92 @@ impl TypeChecker { // do not permanently stick after the action is defined but // never called (PR #606 Codex review). let outer_type_snapshot = self.analyzer.snapshot_symbol_types(); - - for stmt in body { - self.check_statement_types(stmt); + let outer_alias_snapshot = self.list_alias_groups.clone(); + let outer_refinement_snapshot = self.optional_refinement_origins.clone(); + let outer_nonempty_snapshot = self.definitely_nonempty_lists.clone(); + let signature_index = self.signature_index_for(name, parameters).unwrap_or(0); + let summary_key = (name.clone(), signature_index); + self.deferred_action_key_stack.push(summary_key.clone()); + self.deferred_list_effect_stack.push(HashSet::new()); + self.deferred_binding_effect_stack.push(HashMap::new()); + self.deferred_return_type_stack.push(Vec::new()); + + self.try_flow_capture_suspended += 1; + let (body_can_continue, implicit_completion) = + self.check_statement_block_with_completion(body); + self.try_flow_capture_suspended -= 1; + + let recorded_returns = self.deferred_return_type_stack.pop().unwrap_or_default(); + let mut implicit_list_sources = Vec::new(); + if body_can_continue && Self::type_may_contain_list(&implicit_completion) { + self.capture_block_completion_list_sources(body, 0, &mut implicit_list_sources); } - - // Infer the return type while body-locals and parameters are - // still in scope (and still see any in-body widenings). let inferred_return = if return_type.is_none() { - Some(self.infer_action_return_type(body)) + Some(Self::infer_recorded_action_return_type( + &recorded_returns, + body_can_continue.then_some(&implicit_completion), + )) } else { None }; if let Some(ret_type) = return_type { - self.check_return_statements(body, ret_type, *_line, *_column); + self.check_recorded_return_types(&recorded_returns, ret_type); + if body_can_continue { + self.check_implicit_action_result( + &implicit_completion, + ret_type, + *_line, + *_column, + ); + } } + let deferred_effects = self.deferred_list_effect_stack.pop().unwrap_or_default(); + let deferred_binding_effects = + self.deferred_binding_effect_stack.pop().unwrap_or_default(); + let popped_summary_key = self.deferred_action_key_stack.pop(); + debug_assert_eq!(popped_summary_key.as_ref(), Some(&summary_key)); + let returned_list_sources = recorded_returns + .iter() + .flat_map(|record| record.list_sources.iter().cloned()) + .chain(implicit_list_sources); self.analyzer.restore_symbol_types(outer_type_snapshot); + self.list_alias_groups = outer_alias_snapshot; + self.optional_refinement_origins = outer_refinement_snapshot; + self.definitely_nonempty_lists = outer_nonempty_snapshot; self.analyzer.pop_scope(); + let mut shared_return_provenance = SharedListReturnProvenance::new(); + for (depth, sources) in returned_list_sources { + let live_sources = sources + .into_iter() + .filter(|source| self.analyzer.binding_key_is_live(&source.binding)) + .collect::>(); + if !live_sources.is_empty() { + shared_return_provenance + .entry(depth) + .or_default() + .extend(live_sources); + } + } + self.user_action_list_effects + .entry(summary_key.clone()) + .or_default() + .extend(deferred_effects); + let binding_effects = self + .user_action_binding_effects + .entry(summary_key.clone()) + .or_default(); + for (binding, effect_type) in deferred_binding_effects { + Self::join_binding_effect(binding_effects, binding, effect_type); + } + if shared_return_provenance.is_empty() { + self.user_action_shared_list_returns.remove(&summary_key); + } else { + self.user_action_shared_list_returns + .insert(summary_key.clone(), shared_return_provenance); + } + self.propagate_user_action_summaries(); // Update the action's symbol so call sites see the real result // type instead of the provisional `Unknown` seed. Pure `Nothing` @@ -1735,10 +5002,12 @@ impl TypeChecker { .cloned() .or(inferred_return) .unwrap_or(Type::Unknown); - if let Some(index) = self.signature_index_for(name, parameters) { - self.overload_returns - .insert((name.clone(), index), overload_return); - } + self.overload_returns.insert(summary_key, overload_return); + self.current_statement_completion = self + .analyzer + .get_symbol(name) + .and_then(|symbol| symbol.symbol_type.clone()) + .unwrap_or(Type::Unknown); } Statement::IfStatement { condition, @@ -1747,11 +5016,9 @@ impl TypeChecker { line: _line, column: _column, } => { + self.definitely_nonempty_lists.clear(); let condition_type = self.infer_expression_type(condition); - if condition_type != Type::Boolean - && condition_type != Type::Unknown - && condition_type != Type::Error - { + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { self.type_error( "Condition must be a boolean expression".to_string(), Some(Type::Boolean), @@ -1761,23 +5028,119 @@ impl TypeChecker { ); } + let refinement = self.optional_condition_refinement(condition); + let literal_condition = match condition { + Expression::Literal(Literal::Boolean(value), ..) => Some(*value), + _ => None, + }; + let summary_entry = self.snapshot_deferred_summary(); + let refinement_origins_entry = self.optional_refinement_origins.clone(); + let refinement_origin = refinement.as_ref().and_then(|(name, _, _)| { + let binding = self.analyzer.get_symbol_binding_key(name)?; + let origin = self + .analyzer + .get_symbol_by_binding_key(&binding)? + .symbol_type + .clone()?; + Some((binding, origin)) + }); + let entry_aliases = self.list_alias_groups.clone(); let entry_types = self.analyzer.snapshot_symbol_types(); - for stmt in then_block { - self.check_statement_types(stmt); + if let Some((name, then_type, _)) = &refinement { + self.refine_symbol_type(name, then_type); + } + if literal_condition == Some(false) { + self.try_flow_capture_suspended += 1; + } + let (then_can_continue, then_completion) = + self.check_statement_block_with_completion(then_block); + if literal_condition == Some(false) { + self.try_flow_capture_suspended -= 1; } let then_types = self.analyzer.snapshot_symbol_types(); + let then_aliases = self.list_alias_groups.clone(); + let then_summary = self.snapshot_deferred_summary(); self.analyzer.restore_symbol_types(entry_types.clone()); + self.list_alias_groups = entry_aliases.clone(); + self.restore_deferred_summary(summary_entry.clone()); + self.optional_refinement_origins = refinement_origins_entry.clone(); - let else_types = if let Some(else_stmts) = else_block { - for stmt in else_stmts { - self.check_statement_types(stmt); - } - self.analyzer.snapshot_symbol_types() + if let Some((name, _, else_type)) = &refinement { + self.refine_symbol_type(name, else_type); + } + let (else_types, else_aliases, else_can_continue, else_completion) = + if let Some(else_stmts) = else_block { + if literal_condition == Some(true) { + self.try_flow_capture_suspended += 1; + } + let (can_continue, completion) = + self.check_statement_block_with_completion(else_stmts); + if literal_condition == Some(true) { + self.try_flow_capture_suspended -= 1; + } + ( + self.analyzer.snapshot_symbol_types(), + self.list_alias_groups.clone(), + can_continue, + completion, + ) + } else { + ( + self.analyzer.snapshot_symbol_types(), + self.list_alias_groups.clone(), + true, + Type::Nothing, + ) + }; + let else_summary = self.snapshot_deferred_summary(); + let reachable_summaries = match literal_condition { + Some(true) => vec![then_summary], + Some(false) => vec![else_summary], + None => vec![then_summary, else_summary], + }; + self.join_deferred_summaries(&summary_entry, &reachable_summaries); + let mut continuation_types = Vec::with_capacity(2); + let mut continuation_aliases = Vec::with_capacity(2); + let mut continuation_completions = Vec::with_capacity(2); + if literal_condition != Some(false) && then_can_continue { + continuation_types.push(then_types); + continuation_aliases.push(then_aliases); + continuation_completions.push(then_completion); + } + if literal_condition != Some(true) && else_can_continue { + continuation_types.push(else_types); + continuation_aliases.push(else_aliases); + continuation_completions.push(else_completion); + } + let joined = if continuation_types.is_empty() { + entry_types.clone() } else { - entry_types + Self::join_type_snapshots(&continuation_types) }; - let joined = Self::join_type_snapshots(&[then_types, else_types]); self.analyzer.restore_symbol_types(joined); + self.list_alias_groups = if continuation_aliases.is_empty() { + entry_aliases + } else { + Self::join_list_alias_snapshots(&continuation_aliases) + }; + self.current_statement_completion = continuation_completions + .into_iter() + .reduce(Self::join_inferred_types) + .unwrap_or(Type::Nothing); + self.optional_refinement_origins = refinement_origins_entry; + if let Some((binding, origin @ Type::Optional(_))) = refinement_origin + && let Some(current) = self + .analyzer + .get_symbol_by_binding_key(&binding) + .and_then(|symbol| symbol.symbol_type.as_ref()) + && refinement + .as_ref() + .is_some_and(|(_, then_type, else_type)| { + current == then_type || current == else_type + }) + { + self.optional_refinement_origins.insert(binding, origin); + } } Statement::SingleLineIf { condition, @@ -1786,11 +5149,9 @@ impl TypeChecker { line: _line, column: _column, } => { + self.definitely_nonempty_lists.clear(); let condition_type = self.infer_expression_type(condition); - if condition_type != Type::Boolean - && condition_type != Type::Unknown - && condition_type != Type::Error - { + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { self.type_error( "Condition must be a boolean expression".to_string(), Some(Type::Boolean), @@ -1800,19 +5161,118 @@ impl TypeChecker { ); } + let refinement = self.optional_condition_refinement(condition); + let literal_condition = match condition { + Expression::Literal(Literal::Boolean(value), ..) => Some(*value), + _ => None, + }; + let summary_entry = self.snapshot_deferred_summary(); + let refinement_origins_entry = self.optional_refinement_origins.clone(); + let refinement_origin = refinement.as_ref().and_then(|(name, _, _)| { + let binding = self.analyzer.get_symbol_binding_key(name)?; + let origin = self + .analyzer + .get_symbol_by_binding_key(&binding)? + .symbol_type + .clone()?; + Some((binding, origin)) + }); + let entry_aliases = self.list_alias_groups.clone(); let entry_types = self.analyzer.snapshot_symbol_types(); - self.check_statement_types(then_stmt); + if let Some((name, then_type, _)) = &refinement { + self.refine_symbol_type(name, then_type); + } + if literal_condition == Some(false) { + self.try_flow_capture_suspended += 1; + } + let then_completion = self.check_statement_types(then_stmt); + if literal_condition == Some(false) { + self.try_flow_capture_suspended -= 1; + } let then_types = self.analyzer.snapshot_symbol_types(); + let then_can_continue = !Self::statement_definitely_stops_current_block(then_stmt); + let then_aliases = self.list_alias_groups.clone(); + let then_summary = self.snapshot_deferred_summary(); self.analyzer.restore_symbol_types(entry_types.clone()); + self.list_alias_groups = entry_aliases.clone(); + self.restore_deferred_summary(summary_entry.clone()); + self.optional_refinement_origins = refinement_origins_entry.clone(); - let else_types = if let Some(else_stmt) = else_stmt { - self.check_statement_types(else_stmt); - self.analyzer.snapshot_symbol_types() + if let Some((name, _, else_type)) = &refinement { + self.refine_symbol_type(name, else_type); + } + let (else_types, else_aliases, else_can_continue, else_completion) = + if let Some(else_stmt) = else_stmt { + if literal_condition == Some(true) { + self.try_flow_capture_suspended += 1; + } + let completion = self.check_statement_types(else_stmt); + if literal_condition == Some(true) { + self.try_flow_capture_suspended -= 1; + } + ( + self.analyzer.snapshot_symbol_types(), + self.list_alias_groups.clone(), + !Self::statement_definitely_stops_current_block(else_stmt), + completion, + ) + } else { + ( + self.analyzer.snapshot_symbol_types(), + self.list_alias_groups.clone(), + true, + Type::Nothing, + ) + }; + let else_summary = self.snapshot_deferred_summary(); + let reachable_summaries = match literal_condition { + Some(true) => vec![then_summary], + Some(false) => vec![else_summary], + None => vec![then_summary, else_summary], + }; + self.join_deferred_summaries(&summary_entry, &reachable_summaries); + let mut continuation_types = Vec::with_capacity(2); + let mut continuation_aliases = Vec::with_capacity(2); + let mut continuation_completions = Vec::with_capacity(2); + if literal_condition != Some(false) && then_can_continue { + continuation_types.push(then_types); + continuation_aliases.push(then_aliases); + continuation_completions.push(then_completion); + } + if literal_condition != Some(true) && else_can_continue { + continuation_types.push(else_types); + continuation_aliases.push(else_aliases); + continuation_completions.push(else_completion); + } + let joined = if continuation_types.is_empty() { + entry_types.clone() } else { - entry_types + Self::join_type_snapshots(&continuation_types) }; - let joined = Self::join_type_snapshots(&[then_types, else_types]); self.analyzer.restore_symbol_types(joined); + self.list_alias_groups = if continuation_aliases.is_empty() { + entry_aliases + } else { + Self::join_list_alias_snapshots(&continuation_aliases) + }; + self.current_statement_completion = continuation_completions + .into_iter() + .reduce(Self::join_inferred_types) + .unwrap_or(Type::Nothing); + self.optional_refinement_origins = refinement_origins_entry; + if let Some((binding, origin @ Type::Optional(_))) = refinement_origin + && let Some(current) = self + .analyzer + .get_symbol_by_binding_key(&binding) + .and_then(|symbol| symbol.symbol_type.as_ref()) + && refinement + .as_ref() + .is_some_and(|(_, then_type, else_type)| { + current == then_type || current == else_type + }) + { + self.optional_refinement_origins.insert(binding, origin); + } } Statement::ForEachLoop { item_name, @@ -1822,6 +5282,7 @@ impl TypeChecker { column: _column, .. } => { + let guaranteed_iteration = self.expression_is_definitely_nonempty_list(collection); let collection_type = self.infer_expression_type(collection); let mut item_type_inferred = Type::Unknown; @@ -1832,7 +5293,7 @@ impl TypeChecker { Type::Map(_, value_type) => { item_type_inferred = *value_type; } - Type::Unknown | Type::Error => {} + Type::Unknown | Type::Any | Type::Error => {} _ => { self.type_error( "Collection in for-each loop must be a list or map".to_string(), @@ -1848,6 +5309,7 @@ impl TypeChecker { self.analyzer.push_scope(); // Define the loop variable in the new scope + let item_may_be_list = Self::type_may_be_list(&item_type_inferred); let symbol = Symbol { name: item_name.clone(), kind: SymbolKind::Variable { mutable: false }, @@ -1858,13 +5320,29 @@ impl TypeChecker { // Ignore errors (e.g., if already defined, though in a new scope it shouldn't be) let _ = self.analyzer.define_symbol(symbol); - - for stmt in body { - self.check_statement_types(stmt); + if item_may_be_list + && let Some(mut source_path) = self.list_target_binding_path(collection) + && let Some(item_binding) = self.analyzer.get_symbol_binding_key(item_name) + { + source_path.index_depth += 1; + self.add_structural_list_alias( + source_path, + ListAliasPath { + binding: item_binding, + index_depth: 0, + }, + ); } + self.check_fresh_iteration_loop_body(body, guaranteed_iteration); + // Pop the scope self.analyzer.pop_scope(); + self.prune_dead_list_alias_paths(); + // Loop-body mutations can invalidate cardinality facts. The + // guaranteed-iteration flag above is intentionally consumed + // only for this loop's control-flow join. + self.definitely_nonempty_lists.clear(); } Statement::CountLoop { start, @@ -1877,10 +5355,7 @@ impl TypeChecker { .. } => { let start_type = self.infer_expression_type(start); - if start_type != Type::Number - && start_type != Type::Unknown - && start_type != Type::Error - { + if start_type != Type::Number && !self.is_gradual_type(&start_type) { self.type_error( "Start value in count loop must be a number".to_string(), Some(Type::Number), @@ -1891,8 +5366,7 @@ impl TypeChecker { } let end_type = self.infer_expression_type(end); - if end_type != Type::Number && end_type != Type::Unknown && end_type != Type::Error - { + if end_type != Type::Number && !self.is_gradual_type(&end_type) { self.type_error( "End value in count loop must be a number".to_string(), Some(Type::Number), @@ -1904,10 +5378,7 @@ impl TypeChecker { if let Some(step_expr) = step { let step_type = self.infer_expression_type(step_expr); - if step_type != Type::Number - && step_type != Type::Unknown - && step_type != Type::Error - { + if step_type != Type::Number && !self.is_gradual_type(&step_type) { self.type_error( "Step value in count loop must be a number".to_string(), Some(Type::Number), @@ -1930,10 +5401,9 @@ impl TypeChecker { column: *_column, }); - for stmt in body { - self.check_statement_types(stmt); - } + self.check_fresh_iteration_loop_body(body, false); self.analyzer.pop_scope(); + self.definitely_nonempty_lists.clear(); } Statement::WhileLoop { condition, @@ -1941,16 +5411,27 @@ impl TypeChecker { line: _line, column: _column, } => { - self.check_loop_body_fixed_point(body); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { + self.type_error( + "Condition in while loop must be a boolean expression".to_string(), + Some(Type::Boolean), + Some(condition_type), + *_line, + *_column, + ); + } + let first_condition_error_end = self.errors.len(); + self.check_persistent_loop_body_fixed_point( + body, + !matches!(condition, Expression::Literal(Literal::Boolean(false), ..)), + ); if self.budget_error.is_some() { return; } let condition_type = self.infer_expression_type(condition); - if condition_type != Type::Boolean - && condition_type != Type::Unknown - && condition_type != Type::Error - { + if condition_type != Type::Boolean && !self.is_gradual_type(&condition_type) { self.type_error( "Condition in while loop must be a boolean expression".to_string(), Some(Type::Boolean), @@ -1959,6 +5440,13 @@ impl TypeChecker { *_column, ); } + self.deduplicate_errors_from(first_condition_error_end); + self.current_statement_completion = + if matches!(condition, Expression::Literal(Literal::Boolean(false), ..)) { + Type::Nothing + } else { + Type::Any + }; } Statement::RepeatUntilLoop { condition, @@ -1967,39 +5455,48 @@ impl TypeChecker { column: _column, } => { // Runtime order: the body ALWAYS runs before the condition is - // evaluated, in the same scope. Check in that order (with the - // same backedge fixed point as `while`/`repeat while`, but - // keeping the post-body state) so body retypings are visible - // to the condition (#642). - let header = self.check_loop_body_fixed_point_post_body(body); - if self.budget_error.is_some() { - return; - } - // A body that can `break`/`exit`/`return` skips the condition - // on that path at runtime, so the strict post-body state would - // falsely reject retype-then-break bodies (fatal inside - // `load module`). Soften to the join of header and post-body - // for the condition — and for code after the loop, which such - // a path also reaches with pre-break state. + // evaluated, in the same scope. Both the #642 fixed point and + // the gradual-contract fixed point check in that order so body + // retypings are visible to the condition. if Self::body_may_exit_loop_early(body) { + // A body that can `break`/`exit`/`return` skips the + // condition on that path at runtime, so the strict + // post-body state would falsely reject retype-then-break + // bodies (fatal inside `load module`). Walk the body with + // the post-body fixed point, then soften to the join of + // header and post-body for the condition — and for code + // after the loop, which such a path also reaches with + // pre-break state (#642). + let header = self.check_loop_body_fixed_point_post_body(body); + if self.budget_error.is_some() { + return; + } let post_body = self.analyzer.snapshot_symbol_types(); self.analyzer .restore_symbol_types(Self::join_type_snapshots(&[header, post_body])); - } - let condition_type = self.infer_expression_type(condition); - if condition_type != Type::Boolean - && condition_type != Type::Unknown - && condition_type != Type::Error - { - self.type_error( - "Condition in repeat-until loop must be a boolean expression".to_string(), - Some(Type::Boolean), - Some(condition_type), - *_line, - *_column, - ); + let condition_type = self.infer_expression_type(condition); + if condition_type != Type::Boolean + && condition_type != Type::Unknown + && condition_type != Type::Error + { + self.type_error( + "Condition in repeat-until loop must be a boolean expression" + .to_string(), + Some(Type::Boolean), + Some(condition_type), + *_line, + *_column, + ); + } + } else { + // No early exit: the condition and all code after the loop + // are always reached from the body's final state, so use + // the gradual-contract fixed point directly (post-body + // condition check, alias/deferred-summary tracking). + self.check_repeat_until_fixed_point(condition, body, *_line, *_column); } + self.current_statement_completion = Type::Any; } Statement::ForeverLoop { body, .. } => { // Push a scope so bindings introduced in the body (e.g. @@ -2007,32 +5504,58 @@ impl TypeChecker { // statements in the same body for type checking. Analyzer loop // scopes are discarded after analysis. self.analyzer.push_scope(); - for stmt in body { - self.check_statement_types(stmt); - } + self.check_fresh_iteration_loop_body(body, true); self.analyzer.pop_scope(); + self.definitely_nonempty_lists.clear(); + self.current_statement_completion = Type::Any; } Statement::MainLoop { body, .. } => { self.analyzer.push_scope(); - for stmt in body { - self.check_statement_types(stmt); - } + self.check_fresh_iteration_loop_body(body, true); self.analyzer.pop_scope(); + self.definitely_nonempty_lists.clear(); + self.current_statement_completion = Type::Any; } Statement::DisplayStatement { value, .. } => { self.infer_expression_type(value); } Statement::ReturnStatement { value, - line: _, - column: _, + line, + column, } => { - if let Some(expr) = value { - self.infer_expression_type(expr); + let (value_type, list_sources) = if let Some(expr) = value { + let value_type = self.infer_expression_type(expr); + let mut captured = Vec::new(); + if Self::type_may_contain_list(&value_type) { + self.capture_nested_list_alias_sources(expr, 0, &mut captured); + } + (value_type, captured) + } else { + (Type::Nothing, Vec::new()) + }; + if let Some(active_returns) = self.deferred_return_type_stack.last_mut() { + active_returns.push(RecordedReturn { + return_type: value_type, + line: *line, + column: *column, + has_value: value.is_some(), + list_sources, + }); } } - Statement::ExpressionStatement { expression, .. } => { - self.infer_expression_type(expression); + Statement::ExpressionStatement { + expression, + line, + column, + } => { + let completion = match expression { + Expression::Variable(name, ..) => self + .infer_bare_variable_statement(name, *line, *column) + .unwrap_or_else(|| self.infer_expression_type(expression)), + _ => self.infer_expression_type(expression), + }; + self.current_statement_completion = completion; } Statement::BreakStatement { .. } | Statement::ContinueStatement { .. } => {} Statement::OpenFileStatement { @@ -2043,8 +5566,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "File path must be a text string".to_string(), Some(Type::Text), @@ -2074,21 +5596,19 @@ impl TypeChecker { } => { let file_type = self.infer_expression_type(path); if file_type != Type::Custom("File".to_string()) - && file_type != Type::Unknown - && file_type != Type::Error + && file_type != Type::Text + && !self.is_gradual_type(&file_type) { self.type_error( - "Expected a File object".to_string(), - Some(Type::Custom("File".to_string())), + "Expected a file path or File handle".to_string(), + None, Some(file_type), *_line, *_column, ); } - if let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) { - symbol.symbol_type = Some(Type::Text); - } + self.bind_runtime_value(variable_name, Type::Text, true, *_line, *_column); } Statement::WriteFileStatement { file, @@ -2099,12 +5619,12 @@ impl TypeChecker { } => { let file_type = self.infer_expression_type(file); if file_type != Type::Custom("File".to_string()) - && file_type != Type::Unknown - && file_type != Type::Error + && file_type != Type::Text + && !self.is_gradual_type(&file_type) { self.type_error( - "Expected a File object".to_string(), - Some(Type::Custom("File".to_string())), + "Expected a file path or File handle".to_string(), + None, Some(file_type), *_line, *_column, @@ -2112,10 +5632,7 @@ impl TypeChecker { } let content_type = self.infer_expression_type(content); - if content_type != Type::Text - && content_type != Type::Unknown - && content_type != Type::Error - { + if content_type != Type::Text && !self.is_gradual_type(&content_type) { self.type_error( "File content must be a text string".to_string(), Some(Type::Text), @@ -2151,7 +5668,7 @@ impl TypeChecker { column: _column, } => { let url_type = self.infer_expression_type(url); - if url_type != Type::Text && url_type != Type::Unknown && url_type != Type::Error { + if url_type != Type::Text && !self.is_gradual_type(&url_type) { self.type_error( "Database URL must be a text string".to_string(), Some(Type::Text), @@ -2161,9 +5678,13 @@ impl TypeChecker { ); } - if let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) { - symbol.symbol_type = Some(Type::Custom("Database".to_string())); - } + self.bind_runtime_value( + variable_name, + Type::Custom("Database".to_string()), + true, + *_line, + *_column, + ); } Statement::DatabaseQueryStatement { db, @@ -2176,9 +5697,13 @@ impl TypeChecker { } => { self.check_database_query_operands(db, sql, parameters.as_ref(), *line, *column); - if let Some(symbol) = self.analyzer.get_symbol_mut(variable_name) { - symbol.symbol_type = Some(Self::database_result_type(*kind)); - } + self.bind_runtime_value( + variable_name, + Self::database_result_type(*kind), + true, + *line, + *column, + ); } Statement::CloseDatabaseStatement { db, @@ -2187,8 +5712,7 @@ impl TypeChecker { } => { let db_type = self.infer_expression_type(db); if db_type != Type::Custom("Database".to_string()) - && db_type != Type::Unknown - && db_type != Type::Error + && !self.is_gradual_type(&db_type) { self.type_error( "Expected a Database connection".to_string(), @@ -2205,8 +5729,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for directory path".to_string(), Some(Type::Text), @@ -2223,8 +5746,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for file path".to_string(), Some(Type::Text), @@ -2241,8 +5763,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for file path".to_string(), Some(Type::Text), @@ -2258,8 +5779,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for directory path".to_string(), Some(Type::Text), @@ -2276,8 +5796,7 @@ impl TypeChecker { .. } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for module path".to_string(), Some(Type::Text), @@ -2290,13 +5809,13 @@ impl TypeChecker { Statement::ExecuteCommandStatement { command, arguments, - variable_name: _, + variable_name, use_shell: _, line: _line, column: _column, } => { let cmd_type = self.infer_expression_type(command); - if cmd_type != Type::Text && cmd_type != Type::Unknown && cmd_type != Type::Error { + if cmd_type != Type::Text && !self.is_gradual_type(&cmd_type) { self.type_error( "Expected string for command".to_string(), Some(Type::Text), @@ -2306,8 +5825,25 @@ impl TypeChecker { ); } if let Some(args) = arguments { - let _args_type = self.infer_expression_type(args); - // Arguments can be a list or a single string + let args_type = self.infer_expression_type(args); + if !self.is_process_arguments_type(&args_type) { + self.type_error( + "Command arguments must be text or a list".to_string(), + None, + Some(args_type), + *_line, + *_column, + ); + } + } + if let Some(var_name) = variable_name { + self.bind_runtime_value( + var_name, + Type::Map(Box::new(Type::Text), Box::new(Type::Any)), + true, + *_line, + *_column, + ); } } Statement::ExecuteFileStatement { @@ -2318,8 +5854,7 @@ impl TypeChecker { column: _column, } => { let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for execute file path".to_string(), Some(Type::Text), @@ -2329,26 +5864,32 @@ impl TypeChecker { ); } if let Some(request_expr) = request { - // Request context is a request object; no constraint beyond inference - let _request_type = self.infer_expression_type(request_expr); + let request_type = self.infer_expression_type(request_expr); + if !self.is_execute_file_request_type(&request_type) { + self.type_error( + "Execute-file request must be a request object".to_string(), + Some(Type::Custom("Request".to_string())), + Some(request_type), + *_line, + *_column, + ); + } } // Captured display output of the executed file is text - if let Some(var_name) = variable_name - && let Some(symbol) = self.analyzer.get_symbol_mut(var_name) - { - symbol.symbol_type = Some(Type::Text); + if let Some(var_name) = variable_name { + self.bind_runtime_value(var_name, Type::Text, true, *_line, *_column); } } Statement::SpawnProcessStatement { command, arguments, - variable_name: _, + variable_name, use_shell: _, line: _line, column: _column, } => { let cmd_type = self.infer_expression_type(command); - if cmd_type != Type::Text && cmd_type != Type::Unknown && cmd_type != Type::Error { + if cmd_type != Type::Text && !self.is_gradual_type(&cmd_type) { self.type_error( "Expected string for command".to_string(), Some(Type::Text), @@ -2358,18 +5899,27 @@ impl TypeChecker { ); } if let Some(args) = arguments { - let _args_type = self.infer_expression_type(args); + let args_type = self.infer_expression_type(args); + if !self.is_process_arguments_type(&args_type) { + self.type_error( + "Process arguments must be text or a list".to_string(), + None, + Some(args_type), + *_line, + *_column, + ); + } } + self.bind_runtime_value(variable_name, Type::Text, true, *_line, *_column); } Statement::ReadProcessOutputStatement { process_id, - variable_name: _, + variable_name, line: _line, column: _column, } => { let proc_type = self.infer_expression_type(process_id); - if proc_type != Type::Text && proc_type != Type::Unknown && proc_type != Type::Error - { + if proc_type != Type::Text && !self.is_gradual_type(&proc_type) { self.type_error( "Expected string for process ID".to_string(), Some(Type::Text), @@ -2378,6 +5928,7 @@ impl TypeChecker { *_column, ); } + self.bind_runtime_value(variable_name, Type::Text, true, *_line, *_column); } Statement::KillProcessStatement { process_id, @@ -2385,8 +5936,7 @@ impl TypeChecker { column: _column, } => { let proc_type = self.infer_expression_type(process_id); - if proc_type != Type::Text && proc_type != Type::Unknown && proc_type != Type::Error - { + if proc_type != Type::Text && !self.is_gradual_type(&proc_type) { self.type_error( "Expected string for process ID".to_string(), Some(Type::Text), @@ -2398,13 +5948,12 @@ impl TypeChecker { } Statement::WaitForProcessStatement { process_id, - variable_name: _, + variable_name, line: _line, column: _column, } => { let proc_type = self.infer_expression_type(process_id); - if proc_type != Type::Text && proc_type != Type::Unknown && proc_type != Type::Error - { + if proc_type != Type::Text && !self.is_gradual_type(&proc_type) { self.type_error( "Expected string for process ID".to_string(), Some(Type::Text), @@ -2413,6 +5962,9 @@ impl TypeChecker { *_column, ); } + if let Some(var_name) = variable_name { + self.bind_runtime_value(var_name, Type::Number, true, *_line, *_column); + } } Statement::WriteToStatement { content, @@ -2424,8 +5976,7 @@ impl TypeChecker { let file_type = self.infer_expression_type(file); if file_type != Type::Custom("File".to_string()) && file_type != Type::Text // Allow string file handles - && file_type != Type::Unknown - && file_type != Type::Error + && !self.is_gradual_type(&file_type) { self.type_error( "Expected a file handle or string".to_string(), @@ -2446,8 +5997,7 @@ impl TypeChecker { let target_type = self.infer_expression_type(target); if target_type != Type::Custom("File".to_string()) && target_type != Type::Text // Allow string file handles - && target_type != Type::Unknown - && target_type != Type::Error + && !self.is_gradual_type(&target_type) { self.type_error( "Expected a file handle or string".to_string(), @@ -2469,9 +6019,7 @@ impl TypeChecker { && content_type != Type::List(Box::new(Type::Number)) && content_type != Type::List(Box::new(Type::Any)) && content_type != Type::List(Box::new(Type::Unknown)) - && content_type != Type::Unknown - && content_type != Type::Error - && content_type != Type::Any + && !self.is_gradual_type(&content_type) { self.type_error( "Expected Binary or List of Number for write binary content".to_string(), @@ -2483,12 +6031,10 @@ impl TypeChecker { } let target_type = self.infer_expression_type(target); if target_type != Type::Custom("File".to_string()) - && target_type != Type::Text - && target_type != Type::Unknown - && target_type != Type::Error + && !self.is_gradual_type(&target_type) { self.type_error( - "Expected a file handle or string".to_string(), + "Expected an open File handle for binary output".to_string(), Some(Type::Custom("File".to_string())), Some(target_type), *_line, @@ -2503,27 +6049,21 @@ impl TypeChecker { column, } => { // Infer the element type from initial values - let mut element_type = Type::Unknown; + let mut element_type = None; for value in initial_values { let value_type = self.infer_expression_type(value); - if element_type == Type::Unknown { - element_type = value_type; - } else if element_type != value_type && value_type != Type::Unknown { - self.type_error( - format!("Mixed types in list initialization. Expected {element_type:?}, got {value_type:?}"), - Some(element_type.clone()), - Some(value_type), - *line, - *column, - ); - } + element_type = Some(Self::join_collection_value_type(element_type, value_type)); } // If empty list, element type remains Unknown - let list_type = Type::List(Box::new(element_type)); - if let Some(symbol) = self.analyzer.get_symbol_mut(name) { - symbol.symbol_type = Some(list_type); + let list_type = Type::List(Box::new(element_type.unwrap_or(Type::Unknown))); + self.bind_runtime_value(name, list_type, true, *line, *column); + if let Some(target_binding) = self.analyzer.get_symbol_binding_key(name) { + for value in initial_values { + self.record_nested_list_alias_expression(&target_binding, 1, value); + } } + self.update_binding_nonempty_fact(name, !initial_values.is_empty()); } Statement::AddToListStatement { value, @@ -2533,61 +6073,91 @@ impl TypeChecker { } => { let value_type = self.infer_expression_type(value); - if let Some(symbol) = self.analyzer.get_symbol(list_name) { - match &symbol.symbol_type { - Some(Type::List(element_type)) => { - // A `List(Any)`/`List(Unknown)` is a list of statically - // unknown element type (e.g. a `[1, 2]` literal or an - // untyped-parameter list), so adding any concrete value - // is valid — only flag a concrete element type that is - // provably incompatible (gradual typing, issue #567). - if **element_type != Type::Unknown - && **element_type != Type::Any - && **element_type != value_type - && value_type != Type::Unknown - && value_type != Type::Any - { - self.type_error( - format!( - "Cannot add {value_type:?} to list of {element_type:?}" - ), - Some((**element_type).clone()), - Some(value_type), - *line, - *column, - ); - } - } - Some(Type::Number) => { - // This is arithmetic add. Accept Unknown/Any operands - // (statically unknown, verified at runtime) rather than - // emitting a false ERROR — gradual typing, issue #567. - if value_type != Type::Number - && value_type != Type::Unknown - && value_type != Type::Any - { - self.type_error( - "Cannot add non-numeric value to number".to_string(), - Some(Type::Number), - Some(value_type), - *line, - *column, - ); - } + let (target_type, is_container_property) = + self.resolve_bare_mutation_target_type(list_name); + match &target_type { + Some(Type::List(element_type)) => { + // A `List(Any)`/`List(Unknown)` is a list of statically + // unknown element type (e.g. a `[1, 2]` literal or an + // untyped-parameter list), so adding any concrete value + // is valid — only flag a concrete element type that is + // provably incompatible (gradual typing, issue #567). + if is_container_property + && !self.are_declared_property_values_compatible( + element_type, + &value_type, + value, + ) + { + self.type_error( + format!( + "Cannot add {value_type} to property '{list_name}' because its \ + declared element type is {element_type}" + ), + Some((**element_type).clone()), + Some(value_type), + *line, + *column, + ); + } else if !is_container_property { + // Bare properties live in the container environment, + // not in the lexical analyzer scope. Applying alias + // effects to their bare name would mutate a + // same-named outer binding. + self.apply_list_mutation_effect( + &Expression::Variable(list_name.clone(), *line, *column), + ListMutationEffect::Join(value_type.clone()), + ); + self.record_list_insertion_aliases( + &Expression::Variable(list_name.clone(), *line, *column), + value, + ); + self.mark_list_target_nonempty(&Expression::Variable( + list_name.clone(), + *line, + *column, + )); } - _ => { - // Variable might not be a list - if symbol.symbol_type != Some(Type::Unknown) { - self.type_error( - format!("Cannot add to non-list variable '{list_name}'"), - Some(Type::List(Box::new(Type::Any))), - symbol.symbol_type.clone(), - *line, - *column, - ); - } + } + Some(Type::Number) => { + // This is arithmetic add. Accept Unknown/Any operands + // (statically unknown, verified at runtime) rather than + // emitting a false ERROR — gradual typing, issue #567. + if value_type != Type::Number && !self.is_gradual_type(&value_type) { + self.type_error( + "Cannot add non-numeric value to number".to_string(), + Some(Type::Number), + Some(value_type), + *line, + *column, + ); } } + Some(Type::Unknown | Type::Any | Type::Error) => { + self.apply_list_mutation_effect( + &Expression::Variable(list_name.clone(), *line, *column), + ListMutationEffect::Join(value_type.clone()), + ); + self.record_list_insertion_aliases( + &Expression::Variable(list_name.clone(), *line, *column), + value, + ); + self.mark_list_target_nonempty(&Expression::Variable( + list_name.clone(), + *line, + *column, + )); + } + _ => { + // Variable might not be a list + self.type_error( + format!("Cannot add to non-list variable '{list_name}'"), + Some(Type::List(Box::new(Type::Any))), + target_type.clone(), + *line, + *column, + ); + } } } Statement::RemoveFromListStatement { @@ -2597,17 +6167,20 @@ impl TypeChecker { column, } => { let _value_type = self.infer_expression_type(value); + self.definitely_nonempty_lists.clear(); - if let Some(symbol) = self.analyzer.get_symbol(list_name) + let (target_type, _is_container_property) = + self.resolve_bare_mutation_target_type(list_name); + if let Some(target_type) = target_type && !matches!( - symbol.symbol_type, - Some(Type::List(_)) | Some(Type::Unknown) + target_type, + Type::List(_) | Type::Unknown | Type::Any | Type::Error ) { self.type_error( format!("Cannot remove from non-list variable '{list_name}'"), Some(Type::List(Box::new(Type::Any))), - symbol.symbol_type.clone(), + Some(target_type), *line, *column, ); @@ -2618,16 +6191,26 @@ impl TypeChecker { line, column, } => { - if let Some(symbol) = self.analyzer.get_symbol(list_name) + self.definitely_nonempty_lists.clear(); + let (target_type, is_container_property) = + self.resolve_bare_mutation_target_type(list_name); + if !is_container_property { + self.detach_list_alias_descendants(&Expression::Variable( + list_name.clone(), + *line, + *column, + )); + } + if let Some(target_type) = target_type && !matches!( - symbol.symbol_type, - Some(Type::List(_)) | Some(Type::Unknown) + target_type, + Type::List(_) | Type::Unknown | Type::Any | Type::Error ) { self.type_error( format!("Cannot clear non-list variable '{list_name}'"), Some(Type::List(Box::new(Type::Any))), - symbol.symbol_type.clone(), + Some(target_type), *line, *column, ); @@ -2641,11 +6224,21 @@ impl TypeChecker { properties, methods, events: _events, - static_properties: _static_properties, + static_properties, static_methods, line, column, } => { + if self.analyzer.get_local_symbol(_name).is_none() { + let _ = self.analyzer.define_symbol(Symbol { + name: _name.clone(), + kind: SymbolKind::Variable { mutable: false }, + symbol_type: Some(Type::Container(_name.clone())), + line: *line, + column: *column, + }); + } + if let Some(parent_name) = extends { if let Some(parent_symbol) = self.analyzer.get_symbol(parent_name) { if parent_symbol.symbol_type != Some(Type::Container(parent_name.clone())) { @@ -2692,11 +6285,15 @@ impl TypeChecker { } } - for property in properties { + for property in properties.iter().chain(static_properties.iter()) { if let Some(default_expr) = &property.default_value { let default_type = self.infer_expression_type(default_expr); if let Some(declared_type) = &property.property_type - && !self.are_types_compatible(&default_type, declared_type) + && !self.are_declared_property_values_compatible( + declared_type, + &default_type, + default_expr, + ) { self.type_error( format!( @@ -2735,13 +6332,19 @@ impl TypeChecker { parameters, body, return_type, - line: method_line, - column: method_column, + line: _method_line, + column: _method_column, } = method { // Set container context for method body analysis let previous_container = self.current_container.clone(); + let previous_method_is_static = self.current_method_is_static; + let previous_outer_property_bindings = + self.current_method_outer_property_bindings.take(); self.current_container = Some(_name.clone()); + self.current_method_is_static = Some(is_static); + self.current_method_outer_property_bindings = + Some(self.snapshot_current_method_outer_property_bindings()); // Parameters must be resolvable while checking the body // and inferring return expressions, mirroring the @@ -2755,27 +6358,40 @@ impl TypeChecker { line: param.line, column: param.column, }; - let _ = self.analyzer.define_symbol(param_symbol); + self.analyzer.define_or_replace_symbol(param_symbol); } // Same as top-level actions: do not permanently refine // outer bindings while checking an uncalled method body // (PR #606 review). let outer_type_snapshot = self.analyzer.snapshot_symbol_types(); - - for stmt in body { - self.check_statement_types(stmt); - } + let outer_alias_snapshot = self.list_alias_groups.clone(); + let outer_refinement_snapshot = self.optional_refinement_origins.clone(); + let outer_nonempty_snapshot = self.definitely_nonempty_lists.clone(); + self.deferred_return_type_stack.push(Vec::new()); + + self.try_flow_capture_suspended += 1; + let (body_can_continue, implicit_completion) = + self.check_statement_block_with_completion(body); + self.try_flow_capture_suspended -= 1; + let recorded_returns = + self.deferred_return_type_stack.pop().unwrap_or_default(); if let Some(ret_type) = return_type { - self.check_return_statements( - body, - ret_type, - *method_line, - *method_column, - ); + self.check_recorded_return_types(&recorded_returns, ret_type); + if body_can_continue { + self.check_implicit_action_result( + &implicit_completion, + ret_type, + *_method_line, + *_method_column, + ); + } } else { - let inferred = self.infer_action_return_type(body); + let inferred = Self::infer_recorded_action_return_type( + &recorded_returns, + body_can_continue.then_some(&implicit_completion), + ); if is_static { inferred_static_method_returns .push((method_name.clone(), inferred)); @@ -2785,10 +6401,16 @@ impl TypeChecker { } self.analyzer.restore_symbol_types(outer_type_snapshot); + self.list_alias_groups = outer_alias_snapshot; + self.optional_refinement_origins = outer_refinement_snapshot; + self.definitely_nonempty_lists = outer_nonempty_snapshot; self.analyzer.pop_scope(); // Restore previous container context self.current_container = previous_container; + self.current_method_is_static = previous_method_is_static; + self.current_method_outer_property_bindings = + previous_outer_property_bindings; } } @@ -2807,16 +6429,18 @@ impl TypeChecker { } } - // Container type registration would be handled by analyzer + // Runtime returns the newly registered container definition. + self.current_statement_completion = Type::Container(_name.clone()); } Statement::ContainerInstantiation { container_type, - instance_name: _instance_name, - arguments: _arguments, + instance_name, + arguments, property_initializers, line, column, } => { + let mut valid_container = false; if let Some(container_symbol) = self.analyzer.get_symbol(container_type) { if container_symbol.symbol_type != Some(Type::Container(container_type.clone())) { @@ -2827,6 +6451,8 @@ impl TypeChecker { *line, *column, ); + } else { + valid_container = true; } } else { self.type_error( @@ -2838,8 +6464,123 @@ impl TypeChecker { ); } + let argument_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); + + if valid_container && !arguments.is_empty() { + let initialize_parameters = self + .analyzer + .get_container(container_type) + .and_then(|container| container.methods.get("initialize")) + .map(|method| method.parameters.clone()); + + if let Some(parameters) = initialize_parameters { + if parameters.len() != argument_types.len() { + self.type_error( + format!( + "Container '{}' initialize method expects {} arguments, but {} were provided", + container_type, + parameters.len(), + argument_types.len() + ), + None, + None, + *line, + *column, + ); + } + + for (index, (parameter, argument_type)) in + parameters.iter().zip(&argument_types).enumerate() + { + let expected = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + if !self.are_types_compatible(&expected, argument_type) { + self.type_error( + format!( + "Argument {} of container '{}' initialize method expects {}, but found {}", + index + 1, + container_type, + expected, + argument_type + ), + Some(expected), + Some(argument_type.clone()), + *line, + *column, + ); + } + } + } else { + self.type_error( + format!( + "Container '{container_type}' has no direct initialize method for constructor arguments" + ), + None, + None, + *line, + *column, + ); + } + } + for initializer in property_initializers { - let _init_type = self.infer_expression_type(&initializer.value); + let initializer_type = self.infer_expression_type(&initializer.value); + if let Some(property_type) = + self.container_property_type(container_type, &initializer.name) + { + if !self.are_declared_property_values_compatible( + &property_type, + &initializer_type, + &initializer.value, + ) { + self.type_error( + format!( + "Property '{}' of container '{}' expects {}, but found {}", + initializer.name, + container_type, + property_type, + initializer_type + ), + Some(property_type), + Some(initializer_type), + initializer.line, + initializer.column, + ); + } + } else if valid_container { + self.type_error( + format!( + "Property '{}' not found in container '{}'", + initializer.name, container_type + ), + None, + Some(initializer_type), + initializer.line, + initializer.column, + ); + } + } + + if valid_container { + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); + self.bind_runtime_value( + instance_name, + Type::ContainerInstance(container_type.clone()), + true, + *line, + *column, + ); + // Runtime returns the newly constructed instance as this + // statement's value. + self.current_statement_completion = + Type::ContainerInstance(container_type.clone()); } } Statement::InterfaceDefinition { @@ -2850,97 +6591,358 @@ impl TypeChecker { column: _column, } => { // Interface type registration would be handled by analyzer + self.current_statement_completion = Type::Interface(_name.clone()); } Statement::EventDefinition { - name: _name, - parameters: _parameters, - line: _line, - column: _column, - } => {} + parameters, + line, + column, + .. + } => { + for parameter in parameters { + if let Some(default_value) = ¶meter.default_value { + let actual = self.infer_expression_type(default_value); + if let Some(expected) = ¶meter.param_type + && !self.are_types_compatible(expected, &actual) + { + self.type_error( + format!( + "Default value for event parameter '{}' expects {}, but found {}", + parameter.name, expected, actual + ), + Some(expected.clone()), + Some(actual), + *line, + *column, + ); + } + } + } + // Events have runtime values, but the static model has no + // dedicated event type. + self.current_statement_completion = Type::Any; + } Statement::EventTrigger { - name: _name, - arguments: _arguments, - line: _line, - column: _column, - } => {} + name, + arguments, + line, + column, + } => { + let argument_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); + let event_parameters = self + .current_container + .as_deref() + .and_then(|container_name| self.analyzer.get_container(container_name)) + .and_then(|container| container.events.get(name)) + .map(|event| event.parameters.clone()) + .or_else(|| { + self.analyzer + .get_event(name) + .map(|event| event.parameters.clone()) + }); + + if let Some(parameters) = event_parameters { + // Runtime fills missing parameters with Nothing and ignores + // extra values after evaluating them. Only overlapping + // positions therefore have a static parameter contract. + for (index, (parameter, argument_type)) in + parameters.iter().zip(&argument_types).enumerate() + { + let expected = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + if !self.are_types_compatible(&expected, argument_type) { + self.type_error( + format!( + "Argument {} of event '{}' expects {}, but found {}", + index + 1, + name, + expected, + argument_type + ), + Some(expected), + Some(argument_type.clone()), + *line, + *column, + ); + } + } + } else if !self.has_includes { + self.type_error( + format!("Event '{name}' not found"), + None, + None, + *line, + *column, + ); + } + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); + } Statement::EventHandler { - event_name: _event_name, - event_source: _event_source, + event_name, + event_source, handler_body, - line: _line, - column: _column, + line, + column, } => { + let source_type = self.infer_expression_type(event_source); + let event_parameters = match &source_type { + Type::ContainerInstance(container_name) => { + let event = self + .analyzer + .get_container(container_name) + .and_then(|container| container.events.get(event_name)) + .cloned(); + if event.is_none() { + self.type_error( + format!( + "Event '{event_name}' not found in container '{container_name}'" + ), + None, + None, + *line, + *column, + ); + } + event.map(|event| event.parameters) + } + Type::Unknown | Type::Any => self + .analyzer + .get_event(event_name) + .map(|event| event.parameters.clone()), + Type::Error => None, + _ => { + self.type_error( + "Cannot add event handler to non-container value".to_string(), + Some(Type::ContainerInstance("Unknown".to_string())), + Some(source_type.clone()), + *line, + *column, + ); + None + } + }; + self.analyzer.push_scope(); + if let Some(parameters) = event_parameters { + for parameter in parameters { + self.bind_runtime_value( + ¶meter.name, + parameter.param_type.unwrap_or(Type::Unknown), + false, + parameter.line, + parameter.column, + ); + } + } let outer_type_snapshot = self.analyzer.snapshot_symbol_types(); + let outer_alias_snapshot = self.list_alias_groups.clone(); + let outer_refinement_snapshot = self.optional_refinement_origins.clone(); + let outer_nonempty_snapshot = self.definitely_nonempty_lists.clone(); + self.try_flow_capture_suspended += 1; for stmt in handler_body { self.check_statement_types(stmt); } + self.try_flow_capture_suspended -= 1; self.analyzer.restore_symbol_types(outer_type_snapshot); + self.list_alias_groups = outer_alias_snapshot; + self.optional_refinement_origins = outer_refinement_snapshot; + self.definitely_nonempty_lists = outer_nonempty_snapshot; self.analyzer.pop_scope(); } Statement::ParentMethodCall { - method_name: _method_name, - arguments: _arguments, - line: _line, - column: _column, - } => {} + method_name, + arguments, + line, + column, + } => { + let argument_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); + + let Some(container_name) = self.current_container.clone() else { + self.type_error( + "A parent method call is only valid inside a container instance method" + .to_string(), + None, + None, + *line, + *column, + ); + return; + }; + + if self.current_method_is_static == Some(true) { + self.type_error( + "A parent method call cannot be used inside a static method".to_string(), + None, + None, + *line, + *column, + ); + return; + } + + let Some(parent_name) = self + .analyzer + .get_container(&container_name) + .and_then(|container| container.extends.clone()) + else { + self.type_error( + format!("Container '{container_name}' has no parent container"), + None, + None, + *line, + *column, + ); + return; + }; + + let method_contract = self + .analyzer + .get_container(&parent_name) + .and_then(|parent| parent.methods.get(method_name)) + .map(|method| (method.parameters.clone(), method.return_type.clone())); + let Some((parameters, return_type)) = method_contract else { + self.type_error( + format!( + "Method '{method_name}' not found in direct parent container '{parent_name}'" + ), + None, + None, + *line, + *column, + ); + return; + }; + + if parameters.len() != argument_types.len() { + self.type_error( + format!( + "Parent method '{}' expects {} arguments, but {} were provided", + method_name, + parameters.len(), + argument_types.len() + ), + None, + None, + *line, + *column, + ); + } + + for (index, (parameter, argument_type)) in + parameters.iter().zip(&argument_types).enumerate() + { + let expected = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + if !self.are_types_compatible(&expected, argument_type) { + self.type_error( + format!( + "Argument {} of parent method '{}' expects {}, but found {}", + index + 1, + method_name, + expected, + argument_type + ), + Some(expected), + Some(argument_type.clone()), + *line, + *column, + ); + } + } + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); + self.current_statement_completion = return_type; + } Statement::PatternDefinition { - name: _name, + name, pattern, - line: _line, - column: _column, + line, + column, } => { - self.check_pattern_expression_types(pattern, *_line, *_column); + self.check_pattern_expression_types(pattern, *line, *column); + self.analyzer.define_or_replace_symbol(Symbol { + name: name.clone(), + kind: SymbolKind::Pattern, + symbol_type: Some(Type::Pattern), + line: *line, + column: *column, + }); + self.current_statement_completion = Type::Pattern; } Statement::MapCreation { - name: _name, + name, entries, - line: _line, - column: _column, + line, + column, } => { - // Check each entry value type + let mut value_type = None; for (_key, value) in entries { - self.infer_expression_type(value); + let inferred = self.infer_expression_type(value); + value_type = Some(Self::join_collection_value_type(value_type, inferred)); + } + self.bind_runtime_value( + name, + Type::Map( + Box::new(Type::Text), + Box::new(value_type.unwrap_or(Type::Unknown)), + ), + true, + *line, + *column, + ); + if let Some(target_binding) = self.analyzer.get_symbol_binding_key(name) { + for (_, value) in entries { + self.record_nested_list_alias_expression(&target_binding, 1, value); + } } - // The map will be added to the environment at runtime } Statement::CreateDateStatement { - name: _name, + name, value, - line: _line, - column: _column, + line, + column, } => { - // Check the value expression if provided - if let Some(expr) = value { - self.infer_expression_type(expr); - } - // The date will be added to the environment at runtime + let value_type = value + .as_ref() + .map(|expr| self.infer_expression_type(expr)) + .unwrap_or(Type::Date); + self.bind_runtime_value(name, value_type, true, *line, *column); } Statement::CreateTimeStatement { - name: _name, + name, value, - line: _line, - column: _column, + line, + column, } => { - // Check the value expression if provided - if let Some(expr) = value { - self.infer_expression_type(expr); - } - // The time will be added to the environment at runtime + let value_type = value + .as_ref() + .map(|expr| self.infer_expression_type(expr)) + .unwrap_or(Type::Time); + self.bind_runtime_value(name, value_type, true, *line, *column); } Statement::ListenStatement { port, - server_name: _server_name, + server_name, tls, redirect_to_port, line: _line, column: _column, } => { let port_type = self.infer_expression_type(port); - if port_type != Type::Number - && port_type != Type::Unknown - && port_type != Type::Error - { + if port_type != Type::Number && !self.is_gradual_type(&port_type) { self.type_error( "Port must be a number".to_string(), Some(Type::Number), @@ -2958,10 +6960,7 @@ impl TypeChecker { ] { if let Some(expr) = path_expr { let path_type = self.infer_expression_type(expr); - if path_type != Type::Text - && path_type != Type::Unknown - && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( format!("{what} must be text"), Some(Type::Text), @@ -2977,10 +6976,7 @@ impl TypeChecker { // Redirect target must be a number if let Some(target_port) = redirect_to_port { let target_type = self.infer_expression_type(target_port); - if target_type != Type::Number - && target_type != Type::Unknown - && target_type != Type::Error - { + if target_type != Type::Number && !self.is_gradual_type(&target_type) { self.type_error( "Redirect target port must be a number".to_string(), Some(Type::Number), @@ -2990,10 +6986,11 @@ impl TypeChecker { ); } } + self.bind_runtime_value(server_name, Type::Text, false, *_line, *_column); } Statement::WaitForRequestStatement { server, - request_name: _request_name, + request_name, timeout, line, column, @@ -3002,7 +6999,7 @@ impl TypeChecker { if let Some(timeout_expr) = timeout { let timeout_type = self.infer_expression_type(timeout_expr); - if timeout_type != Type::Number { + if timeout_type != Type::Number && !self.is_gradual_type(&timeout_type) { self.type_error( "Timeout must be a number".to_string(), Some(Type::Number), @@ -3012,9 +7009,30 @@ impl TypeChecker { ); } } + self.bind_runtime_value( + request_name, + Type::Custom("Request".to_string()), + false, + *line, + *column, + ); + for (name, value_type) in [ + ("method", Type::Text), + ("path", Type::Text), + ("query", Type::Text), + ("client_ip", Type::Text), + ("body", Type::Text), + ("body_bytes", Type::Binary), + ( + "headers", + Type::Map(Box::new(Type::Text), Box::new(Type::Text)), + ), + ] { + self.bind_runtime_value(name, value_type, false, *line, *column); + } } Statement::RespondStatement { - request: _request, + request, content, status, content_type, @@ -3022,19 +7040,24 @@ impl TypeChecker { line: _line, column: _column, } => { - // Check content type (text or binary). Binary content is served - // losslessly as raw bytes (e.g. fonts, images); text content keeps - // its UTF-8 encoding. + let request_type = self.infer_expression_type(request); + if !self.is_pending_request_type(&request_type) { + self.type_error( + "Response target must be a request object".to_string(), + Some(Type::Custom("Request".to_string())), + Some(request_type.clone()), + *_line, + *_column, + ); + } + + // Runtime serves text/binary losslessly and stringifies scalar + // number/boolean/nothing values. Composite values are rejected. let content_type_result = self.infer_expression_type(content); - if content_type_result != Type::Text - && content_type_result != Type::Binary - && content_type_result != Type::Unknown - && content_type_result != Type::Error - { + if !Self::is_response_content_type(&content_type_result) { self.type_error( - "Response content must be text or binary".to_string(), - // No single expected type: both Text and Binary are - // accepted, so `None` avoids a misleading "expected Text". + "Response content must be text, binary, a number, a boolean, or nothing" + .to_string(), None, Some(content_type_result), *_line, @@ -3045,10 +7068,7 @@ impl TypeChecker { // Check status if provided (should be number) if let Some(status_expr) = status { let status_type = self.infer_expression_type(status_expr); - if status_type != Type::Number - && status_type != Type::Unknown - && status_type != Type::Error - { + if status_type != Type::Number && !self.is_gradual_type(&status_type) { self.type_error( "HTTP status must be a number".to_string(), Some(Type::Number), @@ -3062,7 +7082,7 @@ impl TypeChecker { // Check content_type if provided (should be text) if let Some(ct_expr) = content_type { let ct_type = self.infer_expression_type(ct_expr); - if ct_type != Type::Text && ct_type != Type::Unknown && ct_type != Type::Error { + if ct_type != Type::Text && !self.is_gradual_type(&ct_type) { self.type_error( "Content type must be text".to_string(), Some(Type::Text), @@ -3113,13 +7133,13 @@ impl TypeChecker { } // WebSocket statements Statement::ListenWebSocketStatement { - port, line, column, .. + port, + server_name, + line, + column, } => { let port_type = self.infer_expression_type(port); - if port_type != Type::Number - && port_type != Type::Unknown - && port_type != Type::Error - { + if port_type != Type::Number && !self.is_gradual_type(&port_type) { self.type_error( "WebSocket port must be a number".to_string(), Some(Type::Number), @@ -3128,20 +7148,35 @@ impl TypeChecker { *column, ); } + self.bind_runtime_value(server_name, Type::Text, false, *line, *column); } Statement::WebSocketHandlerStatement { + event, server, binding, body, line, column, - .. } => { // The server operand and handler body are checked; the handler's - // bound variable resolves as an object at runtime (gradual typing - // keeps member access like `body of msg` permissive). + // bound variable resolves as an object at runtime. Connection + // lifecycle objects contain only text fields; message objects + // additionally contain a nested sender object and therefore + // retain a heterogeneous value type. self.check_server_expression_type(server, *line, *column); + self.has_websocket_handlers = true; self.analyzer.push_scope(); + let event_value_type = match event { + WsHandlerEvent::Connect | WsHandlerEvent::Disconnect => Type::Text, + WsHandlerEvent::Message => Type::Any, + }; + self.bind_runtime_value( + binding, + Type::Map(Box::new(Type::Text), Box::new(event_value_type)), + false, + *line, + *column, + ); let outer_type_snapshot = self.analyzer.snapshot_symbol_types(); // Runtime binds the event object with `define_direct`, // deliberately shadowing an outer same-named variable. Analyzer @@ -3156,17 +7191,38 @@ impl TypeChecker { line: *line, column: *column, }); + let outer_alias_snapshot = self.list_alias_groups.clone(); + let outer_refinement_snapshot = self.optional_refinement_origins.clone(); + let outer_nonempty_snapshot = self.definitely_nonempty_lists.clone(); + self.try_flow_capture_suspended += 1; for stmt in body { self.check_statement_types(stmt); } + self.try_flow_capture_suspended -= 1; self.analyzer.restore_symbol_types(outer_type_snapshot); + self.list_alias_groups = outer_alias_snapshot; + self.optional_refinement_origins = outer_refinement_snapshot; + self.definitely_nonempty_lists = outer_nonempty_snapshot; self.analyzer.pop_scope(); } Statement::SendWebSocketMessageStatement { - message, target, .. + message, + target, + line, + column, } => { - self.infer_expression_type(message); - self.infer_expression_type(target); + let message_type = self.infer_expression_type(message); + self.check_websocket_message_type(message_type, *line, *column); + let target_type = self.infer_expression_type(target); + if !self.is_websocket_connection_target_type(&target_type) { + self.type_error( + "WebSocket connection target must be an object".to_string(), + Some(Type::Map(Box::new(Type::Text), Box::new(Type::Any))), + Some(target_type), + *line, + *column, + ); + } } Statement::BroadcastWebSocketMessageStatement { message, @@ -3174,7 +7230,8 @@ impl TypeChecker { line, column, } => { - self.infer_expression_type(message); + let message_type = self.infer_expression_type(message); + self.check_websocket_message_type(message_type, *line, *column); self.check_server_expression_type(server, *line, *column); } // Test framework statements @@ -3186,6 +7243,10 @@ impl TypeChecker { line: _line, column: _column, } => { + // Runtime creates one describe-level child environment shared + // by setup, every isolated test child, and teardown. + self.analyzer.push_scope(); + // Type check setup block if present if let Some(setup_stmts) = setup { for stmt in setup_stmts { @@ -3204,6 +7265,8 @@ impl TypeChecker { self.check_statement_types(stmt); } } + + self.analyzer.pop_scope(); } Statement::TestBlock { description: _description, @@ -3211,10 +7274,18 @@ impl TypeChecker { line: _line, column: _column, } => { - // Type check test body + // Each test runs in an isolated child of the describe + // environment. Its declarations and type refinements cannot + // leak to sibling tests or teardown. + self.analyzer.push_scope(); + let describe_type_snapshot = self.analyzer.snapshot_symbol_types(); + let describe_alias_snapshot = self.list_alias_groups.clone(); for stmt in body { self.check_statement_types(stmt); } + self.analyzer.restore_symbol_types(describe_type_snapshot); + self.list_alias_groups = describe_alias_snapshot; + self.analyzer.pop_scope(); } Statement::ExpectStatement { subject, @@ -3234,10 +7305,7 @@ impl TypeChecker { } Assertion::GreaterThan(expr) | Assertion::LessThan(expr) => { // Check that subject is a number - if subject_type != Type::Number - && subject_type != Type::Unknown - && subject_type != Type::Error - { + if subject_type != Type::Number && !self.is_gradual_type(&subject_type) { self.type_error( "Comparison assertions require numeric types".to_string(), Some(Type::Number), @@ -3248,10 +7316,7 @@ impl TypeChecker { } // Type check the comparison value let expr_type = self.infer_expression_type(expr); - if expr_type != Type::Number - && expr_type != Type::Unknown - && expr_type != Type::Error - { + if expr_type != Type::Number && !self.is_gradual_type(&expr_type) { self.type_error( "Comparison value must be numeric".to_string(), Some(Type::Number), @@ -3271,7 +7336,7 @@ impl TypeChecker { // Check that subject is a list or text if !matches!( subject_type, - Type::List(_) | Type::Text | Type::Unknown | Type::Error + Type::List(_) | Type::Text | Type::Unknown | Type::Any | Type::Error ) { self.type_error( "contain assertion requires List or Text type".to_string(), @@ -3288,7 +7353,7 @@ impl TypeChecker { // Check that subject is a list or text if !matches!( subject_type, - Type::List(_) | Type::Text | Type::Unknown | Type::Error + Type::List(_) | Type::Text | Type::Unknown | Type::Any | Type::Error ) { self.type_error( "be empty assertion requires List or Text type".to_string(), @@ -3303,7 +7368,7 @@ impl TypeChecker { // Check that subject is a list or text if !matches!( subject_type, - Type::List(_) | Type::Text | Type::Unknown | Type::Error + Type::List(_) | Type::Text | Type::Unknown | Type::Any | Type::Error ) { self.type_error( "have length assertion requires List or Text type".to_string(), @@ -3315,10 +7380,7 @@ impl TypeChecker { } // Type check the length value (should be number) let length_type = self.infer_expression_type(expr); - if length_type != Type::Number - && length_type != Type::Unknown - && length_type != Type::Error - { + if length_type != Type::Number && !self.is_gradual_type(&length_type) { self.type_error( "Length value must be numeric".to_string(), Some(Type::Number), @@ -3339,8 +7401,7 @@ impl TypeChecker { } => { // Type check the path expression - must be a string let path_type = self.infer_expression_type(path); - if path_type != Type::Text && path_type != Type::Unknown && path_type != Type::Error - { + if path_type != Type::Text && !self.is_gradual_type(&path_type) { self.type_error( "Expected string for include path".to_string(), Some(Type::Text), @@ -3354,6 +7415,8 @@ impl TypeChecker { // which can result in false "not found" errors for symbols defined in included files. // Future improvement: Parse and analyze included files during type checking // to register their symbols in the current scope for more accurate diagnostics. + // An included file may propagate an arbitrary `return` value. + self.current_statement_completion = Type::Any; } Statement::ExportStatement { @@ -3363,13 +7426,15 @@ impl TypeChecker { column, .. } => { - // Basic type checking for export statements - // Check if the exported item exists in the current scope + // Runtime exports are explicitly local-only: a definition + // inherited from a parent environment cannot be re-exported. + let local_symbol = self.analyzer.get_local_symbol(name); match export_type { crate::parser::ast::ExportType::Container => { - if let Some(_container) = self.analyzer.get_container(name) { - // Container exists - export is valid - } else { + if !matches!( + local_symbol.and_then(|symbol| symbol.symbol_type.as_ref()), + Some(Type::Container(container_name)) if container_name == name + ) { self.type_error( format!("Container '{}' not found for export", name), None, @@ -3380,12 +7445,9 @@ impl TypeChecker { } } crate::parser::ast::ExportType::Action => { - // Check if action exists as a symbol in the current scope - if let Some(symbol) = self.analyzer.get_symbol(name) { + if let Some(symbol) = local_symbol { match &symbol.kind { - crate::analyzer::SymbolKind::Function { .. } => { - // Action exists - export is valid - } + crate::analyzer::SymbolKind::Function { .. } => {} _ => { self.type_error( format!( @@ -3410,11 +7472,9 @@ impl TypeChecker { } } crate::parser::ast::ExportType::Constant => { - // Check if variable exists as a symbol in the current scope - if let Some(symbol) = self.analyzer.get_symbol(name) { + if let Some(symbol) = local_symbol { match &symbol.kind { crate::analyzer::SymbolKind::Variable { mutable } => { - // Only immutable variables can be exported as constants if *mutable { self.type_error( format!( @@ -3427,7 +7487,6 @@ impl TypeChecker { *column, ); } - // Otherwise, immutable variable is valid for constant export } _ => { self.type_error( @@ -3466,6 +7525,23 @@ impl TypeChecker { || name == "nested_function" } + /// Whether this call site resolves to the standard-library native rather + /// than a stored callable or a user action using a future-reserved name. + fn should_use_builtin_contract(&self, name: &str, line: usize, column: usize) -> bool { + if !Analyzer::is_builtin_function(name) + || self + .analyzer + .alias_call_resolution(name, line, column) + .is_some() + { + return false; + } + if builtins::is_implemented_builtin_function(name) { + return true; + } + self.analyzer.get_symbol(name).is_none() && !self.has_includes + } + fn infer_expression_type(&mut self, expression: &Expression) -> Type { // Recursive front-end checkpoint for expressions (mirrors the analyzer's // `analyze_expression`): `check_statement_types` polls per statement, but @@ -3492,9 +7568,27 @@ impl TypeChecker { Literal::Boolean(_) => Type::Boolean, Literal::Nothing => Type::Nothing, Literal::Pattern(_) => Type::Pattern, - Literal::List(_) => Type::List(Box::new(Type::Any)), + Literal::List(elements) => { + let mut element_type = None; + for element in elements { + let inferred = self.infer_expression_type(element); + element_type = + Some(Self::join_collection_value_type(element_type, inferred)); + } + // Preserve useful precision for homogeneous literals while + // widening genuinely heterogeneous values to Any. + Type::List(Box::new(element_type.unwrap_or(Type::Unknown))) + } }, - Expression::Variable(name, _line, _column) => { + Expression::Variable(name, line, column) => { + let (resolved_type, is_property) = self.resolve_bare_mutation_target_type(name); + if is_property && let Some(property_type) = resolved_type { + return property_type; + } + if let Some(result) = self.infer_zero_arg_variable_expression(name, *line, *column) + { + return result; + } if let Some(symbol) = self.analyzer.get_symbol(name) { // Builtin stdlib functions get injected into an included // file's scope as parent-scope variable bindings (defined @@ -3510,13 +7604,22 @@ impl TypeChecker { if is_injected_builtin && matches!(symbol.symbol_type, None | Some(Type::Unknown)) { - let param_count = builtins::get_function_arity(name); - return Type::Function { - parameters: vec![Type::Any; param_count], - return_type: Box::new( - self.get_builtin_function_type(name, param_count), + if builtins::is_implemented_builtin_function(name) { + return self.get_bare_builtin_type(name); + } + if self.has_includes { + return Type::Unknown; + } + self.type_error( + format!( + "Builtin '{name}' is recognized but not implemented by the runtime" ), - }; + None, + None, + *line, + *column, + ); + return Type::Error; } if let Some(var_type) = &symbol.symbol_type { var_type.clone() @@ -3529,8 +7632,6 @@ impl TypeChecker { // where a concrete type is required at runtime. Type::Unknown } - } else if let Some(property_type) = self.current_container_property_type(name) { - property_type } else { // Check if this is an action parameter, builtin function, or special function name before reporting it as undefined if self.analyzer.get_action_parameters().contains(name) @@ -3546,13 +7647,22 @@ impl TypeChecker { // For builtin functions, return their proper type if Analyzer::is_builtin_function(name) { - let param_count = builtins::get_function_arity(name); - return Type::Function { - parameters: vec![Type::Any; param_count], - return_type: Box::new( - self.get_builtin_function_type(name, param_count), + if builtins::is_implemented_builtin_function(name) { + return self.get_bare_builtin_type(name); + } + if self.has_includes { + return Type::Unknown; + } + self.type_error( + format!( + "Builtin '{name}' is recognized but not implemented by the runtime" ), - }; + None, + None, + *line, + *column, + ); + return Type::Error; } Type::Unknown @@ -3672,20 +7782,10 @@ impl TypeChecker { } } Operator::Equals | Operator::NotEquals => { - if !self.are_types_compatible(&left_type, &right_type) - && !self.are_types_compatible(&right_type, &left_type) - { - self.type_error( - format!("Cannot compare {left_type} and {right_type} for equality"), - Some(left_type.clone()), - Some(right_type), - *line, - *column, - ); - Type::Error - } else { - Type::Boolean - } + // Runtime equality is total: unlike types simply compare + // unequal. Rejecting unlike concrete types here would be + // stricter than the language's actual semantics. + Type::Boolean } Operator::GreaterThan | Operator::LessThan @@ -3693,6 +7793,7 @@ impl TypeChecker { | Operator::LessThanOrEqual => { if (left_type == Type::Number && right_type == Type::Number) || (left_type == Type::Text && right_type == Type::Text) + || self.are_same_temporal_type(&left_type, &right_type) { Type::Boolean } else { @@ -3700,11 +7801,16 @@ impl TypeChecker { format!( "Cannot compare {left_type} and {right_type} with {operator:?}" ), - Some(if left_type == Type::Number || left_type == Type::Text { - left_type.clone() - } else { - Type::Number - }), + Some( + if left_type == Type::Number + || left_type == Type::Text + || Self::temporal_kind(&left_type).is_some() + { + left_type.clone() + } else { + Type::Number + }, + ), Some(right_type), *line, *column, @@ -3733,22 +7839,9 @@ impl TypeChecker { } } Operator::Contains => match &left_type { - Type::List(item_type) => { - if !self.are_types_compatible(item_type, &right_type) { - self.type_error( - format!( - "Cannot check if {left_type} contains {right_type}, list items are {item_type}" - ), - Some(*item_type.clone()), - Some(right_type), - *line, - *column, - ); - Type::Error - } else { - Type::Boolean - } - } + // Runtime list membership uses total equality and + // therefore accepts a needle of any type. + Type::List(_) => Type::Boolean, Type::Map(key_type, _) => { if !self.are_types_compatible(key_type, &right_type) { self.type_error( @@ -3766,7 +7859,7 @@ impl TypeChecker { } } Type::Text => { - if right_type != Type::Text { + if right_type != Type::Text && !self.is_gradual_type(&right_type) { self.type_error( format!("Cannot check if {left_type} contains {right_type}"), Some(Type::Text), @@ -3806,7 +7899,7 @@ impl TypeChecker { match operator { UnaryOperator::Not => { - if expr_type == Type::Boolean { + if expr_type == Type::Boolean || self.is_gradual_type(&expr_type) { Type::Boolean } else { self.type_error( @@ -3822,6 +7915,8 @@ impl TypeChecker { UnaryOperator::Minus => { if expr_type == Type::Number { Type::Number + } else if self.is_gradual_type(&expr_type) { + expr_type } else { self.type_error( format!("Cannot negate {expr_type}"), @@ -3841,6 +7936,12 @@ impl TypeChecker { line, column, } => { + if let Expression::Variable(callee, _, _) = &**function + && self.should_use_builtin_contract(callee, *line, *column) + { + return self.infer_builtin_call_type(callee, arguments, *line, *column); + } + // The idiomatic `of` call form (`greet of "bob"`) parses as a // FunctionCall whose callee is a bare Variable. When that callee // is not resolvable statically but the program uses `include @@ -3853,9 +7954,12 @@ impl TypeChecker { let is_known = self.analyzer.get_symbol(callee).is_some() || self.is_callable_without_symbol(callee); if !is_known && self.has_includes { - for arg in arguments { - let _ = self.infer_expression_type(&arg.value); - } + let argument_types: Vec<_> = arguments + .iter() + .map(|arg| self.infer_expression_type(&arg.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); return Type::Any; } @@ -3871,11 +7975,18 @@ impl TypeChecker { { match resolution { crate::analyzer::AliasState::Dynamic => { - for arg in arguments { - let _ = self.infer_expression_type(&arg.value); - } + let argument_types: Vec<_> = arguments + .iter() + .map(|arg| self.infer_expression_type(&arg.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); return Type::Unknown; } + crate::analyzer::AliasState::Builtin { name } => { + return self + .infer_builtin_call_type(&name, arguments, *line, *column); + } crate::analyzer::AliasState::Bound { action, visible_signatures, @@ -3894,12 +8005,12 @@ impl TypeChecker { } } - // Overloaded user actions called in the `of` form resolve - // through the signature list (the single Type::Function - // below can only describe one definition). - if !Analyzer::is_builtin_function(callee) + // Direct user actions called in the `of` form resolve + // through the signature list. This also gives a + // forward-referenced single action its provisional + // Unknown return without inventing a missing-type error. + if !builtins::is_implemented_builtin_function(callee) && let Some(signatures) = self.action_signatures(callee) - && signatures.len() > 1 { return self.infer_overloaded_call_type( callee, @@ -3912,6 +8023,14 @@ impl TypeChecker { } let function_type = self.infer_expression_type(function); + let user_callee = match function.as_ref() { + Expression::Variable(name, ..) => Some(name.as_str()), + _ => None, + }; + let argument_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); match function_type { Type::Function { @@ -3934,11 +8053,10 @@ impl TypeChecker { } let mut has_type_error = false; - for (i, (arg, param_type)) in - arguments.iter().zip(parameters.iter()).enumerate() + for (i, (arg_type, param_type)) in + argument_types.iter().zip(parameters.iter()).enumerate() { - let arg_type = self.infer_expression_type(&arg.value); - if !self.are_types_compatible(param_type, &arg_type) { + if !self.are_types_compatible(param_type, arg_type) { self.type_error( format!( "Argument {} has incorrect type: expected {}, found {}", @@ -3947,7 +8065,7 @@ impl TypeChecker { arg_type ), Some(param_type.clone()), - Some(arg_type), + Some(arg_type.clone()), *line, *column, ); @@ -3958,10 +8076,27 @@ impl TypeChecker { if has_type_error { Type::Error } else { + self.escape_user_action_list_arguments(arguments, &argument_types); + let _ = user_callee; + // Reaching this generic Function path means there + // is no named WFL action summary (for example, a + // stored container method reference). Treat it as + // an opaque closure boundary. + self.escape_all_visible_mutable_state(); *return_type } } - Type::Unknown | Type::Error => Type::Unknown, + Type::Unknown => { + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); + Type::Unknown + } + Type::Any => { + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); + Type::Any + } + Type::Error => Type::Error, _ => { self.type_error( format!("Cannot call {function_type}, not a function"), @@ -3990,8 +8125,9 @@ impl TypeChecker { } match object_type { - Type::Custom(_) => Type::Unknown, - Type::Unknown => Type::Unknown, + Type::Custom(_) | Type::Unknown => Type::Unknown, + Type::Any => Type::Any, + Type::Error => Type::Error, _ => { self.type_error( format!("Cannot access property '{property}' on {object_type}"), @@ -4089,7 +8225,12 @@ impl TypeChecker { ); Type::Error } else { - Type::Any + match &**index { + Expression::Literal(Literal::String(field), ..) => { + Self::stream_field_type(name, field).unwrap_or(Type::Any) + } + _ => Type::Any, + } } } _ => { @@ -4130,7 +8271,7 @@ impl TypeChecker { let text_type = self.infer_expression_type(text); let pattern_type = self.infer_expression_type(pattern); - if text_type != Type::Text && text_type != Type::Unknown { + if text_type != Type::Text && !self.is_gradual_type(&text_type) { self.type_error( format!("Expected Text for pattern matching, got {text_type}"), Some(Type::Text), @@ -4140,10 +8281,7 @@ impl TypeChecker { ); } - if pattern_type != Type::Pattern - && pattern_type != Type::Text - && pattern_type != Type::Unknown - { + if pattern_type != Type::Pattern && !self.is_gradual_type(&pattern_type) { self.type_error( format!("Expected Pattern for pattern matching, got {pattern_type}"), Some(Type::Pattern), @@ -4155,31 +8293,39 @@ impl TypeChecker { Type::Boolean } - Expression::PatternFind { text, pattern, .. } => { + Expression::PatternFind { + text, + pattern, + line, + column, + } => { let text_type = self.infer_expression_type(text); let pattern_type = self.infer_expression_type(pattern); - if text_type != Type::Text { + if text_type != Type::Text && !self.is_gradual_type(&text_type) { self.type_error( format!("Expected Text for pattern finding, got {text_type}"), Some(Type::Text), Some(text_type), - 0, - 0, + *line, + *column, ); } - if pattern_type != Type::Pattern && pattern_type != Type::Text { + if pattern_type != Type::Pattern && !self.is_gradual_type(&pattern_type) { self.type_error( format!("Expected Pattern for pattern finding, got {pattern_type}"), Some(Type::Pattern), Some(pattern_type), - 0, - 0, + *line, + *column, ); } - Type::Map(Box::new(Type::Text), Box::new(Type::Nothing)) + Type::Optional(Box::new(Type::Map( + Box::new(Type::Text), + Box::new(Type::Any), + ))) } Expression::PatternReplace { text, @@ -4191,7 +8337,7 @@ impl TypeChecker { let pattern_type = self.infer_expression_type(pattern); let replacement_type = self.infer_expression_type(replacement); - if text_type != Type::Text { + if text_type != Type::Text && !self.is_gradual_type(&text_type) { self.type_error( format!("Expected Text for pattern replacement, got {text_type}"), Some(Type::Text), @@ -4201,7 +8347,7 @@ impl TypeChecker { ); } - if pattern_type != Type::Pattern && pattern_type != Type::Text { + if pattern_type != Type::Pattern && !self.is_gradual_type(&pattern_type) { self.type_error( format!("Expected Pattern for pattern replacement, got {pattern_type}"), Some(Type::Pattern), @@ -4211,7 +8357,7 @@ impl TypeChecker { ); } - if replacement_type != Type::Text { + if replacement_type != Type::Text && !self.is_gradual_type(&replacement_type) { self.type_error( format!("Expected Text for replacement, got {replacement_type}"), Some(Type::Text), @@ -4227,7 +8373,7 @@ impl TypeChecker { let text_type = self.infer_expression_type(text); let pattern_type = self.infer_expression_type(pattern); - if text_type != Type::Text { + if text_type != Type::Text && !self.is_gradual_type(&text_type) { self.type_error( format!("Expected Text for pattern splitting, got {text_type}"), Some(Type::Text), @@ -4237,7 +8383,7 @@ impl TypeChecker { ); } - if pattern_type != Type::Pattern && pattern_type != Type::Text { + if pattern_type != Type::Pattern && !self.is_gradual_type(&pattern_type) { self.type_error( format!("Expected Pattern for pattern splitting, got {pattern_type}"), Some(Type::Pattern), @@ -4261,7 +8407,7 @@ impl TypeChecker { // Accept statically-unknown operands (Unknown from untyped params, // Any from list-index/map results) without a false ERROR — they are // verified at runtime (gradual typing, issue #567). - if text_type != Type::Text && text_type != Type::Unknown && text_type != Type::Any { + if text_type != Type::Text && !self.is_gradual_type(&text_type) { self.type_error( format!("Expected Text for string splitting, got {text_type}"), Some(Type::Text), @@ -4271,10 +8417,7 @@ impl TypeChecker { ); } - if delimiter_type != Type::Text - && delimiter_type != Type::Unknown - && delimiter_type != Type::Any - { + if delimiter_type != Type::Text && !self.is_gradual_type(&delimiter_type) { self.type_error( format!("Expected Text for delimiter, got {delimiter_type}"), Some(Type::Text), @@ -4295,6 +8438,9 @@ impl TypeChecker { match expr_type { Type::Async(inner_type) => *inner_type, + Type::Unknown => Type::Unknown, + Type::Any => Type::Any, + Type::Error => Type::Error, _ => { self.type_error( format!("Cannot await non-async value of type {expr_type}"), @@ -4313,9 +8459,10 @@ impl TypeChecker { line: _line, column: _column, } => { - // For builtin functions, use special handling (variadic support, etc.) - if Analyzer::is_builtin_function(name) { - return self.get_builtin_function_type(name, arguments.len()); + // Builtins share argument traversal, arity checks, registered + // parameter contracts, and return inference with the `of` form. + if self.should_use_builtin_contract(name, *_line, *_column) { + return self.infer_builtin_call_type(name, arguments, *_line, *_column); } // Stored action references called with `call ... with` get the @@ -4327,11 +8474,18 @@ impl TypeChecker { { match resolution { crate::analyzer::AliasState::Dynamic => { - for arg in arguments { - let _ = self.infer_expression_type(&arg.value); - } + let argument_types: Vec<_> = arguments + .iter() + .map(|arg| self.infer_expression_type(&arg.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); return Type::Unknown; } + crate::analyzer::AliasState::Builtin { name } => { + return self + .infer_builtin_call_type(&name, arguments, *_line, *_column); + } crate::analyzer::AliasState::Bound { action, visible_signatures, @@ -4350,12 +8504,10 @@ impl TypeChecker { } } - // Overloaded actions (several registered signatures) resolve - // through the signature list; the single symbol_type below can - // only describe one definition. - if let Some(signatures) = self.action_signatures(name) - && signatures.len() > 1 - { + // Direct actions resolve through their registered signatures, + // including a forward-referenced single definition whose + // result is still provisional. + if let Some(signatures) = self.action_signatures(name) { return self.infer_overloaded_call_type( name, &signatures, @@ -4373,8 +8525,14 @@ impl TypeChecker { // It's an action parameter or a special function name, so don't report an error // For builtin functions, return their proper type if Analyzer::is_builtin_function(name) { - return self.get_builtin_function_type(name, arguments.len()); + return self.infer_builtin_call_type(name, arguments, *_line, *_column); } + let argument_types: Vec<_> = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); return Type::Unknown; } else if self.has_includes { // Action may be provided by an included file at runtime; @@ -4383,11 +8541,17 @@ impl TypeChecker { // Still infer each argument expression first so type errors // inside the arguments are not missed in include-using // programs. - for arg in arguments { - let _ = self.infer_expression_type(&arg.value); - } + let argument_types: Vec<_> = arguments + .iter() + .map(|arg| self.infer_expression_type(&arg.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); + self.escape_all_visible_mutable_state(); return Type::Any; } else { + for argument in arguments { + self.infer_expression_type(&argument.value); + } self.type_error( format!("Undefined action '{name}'"), None, @@ -4402,6 +8566,11 @@ impl TypeChecker { let symbol = symbol_opt.unwrap(); if symbol.symbol_type.is_none() { + let argument_types: Vec<_> = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); + self.escape_user_action_list_arguments(arguments, &argument_types); self.type_error( format!("Cannot determine type of action '{name}'"), None, @@ -4413,6 +8582,10 @@ impl TypeChecker { } let symbol_type = symbol.symbol_type.clone().unwrap(); + let arg_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); match symbol_type { Type::Function { @@ -4435,11 +8608,6 @@ impl TypeChecker { return Type::Error; } - let mut arg_types = Vec::with_capacity(arguments.len()); - for arg in arguments { - arg_types.push(self.infer_expression_type(&arg.value)); - } - for (i, (param_type, arg_type)) in parameters.iter().zip(arg_types.iter()).enumerate() { @@ -4461,7 +8629,10 @@ impl TypeChecker { } } - *return_type + self.escape_user_action_list_arguments(arguments, &arg_types); + let keys = vec![(name.clone(), 0)]; + self.apply_user_action_list_effects(&keys); + self.escape_shared_list_return_type(&keys, *return_type) } _ => { self.type_error( @@ -4484,26 +8655,22 @@ impl TypeChecker { line, column, } => { - // Look up the container in the analyzer's registry - if let Some(container_info) = self.analyzer.get_container(container) { - // First check static properties - if let Some(prop_info) = container_info.static_properties.get(member) { - return prop_info.property_type.clone(); + if self.analyzer.get_container(container).is_some() { + if let Some(property_type) = + self.container_static_property_type(container, member) + { + return property_type; } - - // Then check static methods - if let Some(method_info) = container_info.static_methods.get(member) { + if let Some(method_info) = self.container_static_method(container, member) { return Type::Function { parameters: method_info .parameters .iter() .map(|p| p.param_type.as_ref().cloned().unwrap_or(Type::Unknown)) .collect(), - return_type: Box::new(method_info.return_type.clone()), + return_type: Box::new(method_info.return_type), }; } - - // Member not found self.errors.push(TypeError::new( format!("Static member '{member}' not found in container '{container}'"), None, @@ -4533,9 +8700,73 @@ impl TypeChecker { } => { // First, determine the type of the object let object_type = self.infer_expression_type(object); + // Runtime evaluates every argument before dispatch, including + // extra arguments and calls that later fail lookup/arity. + let argument_types: Vec = arguments + .iter() + .map(|argument| self.infer_expression_type(&argument.value)) + .collect(); - // Check if the object is a container instance - match object_type { + // Check if the object is a container instance. + let result = match object_type { + Type::Container(container_name) => { + if let Some(method_info) = + self.container_static_method(&container_name, method) + { + if arguments.len() != method_info.parameters.len() { + self.errors.push(TypeError::new( + format!( + "Static method '{}' expects {} arguments but {} were provided", + method, + method_info.parameters.len(), + arguments.len() + ), + None, + None, + *line, + *column, + )); + } + for (index, (argument_type, parameter)) in argument_types + .iter() + .zip(&method_info.parameters) + .enumerate() + { + let expected = parameter + .param_type + .as_ref() + .cloned() + .unwrap_or(Type::Unknown); + if !self.are_types_compatible(&expected, argument_type) { + self.errors.push(TypeError::new( + format!( + "Argument {} of static method '{}' has type {} but expected {}", + index + 1, + method, + argument_type, + expected + ), + Some(expected), + Some(argument_type.clone()), + *line, + *column, + )); + } + } + method_info.return_type + } else { + self.errors.push(TypeError::new( + format!( + "Static method '{method}' not found in container '{container_name}'" + ), + None, + None, + *line, + *column, + )); + Type::Error + } + } Type::ContainerInstance(container_name) => { // Look up the container in the analyzer's registry if let Some(container_info) = self.analyzer.get_container(&container_name) { @@ -4562,17 +8793,13 @@ impl TypeChecker { } // Check argument types - for (i, (arg, param)) in - arguments.iter().zip(&method_params).enumerate() + for (i, (arg_type, param)) in + argument_types.iter().zip(&method_params).enumerate() { - let arg_type = self.infer_expression_type(&arg.value); let expected_type = param.param_type.as_ref().cloned().unwrap_or(Type::Unknown); - if arg_type != Type::Unknown - && expected_type != Type::Unknown - && arg_type != expected_type - { + if !self.are_types_compatible(&expected_type, arg_type) { self.errors.push(TypeError::new( format!( "Argument {} of method '{}' has type {} but expected {}", @@ -4582,7 +8809,7 @@ impl TypeChecker { expected_type ), Some(expected_type), - Some(arg_type), + Some(arg_type.clone()), *line, *column, )); @@ -4595,8 +8822,12 @@ impl TypeChecker { // Check parent containers if the method is not found let mut current_container = container_info.extends.as_ref(); let mut found_method = None; + let mut visited = HashSet::new(); while let Some(parent_name) = current_container { + if !visited.insert(parent_name.as_str()) { + break; + } if let Some(parent_info) = self.analyzer.get_container(parent_name) { @@ -4630,20 +8861,16 @@ impl TypeChecker { )); } - for (i, (arg, param)) in - arguments.iter().zip(&method_params).enumerate() + for (i, (arg_type, param)) in + argument_types.iter().zip(&method_params).enumerate() { - let arg_type = self.infer_expression_type(&arg.value); let expected_type = param .param_type .as_ref() .cloned() .unwrap_or(Type::Unknown); - if arg_type != Type::Unknown - && expected_type != Type::Unknown - && arg_type != expected_type - { + if !self.are_types_compatible(&expected_type, arg_type) { self.errors.push(TypeError::new( format!( "Argument {} of method '{}' has type {} but expected {}", @@ -4653,7 +8880,7 @@ impl TypeChecker { expected_type ), Some(expected_type), - Some(arg_type), + Some(arg_type.clone()), *line, *column, )); @@ -4685,6 +8912,9 @@ impl TypeChecker { Type::Error } } + Type::Unknown => Type::Unknown, + Type::Any => Type::Any, + Type::Error => Type::Error, _ => { self.type_error( format!( @@ -4697,109 +8927,193 @@ impl TypeChecker { ); Type::Error } + }; + if result != Type::Error { + self.escape_user_action_list_arguments(arguments, &argument_types); + // Methods can close over the caller's runtime environment + // and container properties can retain shared list values. + // Until method/property effect summaries carry those paths, + // this is the explicit conservative user-code boundary. + self.escape_all_visible_mutable_state(); + } + Self::escape_possible_shared_list_return_type(result) + } + Expression::PropertyAccess { + object, + property, + line, + column, + } => { + let object_type = self.infer_expression_type(object); + self.infer_property_access_type(object_type, property, *line, *column) + .0 + } + Expression::FileExists { path, line, column } + | Expression::DirectoryExists { path, line, column } + | Expression::ListFiles { path, line, column } => { + let path_type = self.infer_expression_type(path); + if path_type != Type::Text && !self.is_gradual_type(&path_type) { + self.type_error( + "Filesystem path must be text".to_string(), + Some(Type::Text), + Some(path_type), + *line, + *column, + ); + } + match expression { + Expression::FileExists { .. } | Expression::DirectoryExists { .. } => { + Type::Boolean + } + Expression::ListFiles { .. } => Type::List(Box::new(Type::Text)), + _ => unreachable!(), + } + } + Expression::ReadContent { + file_handle, + line, + column, + } + | Expression::ReadBinaryContent { + file_handle, + line, + column, + } + | Expression::FileSizeOf { + file_handle, + line, + column, + } => { + let handle_type = self.infer_expression_type(file_handle); + if handle_type != Type::Text + && handle_type != Type::Custom("File".to_string()) + && !self.is_gradual_type(&handle_type) + { + self.type_error( + "File handle or path must be text".to_string(), + Some(Type::Text), + Some(handle_type), + *line, + *column, + ); + } + match expression { + Expression::ReadContent { .. } => Type::Text, + Expression::ReadBinaryContent { .. } => Type::Binary, + Expression::FileSizeOf { .. } => Type::Number, + _ => unreachable!(), + } + } + Expression::ReadBinaryN { + file_handle, + count, + line, + column, + } => { + let handle_type = self.infer_expression_type(file_handle); + if handle_type != Type::Text + && handle_type != Type::Custom("File".to_string()) + && !self.is_gradual_type(&handle_type) + { + self.type_error( + "File handle or path must be text".to_string(), + Some(Type::Text), + Some(handle_type), + *line, + *column, + ); + } + let count_type = self.infer_expression_type(count); + if count_type != Type::Number && !self.is_gradual_type(&count_type) { + self.type_error( + "Binary byte count must be a number".to_string(), + Some(Type::Number), + Some(count_type), + *line, + *column, + ); } + Type::Binary + } + Expression::ListFilesRecursive { + path, + extensions, + line, + column, + } => { + self.check_file_listing_operands( + path, + extensions.as_deref().unwrap_or_default(), + *line, + *column, + ); + Type::List(Box::new(Type::Text)) + } + Expression::ListFilesFiltered { + path, + extensions, + line, + column, + } => { + self.check_file_listing_operands(path, extensions, *line, *column); + Type::List(Box::new(Type::Text)) } - Expression::PropertyAccess { - object, - property, + Expression::HeaderAccess { + request, line, column, + .. } => { - let object_type = self.infer_expression_type(object); - match object_type { - Type::ContainerInstance(container_name) => { - // Look up the container in the analyzer's registry - if let Some(container_info) = self.analyzer.get_container(&container_name) { - // Look up the property in the container - if let Some(prop_info) = container_info.properties.get(property) { - prop_info.property_type.clone() - } else { - // Check parent containers if property not found - let mut current_container = container_info.extends.as_ref(); - let mut found = false; - let mut prop_type = Type::Unknown; - - while let Some(parent_name) = current_container { - if let Some(parent_info) = - self.analyzer.get_container(parent_name) - { - if let Some(prop_info) = - parent_info.properties.get(property) - { - found = true; - prop_type = prop_info.property_type.clone(); - break; - } - current_container = parent_info.extends.as_ref(); - } else { - break; - } - } - - if !found { - self.errors.push(TypeError::new( - format!( - "Property '{property}' not found in container '{container_name}'" - ), - None, - None, - *line, - *column, - )); - Type::Error - } else { - prop_type - } - } - } else { - self.errors.push(TypeError::new( - format!("Container '{container_name}' not found"), - None, - None, - *line, - *column, - )); - Type::Error - } - } - // Objects/maps support property access at runtime - // (e.g. `response.status` on an HTTP response object); - // the value type is whatever the map stores. - Type::Map(_, value_type) => *value_type, - Type::Unknown | Type::Any | Type::Error => Type::Unknown, - // Stream handles expose fields (`status`/`ok`/`headers`) via - // the documented dot form too; the field type is only known at - // runtime. - Type::Custom(ref name) if name == "HttpStream" || name == "ResponseStream" => { - Type::Unknown - } - _ => { - self.type_error( - format!( - "Cannot access property '{property}' on non-container type {object_type}" - ), - Some(Type::ContainerInstance("Unknown".to_string())), - Some(object_type), - *line, - *column, - ); - Type::Error - } + // Request objects are currently gradual/map-shaped, but the + // operand still needs traversal so nested diagnostics survive. + let request_type = self.infer_expression_type(request); + let headers_fallback = self + .analyzer + .get_symbol("headers") + .and_then(|symbol| symbol.symbol_type.clone()); + let has_headers_fallback = headers_fallback + .as_ref() + .is_some_and(|ty| matches!(ty, Type::Map(_, _)) || self.is_gradual_type(ty)); + if !self.is_execute_file_request_type(&request_type) && !has_headers_fallback { + self.type_error( + "Header access requires a request object or request headers in scope" + .to_string(), + Some(Type::Custom("Request".to_string())), + Some(request_type.clone()), + *line, + *column, + ); } + let header_value_type = match &request_type { + Type::Custom(name) if name == "Request" => Type::Text, + Type::Map(_, _) | Type::Unknown | Type::Any | Type::Error => Type::Any, + _ => match headers_fallback { + Some(Type::Map(_, value_type)) => *value_type, + Some(Type::Unknown | Type::Any | Type::Error) => Type::Any, + _ => Type::Error, + }, + }; + Type::Optional(Box::new(header_value_type)) } - Expression::FileExists { .. } => Type::Boolean, - Expression::DirectoryExists { .. } => Type::Boolean, - Expression::ListFiles { .. } => Type::List(Box::new(Type::Text)), - Expression::ReadContent { .. } => Type::Text, - Expression::ReadBinaryContent { .. } => Type::Binary, - Expression::ReadBinaryN { .. } => Type::Binary, - Expression::FileSizeOf { .. } => Type::Number, - Expression::ListFilesRecursive { .. } => Type::List(Box::new(Type::Text)), - Expression::ListFilesFiltered { .. } => Type::List(Box::new(Type::Text)), - Expression::HeaderAccess { .. } => Type::Text, Expression::CurrentTimeMilliseconds { .. } => Type::Number, Expression::CurrentTimeFormatted { .. } => Type::Text, - Expression::ProcessRunning { .. } => Type::Boolean, + Expression::ProcessRunning { + process_id, + line, + column, + } => { + let process_type = self.infer_expression_type(process_id); + if process_type != Type::Text && !self.is_gradual_type(&process_type) { + self.type_error( + "Process ID must be text".to_string(), + Some(Type::Text), + Some(process_type), + *line, + *column, + ); + } + Type::Boolean + } Expression::DatabaseQuery { db, sql, @@ -4814,6 +9128,57 @@ impl TypeChecker { } } + /// Builtin contracts are independent of program symbols and constructor + /// choice; the CLI supplies an already-run analyzer that does not contain + /// these registrations. + fn builtin_signatures(&self, name: &str) -> Option> { + let symbol = self.builtin_contracts.get_symbol(name)?; + if let SymbolKind::Function { signatures } = &symbol.kind { + Some(signatures.clone()) + } else { + None + } + } + + fn check_file_listing_operands( + &mut self, + path: &Expression, + extensions: &[Expression], + line: usize, + column: usize, + ) { + let path_type = self.infer_expression_type(path); + if path_type != Type::Text && !self.is_gradual_type(&path_type) { + self.type_error( + "Directory path must be text".to_string(), + Some(Type::Text), + Some(path_type), + line, + column, + ); + } + + for extension in extensions { + let extension_type = self.infer_expression_type(extension); + let valid = match &extension_type { + Type::Text => true, + Type::List(item_type) => { + **item_type == Type::Text || self.is_gradual_type(item_type) + } + other => self.is_gradual_type(other), + }; + if !valid { + self.type_error( + "File extension filter must be text or a list of text".to_string(), + None, + Some(extension_type), + line, + column, + ); + } + } + } + /// Validate the operand types of a database query/execute form. Shared by /// `DatabaseQueryStatement` and the `Expression::DatabaseQuery` arm so the /// two paths cannot drift apart. @@ -4826,10 +9191,7 @@ impl TypeChecker { column: usize, ) { let db_type = self.infer_expression_type(db); - if db_type != Type::Custom("Database".to_string()) - && db_type != Type::Unknown - && db_type != Type::Error - { + if db_type != Type::Custom("Database".to_string()) && !self.is_gradual_type(&db_type) { self.type_error( "Expected a Database connection".to_string(), Some(Type::Custom("Database".to_string())), @@ -4840,7 +9202,7 @@ impl TypeChecker { } let sql_type = self.infer_expression_type(sql); - if sql_type != Type::Text && sql_type != Type::Unknown && sql_type != Type::Error { + if sql_type != Type::Text && !self.is_gradual_type(&sql_type) { self.type_error( "SQL statement must be a text string".to_string(), Some(Type::Text), @@ -4852,12 +9214,13 @@ impl TypeChecker { if let Some(params) = parameters { let params_type = self.infer_expression_type(params); - if !matches!(params_type, Type::List(_)) - && params_type != Type::Unknown - && params_type != Type::Error - { + let valid = match ¶ms_type { + Type::List(item_type) => self.is_sql_parameter_type(item_type), + other => self.is_gradual_type(other), + }; + if !valid { self.type_error( - "Query parameters must be a list".to_string(), + "Query parameters must be a list of SQL scalar values".to_string(), Some(Type::List(Box::new(Type::Any))), Some(params_type), line, @@ -4867,6 +9230,26 @@ impl TypeChecker { } } + fn is_sql_parameter_type(&self, ty: &Type) -> bool { + if let Type::Optional(inner) = ty { + return self.is_sql_parameter_type(inner); + } + matches!( + ty, + Type::Text + | Type::Number + | Type::Boolean + | Type::Binary + | Type::Date + | Type::Time + | Type::DateTime + | Type::Nothing + | Type::Unknown + | Type::Any + | Type::Error + ) || self.is_unambiguous_temporal_type(ty) + } + /// Result type of a database query/execute. Rows are objects keyed by /// column name; execute results are {affected_rows, last_insert_id}. /// Typing them as text-keyed maps lets downstream indexing typecheck @@ -4932,7 +9315,7 @@ impl TypeChecker { // If it accepts an argument, it must be a Number (the signal number) // Also allow Unknown for backward compatibility with untyped parameters let param_type = ¶meters[0]; - if *param_type != Type::Number && *param_type != Type::Unknown { + if *param_type != Type::Number && !self.is_gradual_type(param_type) { self.type_error( format!( "Signal handler parameter must be a Number (signal code), but got {}", @@ -4979,47 +9362,301 @@ impl TypeChecker { } } + fn join_return_types(left: Type, right: Type) -> Type { + match (left, right) { + (Type::Error, _) | (_, Type::Error) => Type::Error, + (Type::Nothing, Type::Nothing) => Type::Nothing, + (Type::Nothing, other) | (other, Type::Nothing) => Self::optionalize(other), + (left, right) => Self::join_inferred_types(left, right), + } + } + + #[allow(dead_code)] + fn action_block_must_terminate(statements: &[Statement]) -> bool { + for statement in statements { + let must_terminate = match statement { + Statement::ReturnStatement { .. } | Statement::ExitStatement { .. } => true, + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_block, + .. + } => Self::action_block_must_terminate(then_block), + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_block, + .. + } => else_block + .as_ref() + .is_some_and(|block| Self::action_block_must_terminate(block)), + Statement::IfStatement { + then_block, + else_block: Some(else_block), + .. + } => { + Self::action_block_must_terminate(then_block) + && Self::action_block_must_terminate(else_block) + } + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_stmt, + .. + } => Self::action_block_must_terminate(std::slice::from_ref(then_stmt.as_ref())), + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_stmt, + .. + } => else_stmt.as_ref().is_some_and(|statement| { + Self::action_block_must_terminate(std::slice::from_ref(statement.as_ref())) + }), + Statement::SingleLineIf { + then_stmt, + else_stmt: Some(else_stmt), + .. + } => { + Self::action_block_must_terminate(std::slice::from_ref(then_stmt.as_ref())) + && Self::action_block_must_terminate(std::slice::from_ref( + else_stmt.as_ref(), + )) + } + Statement::TryStatement { + body, + when_clauses, + otherwise_block, + finally_block, + .. + } => { + if finally_block + .as_ref() + .is_some_and(|block| Self::action_block_must_terminate(block)) + { + true + } else { + Self::action_block_must_terminate(body) + && when_clauses + .iter() + .all(|clause| Self::action_block_must_terminate(&clause.body)) + && otherwise_block + .as_ref() + .is_none_or(|block| Self::action_block_must_terminate(block)) + } + } + Statement::WaitForStatement { inner, .. } => { + Self::action_block_must_terminate(std::slice::from_ref(inner.as_ref())) + } + Statement::ForeverLoop { body, .. } | Statement::MainLoop { body, .. } => { + !Self::block_may_break_current_loop(body) + } + Statement::WhileLoop { + condition: Expression::Literal(Literal::Boolean(true), ..), + body, + .. + } + | Statement::RepeatWhileLoop { + condition: Expression::Literal(Literal::Boolean(true), ..), + body, + .. + } => !Self::block_may_break_current_loop(body), + Statement::RepeatUntilLoop { + condition, body, .. + } => { + Self::action_block_must_terminate(body) + || (matches!(condition, Expression::Literal(Literal::Boolean(false), ..)) + && !Self::block_may_break_current_loop(body)) + } + _ => false, + }; + if must_terminate { + return true; + } + } + false + } + + fn block_may_break_current_loop(statements: &[Statement]) -> bool { + statements + .iter() + .any(Self::statement_may_break_current_loop) + } + + fn statement_may_break_current_loop(statement: &Statement) -> bool { + match statement { + Statement::BreakStatement { .. } => true, + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_block, + .. + } => Self::block_may_break_current_loop(then_block), + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_block, + .. + } => else_block + .as_ref() + .is_some_and(|block| Self::block_may_break_current_loop(block)), + Statement::IfStatement { + then_block, + else_block, + .. + } => { + Self::block_may_break_current_loop(then_block) + || else_block + .as_ref() + .is_some_and(|block| Self::block_may_break_current_loop(block)) + } + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), ..), + then_stmt, + .. + } => Self::statement_may_break_current_loop(then_stmt), + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(false), ..), + else_stmt, + .. + } => else_stmt + .as_ref() + .is_some_and(|statement| Self::statement_may_break_current_loop(statement)), + Statement::SingleLineIf { + then_stmt, + else_stmt, + .. + } => { + Self::statement_may_break_current_loop(then_stmt) + || else_stmt + .as_ref() + .is_some_and(|statement| Self::statement_may_break_current_loop(statement)) + } + Statement::TryStatement { + body, + when_clauses, + otherwise_block, + finally_block, + .. + } => { + Self::block_may_break_current_loop(body) + || when_clauses + .iter() + .any(|clause| Self::block_may_break_current_loop(&clause.body)) + || otherwise_block + .as_ref() + .is_some_and(|block| Self::block_may_break_current_loop(block)) + || finally_block + .as_ref() + .is_some_and(|block| Self::block_may_break_current_loop(block)) + } + Statement::WaitForStatement { inner, .. } => { + Self::statement_may_break_current_loop(inner) + } + // A break inside a nested loop belongs to that nested loop. + Statement::ForEachLoop { .. } + | Statement::CountLoop { .. } + | Statement::WhileLoop { .. } + | Statement::RepeatWhileLoop { .. } + | Statement::RepeatUntilLoop { .. } + | Statement::ForeverLoop { .. } + | Statement::MainLoop { .. } => false, + _ => false, + } + } + + fn infer_recorded_action_return_type( + returns: &[RecordedReturn], + implicit_completion: Option<&Type>, + ) -> Type { + returns + .iter() + .map(|record| record.return_type.clone()) + .chain(implicit_completion.cloned()) + .reduce(Self::join_return_types) + .unwrap_or(Type::Nothing) + } + + fn check_recorded_return_types(&mut self, returns: &[RecordedReturn], expected_type: &Type) { + for record in returns { + if !record.has_value && *expected_type != Type::Nothing { + self.type_error( + "Function must return a value".to_string(), + Some(expected_type.clone()), + Some(Type::Nothing), + record.line, + record.column, + ); + } else if record.has_value + && !self.are_types_compatible(expected_type, &record.return_type) + { + self.type_error( + "Return statement has incorrect type".to_string(), + Some(expected_type.clone()), + Some(record.return_type.clone()), + record.line, + record.column, + ); + } + } + } + + fn check_implicit_action_result( + &mut self, + actual_type: &Type, + expected_type: &Type, + line: usize, + column: usize, + ) { + if (*actual_type == Type::Nothing && *expected_type != Type::Nothing) + || !self.are_types_compatible(expected_type, actual_type) + { + self.type_error( + "Action's implicit result has incorrect type".to_string(), + Some(expected_type.clone()), + Some(actual_type.clone()), + line, + column, + ); + } + } + /// Infer an action's return type from its `return` statements (issue #569). /// /// WFL has no return-type annotation, so the type checker must derive it /// from the body. Collect the type of every reachable `return ` and - /// merge them: identical types collapse to that type; differing concrete - /// types (or an `Unknown`) widen to a permissive type so we never turn an - /// un-inferrable body into a false positive at the call site. A body with no - /// value-returning `return` yields `Nothing`, preserving void-action - /// behavior. + /// merge them: identical types collapse to that type, common collection + /// structure is retained with joined inner types, and otherwise differing + /// concrete types widen to `Any`. `Unknown` remains unknown so we never turn + /// an un-inferrable body into a false positive at the call site. A body with + /// no value-returning `return` yields `Nothing`, preserving void-action + /// behavior. If execution can fall through after at least one value return, + /// retain that fact as `Optional` rather than claiming every call + /// produces `T`. + #[allow(dead_code)] fn infer_action_return_type(&mut self, body: &[Statement]) -> Type { let mut return_types = Vec::new(); - self.collect_return_types(body, &mut return_types); + let must_return = self.collect_return_types(body, &mut return_types); let mut result: Option = None; for t in return_types { result = Some(match result { None => t, - Some(existing) if existing == t => existing, - // Differing return types (or an un-inferrable one): widen. - // `Unknown` stays `Unknown` (still permissive, and preserves the - // "could not infer" signal); otherwise fall back to `Any`, which - // is accepted everywhere a concrete type is required. - Some(existing) => { - if existing == Type::Unknown || t == Type::Unknown { - Type::Unknown - } else { - Type::Any - } - } + Some(existing) => Self::join_return_types(existing, t), }); } - result.unwrap_or(Type::Nothing) + let inferred = result.unwrap_or(Type::Nothing); + if must_return || inferred == Type::Nothing { + inferred + } else { + match inferred { + Type::Optional(_) => inferred, + other => Type::Optional(Box::new(other)), + } + } } /// Gather the inferred type of each `return ` reachable in `body`, /// descending into conditionals and loops (mirrors `check_return_statements` /// traversal). Diagnostics produced while inferring are discarded: the body /// pass has already reported them, so this is purely for type collection. - fn collect_return_types(&mut self, statements: &[Statement], out: &mut Vec) { + #[allow(dead_code)] + fn collect_return_types(&mut self, statements: &[Statement], out: &mut Vec) -> bool { for statement in statements { - match statement { + let must_return = match statement { Statement::ReturnStatement { value, .. } => { if let Some(expr) = value { let errors_before = self.errors.len(); @@ -5034,27 +9671,31 @@ impl TypeChecker { // Stop here: collecting their returns would let dead code // widen a precise type (e.g. `Text`) to `Any` and mask a // genuine mismatch at the call site. - break; + true } + Statement::ExitStatement { .. } => true, Statement::IfStatement { then_block, else_block, .. } => { - self.collect_return_types(then_block, out); - if let Some(else_stmts) = else_block { - self.collect_return_types(else_stmts, out); - } + let then_returns = self.collect_return_types(then_block, out); + let else_returns = else_block + .as_ref() + .is_some_and(|else_stmts| self.collect_return_types(else_stmts, out)); + then_returns && else_returns } Statement::SingleLineIf { then_stmt, else_stmt, .. } => { - self.collect_return_types(&[*(*then_stmt).clone()], out); - if let Some(else_stmt) = else_stmt { - self.collect_return_types(&[*(*else_stmt).clone()], out); - } + let then_returns = + self.collect_return_types(std::slice::from_ref(then_stmt.as_ref()), out); + let else_returns = else_stmt.as_ref().is_some_and(|else_stmt| { + self.collect_return_types(std::slice::from_ref(else_stmt.as_ref()), out) + }); + then_returns && else_returns } Statement::ForEachLoop { body, .. } | Statement::CountLoop { body, .. } @@ -5063,7 +9704,8 @@ impl TypeChecker { | Statement::RepeatUntilLoop { body, .. } | Statement::ForeverLoop { body, .. } | Statement::MainLoop { body, .. } => { - self.collect_return_types(body, out); + let _ = self.collect_return_types(body, out); + false } // Actions commonly return from inside error handling — a `try:` // body, its `when error` clauses, `otherwise`, or `finally`. @@ -5077,29 +9719,56 @@ impl TypeChecker { finally_block, .. } => { - self.collect_return_types(body, out); + let primary_start = out.len(); + let body_must_return = self.collect_return_types(body, out); + let mut handlers_must_return = true; for clause in when_clauses { - self.collect_return_types(&clause.body, out); - } - if let Some(otherwise_stmts) = otherwise_block { - self.collect_return_types(otherwise_stmts, out); + handlers_must_return &= self.collect_return_types(&clause.body, out); } + let otherwise_must_return = + otherwise_block.as_ref().is_none_or(|otherwise_stmts| { + self.collect_return_types(otherwise_stmts, out) + }); + // An unhandled error propagates out of the action rather + // than producing Nothing. Only normally-completing try + // paths contribute a fallthrough value. + let primary_must_return = + body_must_return && handlers_must_return && otherwise_must_return; if let Some(finally_stmts) = finally_block { - self.collect_return_types(finally_stmts, out); + let mut finally_returns = Vec::new(); + let finally_must_return = + self.collect_return_types(finally_stmts, &mut finally_returns); + if finally_must_return { + // A definitely-returning finally overrides every + // success/error-path return from the primary try. + out.truncate(primary_start); + out.extend(finally_returns); + true + } else { + out.extend(finally_returns); + primary_must_return + } + } else { + primary_must_return } } Statement::WaitForStatement { inner, .. } => { - self.collect_return_types(std::slice::from_ref(inner), out); + self.collect_return_types(std::slice::from_ref(inner), out) } - _ => {} + _ => false, + }; + if must_return { + return true; } } + false } // `line`/`column` are the action's fallback location, threaded through the // recursive descent; each error site prefers the offending statement's own // position, so the parameters are only forwarded to recursive calls. #[allow(clippy::only_used_in_recursion)] + #[allow(dead_code)] fn check_return_statements( &mut self, statements: &[Statement], @@ -5226,6 +9895,33 @@ impl TypeChecker { .push(TypeError::new(message, expected, found, line, column)); } + /// Recreate a value that the interpreter binds while executing a statement. + /// + /// The analyzer checks action/loop/handler bodies in temporary scopes and + /// discards those scopes before the type-checker pass. Updating an existing + /// symbol with `get_symbol_mut` therefore loses statement-produced locals in + /// exactly the places where their types matter most. Bind into the current + /// checker scope instead, matching the interpreter's local environment. + fn bind_runtime_value( + &mut self, + name: &str, + value_type: Type, + mutable: bool, + line: usize, + column: usize, + ) { + if name.is_empty() { + return; + } + self.analyzer.define_or_replace_symbol(Symbol { + name: name.to_string(), + kind: SymbolKind::Variable { mutable }, + symbol_type: Some(value_type), + line, + column, + }); + } + /// Whether an inferred type is acceptable as an HTTP header map. Header names /// must be text, and header values are what the interpreter accepts and /// stringifies — text, number, or boolean (see the `respond`/HTTP header @@ -5255,6 +9951,90 @@ impl TypeChecker { } } + /// Server response statements require the opaque pending Request produced + /// by `wait for request`; an ordinary map has no response sender. + fn is_pending_request_type(&self, ty: &Type) -> bool { + matches!( + ty, + Type::Custom(name) if name == "Request" + ) || matches!(ty, Type::Unknown | Type::Any | Type::Error) + } + + /// `execute file ... with ` accepts either a live Request or a + /// structurally complete object. Static Map types do not retain field + /// shape, so map-shaped values must defer to the runtime field validator. + fn is_execute_file_request_type(&self, ty: &Type) -> bool { + self.is_pending_request_type(ty) || matches!(ty, Type::Map(_, _)) + } + + fn is_process_arguments_type(&self, ty: &Type) -> bool { + matches!(ty, Type::Text | Type::List(_)) || self.is_gradual_type(ty) + } + + /// WebSocket send targets are runtime objects whose text `id` field names + /// the connection. Handler bindings are `Map` for lifecycle + /// events and `Map` for message events; gradual key/value types + /// stay deferred to the runtime shape check. + fn is_websocket_connection_target_type(&self, ty: &Type) -> bool { + match ty { + Type::Map(key_type, value_type) => { + (matches!(key_type.as_ref(), Type::Text) || self.is_gradual_type(key_type)) + && (matches!(value_type.as_ref(), Type::Text) + || self.is_gradual_type(value_type)) + } + _ => self.is_gradual_type(ty), + } + } + + fn check_websocket_message_type(&mut self, ty: Type, line: usize, column: usize) { + if !matches!(&ty, Type::Text | Type::Number | Type::Boolean) && !self.is_gradual_type(&ty) { + self.type_error( + "WebSocket message must be text, a number, or a boolean".to_string(), + None, + Some(ty), + line, + column, + ); + } + } + + fn temporal_kind(ty: &Type) -> Option<&'static str> { + match ty { + Type::Date => Some("date"), + Type::Time => Some("time"), + Type::DateTime => Some("datetime"), + Type::Custom(name) if name.eq_ignore_ascii_case("date") => Some("date"), + Type::Custom(name) if name.eq_ignore_ascii_case("time") => Some("time"), + Type::Custom(name) if name.eq_ignore_ascii_case("datetime") => Some("datetime"), + _ => None, + } + } + + fn custom_temporal_is_unambiguous(&self, ty: &Type) -> bool { + let Type::Custom(name) = ty else { + return true; + }; + if Self::temporal_kind(ty).is_none() { + return false; + } + !self + .analyzer + .get_containers() + .keys() + .any(|container_name| container_name == name) + } + + fn is_unambiguous_temporal_type(&self, ty: &Type) -> bool { + Self::temporal_kind(ty).is_some() && self.custom_temporal_is_unambiguous(ty) + } + + fn are_same_temporal_type(&self, left: &Type, right: &Type) -> bool { + Self::temporal_kind(left) == Self::temporal_kind(right) + && Self::temporal_kind(left).is_some() + && self.custom_temporal_is_unambiguous(left) + && self.custom_temporal_is_unambiguous(right) + } + /// Whether a type can name a closeable resource: a file handle /// (`Custom("File")`), a stream handle (`Custom("HttpStream")` outbound or /// `Custom("ResponseStream")` server-side), or a statically-unresolved value. @@ -5266,7 +10046,7 @@ impl TypeChecker { Type::Custom(name) => { name == "File" || name == "HttpStream" || name == "ResponseStream" } - Type::Unknown | Type::Any | Type::Error => true, + Type::Text | Type::Unknown | Type::Any | Type::Error => true, _ => false, } } @@ -5282,6 +10062,21 @@ impl TypeChecker { } } + /// Concrete fields stored in runtime stream-handle objects. Unknown fields + /// remain gradual because a historical custom annotation can also carry + /// these names, but documented literal fields retain their real type. + fn stream_field_type(stream_name: &str, field: &str) -> Option { + match (stream_name, field) { + ("HttpStream", "status") | ("ResponseStream", "status") => Some(Type::Number), + ("HttpStream", "ok") => Some(Type::Boolean), + ("HttpStream", "headers") => { + Some(Type::Map(Box::new(Type::Text), Box::new(Type::Text))) + } + ("HttpStream", "_stream") | ("ResponseStream", "_server_stream") => Some(Type::Text), + _ => None, + } + } + /// The `` of `write line|chunk` / `flush` must be a server response /// stream handle (`start streaming response as ...` binds `ResponseStream`). /// Unknown/Any/Error pass for gradual typing. @@ -5300,6 +10095,21 @@ impl TypeChecker { matches!(ty, Type::Unknown | Type::Any | Type::Error) } + fn is_response_content_type(ty: &Type) -> bool { + match ty { + Type::Optional(inner) => Self::is_response_content_type(inner), + Type::Text + | Type::Binary + | Type::Number + | Type::Boolean + | Type::Nothing + | Type::Unknown + | Type::Any + | Type::Error => true, + _ => false, + } + } + /// The value types `write line|chunk` can send to a response stream — the /// runtime stringifies numbers/booleans and sends text/binary as-is, and /// rejects everything else (Map/List/Nothing/...). Gradual types pass. @@ -5348,15 +10158,23 @@ impl TypeChecker { // bodies. Use TypeChecker's live container context here so direct and // inherited properties remain defined on the selected write branch. let mut container_name = self.current_container.as_deref(); + let mut visited = HashSet::new(); while let Some(container_key) = container_name { + if !visited.insert(container_key) { + break; + } let Some(container) = self.analyzer.get_container(container_key) else { break; }; - if let Some(property) = container - .properties - .get(name) - .or_else(|| container.static_properties.get(name)) - { + let property = match self.current_method_is_static { + Some(true) => container.static_properties.get(name), + Some(false) => container.properties.get(name), + None => container + .properties + .get(name) + .or_else(|| container.static_properties.get(name)), + }; + if let Some(property) = property { return Some(property.property_type.clone()); } container_name = container.extends.as_deref(); @@ -5365,6 +10183,223 @@ impl TypeChecker { None } + /// Capture the true outer lexical binding for every property visible to + /// the active method. Method parameters and locals receive different + /// binding keys, and keep those keys when referenced through nested + /// try/loop scopes; a same-named global retains the captured key. + fn snapshot_current_method_outer_property_bindings( + &self, + ) -> HashMap> { + let mut result = HashMap::new(); + let mut container_name = self.current_container.as_deref(); + let mut visited = HashSet::new(); + while let Some(container_key) = container_name { + if !visited.insert(container_key) { + break; + } + let Some(container) = self.analyzer.get_container(container_key) else { + break; + }; + let properties = if self.current_method_is_static == Some(true) { + &container.static_properties + } else { + &container.properties + }; + for name in properties.keys() { + result + .entry(name.clone()) + .or_insert_with(|| self.analyzer.get_symbol_binding_key(name)); + } + container_name = container.extends.as_deref(); + } + result + } + + /// True when the nearest lexical binding is owned by the active method + /// (a parameter, body local, or nested implicit binding), rather than the + /// same-named lexical binding that existed outside the method. + fn method_lexical_binding_shadows_property(&self, name: &str) -> bool { + let Some(outer_bindings) = &self.current_method_outer_property_bindings else { + return self.analyzer.get_local_symbol(name).is_some(); + }; + let Some(outer_binding) = outer_bindings.get(name) else { + return self.analyzer.get_local_symbol(name).is_some(); + }; + self.analyzer.get_symbol_binding_key(name).as_ref() != outer_binding.as_ref() + } + + /// Resolve a legacy bare mutation target with the same precedence as the + /// runtime environment: a method-local binding shadows a current container + /// property, which in turn shadows an outer lexical binding. + fn resolve_bare_mutation_target_type(&self, name: &str) -> (Option, bool) { + if self.method_lexical_binding_shadows_property(name) + && let Some(symbol) = self.analyzer.get_symbol(name) + { + return (symbol.symbol_type.clone(), false); + } + if let Some(property_type) = self.current_container_property_type(name) { + return (Some(property_type), true); + } + ( + self.analyzer + .get_symbol(name) + .and_then(|symbol| symbol.symbol_type.clone()), + false, + ) + } + + fn container_static_property_type( + &self, + container_name: &str, + property_name: &str, + ) -> Option { + let mut current = Some(container_name); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name) { + return None; + } + let container = self.analyzer.get_container(name)?; + if let Some(property) = container.static_properties.get(property_name) { + return Some(property.property_type.clone()); + } + current = container.extends.as_deref(); + } + None + } + + fn container_static_method( + &self, + container_name: &str, + method_name: &str, + ) -> Option { + let mut current = Some(container_name); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name) { + return None; + } + let container = self.analyzer.get_container(name)?; + if let Some(method) = container.static_methods.get(method_name) { + return Some(method.clone()); + } + current = container.extends.as_deref(); + } + None + } + + fn container_property_type(&self, container_name: &str, property_name: &str) -> Option { + let mut current = Some(container_name); + let mut visited = HashSet::new(); + while let Some(name) = current { + if !visited.insert(name) { + return None; + } + let container = self.analyzer.get_container(name)?; + if let Some(property) = container.properties.get(property_name) { + return Some(property.property_type.clone()); + } + current = container.extends.as_deref(); + } + None + } + + /// Resolve a dot-property from an already-inferred receiver. The boolean + /// identifies registry-backed instance/static properties (as opposed to a + /// static method, map field, or gradual value), allowing mutation sites to + /// preserve declared property contracts without evaluating the receiver a + /// second time. + fn infer_property_access_type( + &mut self, + object_type: Type, + property: &str, + line: usize, + column: usize, + ) -> (Type, bool) { + match object_type { + Type::Container(container_name) => { + if let Some(property_type) = + self.container_static_property_type(&container_name, property) + { + (property_type, true) + } else if let Some(method_info) = + self.container_static_method(&container_name, property) + { + ( + Type::Function { + parameters: method_info + .parameters + .iter() + .map(|parameter| { + parameter.param_type.clone().unwrap_or(Type::Unknown) + }) + .collect(), + return_type: Box::new(method_info.return_type), + }, + false, + ) + } else { + self.type_error( + format!( + "Static property '{property}' not found in container '{container_name}'" + ), + None, + None, + line, + column, + ); + (Type::Error, false) + } + } + Type::ContainerInstance(container_name) => { + if self.analyzer.get_container(&container_name).is_none() { + self.type_error( + format!("Container '{container_name}' not found"), + None, + None, + line, + column, + ); + return (Type::Error, false); + } + if let Some(property_type) = self.container_property_type(&container_name, property) + { + (property_type, true) + } else { + self.type_error( + format!("Property '{property}' not found in container '{container_name}'"), + None, + None, + line, + column, + ); + (Type::Error, false) + } + } + // Objects/maps support property access at runtime + // (e.g. `response.status` on an HTTP response object). + Type::Map(_, value_type) => (*value_type, false), + Type::Unknown => (Type::Unknown, false), + Type::Any => (Type::Any, false), + Type::Error => (Type::Error, false), + // Stream handles expose documented dot fields. + Type::Custom(ref name) if name == "HttpStream" || name == "ResponseStream" => ( + Self::stream_field_type(name, property).unwrap_or(Type::Unknown), + false, + ), + other => { + self.type_error( + format!("Cannot access property '{property}' on non-container type {other}"), + Some(Type::ContainerInstance("Unknown".to_string())), + Some(other), + line, + column, + ); + (Type::Error, false) + } + } + } + /// Walk an expression and report every undefined bare name. Used for the /// selected (or every viable gradual) `write line|chunk` branch so a missing /// classic `line ` lead is not accepted just because the stream lead @@ -5523,6 +10558,103 @@ impl TypeChecker { } } + /// Builtin `Custom` contracts describe runtime-branded values (Date, + /// Database, Request, and so on), not user containers that happen to use + /// the same name. User action annotations retain their historical + /// container-name semantics through `are_types_compatible`. + fn are_builtin_types_compatible(&self, target_type: &Type, source_type: &Type) -> bool { + if source_type == &Type::Nothing + && !matches!( + target_type, + Type::Any | Type::Unknown | Type::Nothing | Type::Optional(_) + ) + { + return false; + } + if matches!( + (target_type, source_type), + (Type::Custom(_), Type::ContainerInstance(_)) + ) { + return false; + } + if Self::temporal_kind(target_type) == Self::temporal_kind(source_type) + && matches!(target_type, Type::Date | Type::Time | Type::DateTime) + && self.custom_temporal_is_unambiguous(source_type) + { + return true; + } + self.are_types_compatible(target_type, source_type) + } + + /// Container property annotations are persistent runtime invariants, not + /// one-shot flow hints. Unlike an ordinary mutable local, a property is + /// read later from the container registry using its declared type, so + /// accepting an `Any`/`Unknown` or incompatible replacement would leave + /// those later reads unsafely precise. + fn are_declared_property_types_compatible( + &self, + target_type: &Type, + source_type: &Type, + ) -> bool { + if source_type == &Type::Error { + return true; + } + match (target_type, source_type) { + (a, b) if a == b => true, + (Type::Any | Type::Unknown, _) => true, + (_, Type::Any | Type::Unknown) => false, + (Type::Optional(target), Type::Optional(source)) => { + self.are_declared_property_types_compatible(target, source) + } + (Type::Optional(_), Type::Nothing) => true, + (Type::Optional(target), source) => { + self.are_declared_property_types_compatible(target, source) + } + (_, Type::Optional(_)) | (_, Type::Nothing) => false, + (Type::List(target), Type::List(source)) => { + self.are_declared_property_types_compatible(target, source) + } + (Type::Map(target_key, target_value), Type::Map(source_key, source_value)) => { + self.are_declared_property_types_compatible(target_key, source_key) + && self.are_declared_property_types_compatible(target_value, source_value) + } + (Type::Async(target), Type::Async(source)) => { + self.are_declared_property_types_compatible(target, source) + } + _ => self.are_types_compatible(target_type, source_type), + } + } + + /// Expression-aware form of the persistent property contract. A fresh + /// empty list literal is safe for any declared list element type: it has no + /// elements that could violate the contract, while a shared + /// `List` binding remains unsafe because another alias may later + /// insert an incompatible value. + fn are_declared_property_values_compatible( + &self, + target_type: &Type, + source_type: &Type, + source: &Expression, + ) -> bool { + self.are_declared_property_types_compatible(target_type, source_type) + || Self::is_fresh_empty_list_shape_compatible(target_type, source) + } + + fn is_fresh_empty_list_shape_compatible(target_type: &Type, source: &Expression) -> bool { + match (target_type, source) { + (Type::Optional(inner), source) => { + Self::is_fresh_empty_list_shape_compatible(inner, source) + } + (Type::List(element_type), Expression::Literal(Literal::List(elements), ..)) => { + elements.is_empty() + || elements.iter().all(|element| { + Self::is_fresh_empty_list_shape_compatible(element_type, element) + }) + } + _ => false, + } + } + fn are_types_compatible(&self, target_type: &Type, source_type: &Type) -> bool { #[allow(clippy::only_used_in_recursion)] let _self = self; // Suppress the warning for self parameter @@ -5535,12 +10667,30 @@ impl TypeChecker { (Type::Any, _) => true, // Any can accept any type (_, Type::Any) => true, // Any can be assigned to any type + // Optional return inference is deliberately stricter than the + // general gradual `Any` type: a value that may fall through as + // Nothing cannot satisfy a consumer requiring a definite value. + (Type::Optional(target), Type::Optional(source)) => { + self.are_types_compatible(target, source) + } + (Type::Optional(_), Type::Nothing) => true, + (Type::Optional(target), source) => self.are_types_compatible(target, source), + (_, Type::Optional(_)) => false, + (_, Type::Nothing) => true, (_, Type::Error) => true, (inner, Type::Async(async_type)) => self.are_types_compatible(inner, async_type), + // Lowercase temporal annotations use dedicated runtime-value + // types. Historical named annotations remain Custom(...) and + // accept those values, but not conversely: a runtime-branded + // temporal contract must never accept a same-named container. + (Type::Custom(name), Type::Date) if name.eq_ignore_ascii_case("date") => true, + (Type::Custom(name), Type::Time) if name.eq_ignore_ascii_case("time") => true, + (Type::Custom(name), Type::DateTime) if name.eq_ignore_ascii_case("datetime") => true, + (Type::List(a), Type::List(b)) => self.are_types_compatible(a, b), (Type::Map(a_key, a_val), Type::Map(b_key, b_val)) => { self.are_types_compatible(a_key, b_key) && self.are_types_compatible(a_val, b_val) @@ -5587,9 +10737,184 @@ impl TypeChecker { #[cfg(test)] mod tests { use super::*; + use crate::lexer::lex_wfl_with_positions; + use crate::parser::Parser; use crate::parser::ast::{Argument, Expression, Literal, Parameter, Program, Statement, Type}; use std::sync::Arc; + fn typecheck_symbol_type(source: &str, name: &str) -> Type { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("program should parse"); + let mut checker = TypeChecker::new(); + checker + .check_types(&program) + .unwrap_or_else(|error| panic!("program should type-check: {error:?}")); + checker + .analyzer + .get_symbol(name) + .and_then(|symbol| symbol.symbol_type.clone()) + .unwrap_or_else(|| panic!("symbol {name:?} should have a type")) + } + + fn list_of(element: Type) -> Type { + Type::List(Box::new(element)) + } + + #[test] + fn clear_through_may_alias_preserves_unselected_descendant_type_effects() { + let leaf_type = typecheck_symbol_type( + r#" +store leaf_b as [1] +store leaf_c as [1] +store b as [leaf_b] +store c as [leaf_c] +store selected as b +store choose_c as yes +check if choose_c: + change selected to c +end check +clear selected +push with b[0] and "text" +"#, + "leaf_b", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn known_action_map_argument_escapes_nested_list_alias_type() { + let leaf_type = typecheck_symbol_type( + r#" +define action called append_text with parameters wrapper: + push with wrapper["items"] and "text" +end action +store leaf as [1] +create map wrapper: + "items" is leaf +end map +call append_text with wrapper +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn known_action_nested_list_argument_escapes_every_alias_depth_type() { + let leaf_type = typecheck_symbol_type( + r#" +define action called append_text with parameters wrapper: + push with wrapper[0] and "text" +end action +store leaf as [1] +store wrapper as [leaf] +call append_text with wrapper +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn returned_map_carries_captured_nested_list_type_effect() { + let leaf_type = typecheck_symbol_type( + r#" +store leaf as [1] +define action called expose: + create map result: + "items" is leaf + end map + return result +end action +store exposed as call expose +push with exposed["items"] and "text" +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn projection_reassignment_rebases_descendant_alias_type_effects() { + let leaf_type = typecheck_symbol_type( + r#" +store leaf as [1] +store outer as [0 and [leaf]] +change outer to pop of outer +push with outer[0] and "text" +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn gradual_add_records_inserted_list_alias_type_effect() { + let leaf_type = typecheck_symbol_type( + r#" +store leaf as [1] +store target as parse_json of "[]" +add leaf to target +push with target[0] and "text" +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn implicit_shared_return_through_try_carries_captured_type_effect() { + let leaf_type = typecheck_symbol_type( + r#" +store leaf as [1] +define action called expose: + try: + leaf + when error: + leaf + end try +end action +store exposed as call expose +push with exposed and "text" +"#, + "leaf", + ); + assert_eq!(leaf_type, list_of(Type::Any)); + } + + #[test] + fn optional_joins_preserve_the_known_nothing_path() { + let optional_text = Type::Optional(Box::new(Type::Text)); + for (other, expected) in [ + (Type::Text, optional_text.clone()), + ( + Type::Optional(Box::new(Type::Number)), + Type::Optional(Box::new(Type::Any)), + ), + (Type::Unknown, Type::Optional(Box::new(Type::Unknown))), + (Type::Any, Type::Optional(Box::new(Type::Any))), + (Type::Nothing, optional_text.clone()), + ] { + assert_eq!( + TypeChecker::join_inferred_types(optional_text.clone(), other), + expected + ); + } + + assert_eq!( + TypeChecker::join_inferred_types( + Type::List(Box::new(optional_text.clone())), + Type::List(Box::new(Type::Text)), + ), + Type::List(Box::new(optional_text)), + ); + assert_eq!( + TypeChecker::join_inferred_types(Type::Nothing, Type::Text), + Type::Optional(Box::new(Type::Text)), + ); + } + #[test] fn test_header_map_type_requires_text_keys() { // HTTP header names must be text. The header-map validity check (shared by @@ -6637,4 +11962,36 @@ end "void static method should be refined to Nothing, not left as the Unknown seed" ); } + + #[test] + fn opaque_method_calls_escape_captured_mutable_scalars() { + let code = r#" +store captured_number as 1 +create container Mutator: + action reset: + change captured_number to nothing + end +end +create new Mutator as mutator: +end +mutator.reset() +"#; + let tokens = crate::lexer::lex_wfl_with_positions(code); + let program = crate::parser::Parser::new(&tokens) + .parse() + .expect("program should parse"); + let mut checker = TypeChecker::new(); + checker + .check_types(&program) + .expect("the opaque call is gradual rather than a static rejection"); + + assert_eq!( + checker + .analyzer + .get_symbol("captured_number") + .and_then(|symbol| symbol.symbol_type.clone()), + Some(Type::Any), + "a method can rebind a captured mutable scalar, so its old Number type is stale" + ); + } } diff --git a/tests/action_return_type_residuals_test.rs b/tests/action_return_type_residuals_test.rs index e66b0145..c36b618a 100644 --- a/tests/action_return_type_residuals_test.rs +++ b/tests/action_return_type_residuals_test.rs @@ -13,6 +13,8 @@ //! unannotated and never refined, so `instance.method()` results hit the //! same false diagnostic. +use wfl::interpreter::Interpreter; +use wfl::interpreter::value::Value; use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; use wfl::typechecker::TypeChecker; @@ -35,6 +37,80 @@ fn assert_typechecks_clean(code: &str) { ); } +fn assert_type_error_contains(code: &str, needle: &str) { + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("Should parse"); + + let errors = TypeChecker::new() + .check_types(&program) + .expect_err("Expected a type error") + .into_diagnostics(); + assert!( + errors.iter().any(|error| error.message.contains(needle)), + "Expected a type error containing {needle:?}, got: {errors:?}" + ); +} + +async fn interpret_result(code: &str) -> Value { + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("Should parse"); + + TypeChecker::new() + .check_types(&program) + .expect("Program should type-check cleanly"); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .expect("Program should execute successfully"); + + interpreter + .global_env() + .borrow() + .get("result") + .expect("Program should define result") +} + +#[test] +fn literal_false_repeat_while_infers_nothing_completion() { + assert_type_error_contains( + r#" +define action called never_runs: + repeat while no: + 42 + end repeat +end action + +store result as call never_runs +close result +"#, + "Expected a file or stream handle", + ); +} + +#[tokio::test] +async fn repeat_while_returns_its_last_body_value_at_runtime() { + let result = interpret_result( + r#" +define action called run_once: Number: + store should_continue as yes + repeat while should_continue: + change should_continue to no + 42 + end repeat +end action + +store result as call run_once +"#, + ) + .await; + + assert_eq!(result, Value::Number(42.0)); +} + /// An action whose only `return`s are inside a `try:` body / `when error` /// clause must infer its return type from them, not default to `Nothing`. #[test] @@ -132,3 +208,396 @@ display x0 "#, ); } + +#[test] +fn exhaustive_return_makes_following_returns_unreachable() { + assert_type_error_contains( + r#" +define action called choose with parameters flag: + check if flag: + return 1 + otherwise: + return 2 + end check + return "dead" +end action + +store result as call choose with yes +close result +"#, + "Expected a file or stream handle", + ); +} + +#[test] +fn definitely_returning_finally_overrides_primary_return_type() { + assert_type_error_contains( + r#" +define action called final_number: + try: + return "primary" + finally: + return 42 + end try +end action + +create directory at call final_number +"#, + "directory path", + ); + + assert_type_error_contains( + r#" +define action called final_text: + try: + return 42 + finally: + return "final" + end try +end action + +store invalid as (call final_text) minus 1 +"#, + "Cannot perform Minus", + ); +} + +#[test] +fn partial_action_return_does_not_satisfy_a_text_builtin() { + assert_type_error_contains( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +store label as call maybe_label with no +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn partial_instance_method_return_does_not_satisfy_a_text_builtin() { + assert_type_error_contains( + r#" +create container Labeler: + action maybe_label needs enabled: Boolean: + check if enabled: + return "ready" + end check + end +end + +create new Labeler as labeler: +end +store label as labeler.maybe_label(no) +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn partial_static_method_return_does_not_satisfy_a_text_builtin() { + assert_type_error_contains( + r#" +create container Labels: + static action maybe_label needs enabled: Boolean: + check if enabled: + return "ready" + end check + end +end + +store label as Labels.maybe_label(no) +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn nested_partial_return_stays_optional_through_return_join() { + assert_type_error_contains( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +define action called wrapped_label with parameters use_partial: + check if use_partial: + return call maybe_label with yes + otherwise: + return "fallback" + end check +end action + +store label as call wrapped_label with yes +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn partial_return_stays_optional_in_list_element_join() { + assert_type_error_contains( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +store first_label as call maybe_label with no +store labels as [first_label and "fallback"] +store invalid as touppercase of labels[0] +"#, + "expected Text", + ); +} + +#[test] +fn partial_return_stays_optional_across_if_binding_join() { + assert_type_error_contains( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +store flag as yes +check if flag: + store label as call maybe_label with no +otherwise: + store label as "fallback" +end check +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn explicit_nothing_return_stays_optional() { + assert_type_error_contains( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + otherwise: + return nothing + end check +end action + +store label as call maybe_label with no +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn nothing_checks_narrow_optional_values_inside_the_guarded_branch() { + assert_typechecks_clean( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +store first_label as call maybe_label with yes +check if first_label is not nothing: + store upper_first as touppercase of first_label +end check + +store second_label as call maybe_label with yes +check if isnothing of second_label: + display "missing" +otherwise: + store upper_second as touppercase of second_label +end check +"#, + ); +} + +#[test] +fn terminating_nothing_guard_narrows_the_continuation() { + assert_typechecks_clean( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +define action called guarded_label with parameters enabled: + store label as call maybe_label with enabled + check if label is nothing: + return "missing" + end check + return touppercase of label +end action + +store result as call guarded_label with yes +display result +"#, + ); +} + +#[test] +fn exit_path_does_not_make_an_action_return_optional() { + assert_typechecks_clean( + r#" +define action called label_or_exit with parameters enabled: + check if enabled: + return "ready" + otherwise: + exit + end check +end action + +store label as call label_or_exit with yes +store upper as touppercase of label +display upper +"#, + ); +} + +#[test] +fn return_type_is_captured_at_the_return_program_point() { + assert_type_error_contains( + r#" +define action called early_number: + store values as [1] + return pop of values + push with values and "dead" +end action + +store result as call early_number +store invalid as touppercase of result +"#, + "expected Text", + ); +} + +#[test] +fn literal_true_return_path_does_not_join_an_unreachable_else_return() { + assert_type_error_contains( + r#" +define action called definite_number: + check if yes: + return 1 + otherwise: + return "unreachable" + end check +end action + +store result as call definite_number +store invalid as touppercase of result +"#, + "expected Text", + ); +} + +#[test] +fn terminating_try_guard_narrows_the_continuation() { + assert_typechecks_clean( + r#" +define action called maybe_label with parameters enabled: + check if enabled: + return "ready" + end check +end action + +define action called guarded_label with parameters enabled: + store label as call maybe_label with enabled + check if label is nothing: + try: + return "missing" + finally: + display "cleanup" + end try + end check + return touppercase of label +end action + +store result as call guarded_label with yes +display result +"#, + ); +} + +#[test] +fn ambiguous_overload_join_preserves_an_optional_return() { + assert_type_error_contains( + r#" +define action called choose_label with parameters value as number: + check if value is greater than 0: + return "positive" + end check +end action + +define action called choose_label with parameters value as text: + return value +end action + +store selector as nothing +store label as choose_label of selector +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn implicit_expression_fallthrough_is_the_action_result() { + assert_typechecks_clean( + r#" +define action called label: + "ready" +end action + +store upper as touppercase of call label +display upper +"#, + ); +} + +#[test] +fn explicit_and_implicit_action_results_join_gradually() { + assert_typechecks_clean( + r#" +define action called mixed_result with parameters choose_number as boolean: + check if choose_number: + return 1 + end check + "text" +end action + +store result as call mixed_result with no +check if result is not nothing: + store upper as touppercase of result +end check +"#, + ); +} + +#[test] +fn annotated_action_checks_its_implicit_result() { + assert_type_error_contains( + r#" +define action called mislabeled: Number: + "text" +end action +"#, + "implicit result", + ); +} diff --git a/tests/container_parsing_fixes.rs b/tests/container_parsing_fixes.rs index 47478a9f..ba2d5d94 100644 --- a/tests/container_parsing_fixes.rs +++ b/tests/container_parsing_fixes.rs @@ -3,6 +3,7 @@ use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; +use wfl::parser::ast::{Statement, Type}; #[test] fn test_container_action_without_return_type_should_parse() { @@ -97,3 +98,208 @@ end result.err() ); } + +#[test] +fn lowercase_date_and_time_types_parse_in_action_parameters() { + let source = r#" +define action called inspect with parameters day as date and clock as time and instant as datetime: + display day +end action +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("temporal annotations should parse"); + let Statement::ActionDefinition { parameters, .. } = &program.statements[0] else { + panic!("expected an action definition"); + }; + assert_eq!( + parameters + .iter() + .map(|parameter| parameter.param_type.clone()) + .collect::>(), + vec![ + Some(Type::Date), + Some(Type::Time), + Some(Type::Custom("datetime".to_string())), + ] + ); +} + +#[test] +fn lowercase_date_and_time_types_parse_in_container_methods() { + let source = r#" +create container Clock: + action set needs day: date, clock: time: + display day + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("temporal annotations should parse"); + let Statement::ContainerDefinition { methods, .. } = &program.statements[0] else { + panic!("expected a container definition"); + }; + let Statement::ActionDefinition { parameters, .. } = &methods[0] else { + panic!("expected a container method"); + }; + assert_eq!( + parameters + .iter() + .map(|parameter| parameter.param_type.clone()) + .collect::>(), + vec![Some(Type::Date), Some(Type::Time),] + ); +} + +#[test] +fn lowercase_temporal_types_parse_for_container_properties_and_returns() { + let source = r#" +create container Clock: + property day: date + property clock: time + + action get_day: date + return today + end + + action get_clock: time + return now + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser + .parse() + .expect("temporal property and return annotations should parse"); + let Statement::ContainerDefinition { + properties, + methods, + .. + } = &program.statements[0] + else { + panic!("expected a container definition"); + }; + assert_eq!( + properties + .iter() + .map(|property| property.property_type.clone()) + .collect::>(), + vec![Some(Type::Date), Some(Type::Time),] + ); + assert_eq!( + methods + .iter() + .map(|method| match method { + Statement::ActionDefinition { return_type, .. } => return_type.clone(), + other => panic!("expected action definition, got {other:?}"), + }) + .collect::>(), + vec![Some(Type::Date), Some(Type::Time),] + ); +} + +#[test] +fn colon_style_lowercase_primitive_names_remain_custom_types() { + let source = r#" +create container Holder: + property numeric: number + property logical: bOoLeAn + + action accept needs numeric_value: number, logical_value: bOoLeAn: + display numeric_value + end +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser + .parse() + .expect("historical lowercase custom annotations should parse"); + let Statement::ContainerDefinition { + properties, + methods, + .. + } = &program.statements[0] + else { + panic!("expected a container definition"); + }; + assert_eq!( + properties + .iter() + .map(|property| property.property_type.clone()) + .collect::>(), + vec![ + Some(Type::Custom("number".to_string())), + Some(Type::Custom("bOoLeAn".to_string())), + ] + ); + let Statement::ActionDefinition { parameters, .. } = &methods[0] else { + panic!("expected a container action"); + }; + assert_eq!( + parameters + .iter() + .map(|parameter| parameter.param_type.clone()) + .collect::>(), + vec![ + Some(Type::Custom("number".to_string())), + Some(Type::Custom("bOoLeAn".to_string())), + ] + ); +} + +#[test] +fn list_property_annotations_produce_real_list_types() { + let source = r#" +create container Collections: + property anything: List + property labels: List of Text + property groups: List of List of Number +end +"#; + let program = Parser::new(&lex_wfl_with_positions(source)) + .parse() + .expect("documented colon-style list property annotations should parse"); + let Statement::ContainerDefinition { properties, .. } = &program.statements[0] else { + panic!("expected a container definition"); + }; + assert_eq!( + properties + .iter() + .map(|property| property.property_type.clone()) + .collect::>(), + vec![ + Some(Type::List(Box::new(Type::Any))), + Some(Type::List(Box::new(Type::Text))), + Some(Type::List(Box::new(Type::List(Box::new(Type::Number))))), + ] + ); +} + +#[test] +fn typed_list_property_contract_is_reachable_from_source() { + let source = r#" +create container Messages: + property items: List of Text defaults [] + + action corrupt: + push with items and 1 + end +end +"#; + let program = Parser::new(&lex_wfl_with_positions(source)) + .parse() + .expect("typed list property should parse"); + let diagnostics = wfl::typechecker::TypeChecker::new() + .check_types(&program) + .expect_err("the parsed List of Text contract must reject a Number insertion") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.message.contains("items") && diagnostic.message.contains("Text") + }), + "expected the source-level property contract diagnostic: {diagnostics:?}" + ); +} diff --git a/tests/fixer_return_type_roundtrip_test.rs b/tests/fixer_return_type_roundtrip_test.rs new file mode 100644 index 00000000..2dd0e1ff --- /dev/null +++ b/tests/fixer_return_type_roundtrip_test.rs @@ -0,0 +1,74 @@ +use wfl::fixer::CodeFixer; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::{Program, Statement, Type}; + +#[test] +fn compound_action_return_types_survive_fix_and_reparse() { + let cases = [ + (Type::List(Box::new(Type::Text)), "produce: List of Text:"), + ( + Type::Map(Box::new(Type::Text), Box::new(Type::Binary)), + "produce: Map of Text to Binary:", + ), + ( + Type::Optional(Box::new(Type::List(Box::new(Type::Number)))), + "produce: Optional of List of Number:", + ), + ( + Type::List(Box::new(Type::Optional(Box::new(Type::Number)))), + "produce: List of Optional of Number:", + ), + (Type::Custom("datetime".to_string()), "produce: datetime:"), + ]; + + for (expected_type, expected_source) in cases { + let program = Program { + statements: vec![Statement::ActionDefinition { + name: "produce".to_string(), + parameters: vec![], + body: vec![], + return_type: Some(expected_type.clone()), + line: 1, + column: 1, + }], + }; + + let (fixed_code, _) = CodeFixer::new().fix(&program, ""); + assert!( + fixed_code.contains(expected_source), + "fixer emitted an unexpected compound return type: {fixed_code}" + ); + + let reparsed = Parser::new(&lex_wfl_with_positions(&fixed_code)) + .parse() + .unwrap_or_else(|error| { + panic!("fixed compound return type must reparse: {fixed_code}\n{error:?}") + }); + let Statement::ActionDefinition { return_type, .. } = &reparsed.statements[0] else { + panic!("expected an action definition after fixing"); + }; + assert_eq!( + return_type, + &Some(expected_type), + "fixed return annotation changed type: {fixed_code}" + ); + } +} + +#[test] +fn returns_inside_a_legacy_multi_word_action_name_is_not_an_annotation() { + let source = "define action called calculate returns schedule:\nend action"; + let program = Parser::new(&lex_wfl_with_positions(source)) + .parse() + .expect("legacy multi-word action name must parse"); + let Statement::ActionDefinition { + name, return_type, .. + } = &program.statements[0] + else { + panic!("expected an action definition"); + }; + + assert_eq!(name, "calculate returns schedule"); + assert_eq!(return_type, &None); +} diff --git a/tests/nothing_reassign_widen_test.rs b/tests/nothing_reassign_widen_test.rs index e277d4d8..a4d5ab1f 100644 --- a/tests/nothing_reassign_widen_test.rs +++ b/tests/nothing_reassign_widen_test.rs @@ -127,6 +127,33 @@ change x to "hello" ); } +#[test] +fn test_change_to_nothing_updates_the_flow_type() { + assert_type_error_contains( + r#" +store label as "ready" +change label to nothing +store invalid as touppercase of label +"#, + "expected Text", + ); +} + +#[test] +fn test_conditional_change_to_nothing_is_optional() { + assert_type_error_contains( + r#" +store label as "ready" +store flag as yes +check if flag: + change label to nothing +end check +store invalid as touppercase of label +"#, + "expected Text", + ); +} + /// Defining an action that would widen an outer Nothing binding must not /// permanently refine that outer binding — the action may never be called /// (PR #606 Codex review). diff --git a/tests/open_file_local_type_test.rs b/tests/open_file_local_type_test.rs index 0fad03b0..6cd4aa4b 100644 --- a/tests/open_file_local_type_test.rs +++ b/tests/open_file_local_type_test.rs @@ -68,8 +68,9 @@ fn fresh_local_file_handles_are_concrete_in_action_loop_and_method_scopes() { #[test] fn reconstructed_local_file_type_does_not_retype_an_outer_visible_binding() { // The type checker must reconstruct `out` as File while checking the loop, - // then expose the original outer Text binding after leaving that scope. - let source = "store out as \"outer.txt\"\n\ + // then expose the original outer Number binding after leaving that scope. + // Text is intentionally accepted as an opaque runtime file-handle ID. + let source = "store out as 42\n\ main loop:\n\ \x20\x20\x20\x20open file at \"inner.txt\" for writing as out\n\ \x20\x20\x20\x20close out\n\ @@ -77,10 +78,10 @@ fn reconstructed_local_file_type_does_not_retype_an_outer_visible_binding() { end loop\n\ close out\n"; let errors = - typecheck(source).expect_err("the outer Text binding must remain Text after the loop"); + typecheck(source).expect_err("the outer Number binding must remain Number after the loop"); assert!( errors.contains("file or stream handle") || errors.contains("File"), - "expected the outer Text/handle diagnostic, got: {errors}" + "expected the outer Number/handle diagnostic, got: {errors}" ); } diff --git a/tests/overload_test.rs b/tests/overload_test.rs index 3663df7d..70086d22 100644 --- a/tests/overload_test.rs +++ b/tests/overload_test.rs @@ -467,6 +467,50 @@ mod typechecker { ); } + #[test] + fn branded_date_overload_wins_static_inference_in_both_definition_orders() { + for (order, definitions) in [ + ( + "historical annotation first", + r#" + define action called classify with parameters value as Date: + return 1 + end action + + define action called classify with parameters value as date: + return "temporal" + end action + "#, + ), + ( + "branded annotation first", + r#" + define action called classify with parameters value as date: + return "temporal" + end action + + define action called classify with parameters value as Date: + return 1 + end action + "#, + ), + ] { + let code = format!( + "{definitions} + store result as classify of today + store invalid as result times 2 + display invalid + " + ); + let errors = typecheck_errors(&code); + assert!( + errors.iter().any(|error| error.contains("Cannot perform")), + "{order}: the known Date argument must infer the branded overload's Text return; \ + got {errors:?}" + ); + } + } + #[test] fn forward_reference_to_later_overload() { // PASS 1 registers all top-level signatures before checking, so a call @@ -1053,6 +1097,186 @@ mod full_pipeline { } } + #[tokio::test] + async fn temporal_values_match_temporal_overload_annotations() { + let interpreter = run_pipeline( + r#" +define action called classify_date with parameters value as Date: + return "date" +end action +define action called classify_date with parameters value as Text: + return "text" +end action + +define action called classify_time with parameters value as Time: + return "time" +end action +define action called classify_time with parameters value as Text: + return "text" +end action + +define action called classify_datetime with parameters value as DateTime: + return "datetime" +end action +define action called classify_datetime with parameters value as Text: + return "text" +end action + +store date_result as classify_date of today +store time_result as classify_time of now +store datetime_result as classify_datetime of datetime_now +"#, + ) + .await + .expect("temporal runtime values should select temporal overloads"); + + assert_eq!(global_text(&interpreter, "date_result"), "date"); + assert_eq!(global_text(&interpreter, "time_result"), "time"); + assert_eq!(global_text(&interpreter, "datetime_result"), "datetime"); + } + + #[tokio::test] + async fn same_named_date_container_still_matches_user_annotation() { + let interpreter = run_pipeline( + r#" +create container Date: +end + +define action called classify with parameters value as Date: + return "date annotation" +end action +define action called classify with parameters value as Text: + return "text" +end action + +create new Date as date_container: +end +store result as classify of date_container +"#, + ) + .await + .expect("a historical Date container annotation must keep working"); + assert_eq!(global_text(&interpreter, "result"), "date annotation"); + } + + #[tokio::test] + async fn lowercase_datetime_container_annotation_remains_container_compatible() { + let interpreter = run_pipeline( + r#" +create container datetime: +end + +define action called classify with parameters value as datetime: + return "datetime container" +end action +define action called classify with parameters value as Text: + return "text" +end action + +create new datetime as datetime_container: +end +store result as classify of datetime_container +"#, + ) + .await + .expect("the historical lowercase datetime container annotation must keep working"); + assert_eq!(global_text(&interpreter, "result"), "datetime container"); + } + + #[tokio::test] + async fn historical_temporal_annotations_can_feed_temporal_builtins_when_unambiguous() { + let interpreter = run_pipeline( + r#" +create container DATE: +end + +define action called render with parameters value as Date: + return format_date of value and "%Y-%m-%d" +end action + +store result as render of today +"#, + ) + .await + .expect( + "a Date annotation without a same-named container is an unambiguous temporal value", + ); + assert_eq!(global_text(&interpreter, "result").len(), 10); + } + + #[test] + fn unrelated_include_keeps_historical_temporal_contract_gradual() { + let code = r#" +include from "unrelated.wfl" + +define action called render with parameters value as Date: + return format_date of value and "%Y-%m-%d" +end action +"#; + let tokens = lex_wfl_with_positions(code); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("parse"); + + let mut analyzer = Analyzer::new(); + analyzer + .analyze(&program) + .expect("an unrelated include must not make Date internally incompatible"); + TypeChecker::with_analyzer(analyzer) + .check_types(&program) + .expect("an unknown include should defer temporal ambiguity to runtime"); + } + + #[tokio::test] + async fn branded_date_overloads_outrank_historical_annotations_in_both_definition_orders() { + let interpreter = run_pipeline( + r#" +create container Date: +end + +define action called historical_first with parameters value as Date: + return "container" +end action +define action called historical_first with parameters value as date: + return "temporal" +end action + +define action called branded_first with parameters value as date: + return "temporal" +end action +define action called branded_first with parameters value as Date: + return "container" +end action + +create new Date as date_container: +end + +store historical_first_temporal as historical_first of today +store branded_first_temporal as branded_first of today +store historical_first_container as historical_first of date_container +store branded_first_container as branded_first of date_container +"#, + ) + .await + .expect("temporal and same-named container overload calls should both resolve"); + + assert_eq!( + global_text(&interpreter, "historical_first_temporal"), + "temporal" + ); + assert_eq!( + global_text(&interpreter, "branded_first_temporal"), + "temporal" + ); + assert_eq!( + global_text(&interpreter, "historical_first_container"), + "container" + ); + assert_eq!( + global_text(&interpreter, "branded_first_container"), + "container" + ); + } + #[tokio::test] async fn container_typed_overloads_dispatch() { let interp = run_pipeline( @@ -1650,11 +1874,12 @@ end action ); } - // Round 4, finding 2: temporal dispatch enforcement must reach tests - // nested under `describe`. An interleaved wrong-type call between two - // same-block definitions must be rejected, not run the wrong body. - #[tokio::test] - async fn describe_nested_interleaved_call_rejected() { + // Round 4, finding 2: action visibility and type enforcement must reach + // tests nested under `describe`. The first definition is visible at the + // interleaved call, so the analyzer can reject the wrong argument before + // the test runner ever executes the wrong body. + #[test] + fn describe_nested_interleaved_call_rejected() { let code = r#" describe "temporal": test "interleaved call": @@ -1672,27 +1897,16 @@ end describe let mut parser = Parser::new(&tokens); let program = parser.parse().expect("parse"); - let mut analyzer = Analyzer::new(); - analyzer.analyze(&program).expect("analyze"); - let mut checker = TypeChecker::new(); - checker.check_types(&program).expect("typecheck"); - - let mut interpreter = Interpreter::new(); - interpreter.set_test_mode(true); - interpreter.interpret(&program).await.expect("interpret"); - - let results = interpreter.get_test_results(); - assert_eq!( - results.failed_tests, 1, - "the interleaved call inside a describe-nested test must fail" - ); + let errors = Analyzer::new() + .analyze(&program) + .expect_err("the visible number overload must reject a text argument"); assert!( - results - .failures - .first() - .is_some_and(|f| f.assertion_message.contains("expects")), - "the failure must be the temporal dispatch rejection: {:?}", - results.failures + errors.iter().any(|error| { + error.message.contains("choose") + && error.message.contains("expects Number") + && error.message.contains("got Text") + }), + "the nested call should receive the normal action contract diagnostic: {errors:?}" ); } diff --git a/tests/static_container_member_test.rs b/tests/static_container_member_test.rs new file mode 100644 index 00000000..3aabf30b --- /dev/null +++ b/tests/static_container_member_test.rs @@ -0,0 +1,441 @@ +use wfl::interpreter::Interpreter; +use wfl::interpreter::value::Value; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::{ + Parser, + ast::{Argument, Expression, Literal, Program, Statement}, +}; +use wfl::typechecker::TypeChecker; + +fn parse(source: &str) -> Program { + let tokens = lex_wfl_with_positions(source); + Parser::new(&tokens) + .parse() + .unwrap_or_else(|error| panic!("parse failed: {error:?}")) +} + +fn typecheck(source: &str) -> Result { + let program = parse(source); + TypeChecker::new() + .check_types(&program) + .map_err(|error| { + error + .into_diagnostics() + .into_iter() + .map(|diagnostic| diagnostic.message) + .collect::>() + .join("; ") + }) + .map(|()| program) +} + +#[tokio::test] +async fn parsed_static_properties_and_methods_work_end_to_end() { + let program = typecheck( + r#" +create container Counter: + static property total: Number defaults 41 + + static action answer: Number + return total plus 1 + end + + static action increment: Number + change total to total plus 1 + return total + end +end + +store property_value as Counter.total +store method_value as Counter.answer() +store incremented_value as Counter.increment() +store persisted_value as Counter.total +"#, + ) + .unwrap_or_else(|error| panic!("typecheck failed: {error}")); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| panic!("runtime failed: {errors:?}")); + + for name in [ + "property_value", + "method_value", + "incremented_value", + "persisted_value", + ] { + assert_eq!( + interpreter.global_env().borrow().get(name), + Some(Value::Number(if name == "property_value" { + 41.0 + } else { + 42.0 + })) + ); + } +} + +#[tokio::test] +async fn inherited_static_members_match_the_static_checker() { + let program = typecheck( + r#" +create container Parent: + static property base_value: Number defaults 7 + + static action base_answer: Number + return base_value + end +end + +create container Child extends Parent: +end + +store inherited_property as Child.base_value +store inherited_method as Child.base_answer() +"#, + ) + .unwrap_or_else(|error| panic!("typecheck failed: {error}")); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| panic!("runtime failed: {errors:?}")); + + for name in ["inherited_property", "inherited_method"] { + assert_eq!( + interpreter.global_env().borrow().get(name), + Some(Value::Number(7.0)) + ); + } +} + +#[tokio::test] +async fn legacy_static_member_ast_uses_the_same_inheritance_rules() { + let mut program = parse( + r#" +create container Parent: + static property base_value: Number defaults 7 +end + +create container Child extends Parent: +end +"#, + ); + program.statements.push(Statement::VariableDeclaration { + name: "legacy_value".to_string(), + value: Expression::StaticMemberAccess { + container: "Child".to_string(), + member: "base_value".to_string(), + line: 8, + column: 1, + }, + is_constant: false, + line: 8, + column: 1, + }); + TypeChecker::new() + .check_types(&program) + .expect("legacy static-member AST should follow inheritance"); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| panic!("runtime failed: {errors:?}")); + assert_eq!( + interpreter.global_env().borrow().get("legacy_value"), + Some(Value::Number(7.0)) + ); +} + +#[test] +fn static_property_defaults_follow_their_declared_types() { + let error = typecheck( + r#" +create container Counter: + static property total: Number defaults "not a number" +end +"#, + ) + .expect_err("a mismatched static default must be rejected"); + assert!( + error.contains("Default value type"), + "expected a static-property default diagnostic, got: {error}" + ); +} + +#[test] +fn static_methods_cannot_read_instance_properties_as_bare_names() { + let error = typecheck( + r#" +create container Counter: + property instance_total: Number defaults 1 + + static action invalid: Number + return instance_total + end +end +"#, + ) + .expect_err("a static method has no instance property environment"); + assert!( + error.contains("instance_total"), + "expected an undefined instance-property diagnostic, got: {error}" + ); +} + +#[tokio::test] +async fn stored_static_method_reference_keeps_environment_and_persists_mutation() { + let mut program = parse( + r#" +create container Counter: + static property total: Number defaults 0 + + static action increment_by needs amount: Number: Number + change total to total plus amount + return total + end +end +"#, + ); + program.statements.extend([ + Statement::VariableDeclaration { + name: "increment_counter".into(), + value: Expression::StaticMemberAccess { + container: "Counter".into(), + member: "increment_by".into(), + line: 1, + column: 1, + }, + is_constant: false, + line: 1, + column: 1, + }, + Statement::VariableDeclaration { + name: "direct_value".into(), + value: Expression::MethodCall { + object: Box::new(Expression::Variable("Counter".into(), 1, 1)), + method: "increment_by".into(), + arguments: vec![Argument { + name: None, + value: Expression::Literal(Literal::Integer(1), 1, 1), + }], + line: 1, + column: 1, + }, + is_constant: false, + line: 1, + column: 1, + }, + Statement::VariableDeclaration { + name: "incremented_value".into(), + value: Expression::FunctionCall { + function: Box::new(Expression::Variable("increment_counter".into(), 1, 1)), + arguments: vec![Argument { + name: None, + value: Expression::Literal(Literal::Integer(1), 1, 1), + }], + line: 1, + column: 1, + }, + is_constant: false, + line: 1, + column: 1, + }, + Statement::VariableDeclaration { + name: "persisted_value".into(), + value: Expression::PropertyAccess { + object: Box::new(Expression::Variable("Counter".into(), 1, 1)), + property: "total".into(), + line: 1, + column: 1, + }, + is_constant: false, + line: 1, + column: 1, + }, + ]); + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| panic!("runtime failed: {errors:?}")); + + assert_eq!( + interpreter.global_env().borrow().get("direct_value"), + Some(Value::Number(1.0)) + ); + for name in ["incremented_value", "persisted_value"] { + assert_eq!( + interpreter.global_env().borrow().get(name), + Some(Value::Number(2.0)), + "{name} should observe both the direct and stored-reference mutations" + ); + } +} + +#[tokio::test] +async fn reentrant_static_method_calls_share_the_latest_property_state() { + let program = parse( + r#" +create container Counter: + static property total: Number defaults 0 + + static action increment: Number + change total to total plus 1 + return total + end + + static action set_then_increment: Number + change total to 5 + store nested_value as Counter.increment() + return total + end +end + +store returned_value as Counter.set_then_increment() +store persisted_value as Counter.total +"#, + ); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| panic!("runtime failed: {errors:?}")); + + for name in ["returned_value", "persisted_value"] { + assert_eq!( + interpreter.global_env().borrow().get(name), + Some(Value::Number(6.0)), + "{name} should include the nested static method's increment" + ); + } +} + +#[tokio::test] +async fn static_property_mutations_persist_when_a_direct_call_errors() { + let program = parse( + r#" +create container Counter: + static property total: Number defaults 0 + + static action mutate_then_fail: + change total to 7 + store failure as 1 divided by 0 + end +end + +Counter.mutate_then_fail() +"#, + ); + let mut interpreter = Interpreter::new(); + assert!( + interpreter.interpret(&program).await.is_err(), + "the method's deliberate divide-by-zero should escape" + ); + + let Some(Value::ContainerDefinition(counter)) = + interpreter.global_env().borrow().get("Counter") + else { + panic!("Counter definition should remain available after the error"); + }; + assert_eq!( + counter.static_properties.borrow().get("total"), + Some(&Value::Number(7.0)), + "mutations completed before an error must not be rolled back" + ); +} + +#[tokio::test] +async fn instance_property_mutations_persist_when_a_method_errors() { + let program = parse( + r#" +create container Counter: + property total: Number defaults 0 + + action mutate_then_fail: + change total to 5 + store failure as 1 divided by 0 + end +end + +create new Counter as counter: +end +counter.mutate_then_fail() +"#, + ); + let mut interpreter = Interpreter::new(); + assert!( + interpreter.interpret(&program).await.is_err(), + "the method's deliberate divide-by-zero should escape" + ); + + let Some(Value::ContainerInstance(counter)) = interpreter.global_env().borrow().get("counter") + else { + panic!("Counter instance should remain available after the error"); + }; + assert_eq!( + counter.borrow().properties.get("total"), + Some(&Value::Number(5.0)), + "mutations completed before an error must not be rolled back" + ); +} + +#[tokio::test] +async fn static_property_mutations_persist_when_a_stored_call_errors() { + let program = parse( + r#" +create container Counter: + static property total: Number defaults 0 + + static action mutate_then_fail: + change total to 9 + store failure as 1 divided by 0 + end +end + +store failer as Counter.mutate_then_fail +store ignored as failer +"#, + ); + let mut interpreter = Interpreter::new(); + assert!( + interpreter.interpret(&program).await.is_err(), + "the stored method's deliberate divide-by-zero should escape" + ); + + let Some(Value::ContainerDefinition(counter)) = + interpreter.global_env().borrow().get("Counter") + else { + panic!("Counter definition should remain available after the error"); + }; + assert_eq!( + counter.static_properties.borrow().get("total"), + Some(&Value::Number(9.0)), + "first-class static methods must persist pre-error mutations" + ); +} + +#[test] +fn stored_zero_argument_static_method_auto_calls_to_its_result_type() { + typecheck( + r#" +create container Counter: + static action answer: Number + return 42 + end +end + +store getter as Counter.answer +store value as getter +store incremented as value plus 1 +"#, + ) + .unwrap_or_else(|error| { + panic!("stored zero-argument static method should infer Number: {error}") + }); +} diff --git a/tests/stream_handle_type_test.rs b/tests/stream_handle_type_test.rs index 7420060c..9696ee8c 100644 --- a/tests/stream_handle_type_test.rs +++ b/tests/stream_handle_type_test.rs @@ -112,6 +112,25 @@ fn test_outbound_stream_handle_dot_access_typechecks() { ); } +#[test] +fn test_outbound_stream_known_fields_keep_their_concrete_types() { + for expression in [ + "create directory at upstream.status", + "store invalid as upstream.ok minus 1", + "store invalid as upstream.headers[\"content-type\"] minus 1", + ] { + let code = format!( + "open url at \"http://example.com\" and stream response as upstream\n\ + {expression}\n\ + close upstream" + ); + assert!( + typecheck(&code).is_err(), + "the fixed HttpStream schema must reject invalid use of {expression}" + ); + } +} + #[test] fn test_stream_handle_numeric_index_is_rejected() { // Runtime object indexing requires a text field name; a numeric key must be a @@ -154,6 +173,23 @@ fn test_wait_for_next_from_http_stream_is_ok() { ); } +#[test] +fn test_wait_for_next_result_requires_a_nothing_guard() { + for (verb, consumer) in [ + ("line", "store invalid as touppercase of item"), + ("chunk", "store invalid as item minus 1"), + ] { + let code = format!( + "open url at \"http://example.com\" and stream response as up\n\ + wait for next {verb} from up as item\n\ + {consumer}\n\ + close up" + ); + typecheck(&code) + .expect_err("end-of-stream produces Nothing, so an unguarded definite use is invalid"); + } +} + #[test] fn test_flush_non_stream_is_rejected() { // `flush ` requires a server response-stream handle. diff --git a/tests/typechecker_alias_provenance_residual_test.rs b/tests/typechecker_alias_provenance_residual_test.rs new file mode 100644 index 00000000..6e8ff4cd --- /dev/null +++ b/tests/typechecker_alias_provenance_residual_test.rs @@ -0,0 +1,254 @@ +//! Regression coverage for action-return provenance visible through diagnostics. +//! +//! Flow-sensitive alias-state transitions are asserted directly by the +//! typechecker unit tests, where the inferred symbol types are observable. + +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::TypeChecker; + +fn assert_type_error_contains(source: &str, expected: &str) { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("program should parse"); + let diagnostics = TypeChecker::new() + .check_types(&program) + .expect_err("program should be rejected") + .into_diagnostics(); + + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains(expected)), + "expected a diagnostic containing {expected:?}, got {diagnostics:?}" + ); +} + +fn assert_typechecks(source: &str) { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("program should parse"); + TypeChecker::new() + .check_types(&program) + .unwrap_or_else(|failure| { + panic!( + "program should type-check, got {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn explicit_fresh_list_return_preserves_its_element_type() { + assert_type_error_contains( + r#" +define action called make_numbers: + store fresh_numbers as [1] + return fresh_numbers +end action + +store items as call make_numbers +create directory at items[0] +"#, + "directory path", + ); +} + +#[test] +fn implicit_fresh_list_return_preserves_its_element_type() { + assert_type_error_contains( + r#" +define action called make_numbers: + [1] +end action + +store items as call make_numbers +create directory at items[0] +"#, + "directory path", + ); +} + +#[test] +fn nested_shared_return_escapes_only_the_shared_list_path() { + assert_type_error_contains( + r#" +store leaf as [1] +define action called expose_nested: + return [leaf] +end action + +store exposed as call expose_nested +create directory at exposed[0] +"#, + "directory path", + ); +} + +#[test] +fn stored_user_action_alias_does_not_eagerly_escape_a_returned_captured_list() { + assert_type_error_contains( + r#" +store shared_values as [1] +define action called expose with parameters unused as number: + return shared_values +end action + +store saved_expose as expose +store exposed as saved_expose of 0 +create directory at shared_values[0] +"#, + "directory path", + ); +} + +#[test] +fn mutating_a_stored_user_action_alias_return_updates_the_captured_list() { + assert_typechecks( + r#" +store shared_values as [1] +define action called expose with parameters unused as number: + return shared_values +end action + +store saved_expose as expose +store exposed as saved_expose of 0 +push with exposed and "text" +create directory at shared_values[0] +"#, + ); +} + +#[test] +fn stored_pop_alias_preserves_the_returned_nested_list_provenance() { + assert_typechecks( + r#" +store leaf as [1] +store nested as [leaf] +store take_last as pop +store exposed as take_last of nested +push with exposed and "text" +create directory at leaf[0] +"#, + ); +} + +#[test] +fn bare_zero_argument_action_does_not_eagerly_escape_a_returned_captured_list() { + assert_type_error_contains( + r#" +store shared_values as [1] +define action called expose: + return shared_values +end action + +store exposed as expose +create directory at shared_values[0] +"#, + "directory path", + ); +} + +#[test] +fn mutating_a_bare_zero_argument_action_return_updates_the_captured_list() { + assert_typechecks( + r#" +store shared_values as [1] +define action called expose: + return shared_values +end action + +store exposed as expose +push with exposed and "text" +create directory at shared_values[0] +"#, + ); +} + +#[test] +fn one_iteration_loop_completion_preserves_captured_list_return_provenance() { + assert_typechecks( + r#" +store shared_values as [1] +define action called expose: + repeat until yes: + shared_values + end repeat +end action + +store exposed as call expose +push with exposed and "text" +create directory at shared_values[0] +"#, + ); +} + +#[test] +fn inner_binding_does_not_inherit_a_same_named_outer_action_alias() { + assert_type_error_contains( + r#" +define action called label with parameters value as number: + return "outer" +end action + +store selected as label +define action called increment with parameters selected as number: + return selected of 1 +end action + +store inner_result as increment of 1 +"#, + "not a function", + ); +} + +#[test] +fn calling_a_closure_that_rebinds_a_captured_action_alias_invalidates_it() { + assert_typechecks( + r#" +store values as [1] + +define action called leave_values with parameters unused as number: + display "unchanged" +end action + +define action called widen_values with parameters unused as number: + push with values and "text" +end action + +store selected as leave_values +define action called select_widener: + change selected to widen_values +end action + +call select_widener +call selected with 0 +store removed as pop of values +open file at removed for reading as input_file +"#, + ); +} + +#[test] +fn closure_defined_before_alias_assignment_can_clear_the_later_alias() { + assert_typechecks( + r#" +store values as [1] + +define action called leave_values with parameters unused as number: + display "unchanged" +end action + +store selected as nothing +define action called clear_selected: + change selected to nothing +end action + +change selected to leave_values +call clear_selected +call selected with 0 +store removed as pop of values +open file at removed for reading as input_file +"#, + ); +} diff --git a/tests/typechecker_builtin_contract_test.rs b/tests/typechecker_builtin_contract_test.rs new file mode 100644 index 00000000..39a011dd --- /dev/null +++ b/tests/typechecker_builtin_contract_test.rs @@ -0,0 +1,451 @@ +use wfl::analyzer::Analyzer; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn typecheck(source: &str) -> Result<(), TypeCheckError> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new().check_types(&program) +} + +fn typecheck_like_cli(source: &str) -> Result<(), TypeCheckError> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let mut analyzer = Analyzer::new(); + analyzer + .analyze(&program) + .expect("test program should pass semantic analysis"); + TypeChecker::with_analyzer(analyzer).check_types(&program) +} + +#[test] +fn mutating_list_builtin_results_are_not_typed_as_lists() { + for call in [ + "sort of values", + "reverse_list of values", + "unshift of values and 0", + "insert_at of values and 0 and 9", + "fill of values and 0", + ] { + let result = typecheck(&format!( + r#" +store values as [3, 1, 2] +store mutation_result as {call} +store first as mutation_result[0] +"# + )); + + let failure = match result { + Err(failure) => failure, + Ok(()) => panic!("{call} returns Nothing, so indexing it must be rejected"), + }; + let diagnostics = failure.into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Cannot index into Nothing")), + "expected the result of {call} to be Nothing, got: {diagnostics:?}" + ); + } +} + +#[test] +fn definite_nothing_does_not_satisfy_a_concrete_builtin_parameter() { + let diagnostics = typecheck("store invalid as touppercase of nothing\n") + .expect_err("the native uppercase implementation rejects Nothing") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("expected Text")), + "expected the builtin contract to reject definite Nothing: {diagnostics:?}" + ); +} + +#[test] +fn removing_list_builtins_return_an_element_not_a_list() { + for call in [ + "pop of values", + "shift of values", + "remove_at of values and 0", + ] { + typecheck(&format!( + r#" +store values as [3, 1, 2] +store removed as {call} +store adjusted as removed minus 1 +"# + )) + .unwrap_or_else(|failure| { + panic!( + "{call} returns a dynamically typed list element, so numeric use must remain \ + gradual: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn date_time_builtins_preserve_their_runtime_value_types() { + for (expression, expected_type) in [ + ("create_date of 2026 and 7 and 26", "Date"), + ("parse_date of \"2026-07-26\" and \"%Y-%m-%d\"", "Date"), + ( + "add_days of (create_date of 2026 and 7 and 26) and 1", + "Date", + ), + ("create_time of 12 and 30 and 0", "Time"), + ("create_datetime of 2026 and 7 and 26", "DateTime"), + ("datetime_from_timestamp of 0", "DateTime"), + ] { + let failure = match typecheck(&format!("store invalid as ({expression}) minus 1\n")) { + Err(failure) => failure, + Ok(()) => panic!( + "{expression} returns {expected_type}, so numeric subtraction must be rejected" + ), + }; + let diagnostics = failure.into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains(expected_type)), + "expected {expression} to be reported as {expected_type}, got: {diagnostics:?}" + ); + } +} + +#[test] +fn optional_builtin_arguments_follow_the_runtime_arity_ranges() { + for call in [ + "create_time of 12 and 30", + "create_datetime of 2026 and 7 and 26 and 12 and 30 and 0", + "call timestamp", + ] { + typecheck(&format!("store result as {call}\n")).unwrap_or_else(|failure| { + panic!( + "{call} is within the runtime builtin's accepted arity range: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn action_form_builtin_calls_check_their_arguments() { + let failure = typecheck("store invalid as call abs with \"not a number\"\n") + .expect_err("abs must reject a concretely non-numeric argument"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("expected Number")), + "expected a numeric argument diagnostic" + ); + + let failure = typecheck("store invalid as call abs with (1 minus \"x\")\n") + .expect_err("type errors nested inside builtin arguments must not be skipped"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("Cannot perform Minus")), + "expected the nested expression diagnostic" + ); +} + +#[test] +fn production_pipeline_keeps_builtin_parameter_contracts() { + for source in [ + "store invalid as abs of \"not a number\"\n", + "store invalid as call abs with \"not a number\"\n", + ] { + let diagnostics = typecheck_like_cli(source) + .expect_err("the CLI-style type checker must retain builtin contracts") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("expected Number")), + "expected a numeric builtin argument diagnostic, got: {diagnostics:?}" + ); + } +} + +#[test] +fn pattern_find_all_uses_the_runtime_two_argument_contract() { + typecheck( + "create pattern letters:\n one or more letter\nend pattern\n\ + store hits as pattern_find_all of \"aba\" and letters\n", + ) + .unwrap_or_else(|failure| { + panic!( + "the runtime accepts exactly two arguments: {:?}", + failure.into_diagnostics() + ) + }); + + let diagnostics = typecheck( + "create pattern letters:\n one or more letter\nend pattern\n\ + store hits as pattern_find_all of \"aba\" and letters and 1\n", + ) + .expect_err("the runtime rejects a third argument") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("expects 2 arguments")), + "expected a two-argument arity diagnostic, got: {diagnostics:?}" + ); +} + +#[test] +fn bare_zero_argument_builtins_have_their_runtime_result_types() { + for source in [ + "store value as random plus 1\n", + "store token_size as length of generate_csrf_token\n", + ] { + typecheck(source).unwrap_or_else(|failure| { + panic!( + "the runtime auto-invokes a bare zero-argument builtin: {:?}", + failure.into_diagnostics() + ) + }); + } + + let diagnostics = typecheck("store invalid as today minus 1\n") + .expect_err("today produces a Date, which cannot be subtracted from a number") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Date")) + && diagnostics + .iter() + .all(|error| !error.message.contains("Function")), + "today must be typed as its auto-invoked Date result, got: {diagnostics:?}" + ); +} + +#[test] +fn implemented_builtins_reject_concrete_runtime_type_mismatches() { + for call in [ + "min of \"x\" and 1", + "sqrt of \"x\"", + "random_between of \"low\" and 10", + "path_join of \"root\" and 2", + "create_time of \"12\" and 30", + "timestamp of 1", + ] { + let diagnostics = typecheck(&format!("store invalid as {call}\n")) + .expect_err("the runtime rejects this concrete argument type") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Argument")), + "expected a builtin argument diagnostic for {call}, got: {diagnostics:?}" + ); + } +} + +#[test] +fn builtin_overloads_match_runtime_supported_value_kinds() { + for call in [ + "indexof of \"abc\" and \"b\"", + "index_of of \"abc\" and \"b\"", + ] { + typecheck(&format!("store position as {call}\n")).unwrap_or_else(|failure| { + panic!( + "text index lookup is supported at runtime: {:?}", + failure.into_diagnostics() + ) + }); + } + + typecheck( + r#" +listen on port 8080 as srv +wait for request comes in on srv as req +store byte_count as length of body_bytes +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "runtime length accepts Binary values: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn recognized_but_unimplemented_names_are_not_treated_as_callable_builtins() { + for call in [ + "compile_pattern of \"a\"", + "addmonths of today and 1", + "list_directory of \".\"", + ] { + let diagnostics = typecheck(&format!("store invalid as {call}\n")) + .expect_err("a name without a runtime implementation must fail before execution") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("not implemented")), + "expected an explicit unimplemented-builtin diagnostic for {call}, got: \ + {diagnostics:?}" + ); + } + + let diagnostics = typecheck("store invalid as compile_pattern\n") + .expect_err("a bare reference to an unimplemented builtin has no runtime value") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("not implemented")), + "expected an explicit bare-reference diagnostic, got: {diagnostics:?}" + ); +} + +#[test] +fn user_actions_can_use_names_reserved_for_future_builtins() { + typecheck( + r#" +define action called compile_pattern with parameters value as text: + return value +end action + +store result as compile_pattern of "ok" +store uppercase as touppercase of result +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "a real user action must win over an unimplemented reserved name: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn included_actions_can_use_names_reserved_for_future_builtins() { + for call in [ + "compile_pattern of \"ok\"", + "addmonths of today and 1", + "list_directory of \".\"", + ] { + typecheck(&format!( + "include from \"module.wfl\"\nstore result as {call}\ndisplay result\n" + )) + .unwrap_or_else(|failure| { + panic!( + "an include-exposed action must win over an unimplemented reserved name \ + ({call}): {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn stored_builtin_references_preserve_runtime_contracts() { + let diagnostics = typecheck( + r#" +store magnitude as abs +store invalid as magnitude of "not a number" +"#, + ) + .expect_err("a stored abs reference must retain its Number parameter") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("expected Number")), + "expected the aliased builtin's numeric contract, got: {diagnostics:?}" + ); + + typecheck( + r#" +store make_time as create_time +store clock as make_time of 12 and 30 +store combine_paths as path_join +store combined as combine_paths of "root" and "child" and "file.txt" + +listen on port 8080 as srv +wait for request comes in on srv as req +store measure as length +store byte_count as measure of body_bytes +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "stored optional, variadic, and overloaded builtins must keep their runtime \ + signatures: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn runtime_branded_builtin_types_do_not_accept_same_named_containers() { + let diagnostics = typecheck( + r#" +create container Date: +end +create new Date as date_container: +end +store rendered as format_date of date_container and "%Y-%m-%d" +"#, + ) + .expect_err("a Date container is not the runtime's temporal Date value") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("format_date") && error.message.contains("Date")), + "expected a branded Date contract diagnostic: {diagnostics:?}" + ); + + let diagnostics = typecheck( + r#" +create container Date: +end +define action called render with parameters value as Date: + return format_date of value and "%Y-%m-%d" +end action +"#, + ) + .expect_err("a custom Date annotation is ambiguous when that container exists") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("custom/container annotation") + && error.message.contains("lowercase 'date'") + }), + "the rejection should explain how to request the temporal type: {diagnostics:?}" + ); + + let diagnostics = typecheck( + r#" +create container DateTime: +end +define action called render with parameters value as DateTime: + return format_datetime of value and "%Y-%m-%d" +end action +"#, + ) + .expect_err("a custom DateTime annotation is ambiguous when that container exists") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("no unambiguous DateTime spelling") + && error.message.contains("rename the container") + }), + "DateTime guidance must not recommend lowercase datetime, which stays custom: \ + {diagnostics:?}" + ); +} diff --git a/tests/typechecker_container_contract_test.rs b/tests/typechecker_container_contract_test.rs new file mode 100644 index 00000000..f879b915 --- /dev/null +++ b/tests/typechecker_container_contract_test.rs @@ -0,0 +1,977 @@ +use wfl::analyzer::Analyzer; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::{ + Argument, EventDefinition, Expression, Literal, Parameter, Program, PropertyDefinition, + Statement, Type, Visibility, +}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn typecheck(source: &str) -> Result<(), TypeCheckError> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new().check_types(&program) +} + +#[test] +fn action_local_container_instances_keep_their_instance_type() { + let source = r#" +create container Widget: + property amount: Number +end + +define action called inspect: + create new Widget as item: + amount is 1 + end + store invalid as item minus 1 +end action +"#; + let diagnostics = typecheck(source) + .expect_err("a container instance is not a number") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("Cannot perform Minus") + && error.message.contains("Instance") + }), + "expected the local binding to retain Widget's instance type: {diagnostics:?}" + ); +} + +#[test] +fn container_initializers_check_property_names_and_types() { + let diagnostics = typecheck( + r#" +create container Widget: + property amount: Number +end +create new Widget as item: + amount is "wrong" + extra_prop is 1 +end +"#, + ) + .expect_err("declared container properties are statically typed") + .into_diagnostics(); + + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("amount") && error.message.contains("Number")), + "expected the property type mismatch: {diagnostics:?}" + ); + assert!( + diagnostics.iter().any( + |error| error.message.contains("extra_prop") && error.message.contains("not found") + ), + "expected the unknown-property diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn inherited_properties_are_valid_initializers() { + typecheck( + r#" +create container Parent: + property label: Text +end +create container Child extends Parent: + property amount: Number +end +create new Child as item: + label is "ok" + amount is 1 +end +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "initializers may target inherited properties: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn incompatible_inherited_property_overrides_emit_a_compatibility_warning() { + let source = r#" +create container Parent: + property value: Number defaults 1 + + action reset: + change value to 2 + end +end + +create container Child extends Parent: + property value: Text defaults "child" +end +"#; + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + let mut analyzer = Analyzer::new(); + analyzer + .analyze(&program) + .expect("the compatibility warning must remain non-fatal"); + + assert!( + analyzer.get_warnings().iter().any(|warning| { + warning.message.contains("value") + && warning.message.contains("Parent") + && warning.message.contains("Number") + && warning.message.contains("Text") + }), + "expected an incompatible inherited-property override warning: {:?}", + analyzer.get_warnings() + ); + typecheck(source).expect("the warning must not break an existing WFL program"); +} + +#[test] +fn inherited_property_overrides_accept_the_same_contract() { + typecheck( + r#" +create container Parent: + property value: Number defaults 1 +end + +create container Child extends Parent: + property value: Number defaults 2 +end +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "an invariant same-type override should remain valid: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn cyclic_container_inheritance_is_rejected_without_walking_forever() { + let diagnostics = typecheck( + r#" +create container First extends Second: +end + +create container Second extends First: +end +"#, + ) + .expect_err("a cyclic parent chain has no valid container contract") + .into_diagnostics(); + + assert!( + diagnostics.iter().any(|error| { + error.message.to_lowercase().contains("cyclic") + && error.message.contains("First") + && error.message.contains("Second") + }), + "expected a cyclic-inheritance diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn method_assignments_must_preserve_declared_property_types() { + for source in [ + r#" +create container Counter: + property total: Number defaults 1 + + action reset: + change total to nothing + end +end +"#, + r#" +create container Counter: + property total: Number defaults 1 + + action reset: + change total to "wrong" + end +end +"#, + r#" +create container Counter: + property total: Number defaults 1 + + action reset: + store total as "wrong" + end +end +"#, + r#" +create container Counter: + static property total: Number defaults 1 + + static action reset: + change total to nothing + end +end +"#, + r#" +create container Counter: + static property total: Number defaults 1 + + static action set needs value: Any: + change total to value + end +end +"#, + ] { + let diagnostics = typecheck(source) + .expect_err("a method must not invalidate a declared property type") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("property") + && error.message.contains("total") + && error.message.contains("Number") + }), + "expected a declared-property assignment diagnostic: {diagnostics:?}" + ); + } +} + +#[test] +fn method_assignments_accept_values_that_preserve_property_contracts() { + typecheck( + r#" +create container Counter: + property total: Number defaults 1 + + action set needs value: Number: + change total to value + end + + static property shared_total: Number defaults 1 + + static action set_shared needs value: Number: + change shared_total to value + end +end +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "compatible instance/static property assignments should type-check: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn method_cannot_redeclare_an_existing_property_as_a_constant() { + let diagnostics = typecheck( + r#" +create container Counter: + property total: Number defaults 1 + + action reset: + store new constant total as 2 + end +end +"#, + ) + .expect_err("runtime rejects constant shadowing of a property binding") + .into_diagnostics(); + + assert!( + diagnostics.iter().any(|error| { + error.message.contains("constant") + && error.message.contains("property") + && error.message.contains("total") + }), + "expected a constant/property redeclaration diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn static_methods_can_call_their_own_container_members() { + typecheck( + r#" +create container Counter: + static property total: Number defaults 0 + + static action increment: Number + change total to total plus 1 + return total + end + + static action increment_twice: Number + store first as Counter.increment() + return Counter.increment() + end +end +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "the active container name must resolve inside its own static methods: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn method_list_mutations_preserve_declared_property_element_types() { + let items_property = PropertyDefinition { + name: "items".to_string(), + property_type: Some(Type::List(Box::new(Type::Text))), + default_value: Some(Expression::Literal(Literal::List(vec![]), 1, 1)), + validation_rules: vec![], + visibility: Visibility::Public, + is_static: false, + line: 1, + column: 1, + }; + let messages_container = |methods| Statement::ContainerDefinition { + name: "Messages".to_string(), + extends: None, + implements: vec![], + properties: vec![items_property.clone()], + methods, + events: vec![], + static_properties: vec![], + static_methods: vec![], + line: 1, + column: 1, + }; + + let diagnostics = check(Program { + statements: vec![messages_container(vec![method( + "corrupt", + vec![], + vec![Statement::PushStatement { + list: Expression::Variable("items".to_string(), 2, 1), + value: number(1), + line: 2, + column: 1, + }], + )])], + }) + .expect_err("mutating a typed property list must preserve its element type") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("property") + && error.message.contains("items") + && error.message.contains("Text") + }), + "expected a typed-property list mutation diagnostic: {diagnostics:?}" + ); + + check(Program { + statements: vec![messages_container(vec![ + method( + "append", + vec![parameter("message", Type::Text)], + vec![Statement::PushStatement { + list: Expression::Variable("items".to_string(), 2, 1), + value: Expression::Variable("message".to_string(), 2, 1), + line: 2, + column: 1, + }], + ), + method( + "reset", + vec![], + vec![Statement::Assignment { + name: "items".to_string(), + value: Expression::Literal(Literal::List(vec![]), 3, 1), + line: 3, + column: 1, + }], + ), + ])], + }) + .unwrap_or_else(|failure| { + panic!( + "a compatible typed-property list mutation should type-check: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn mutating_builtins_preserve_bare_list_property_contracts() { + for mutation in [ + "store ignored as push of items and 1", + "store ignored as unshift of items and 1", + "store ignored as insert_at of items and 0 and 1", + "store ignored as insertat of items and 0 and 1", + "store ignored as fill of items and 1", + ] { + let source = format!( + r#" +create container Messages: + property items: List of Text defaults [] + + action corrupt: + {mutation} + end +end +"# + ); + let diagnostics = typecheck(&source) + .expect_err("a mutating builtin must preserve a bare property's element type") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("property") + && error.message.contains("items") + && error.message.contains("Text") + }), + "expected a declared-property mutation diagnostic for `{mutation}`: {diagnostics:?}" + ); + } +} + +#[test] +fn list_property_contracts_follow_instance_inherited_and_static_accesses() { + for mutation in [ + "push with box.items and 1", + "store ignored as push of box.items and 1", + "store ignored as push of Messages.shared_items and 1", + ] { + let source = format!( + r#" +create container BaseMessages: + property items: List of Text defaults [] +end + +create container Messages extends BaseMessages: + static property shared_items: List of Text defaults [] +end + +create new Messages as box: +end + +{mutation} +"# + ); + let diagnostics = typecheck(&source) + .expect_err("mutating a property access must preserve its declared element type") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("property") + && error.message.contains("Text") + && (error.message.contains("items") || error.message.contains("shared_items")) + }), + "expected a property-access mutation diagnostic for `{mutation}`: {diagnostics:?}" + ); + } +} + +#[test] +fn method_properties_shadow_outer_bindings_but_not_parameters() { + typecheck( + r#" +store total as "outer text" + +create container Counter: + property total: Number defaults 1 + + action increment: + try: + change total to total plus 1 + when error: + display "unexpected" + end try + end + + action echo needs total: Text: Text + try: + return touppercase of total + when error: + return "unexpected" + end try + end +end +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "runtime lookup is parameter, then property, then outer lexical binding: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn declared_nothing_property_does_not_widen_on_assignment() { + let diagnostics = typecheck( + r#" +create container State: + property value: Nothing defaults nothing + + action corrupt: + change value to 1 + end +end +"#, + ) + .expect_err("a concrete Nothing property must retain its declared contract") + .into_diagnostics(); + + assert!( + diagnostics.iter().any(|error| { + error.message.contains("property") + && error.message.contains("value") + && error.message.contains("Nothing") + }), + "expected a declared-property assignment diagnostic: {diagnostics:?}" + ); +} + +fn number(value: i64) -> Expression { + Expression::Literal(Literal::Integer(value), 1, 1) +} + +fn text(value: &str) -> Expression { + Expression::Literal(Literal::String(value.into()), 1, 1) +} + +fn parameter(name: &str, param_type: Type) -> Parameter { + Parameter { + name: name.to_string(), + param_type: Some(param_type), + default_value: None, + line: 1, + column: 1, + } +} + +fn method(name: &str, parameters: Vec, body: Vec) -> Statement { + Statement::ActionDefinition { + name: name.to_string(), + parameters, + body, + return_type: None, + line: 1, + column: 1, + } +} + +fn container( + name: &str, + extends: Option<&str>, + methods: Vec, + static_methods: Vec, + events: Vec, +) -> Statement { + Statement::ContainerDefinition { + name: name.to_string(), + extends: extends.map(str::to_string), + implements: vec![], + properties: vec![], + methods, + events, + static_properties: vec![], + static_methods, + line: 1, + column: 1, + } +} + +fn parent_call(method_name: &str, arguments: Vec) -> Statement { + Statement::ParentMethodCall { + method_name: method_name.to_string(), + arguments: arguments + .into_iter() + .map(|value| Argument { name: None, value }) + .collect(), + line: 1, + column: 1, + } +} + +fn instantiate(container_type: &str, arguments: Vec) -> Statement { + Statement::ContainerInstantiation { + container_type: container_type.to_string(), + instance_name: "instance".to_string(), + arguments: arguments + .into_iter() + .map(|value| Argument { name: None, value }) + .collect(), + property_initializers: vec![], + line: 1, + column: 1, + } +} + +fn check(program: Program) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(&program) +} + +#[test] +fn constructor_arguments_match_the_direct_initialize_method() { + let widget = container( + "Widget", + None, + vec![method( + "initialize", + vec![parameter("amount", Type::Number)], + vec![], + )], + vec![], + vec![], + ); + + check(Program { + statements: vec![widget.clone(), instantiate("Widget", vec![number(1)])], + }) + .expect("a matching direct initialize method accepts constructor arguments"); + + let diagnostics = check(Program { + statements: vec![widget, instantiate("Widget", vec![text("wrong")])], + }) + .expect_err("constructor argument types must match initialize") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("initialize") + && error.message.contains("Number") + && error.message.contains("Text") + }), + "expected an initialize argument diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn constructor_arguments_require_a_direct_initialize_method() { + let diagnostics = check(Program { + statements: vec![ + container("Widget", None, vec![], vec![], vec![]), + instantiate("Widget", vec![number(1)]), + ], + }) + .expect_err("runtime rejects constructor arguments without initialize") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("initialize") && error.message.contains("Widget")), + "expected a missing initialize diagnostic: {diagnostics:?}" + ); + + let parent = container( + "Parent", + None, + vec![method( + "initialize", + vec![parameter("amount", Type::Number)], + vec![], + )], + vec![], + vec![], + ); + let child = container("Child", Some("Parent"), vec![], vec![], vec![]); + assert!( + check(Program { + statements: vec![parent, child, instantiate("Child", vec![number(1)])], + }) + .is_err(), + "runtime does not inherit initialize methods" + ); +} + +#[test] +fn parent_calls_require_an_instance_method_and_direct_parent_contract() { + let diagnostics = check(Program { + statements: vec![parent_call("run", vec![])], + }) + .expect_err("parent calls are invalid outside container instance methods") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("instance method")), + "expected an instance-method-context diagnostic: {diagnostics:?}" + ); + + let parent = container( + "Parent", + None, + vec![method( + "receive", + vec![parameter("amount", Type::Number)], + vec![], + )], + vec![], + vec![], + ); + let child = container( + "Child", + Some("Parent"), + vec![method("run", vec![], vec![parent_call("receive", vec![])])], + vec![], + vec![], + ); + let diagnostics = check(Program { + statements: vec![parent, child], + }) + .expect_err("parent calls must match direct-parent arity") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("receive") && error.message.contains("1")), + "expected a parent method arity diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn parent_calls_validate_arguments_and_reject_static_contexts() { + let parent = container( + "Parent", + None, + vec![method( + "receive", + vec![parameter("amount", Type::Number)], + vec![], + )], + vec![], + vec![], + ); + let child_with_bad_type = container( + "Child", + Some("Parent"), + vec![method( + "run", + vec![], + vec![parent_call("receive", vec![text("wrong")])], + )], + vec![], + vec![], + ); + let diagnostics = check(Program { + statements: vec![parent.clone(), child_with_bad_type], + }) + .expect_err("parent call arguments must match the parent method") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("receive") + && error.message.contains("Number") + && error.message.contains("Text") + }), + "expected a parent method argument diagnostic: {diagnostics:?}" + ); + + let child_with_static_call = container( + "StaticChild", + Some("Parent"), + vec![], + vec![method( + "run", + vec![], + vec![parent_call("receive", vec![number(1)])], + )], + vec![], + ); + let diagnostics = check(Program { + statements: vec![parent, child_with_static_call], + }) + .expect_err("runtime has no `this` in a static method") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("static method")), + "expected a static-method parent-call diagnostic: {diagnostics:?}" + ); +} + +fn event(name: &str, parameters: Vec) -> EventDefinition { + EventDefinition { + name: name.to_string(), + parameters, + line: 1, + column: 1, + } +} + +#[test] +fn event_handlers_validate_sources_events_and_parameter_scope() { + let source_container = container( + "Emitter", + None, + vec![], + vec![], + vec![event("changed", vec![parameter("amount", Type::Number)])], + ); + let instance = instantiate("Emitter", vec![]); + let valid_handler = Statement::EventHandler { + event_name: "changed".to_string(), + event_source: Expression::Variable("instance".to_string(), 2, 1), + handler_body: vec![Statement::DisplayStatement { + value: Expression::Variable("amount".to_string(), 3, 1), + line: 3, + column: 1, + }], + line: 2, + column: 1, + }; + check(Program { + statements: vec![source_container.clone(), instance.clone(), valid_handler], + }) + .expect("event parameters should be in scope in a valid handler"); + + let diagnostics = check(Program { + statements: vec![ + source_container, + instance, + Statement::EventHandler { + event_name: "missing".to_string(), + event_source: Expression::Variable("instance".to_string(), 2, 1), + handler_body: vec![], + line: 2, + column: 1, + }, + ], + }) + .expect_err("runtime rejects events absent from the direct container") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("missing") && error.message.contains("Emitter")), + "expected an unknown-event diagnostic: {diagnostics:?}" + ); + + let diagnostics = check(Program { + statements: vec![Statement::EventHandler { + event_name: "changed".to_string(), + event_source: number(1), + handler_body: vec![], + line: 1, + column: 1, + }], + }) + .expect_err("runtime rejects non-container handler sources") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("non-container")), + "expected a handler-source diagnostic: {diagnostics:?}" + ); +} + +#[test] +fn event_triggers_visit_arguments_and_check_overlapping_parameter_types() { + let definition = Statement::EventDefinition { + name: "changed".to_string(), + parameters: vec![parameter("amount", Type::Number)], + line: 1, + column: 1, + }; + + check(Program { + statements: vec![ + definition.clone(), + Statement::EventTrigger { + name: "changed".to_string(), + arguments: vec![], + line: 2, + column: 1, + }, + ], + }) + .expect("runtime fills missing event parameters with Nothing"); + + let diagnostics = check(Program { + statements: vec![ + definition.clone(), + Statement::EventTrigger { + name: "changed".to_string(), + arguments: vec![Argument { + name: None, + value: text("wrong"), + }], + line: 2, + column: 1, + }, + ], + }) + .expect_err("provided event arguments should match overlapping parameters") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|error| { + error.message.contains("changed") + && error.message.contains("Number") + && error.message.contains("Text") + }), + "expected an event argument diagnostic: {diagnostics:?}" + ); + + let invalid_extra = Expression::BinaryOperation { + left: Box::new(number(1)), + operator: wfl::parser::ast::Operator::Minus, + right: Box::new(text("wrong")), + line: 2, + column: 1, + }; + let diagnostics = check(Program { + statements: vec![ + definition, + Statement::EventTrigger { + name: "changed".to_string(), + arguments: vec![ + Argument { + name: None, + value: number(1), + }, + Argument { + name: None, + value: invalid_extra, + }, + ], + line: 2, + column: 1, + }, + ], + }) + .expect_err("extra event arguments are ignored only after evaluation") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Cannot perform Minus")), + "every trigger argument must be traversed: {diagnostics:?}" + ); +} + +#[test] +fn container_methods_resolve_their_own_declared_events() { + let valid = container( + "Emitter", + None, + vec![method( + "emit", + vec![], + vec![Statement::EventTrigger { + name: "changed".to_string(), + arguments: vec![Argument { + name: None, + value: number(1), + }], + line: 1, + column: 1, + }], + )], + vec![], + vec![event("changed", vec![parameter("amount", Type::Number)])], + ); + check(Program { + statements: vec![valid], + }) + .expect("container methods receive their direct container events at runtime"); +} diff --git a/tests/typechecker_definite_binding_test.rs b/tests/typechecker_definite_binding_test.rs new file mode 100644 index 00000000..05ae9e33 --- /dev/null +++ b/tests/typechecker_definite_binding_test.rs @@ -0,0 +1,152 @@ +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::{Expression, Literal, Program, Statement}; +use wfl::typechecker::TypeChecker; + +fn parse(source: &str) -> Program { + Parser::new(&lex_wfl_with_positions(source)) + .parse() + .expect("test program should parse") +} + +fn typechecks(program: &Program) -> bool { + TypeChecker::new().check_types(program).is_ok() +} + +#[test] +fn branch_only_bindings_do_not_escape_multi_line_if() { + for source in [ + r#" +check if no: + store branch_only as 1 +end check +display branch_only +"#, + r#" +check if yes: + display "then" +otherwise: + store branch_only as 1 +end check +display branch_only +"#, + ] { + let program = parse(source); + assert!( + !typechecks(&program), + "a name missing from a reachable branch is not definitely bound: {source}" + ); + } +} + +#[test] +fn bindings_created_in_both_multi_line_branches_escape() { + let program = parse( + r#" +check if yes: + store branch_result as 1 +otherwise: + store branch_result as 2 +end check +display branch_result +"#, + ); + assert!( + typechecks(&program), + "a name created on every branch is definitely bound" + ); +} + +#[test] +fn bindings_created_in_both_single_line_branches_escape() { + let store = |value| Statement::VariableDeclaration { + name: "branch_result".to_string(), + value: Expression::Literal(Literal::Integer(value), 1, 1), + is_constant: false, + line: 1, + column: 1, + }; + let program = Program { + statements: vec![ + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), 1, 1), + then_stmt: Box::new(store(1)), + else_stmt: Some(Box::new(store(2))), + line: 1, + column: 1, + }, + Statement::DisplayStatement { + value: Expression::Variable("branch_result".to_string(), 2, 1), + line: 2, + column: 1, + }, + ], + }; + assert!( + typechecks(&program), + "single-line and multi-line if must use the same definite-binding rule" + ); +} + +fn declaration(name: &str, value: i64, is_constant: bool) -> Statement { + Statement::VariableDeclaration { + name: name.to_string(), + value: Expression::Literal(Literal::Integer(value), 1, 1), + is_constant, + line: 1, + column: 1, + } +} + +fn assignment(name: &str) -> Statement { + Statement::Assignment { + name: name.to_string(), + value: Expression::Literal(Literal::Integer(3), 2, 1), + line: 2, + column: 1, + } +} + +#[test] +fn mixed_mutability_branches_merge_as_immutable_multi_line() { + for (then_constant, else_constant) in [(false, true), (true, false)] { + let program = Program { + statements: vec![ + Statement::IfStatement { + condition: Expression::Literal(Literal::Boolean(true), 1, 1), + then_block: vec![declaration("branch_result", 1, then_constant)], + else_block: Some(vec![declaration("branch_result", 2, else_constant)]), + line: 1, + column: 1, + }, + assignment("branch_result"), + ], + }; + assert!( + !typechecks(&program), + "a binding is mutable after a join only when every branch creates it mutable" + ); + } +} + +#[test] +fn mixed_mutability_branches_merge_as_immutable_single_line() { + for (then_constant, else_constant) in [(false, true), (true, false)] { + let program = Program { + statements: vec![ + Statement::SingleLineIf { + condition: Expression::Literal(Literal::Boolean(true), 1, 1), + then_stmt: Box::new(declaration("branch_result", 1, then_constant)), + else_stmt: Some(Box::new(declaration("branch_result", 2, else_constant))), + line: 1, + column: 1, + }, + assignment("branch_result"), + ], + }; + assert!( + !typechecks(&program), + "single-line joins must not make a maybe-constant binding mutable" + ); + } +} diff --git a/tests/typechecker_expression_coverage_test.rs b/tests/typechecker_expression_coverage_test.rs new file mode 100644 index 00000000..3127d75f --- /dev/null +++ b/tests/typechecker_expression_coverage_test.rs @@ -0,0 +1,91 @@ +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn typecheck(source: &str) -> Result<(), TypeCheckError> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new().check_types(&program) +} + +fn diagnostics(source: &str) -> Vec { + typecheck(source) + .expect_err("program should be rejected") + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect() +} + +#[test] +fn list_literal_elements_are_analyzed_and_typechecked() { + let errors = diagnostics("store values as [missing_value]\n"); + assert!( + errors + .iter() + .any(|message| message.contains("missing_value") && message.contains("not defined")), + "undefined list elements must be analyzed: {errors:?}" + ); + + let errors = diagnostics("store values as [(1 minus \"text\")]\n"); + assert!( + errors + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "invalid operations inside list elements must be typechecked: {errors:?}" + ); +} + +#[test] +fn fixed_result_expressions_validate_and_visit_their_operands() { + for source in [ + "store result as file exists at 1\n", + "store result as directory exists at no\n", + "store result as list files in 1\n", + "store result as read binary from 1\n", + "store byte_count as \"many\"\nstore result as read byte_count bytes from \"handle\"\n", + "store result as file size of no\n", + "store result as process 1 is running\n", + ] { + assert!( + typecheck(source).is_err(), + "a fixed return type must not hide an invalid operand: {source}" + ); + } + + let errors = diagnostics("store result as file exists at (1 minus \"text\")\n"); + assert!( + errors + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "operand expressions must be traversed before applying the fixed result type: {errors:?}" + ); +} + +#[test] +fn pattern_find_uses_the_runtime_pattern_and_optional_match_contract() { + let source = r#" +create pattern letter_a: + "a" +end pattern +store found_match as find letter_a in "abc" +check if found_match is not nothing: + store adjusted as found_match["start"] minus 1 +end check +"#; + typecheck(source).unwrap_or_else(|failure| { + panic!( + "a guarded match object has dynamic fields: {:?}", + failure.into_diagnostics() + ) + }); + + let errors = diagnostics(r#"store found_match as find "a" in "abc""#); + assert!( + errors + .iter() + .any(|message| message.contains("Expected Pattern")), + "runtime pattern-find requires a compiled Pattern, got: {errors:?}" + ); +} diff --git a/tests/typechecker_gradual_any_test.rs b/tests/typechecker_gradual_any_test.rs new file mode 100644 index 00000000..6bcd2103 --- /dev/null +++ b/tests/typechecker_gradual_any_test.rs @@ -0,0 +1,1262 @@ +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::parser::ast::{Argument, Expression, Literal, Program, Statement}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn typecheck(source: &str) -> Result<(), TypeCheckError> { + let program = parse_program(source); + TypeChecker::new().check_types(&program) +} + +fn parse_program(source: &str) -> Program { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + parser.parse().expect("test program should parse") +} + +fn typecheck_program(program: &Program) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(program) +} + +#[test] +fn any_values_defer_operation_specific_validation_to_runtime() { + for source in [ + r#" +store paths as ["data.txt"] +open file at paths[0] for reading as input_file +"#, + r#" +store conditions as [yes] +check if conditions[0]: + display "dynamic condition" +end check +"#, + r#" +store bounds as [1, 2] +count from bounds[0] to bounds[1]: + display count +end count +"#, + ] { + typecheck(source).unwrap_or_else(|failure| { + panic!( + "Any is statically unknown and must be checked at runtime: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn concrete_invalid_values_are_still_rejected() { + for source in [ + "open file at 1 for reading as input_file\n", + "check if 1:\n display \"never\"\nend check\n", + "count from \"one\" to 2:\n display count\nend count\n", + ] { + assert!( + typecheck(source).is_err(), + "a concrete incompatible type must remain a static error: {source}" + ); + } +} + +#[test] +fn homogeneous_list_literals_preserve_their_element_type() { + assert!( + typecheck("store values as [1, 2]\ncreate directory at values[0]\n").is_err(), + "a homogeneous Number literal must not erase its element type to Any" + ); + + typecheck("store values as [1, \"dynamic\"]\ncreate directory at values[0]\n") + .expect("a genuinely heterogeneous literal widens to Any"); +} + +#[test] +fn element_preserving_list_builtins_keep_concrete_types() { + for expression in [ + "random_from of values", + "pop of values", + "shift of values", + "remove_at of values and 0", + ] { + let source = format!( + "store values as [1, 2]\nstore result as {expression}\ncreate directory at result\n" + ); + assert!( + typecheck(&source).is_err(), + "{expression} must preserve the known Number element type" + ); + } + + assert!( + typecheck( + "store values as [1, 2]\nstore result as slice of values and 0 and 1\ncreate directory at result[0]\n", + ) + .is_err(), + "slice must preserve the known Number element type" + ); +} + +#[test] +fn find_result_requires_a_nothing_guard() { + let number = |value| Expression::Literal(Literal::Integer(value), 1, 1); + let program = Program { + statements: vec![ + Statement::VariableDeclaration { + name: "values".to_string(), + value: Expression::Literal(Literal::List(vec![number(1), number(2)]), 1, 1), + is_constant: false, + line: 1, + column: 1, + }, + Statement::VariableDeclaration { + name: "match_value".to_string(), + value: Expression::FunctionCall { + function: Box::new(Expression::Variable("find".to_string(), 2, 1)), + arguments: vec![ + Argument { + name: None, + value: Expression::Variable("values".to_string(), 2, 1), + }, + Argument { + name: None, + value: number(3), + }, + ], + line: 2, + column: 1, + }, + is_constant: false, + line: 2, + column: 1, + }, + Statement::CreateDirectoryStatement { + path: Expression::Variable("match_value".to_string(), 3, 1), + line: 3, + column: 1, + }, + ], + }; + typecheck_program(&program) + .expect_err("find returns Number or Nothing, not an unrestricted gradual value"); +} + +#[test] +fn path_params_result_requires_a_nothing_guard() { + typecheck( + "store params as path_params of \"/posts/42\" and \"/users/:id\"\n\ + store invalid as params minus 1\n", + ) + .expect_err("path_params returns a capture map or Nothing, never an arbitrary scalar"); +} + +#[test] +fn shape_preserving_builtins_return_lists_for_gradual_inputs() { + for operation in ["slice of dynamic and 0 and 1", "unique of dynamic"] { + let source = format!( + "store dynamic as parse_json of \"[1]\"\n\ + store result as {operation}\n\ + store invalid as result minus 1\n" + ); + assert!( + typecheck(&source).is_err(), + "{operation} produces a list when it succeeds, not a top-level Any" + ); + } +} + +#[test] +fn mutating_list_builtins_widen_the_bound_list() { + typecheck( + "store values as [1]\npush with values and \"text\"\ncreate directory at values[1]\n", + ) + .expect("a heterogeneous builtin push widens the list element type to Any"); +} + +#[test] +fn fill_replaces_the_list_element_type_instead_of_joining_it() { + assert!( + typecheck( + "store values as [1]\n\ + store ignored as fill of values and \"text\"\n\ + store invalid as values[0] minus 1\n", + ) + .is_err(), + "fill overwrites every element, so the resulting element type is Text" + ); +} + +#[test] +fn statement_push_through_an_alias_updates_the_original_list_type() { + typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + push with alias_values and \"text\"\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("the original and alias share one runtime list allocation"); +} + +#[test] +fn mutating_builtins_through_an_alias_update_element_return_types() { + for (mutation, removal) in [ + ( + "store ignored as push of alias_values and \"text\"", + "pop of values", + ), + ( + "store ignored as unshift of alias_values and \"text\"", + "shift of values", + ), + ( + "store ignored as insert_at of alias_values and 0 and \"text\"", + "remove_at of values and 0", + ), + ( + "store ignored as fill of alias_values and \"text\"", + "pop of values", + ), + ] { + let source = format!( + "store values as [1]\n\ + store alias_values as values\n\ + {mutation}\n\ + store removed as {removal}\n\ + open file at removed for reading as input_file\n" + ); + typecheck(&source).unwrap_or_else(|failure| { + panic!( + "{mutation} mutates the allocation read by {removal}: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn alias_mutations_retain_exact_types_when_the_effect_is_homogeneous_or_replacing() { + let homogeneous = typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + store ignored as push of alias_values and 2\n\ + create directory at values[1]\n", + ) + .expect_err("pushing another Number keeps both aliases at List") + .into_diagnostics(); + assert!( + homogeneous + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the original alias to retain Number, got {homogeneous:?}" + ); + + let replaced = typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + store ignored as fill of alias_values and \"text\"\n\ + store invalid as values[0] minus 1\n", + ) + .expect_err("fill replaces every element through every alias") + .into_diagnostics(); + assert!( + replaced + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Text)), + "expected the original alias to become List, got {replaced:?}" + ); +} + +#[test] +fn copying_a_list_alias_does_not_widen_it_before_a_mutation() { + let diagnostics = typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + create directory at values[0]\n", + ) + .expect_err("an ordinary alias copy must retain List precision") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the copied list to remain List, got {diagnostics:?}" + ); +} + +#[test] +fn list_alias_mutation_does_not_widen_an_unrelated_list() { + let diagnostics = typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + store unrelated as [2]\n\ + push with alias_values and \"text\"\n\ + create directory at unrelated[0]\n", + ) + .expect_err("mutating one alias group must not widen another list") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the unrelated list to remain List, got {diagnostics:?}" + ); +} + +#[test] +fn action_parameter_list_mutation_widens_only_the_passed_list() { + typecheck( + "define action called append_text with parameters items:\n\ + push with items and \"text\"\n\ + end action\n\ + store values as [1]\n\ + call append_text with values\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a list passed to a user action can be mutated through its parameter"); + + let diagnostics = typecheck( + "define action called append_text with parameters items:\n\ + push with items and \"text\"\n\ + end action\n\ + store values as [1]\n\ + store unrelated as [2]\n\ + call append_text with values\n\ + create directory at unrelated[0]\n", + ) + .expect_err("an action escape must not widen lists that were not passed") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the unrelated list to remain List, got {diagnostics:?}" + ); +} + +#[test] +fn of_form_action_list_arguments_cross_the_same_escape_boundary() { + typecheck( + "define action called append_text with parameters items:\n\ + push with items and \"text\"\n\ + end action\n\ + store values as [1]\n\ + store ignored as append_text of values\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("the of-form user-action call can mutate its list argument"); +} + +#[test] +fn dynamically_resolved_action_calls_escape_list_arguments() { + for invocation in [ + "call selected with values", + "store ignored as selected of values", + ] { + let source = format!( + "define action called append_text with parameters items:\n\ + push with items and \"text\"\n\ + end action\n\ + define action called leave_unchanged with parameters items:\n\ + display items\n\ + end action\n\ + store selected as leave_unchanged\n\ + check if yes:\n\ + change selected to append_text\n\ + end check\n\ + store values as [1]\n\ + {invocation}\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n" + ); + typecheck(&source) + .expect("a dynamically resolved user action may mutate its list argument"); + } +} + +#[test] +fn nested_list_target_mutation_updates_only_the_affected_root_path() { + typecheck( + "store nested as [[1]]\n\ + store unrelated as [2]\n\ + push with nested[0] and \"text\"\n\ + store removed as pop of nested[0]\n\ + open file at removed for reading as input_file\n", + ) + .expect("mutating an indexed inner list must update the nested root type"); + + let diagnostics = typecheck( + "store nested as [[1]]\n\ + store unrelated as [2]\n\ + push with nested[0] and \"text\"\n\ + create directory at unrelated[0]\n", + ) + .expect_err("nested-root mutation must not widen an unrelated list") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the unrelated list to remain List, got {diagnostics:?}" + ); +} + +#[test] +fn extracting_a_nested_list_uses_a_conservative_alias_escape() { + typecheck( + "store nested as [[1]]\n\ + store inner_values as nested[0]\n\ + push with inner_values and \"text\"\n\ + store removed as pop of nested[0]\n\ + open file at removed for reading as input_file\n", + ) + .expect("a nested list extraction shares the inner runtime allocation"); +} + +#[test] +fn control_flow_mutation_propagates_across_a_list_alias_group() { + typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + check if yes:\n\ + push with alias_values and \"text\"\n\ + end check\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a branch mutation can affect the allocation read through the original alias"); +} + +#[test] +fn add_to_list_through_an_alias_updates_the_original_list_type() { + typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + add \"text\" to alias_values\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("the add-to-list statement mutates the allocation shared by both aliases"); +} + +#[test] +fn reassignment_can_create_a_list_alias_group() { + typecheck( + "store source_values as [1]\n\ + store alias_values as [2]\n\ + change alias_values to source_values\n\ + push with alias_values and \"text\"\n\ + store removed as pop of source_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("reassigning a list variable can make it alias the source allocation"); +} + +#[test] +fn reassignment_from_a_gradual_binding_can_create_a_list_alias_group() { + typecheck( + "store source_values as [1]\n\ + store alias_values as parse_json of \"null\"\n\ + change alias_values to source_values\n\ + push with alias_values and \"text\"\n\ + store removed as pop of source_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a definite list assignment replaces a formerly gradual runtime binding"); +} + +#[test] +fn promoted_try_handler_aliases_retain_their_alias_group() { + for mutation in [ + "push with alias_values and \"text\"", + "add \"text\" to alias_values", + "store mutation_result as push of alias_values and \"text\"", + "store mutation_result as fill of alias_values and \"text\"", + ] { + let source = format!( + "store values as [1]\n\ + try:\n\ + store alias_values as values\n\ + store ignored as 1 divided by 0\n\ + when error:\n\ + store alias_values as values\n\ + finally:\n\ + {mutation}\n\ + end try\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n" + ); + typecheck(&source) + .expect("a handler-local alias promoted into finally must still alias its source"); + } +} + +#[test] +fn promoted_gradual_aliases_escape_through_user_actions() { + typecheck( + "define action called mutate with parameters items:\n\ + push with items and \"text\"\n\ + end action\n\ + store values as [1]\n\ + try:\n\ + store alias_values as values\n\ + store ignored as 1 divided by 0\n\ + when error:\n\ + store alias_values as values\n\ + finally:\n\ + call mutate with alias_values\n\ + end try\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a promoted gradual alias passed to user code can mutate its source allocation"); +} + +#[test] +fn promoted_branch_aliases_retain_their_alias_group() { + typecheck( + "store values as [1]\n\ + check if yes:\n\ + store alias_values as values\n\ + otherwise:\n\ + store alias_values as values\n\ + end check\n\ + push with alias_values and \"text\"\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("an alias established on every branch remains linked after promotion"); +} + +#[test] +fn loop_rechecks_preserve_list_alias_effects() { + for source in [ + "store values as [1]\n\ + store alias_values as values\n\ + repeat while yes:\n\ + push with alias_values and \"text\"\n\ + break\n\ + end repeat\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + "store values as [1]\n\ + count from 1 to 2:\n\ + store iteration_alias as values\n\ + push with iteration_alias and \"text\"\n\ + end count\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ] { + typecheck(source) + .expect("fixed-point and fresh-iteration rechecks must retain may-alias effects"); + } +} + +#[test] +fn sequential_loop_local_names_do_not_merge_distinct_alias_groups() { + let diagnostics = typecheck( + "store first_values as [1]\n\ + store second_values as [2]\n\ + count from 1 to 1:\n\ + store iteration_alias as first_values\n\ + end count\n\ + count from 1 to 1:\n\ + store iteration_alias as second_values\n\ + push with iteration_alias and \"text\"\n\ + end count\n\ + create directory at first_values[0]\n", + ) + .expect_err("separate loop scopes may reuse a local name without alias-key collision") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected the first loop's source to retain List, got {diagnostics:?}" + ); +} + +#[test] +fn branch_local_alias_names_do_not_merge_with_later_bindings() { + for source in [ + "store first_values as [1]\n\ + check if no:\n\ + store alias_values as first_values\n\ + end check\n\ + store second_values as [2]\n\ + store alias_values as second_values\n\ + push with alias_values and \"text\"\n\ + create directory at first_values[0]\n", + "store first_values as [1]\n\ + store second_values as [2]\n\ + check if yes:\n\ + store alias_values as first_values\n\ + otherwise:\n\ + store alias_values as second_values\n\ + end check\n\ + push with alias_values and \"text\"\n\ + create directory at second_values[0]\n", + ] { + let diagnostics = typecheck(source) + .expect_err("an unreachable alias branch must not widen an unrelated list") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "only the reachable literal branch may contribute alias effects: {diagnostics:?}" + ); + } +} + +#[test] +fn checker_reuse_does_not_leak_list_alias_groups_between_programs() { + let first = parse_program( + "store values as [1]\n\ + store alias_values as values\n\ + push with alias_values and \"text\"\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ); + let second = parse_program("store values as [1]\ncreate directory at values[0]\n"); + let mut checker = TypeChecker::new(); + + checker + .check_types(&first) + .expect("the first program's alias mutation should be soundly widened"); + let diagnostics = checker + .check_types(&second) + .expect_err("the second program must infer its fresh list independently") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "expected checker reuse to retain fresh List, got {diagnostics:?}" + ); +} + +#[test] +fn structured_collection_joins_preserve_outer_shape() { + assert!( + typecheck( + "store nested as [[1], [\"text\"]]\n\ + store invalid as nested[0] minus 1\n", + ) + .is_err(), + "joining nested list elements must produce List>, not List" + ); + + assert!( + typecheck( + "store values as [1]\n\ + store condition as yes\n\ + check if condition:\n\ + push with values and \"text\"\n\ + end check\n\ + store invalid as values minus 1\n", + ) + .is_err(), + "a control-flow join must preserve that values is still a list" + ); +} + +#[test] +fn nested_list_extraction_escapes_both_alias_views() { + typecheck( + "store nested as [[1]]\n\ + store inner_values as nested[0]\n\ + push with nested[0] and \"text\"\n\ + store removed as pop of inner_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("mutating a nested list path must widen a previously extracted shared list"); +} + +#[test] +fn whole_list_aliases_lift_to_nested_paths() { + typecheck( + "store nested as [[1]]\n\ + store alias_nested as nested\n\ + push with alias_nested[0] and \"text\"\n\ + store removed as pop of nested[0]\n\ + open file at removed for reading as input_file\n", + ) + .expect("whole-list aliases share every nested Rc path"); +} + +#[test] +fn extracted_aliases_translate_deeper_nested_paths() { + typecheck( + "store nested as [[[1]]]\n\ + store inner_values as nested[0]\n\ + push with inner_values[0] and \"text\"\n\ + store removed as pop of nested[0][0]\n\ + open file at removed for reading as input_file\n", + ) + .expect("mutations below an extracted alias retain their relative path depth"); +} + +#[test] +fn nested_list_extraction_preserves_precision_until_a_mutation() { + for source in [ + "store nested as [[1]]\ncreate directory at nested[0][0]\n", + "store nested as [[1]]\n\ + store inner_values as nested[0]\n\ + create directory at inner_values[0]\n", + ] { + let diagnostics = typecheck(source) + .expect_err("alias creation alone cannot make a known Number gradual") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "the untouched nested element must remain Number: {diagnostics:?}" + ); + } +} + +#[test] +fn aggregate_literals_retain_nested_list_alias_provenance() { + typecheck( + "store values as [1]\n\ + store nested as [values]\n\ + push with nested[0] and \"text\"\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("list literals shallow-clone nested list Rc values"); +} + +#[test] +fn aggregate_alias_paths_do_not_merge_distinct_elements() { + let diagnostics = typecheck( + "store first_values as [1]\n\ + store second_values as [2]\n\ + store nested as [first_values, second_values]\n\ + push with first_values and \"text\"\n\ + create directory at second_values[0]\n", + ) + .expect_err("different aggregate elements are distinct list allocations") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "mutating one nested list must not widen its sibling: {diagnostics:?}" + ); +} + +#[test] +fn deeply_nested_aggregate_aliases_retain_leaf_provenance() { + typecheck( + "store leaf_values as [1]\n\ + store inner_values as [leaf_values]\n\ + store outer_values as [inner_values]\n\ + push with outer_values[0][0] and \"text\"\n\ + store removed as pop of leaf_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("nested aggregate paths must retain the shared leaf list allocation"); +} + +#[test] +fn inserted_lists_retain_runtime_alias_provenance() { + typecheck( + "store inner_values as [1]\n\ + store outer_values as []\n\ + push with outer_values and inner_values\n\ + push with outer_values[0] and \"text\"\n\ + store removed as pop of inner_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("pushing a list shallow-clones its shared runtime allocation"); +} + +#[test] +fn aggregate_self_reassignment_preserves_descendant_aliases() { + typecheck( + "store leaf_values as [1]\n\ + store outer_values as [leaf_values]\n\ + change outer_values to outer_values\n\ + push with outer_values[0] and \"text\"\n\ + store removed as pop of leaf_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("self-reassignment must retain descendant list provenance"); +} + +#[test] +fn clearing_an_aggregate_detaches_stale_descendant_aliases() { + let diagnostics = typecheck( + "store leaf_values as [1]\n\ + store outer_values as [leaf_values]\n\ + clear outer_values\n\ + push with outer_values and [2]\n\ + push with outer_values[0] and \"text\"\n\ + create directory at leaf_values[0]\n", + ) + .expect_err("clearing the aggregate removes its old nested allocation") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "the detached leaf must remain List: {diagnostics:?}" + ); +} + +#[test] +fn user_action_results_do_not_hide_shared_list_mutations() { + typecheck( + "store values as [1]\n\ + define action called get_values:\n\ + return values\n\ + end action\n\ + store alias_values as call get_values\n\ + push with values and \"text\"\n\ + store removed as pop of alias_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a list returned by user code may share its runtime allocation with captured state"); +} + +#[test] +fn user_actions_escape_captured_lists_they_can_mutate() { + typecheck( + "store values as [1]\n\ + define action called mutate:\n\ + push with values and \"text\"\n\ + end action\n\ + call mutate\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("calling user code must account for mutations to captured runtime lists"); +} + +#[test] +fn bare_zero_argument_action_statements_apply_captured_list_effects() { + typecheck( + "store values as [1]\n\ + define action called mutate:\n\ + push with values and \"text\"\n\ + end action\n\ + mutate\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("bare zero-argument action statements execute and may mutate captured lists"); +} + +#[test] +fn forward_action_calls_propagate_captured_list_effects() { + typecheck( + "store values as [1]\n\ + define action called first:\n\ + call later\n\ + end action\n\ + define action called later:\n\ + push with values and \"text\"\n\ + end action\n\ + call first\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("effect summaries must not depend on action definition order"); +} + +#[test] +fn reassignment_detaches_a_binding_from_its_previous_list_alias() { + let diagnostics = typecheck( + "store first_values as [1]\n\ + store alias_values as first_values\n\ + change alias_values to [2]\n\ + push with alias_values and \"text\"\n\ + create directory at first_values[0]\n", + ) + .expect_err("replacing one alias must leave the original List precise") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "the detached source must retain its Number element type: {diagnostics:?}" + ); +} + +#[test] +fn self_reassignment_preserves_existing_runtime_aliases() { + typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + change alias_values to alias_values\n\ + push with alias_values and \"text\"\n\ + store removed as pop of values\n\ + open file at removed for reading as input_file\n", + ) + .expect("self-assignment retains the same shared list allocation"); +} + +#[test] +fn branch_alias_join_retains_every_runtime_alias_path() { + typecheck( + "store first_values as [1]\n\ + store alias_values as first_values\n\ + store second_values as [2]\n\ + store flag as yes\n\ + check if flag:\n\ + change alias_values to second_values\n\ + end check\n\ + push with alias_values and \"text\"\n\ + store removed as pop of first_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("the implicit else path still aliases the first list at runtime"); +} + +#[test] +fn branch_alias_join_does_not_invent_transitive_aliases() { + let diagnostics = typecheck( + "store first_values as [1]\n\ + store alias_values as first_values\n\ + store second_values as [2]\n\ + store flag as yes\n\ + check if flag:\n\ + change alias_values to second_values\n\ + end check\n\ + push with first_values and \"text\"\n\ + create directory at second_values[0]\n", + ) + .expect_err("the first and second lists are distinct on every runtime path") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(wfl::parser::ast::Type::Number)), + "the unrelated second list must remain List: {diagnostics:?}" + ); +} + +#[test] +fn loop_alias_join_retains_the_zero_iteration_path() { + typecheck( + "store first_values as [1]\n\ + store alias_values as first_values\n\ + store second_values as [2]\n\ + store flag as yes\n\ + repeat while flag:\n\ + change alias_values to second_values\n\ + break\n\ + end repeat\n\ + push with alias_values and \"text\"\n\ + store removed as pop of first_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a maybe-empty loop must retain aliases from its zero-iteration path"); +} + +#[test] +fn foreach_list_items_retain_nested_alias_provenance() { + typecheck( + "store nested as [[1]]\n\ + for each inner_values in nested:\n\ + push with inner_values and \"text\"\n\ + end for\n\ + store removed as pop of nested[0]\n\ + open file at removed for reading as input_file\n", + ) + .expect("for-each clones the inner list Rc, so item mutations affect the collection"); +} + +#[test] +fn checking_an_uncalled_action_does_not_change_runtime_aliases() { + typecheck( + "store values as [1]\n\ + store alias_values as values\n\ + define action called uncalled:\n\ + change alias_values to [2]\n\ + end action\n\ + push with values and \"text\"\n\ + store removed as pop of alias_values\n\ + open file at removed for reading as input_file\n", + ) + .expect("a deferred action body has no effects until the action is called"); +} + +#[test] +fn action_return_joins_preserve_collection_shape() { + assert!( + typecheck( + "define action called choose with parameters condition as boolean:\n\ + check if condition:\n\ + return [1]\n\ + otherwise:\n\ + return [\"text\"]\n\ + end check\n\ + end action\n\ + store result as choose of yes\n\ + store invalid as result minus 1\n", + ) + .is_err(), + "differing list returns must join to List, not top-level Any" + ); +} + +#[test] +fn any_typed_container_method_parameters_accept_concrete_arguments() { + typecheck( + r#" +create container Sink: + action take needs value: any: + display value + end +end + +create new Sink as sink: +end + +sink.take(1) +sink.take("text") +"#, + ) + .unwrap_or_else(|failure| { + panic!( + "Any method parameters must accept concrete values: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn heterogeneous_collection_inference_is_order_independent() { + for source in [ + r#" +create list values: + add parse_json of "1" + add 1 +end list +create directory at values[0] +"#, + r#" +create list values: + add 1 + add parse_json of "1" +end list +create directory at values[0] +"#, + r#" +create map values: + dynamic is parse_json of "1" + fixed is 1 +end map +create directory at values["dynamic"] +"#, + r#" +create map values: + fixed is 1 + dynamic is parse_json of "1" +end map +create directory at values["dynamic"] +"#, + ] { + typecheck(source).unwrap_or_else(|failure| { + panic!( + "Any must dominate a heterogeneous collection join regardless of order: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn unknown_collection_elements_do_not_narrow_to_later_concrete_values() { + let source = r#" +define action called collect with mystery: + create list values: + add mystery + add 1 + end list + create directory at values[0] +end action +"#; + typecheck(source).unwrap_or_else(|failure| { + panic!( + "an unresolved element keeps the collection gradual: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn heterogeneous_appends_widen_lists_instead_of_rejecting_them() { + for append in ["push with values and \"text\"", "add \"text\" to values"] { + let source = format!( + "create list values:\n\ + \x20\x20\x20\x20add 1\n\ + end list\n\ + {append}\n\ + create directory at values[1]\n" + ); + typecheck(&source).unwrap_or_else(|failure| { + panic!( + "WFL lists are heterogeneous, so append must widen to Any: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn push_visits_both_operands_for_semantic_errors() { + let failure = typecheck("push with missing_list and missing_value\n") + .expect_err("undefined push operands must not be skipped"); + let messages: Vec<_> = failure + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect(); + assert!( + messages + .iter() + .any(|message| message.contains("missing_list")) + && messages + .iter() + .any(|message| message.contains("missing_value")), + "both operands must be analyzed: {messages:?}" + ); +} + +#[test] +fn captured_scalar_action_effect_invalidates_optional_narrowing() { + let failure = typecheck( + r#" +define action called maybe_label with parameters enabled as boolean: + check if enabled: + return "ready" + end check +end action + +store label as call maybe_label with yes +define action called clear_label: + change label to nothing +end action + +check if label is not nothing: + call clear_label + open file at label for reading as input_file +end check +"#, + ) + .expect_err("the action can invalidate the guarded Text refinement"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("File path")), + "expected the post-call Optional to fail the file-path contract" + ); +} + +#[test] +fn stored_zero_argument_action_applies_captured_scalar_effects() { + let failure = typecheck( + r#" +define action called maybe_label with parameters enabled as boolean: + check if enabled: + return "ready" + end check +end action + +store label as call maybe_label with yes +define action called clear_label with parameters ignored as number: + change label to nothing +end action +store clearer as clear_label + +check if label is not nothing: + call clearer with 1 + open file at label for reading as input_file +end check +"#, + ) + .expect_err("stored action aliases must carry captured scalar effects"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("File path")), + "expected the stored call to invalidate the Text refinement" + ); +} + +#[test] +fn forward_action_calls_propagate_captured_scalar_effects() { + let failure = typecheck( + r#" +define action called maybe_label with parameters enabled as boolean: + check if enabled: + return "ready" + end check +end action + +store label as call maybe_label with yes +define action called clear_through_helper: + call clear_label +end action +define action called clear_label: + change label to nothing +end action + +check if label is not nothing: + call clear_through_helper + open file at label for reading as input_file +end check +"#, + ) + .expect_err("forward action dependencies must carry scalar effects"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("File path")), + "expected the forward call graph to invalidate the Text refinement" + ); +} + +#[test] +fn opaque_static_method_calls_invalidate_optional_narrowing() { + let failure = typecheck( + r#" +define action called maybe_label with parameters enabled as boolean: + check if enabled: + return "ready" + end check +end action +store label as call maybe_label with yes + +create container Mutator: + static action reset_label: + change label to nothing + end +end + +check if label is not nothing: + Mutator.reset_label() + open file at label for reading as input_file +end check +"#, + ) + .expect_err("an opaque method can invalidate a captured Optional guard"); + assert!( + failure + .into_diagnostics() + .iter() + .any(|error| error.message.contains("File path")), + "expected the method boundary to restore Optional" + ); +} diff --git a/tests/typechecker_legacy_list_property_test.rs b/tests/typechecker_legacy_list_property_test.rs new file mode 100644 index 00000000..00ff32c1 --- /dev/null +++ b/tests/typechecker_legacy_list_property_test.rs @@ -0,0 +1,230 @@ +use std::sync::Arc; +use wfl::parser::ast::{ + Expression, Literal, Parameter, Program, PropertyDefinition, Statement, Type, Visibility, +}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn number(value: i64) -> Expression { + Expression::Literal(Literal::Integer(value), 1, 1) +} + +fn text(value: &str) -> Expression { + Expression::Literal(Literal::String(Arc::from(value)), 1, 1) +} + +fn empty_list() -> Expression { + Expression::Literal(Literal::List(Vec::new()), 1, 1) +} + +fn variable(name: &str) -> Expression { + Expression::Variable(name.to_string(), 1, 1) +} + +fn property(name: &str, property_type: Type) -> PropertyDefinition { + PropertyDefinition { + name: name.to_string(), + property_type: Some(property_type), + default_value: Some(empty_list()), + validation_rules: Vec::new(), + is_static: false, + visibility: Visibility::Public, + line: 1, + column: 1, + } +} + +fn parameter(name: &str, parameter_type: Type) -> Parameter { + Parameter { + name: name.to_string(), + param_type: Some(parameter_type), + default_value: None, + line: 1, + column: 1, + } +} + +fn action(name: &str, parameters: Vec, body: Vec) -> Statement { + Statement::ActionDefinition { + name: name.to_string(), + parameters, + body, + return_type: None, + line: 1, + column: 1, + } +} + +fn container(properties: Vec, methods: Vec) -> Statement { + Statement::ContainerDefinition { + name: "Messages".to_string(), + extends: None, + implements: Vec::new(), + properties, + methods, + events: Vec::new(), + static_properties: Vec::new(), + static_methods: Vec::new(), + line: 1, + column: 1, + } +} + +fn outer_number(name: &str) -> Statement { + Statement::VariableDeclaration { + name: name.to_string(), + value: number(1), + is_constant: false, + line: 1, + column: 1, + } +} + +fn add(value: Expression, list_name: &str) -> Statement { + Statement::AddToListStatement { + value, + list_name: list_name.to_string(), + line: 1, + column: 1, + } +} + +fn check(statements: Vec) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(&Program { statements }) +} + +fn assert_property_error( + result: Result<(), TypeCheckError>, + property_name: &str, + expected_type: &str, +) { + let diagnostics = result + .expect_err("property element contract should be enforced") + .into_diagnostics(); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.message.contains("property") + && diagnostic.message.contains(property_name) + && diagnostic.message.contains(expected_type) + }), + "expected a {expected_type} contract error for property {property_name:?}, got {diagnostics:?}" + ); +} + +#[test] +fn legacy_add_resolves_property_before_outer_binding() { + check(vec![ + outer_number("items"), + container( + vec![property("items", Type::List(Box::new(Type::Text)))], + vec![action( + "append", + Vec::new(), + vec![add(text("hello"), "items")], + )], + ), + ]) + .unwrap_or_else(|failure| { + panic!( + "the current container property should shadow the outer binding: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn legacy_add_rejects_incompatible_property_element() { + assert_property_error( + check(vec![container( + vec![property("items", Type::List(Box::new(Type::Text)))], + vec![action("corrupt", Vec::new(), vec![add(number(1), "items")])], + )]), + "items", + "Text", + ); +} + +#[test] +fn legacy_add_rejects_gradual_value_for_concrete_property_element() { + assert_property_error( + check(vec![container( + vec![property("items", Type::List(Box::new(Type::Text)))], + vec![action( + "append_dynamic", + vec![parameter("value", Type::Any)], + vec![add(variable("value"), "items")], + )], + )]), + "items", + "Text", + ); +} + +#[test] +fn legacy_add_accepts_fresh_empty_list_for_nested_property_element() { + check(vec![ + outer_number("groups"), + container( + vec![property( + "groups", + Type::List(Box::new(Type::List(Box::new(Type::Number)))), + )], + vec![action( + "append_empty", + Vec::new(), + vec![add(empty_list(), "groups")], + )], + ), + ]) + .unwrap_or_else(|failure| { + panic!( + "a fresh empty list satisfies any declared nested list element shape: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn legacy_remove_and_clear_resolve_property_before_outer_binding() { + check(vec![ + outer_number("items"), + container( + vec![property("items", Type::List(Box::new(Type::Text)))], + vec![action( + "reset", + Vec::new(), + vec![ + Statement::RemoveFromListStatement { + value: text("obsolete"), + list_name: "items".to_string(), + line: 1, + column: 1, + }, + Statement::ClearListStatement { + list_name: "items".to_string(), + line: 1, + column: 1, + }, + ], + )], + ), + ]) + .unwrap_or_else(|failure| { + panic!( + "remove/clear should resolve the current property before the outer binding: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn action_parameter_still_shadows_same_named_list_property() { + check(vec![container( + vec![property("items", Type::List(Box::new(Type::Text)))], + vec![action( + "append", + vec![parameter("items", Type::Number)], + vec![add(number(1), "items")], + )], + )]) + .expect("the local Number parameter should select arithmetic add and shadow the property"); +} diff --git a/tests/typechecker_loop_runtime_parity_test.rs b/tests/typechecker_loop_runtime_parity_test.rs new file mode 100644 index 00000000..4f9db684 --- /dev/null +++ b/tests/typechecker_loop_runtime_parity_test.rs @@ -0,0 +1,312 @@ +use wfl::parser::ast::{Expression, Literal, Program, Statement}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn boolean(value: bool) -> Expression { + Expression::Literal(Literal::Boolean(value), 1, 1) +} + +fn number(value: i64) -> Expression { + Expression::Literal(Literal::Integer(value), 1, 1) +} + +fn variable(name: &str) -> Expression { + Expression::Variable(name.to_string(), 1, 1) +} + +fn declaration(name: &str, value: Expression, is_constant: bool) -> Statement { + Statement::VariableDeclaration { + name: name.to_string(), + value, + is_constant, + line: 1, + column: 1, + } +} + +fn assignment(name: &str, value: Expression) -> Statement { + Statement::Assignment { + name: name.to_string(), + value, + line: 1, + column: 1, + } +} + +fn messages(statements: Vec) -> Vec { + TypeChecker::new() + .check_types(&Program { statements }) + .expect_err("program should be rejected") + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect() +} + +fn typecheck(statements: Vec) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(&Program { statements }) +} + +fn invalid_first_iteration_body() -> Vec { + vec![ + Statement::WaitForDurationStatement { + duration: variable("changing"), + unit: "milliseconds".to_string(), + line: 1, + column: 1, + }, + assignment("changing", number(1)), + ] +} + +#[test] +fn persistent_loops_preserve_diagnostics_from_the_first_iteration() { + let loops = [ + Statement::WhileLoop { + condition: boolean(true), + body: invalid_first_iteration_body(), + line: 1, + column: 1, + }, + Statement::RepeatWhileLoop { + condition: boolean(true), + body: invalid_first_iteration_body(), + line: 1, + column: 1, + }, + Statement::RepeatUntilLoop { + condition: boolean(false), + body: invalid_first_iteration_body(), + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + let diagnostics = messages(vec![ + declaration( + "changing", + Expression::Literal(Literal::Nothing, 1, 1), + false, + ), + loop_statement, + ]); + assert!( + diagnostics + .iter() + .any(|message| message.contains("number for wait duration")), + "the deterministic first-iteration Number mismatch must survive fixed-point checking: \ + {diagnostics:?}" + ); + } +} + +#[test] +fn constants_in_persistent_loop_environments_cannot_be_redeclared() { + let constant_body = || vec![declaration("per_iteration", number(1), true)]; + let loops = [ + Statement::WhileLoop { + condition: boolean(true), + body: constant_body(), + line: 1, + column: 1, + }, + Statement::RepeatWhileLoop { + condition: boolean(true), + body: constant_body(), + line: 1, + column: 1, + }, + Statement::RepeatUntilLoop { + condition: boolean(false), + body: constant_body(), + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + let diagnostics = messages(vec![loop_statement]); + assert!( + diagnostics.iter().any(|message| { + message.to_lowercase().contains("constant") && message.contains("per_iteration") + }), + "a second iteration would redeclare the same runtime constant: {diagnostics:?}" + ); + } +} + +#[test] +fn nested_persistent_loops_inherit_outer_reentry_state() { + let nested_loop = Statement::WhileLoop { + condition: boolean(true), + body: vec![ + declaration("nested_constant", number(1), true), + Statement::BreakStatement { line: 1, column: 1 }, + ], + line: 1, + column: 1, + }; + let outer_loop = Statement::WhileLoop { + condition: boolean(true), + body: vec![nested_loop], + line: 1, + column: 1, + }; + + let diagnostics = messages(vec![outer_loop]); + assert!( + diagnostics.iter().any(|message| { + message.to_lowercase().contains("constant") && message.contains("nested_constant") + }), + "a nested persistent scope survives outer-loop re-entry: {diagnostics:?}" + ); +} + +#[test] +fn constants_in_fresh_iteration_environments_remain_valid() { + let constant_body = || vec![declaration("per_iteration", number(1), true)]; + let loops = [ + Statement::ForEachLoop { + item_name: "item".to_string(), + collection: Expression::Literal(Literal::List(vec![number(1), number(2)]), 1, 1), + reversed: false, + body: constant_body(), + line: 1, + column: 1, + }, + Statement::CountLoop { + start: number(1), + end: number(2), + step: None, + downward: false, + variable_name: None, + body: constant_body(), + line: 1, + column: 1, + }, + Statement::ForeverLoop { + body: constant_body(), + line: 1, + column: 1, + }, + Statement::MainLoop { + body: constant_body(), + concurrent: false, + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + typecheck(vec![loop_statement]) + .expect("the runtime creates or clears the loop child environment every iteration"); + } +} + +#[test] +fn constants_are_valid_when_a_persistent_loop_cannot_reach_a_second_iteration() { + let constant_body = || vec![declaration("single_iteration", number(1), true)]; + let loops = [ + Statement::WhileLoop { + condition: boolean(false), + body: constant_body(), + line: 1, + column: 1, + }, + Statement::RepeatWhileLoop { + condition: boolean(false), + body: constant_body(), + line: 1, + column: 1, + }, + Statement::RepeatUntilLoop { + condition: boolean(true), + body: constant_body(), + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + typecheck(vec![loop_statement]) + .expect("a statically zero/one-iteration loop cannot redeclare its constant"); + } +} + +#[test] +fn pre_test_loops_preserve_diagnostics_from_the_first_condition_check() { + let loops = [ + Statement::WhileLoop { + condition: variable("condition_value"), + body: vec![assignment("condition_value", boolean(true))], + line: 1, + column: 1, + }, + Statement::RepeatWhileLoop { + condition: variable("condition_value"), + body: vec![assignment("condition_value", boolean(true))], + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + let diagnostics = messages(vec![ + declaration( + "condition_value", + Expression::Literal(Literal::Nothing, 1, 1), + false, + ), + loop_statement, + ]); + assert!( + diagnostics + .iter() + .any(|message| message.to_lowercase().contains("boolean")), + "the first condition is evaluated before the body can widen its type: \ + {diagnostics:?}" + ); + } +} + +#[test] +fn statically_false_pre_test_loops_do_not_apply_unreachable_body_types() { + let loops = [ + Statement::WhileLoop { + condition: boolean(false), + body: vec![assignment("duration", number(1))], + line: 1, + column: 1, + }, + Statement::RepeatWhileLoop { + condition: boolean(false), + body: vec![assignment("duration", number(1))], + line: 1, + column: 1, + }, + ]; + + for loop_statement in loops { + let diagnostics = messages(vec![ + declaration( + "duration", + Expression::Literal(Literal::Nothing, 1, 1), + false, + ), + loop_statement, + Statement::WaitForDurationStatement { + duration: variable("duration"), + unit: "milliseconds".to_string(), + line: 1, + column: 1, + }, + ]); + assert!( + diagnostics + .iter() + .any(|message| message.contains("number for wait duration")), + "a statically unreachable body must not widen the post-loop binding: \ + {diagnostics:?}" + ); + } +} diff --git a/tests/typechecker_response_contract_test.rs b/tests/typechecker_response_contract_test.rs new file mode 100644 index 00000000..93f5b3e6 --- /dev/null +++ b/tests/typechecker_response_contract_test.rs @@ -0,0 +1,117 @@ +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn typecheck(source: &str) -> Result<(), TypeCheckError> { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new().check_types(&program) +} + +fn request_program(response: &str) -> String { + format!( + "listen on port 8080 as srv\n\ + wait for request comes in on srv as req\n\ + {response}\n" + ) +} + +#[test] +fn response_content_accepts_every_runtime_scalar_type() { + for content in ["42", "yes", "nothing", "\"text\""] { + let source = request_program(&format!("respond to req with {content}")); + typecheck(&source).unwrap_or_else(|failure| { + panic!( + "runtime-supported response content {content} must typecheck: {:?}", + failure.into_diagnostics() + ) + }); + } +} + +#[test] +fn response_content_accepts_an_optional_runtime_scalar() { + let source = r#" +define action called maybe_content with parameters enabled: + check if enabled: + return "ready" + end check +end action + +listen on port 8080 as srv +wait for request comes in on srv as req +store content_value as call maybe_content with no +respond to req with content_value +"#; + typecheck(source).unwrap_or_else(|failure| { + panic!( + "both Text and Nothing are runtime-supported response values: {:?}", + failure.into_diagnostics() + ) + }); +} + +#[test] +fn response_content_rejects_runtime_unsupported_composites() { + let source = request_program("respond to req with [1 and 2]"); + let diagnostics = typecheck(&source) + .expect_err("composite response content fails at runtime") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Response content")), + "expected a response-content diagnostic, got: {diagnostics:?}" + ); + + let source = "listen on port 8080 as srv\n\ + wait for request comes in on srv as req\n\ + create map payload:\n\ + \x20\x20\x20\x20key is \"value\"\n\ + end map\n\ + respond to req with payload\n"; + let diagnostics = typecheck(source) + .expect_err("map response content fails at runtime") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Response content")), + "expected a response-content diagnostic, got: {diagnostics:?}" + ); +} + +#[test] +fn response_request_operand_is_traversed_and_must_be_request_shaped() { + let diagnostics = typecheck(r#"respond to "not a request" with "ok""#) + .expect_err("a concrete text value is not a request object") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("request object")), + "expected a request-object diagnostic, got: {diagnostics:?}" + ); + + let diagnostics = typecheck(r#"respond to (1 minus "x") with "ok""#) + .expect_err("nested errors in the request expression must be visited") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.message.contains("Cannot perform Minus")), + "expected the nested request-expression diagnostic, got: {diagnostics:?}" + ); +} + +#[test] +fn implicit_request_headers_have_a_reusable_map_type() { + let source = request_program(r#"respond to req with "ok" and headers headers"#); + typecheck(&source).unwrap_or_else(|failure| { + panic!( + "runtime request headers are a text-valued map: {:?}", + failure.into_diagnostics() + ) + }); +} diff --git a/tests/typechecker_response_stream_join_test.rs b/tests/typechecker_response_stream_join_test.rs index dadd9330..67b5131d 100644 --- a/tests/typechecker_response_stream_join_test.rs +++ b/tests/typechecker_response_stream_join_test.rs @@ -29,7 +29,7 @@ fn text_literal(value: &str) -> Expression { fn stream_binding() -> Statement { Statement::StartStreamingResponseStatement { - request: text_literal("request"), + request: Expression::Variable("req".to_string(), 2, 1), status: Some(Expression::Literal(Literal::Integer(200), 2, 1)), content_type: None, headers: None, @@ -39,6 +39,15 @@ fn stream_binding() -> Statement { } } +fn with_request_setup(statements: Vec) -> Program { + let mut program = parse( + "listen on port 8080 as srv\n\ + wait for request comes in on srv as req\n", + ); + program.statements.extend(statements); + program +} + fn invalid_stream_lead_with_valid_file_fallback() -> Statement { Statement::StreamWriteStatement { value: Expression::BinaryOperation { @@ -56,6 +65,23 @@ fn invalid_stream_lead_with_valid_file_fallback() -> Statement { } } +fn valid_stream_lead_with_invalid_file_fallback() -> Statement { + Statement::StreamWriteStatement { + value: text_literal("valid stream text"), + target: Expression::Variable("out".to_string(), 2, 1), + is_line: true, + fallback_content: Some(Box::new(Expression::BinaryOperation { + left: Box::new(Expression::Literal(Literal::Integer(10), 2, 1)), + operator: Operator::Minus, + right: Box::new(text_literal("not a number")), + line: 2, + column: 1, + })), + line: 2, + column: 1, + } +} + fn open_out_file() -> Statement { Statement::OpenFileStatement { path: text_literal("unused.txt"), @@ -68,13 +94,15 @@ fn open_out_file() -> Statement { fn ambiguous_file_write_program(control: Statement) -> Program { let mut program = parse( - "open file at \"unused.txt\" for writing as out\n\ + "listen on port 8080 as srv\n\ + wait for request comes in on srv as req\n\ + open file at \"unused.txt\" for writing as out\n\ store value as 10\n\ store line value as \"text\"\n\ store n as 1\n\ write line value minus n to out\n", ); - program.statements.insert(1, control); + program.statements.insert(3, control); program } @@ -125,20 +153,18 @@ fn maybe_skipped_stream_bindings_require_both_write_readings_to_be_valid() { #[test] fn while_loop_rechecks_stream_lead_after_tail_response_stream_rebind() { - let program = Program { - statements: vec![ - open_out_file(), - Statement::WhileLoop { - condition: bool_literal(true), - body: vec![ - invalid_stream_lead_with_valid_file_fallback(), - stream_binding(), - ], - line: 2, - column: 1, - }, - ], - }; + let program = with_request_setup(vec![ + open_out_file(), + Statement::WhileLoop { + condition: bool_literal(true), + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + ]); let errors = typecheck(&program) .expect_err("the loop backedge must recheck the body under ResponseStream or File"); @@ -151,20 +177,18 @@ fn while_loop_rechecks_stream_lead_after_tail_response_stream_rebind() { #[test] fn repeat_while_loop_rechecks_stream_lead_after_tail_response_stream_rebind() { - let program = Program { - statements: vec![ - open_out_file(), - Statement::RepeatWhileLoop { - condition: bool_literal(true), - body: vec![ - invalid_stream_lead_with_valid_file_fallback(), - stream_binding(), - ], - line: 2, - column: 1, - }, - ], - }; + let program = with_request_setup(vec![ + open_out_file(), + Statement::RepeatWhileLoop { + condition: bool_literal(true), + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + ]); let errors = typecheck(&program) .expect_err("the repeat-loop backedge must recheck the body under ResponseStream or File"); @@ -175,32 +199,85 @@ fn repeat_while_loop_rechecks_stream_lead_after_tail_response_stream_rebind() { ); } +#[test] +fn repeat_until_checks_the_condition_after_the_first_body_execution() { + let program = parse( + "store done as nothing\n\ + repeat until done:\n\ + change done to yes\n\ + end repeat\n", + ); + + assert!( + typecheck(&program).is_ok(), + "repeat-until is a post-test loop, so its body may establish the condition type first: {:?}", + typecheck(&program).err() + ); +} + +#[test] +fn repeat_until_body_bindings_are_visible_to_its_condition() { + let program = parse( + "repeat until done:\n\ + store done as yes\n\ + end repeat\n", + ); + + assert!( + typecheck(&program).is_ok(), + "a post-test loop condition may reference a binding established by its body: {:?}", + typecheck(&program).err() + ); +} + +#[test] +fn repeat_until_rechecks_stream_lead_after_tail_response_stream_rebind() { + let program = with_request_setup(vec![ + open_out_file(), + Statement::RepeatUntilLoop { + condition: bool_literal(false), + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + ]); + + let errors = typecheck(&program) + .expect_err("the repeat-until backedge must recheck the body under joined state"); + assert!( + errors.contains("Cannot perform Minus operation"), + "the first iteration has a valid File fallback, but a later iteration must reject \ + the Number/Text stream lead after the tail ResponseStream rebind; got: {errors}" + ); +} + #[test] fn two_concrete_branch_types_join_instead_of_taking_the_last_checked_branch() { - let program = Program { - statements: vec![ - Statement::IfStatement { - condition: bool_literal(true), - then_block: vec![stream_binding()], - else_block: Some(vec![Statement::OpenFileStatement { - path: text_literal("unused.txt"), - variable_name: "out".to_string(), - mode: FileOpenMode::Write, - line: 3, - column: 1, - }]), - line: 1, + let program = with_request_setup(vec![ + Statement::IfStatement { + condition: bool_literal(true), + then_block: vec![stream_binding()], + else_block: Some(vec![Statement::OpenFileStatement { + path: text_literal("unused.txt"), + variable_name: "out".to_string(), + mode: FileOpenMode::Write, + line: 3, column: 1, - }, - Statement::FlushStreamStatement { - target: Expression::Variable("out".to_string(), 5, 1), - legacy_binding: None, - action_fallback: None, - line: 5, - column: 1, - }, - ], - }; + }]), + line: 1, + column: 1, + }, + Statement::FlushStreamStatement { + target: Expression::Variable("out".to_string(), 5, 1), + legacy_binding: None, + action_fallback: None, + line: 5, + column: 1, + }, + ]); assert!( typecheck(&program).is_ok(), @@ -209,3 +286,131 @@ fn two_concrete_branch_types_join_instead_of_taking_the_last_checked_branch() { typecheck(&program).err() ); } + +#[test] +fn maybe_empty_collection_loops_preserve_the_zero_iteration_type_path() { + let controls = [ + Statement::ForEachLoop { + item_name: "item".to_string(), + collection: Expression::Literal(Literal::List(vec![]), 2, 1), + reversed: false, + body: vec![stream_binding()], + line: 2, + column: 1, + }, + Statement::CountLoop { + start: Expression::Literal(Literal::Integer(2), 2, 1), + end: Expression::Literal(Literal::Integer(1), 2, 1), + step: None, + downward: false, + variable_name: None, + body: vec![stream_binding()], + line: 2, + column: 1, + }, + ]; + + for control in controls { + let program = with_request_setup(vec![ + open_out_file(), + control, + valid_stream_lead_with_invalid_file_fallback(), + ]); + let errors = typecheck(&program) + .expect_err("a maybe-empty loop must retain the possible outer File state"); + assert!( + errors.contains("Cannot perform Minus operation"), + "the possible zero-iteration File path must validate the classic write fallback: \ + {errors}" + ); + } +} + +#[test] +fn maybe_empty_loops_do_not_apply_outer_assignments_as_definite() { + for source in [ + "store value as nothing\n\ + count from 2 to 1:\n\ + change value to today\n\ + end count\n\ + store result as value minus 1\n", + "store value as nothing\n\ + store items as []\n\ + for each item in items:\n\ + change value to today\n\ + end for\n\ + store result as value minus 1\n", + ] { + let program = parse(source); + let errors = + typecheck(&program).expect_err("a maybe-empty loop leaves a Date-or-Nothing value"); + assert!( + errors.contains("Date or Nothing"), + "the zero-iteration and assignment paths must retain Optional: {errors}" + ); + } +} + +#[test] +fn collection_and_infinite_loops_reset_iteration_local_bindings() { + let loops = [ + Statement::ForEachLoop { + item_name: "item".to_string(), + collection: Expression::Literal( + Literal::List(vec![ + Expression::Literal(Literal::Integer(1), 2, 1), + Expression::Literal(Literal::Integer(2), 2, 1), + ]), + 2, + 1, + ), + reversed: false, + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + Statement::CountLoop { + start: Expression::Literal(Literal::Integer(1), 2, 1), + end: Expression::Literal(Literal::Integer(2), 2, 1), + step: None, + downward: false, + variable_name: None, + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + Statement::ForeverLoop { + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + line: 2, + column: 1, + }, + Statement::MainLoop { + body: vec![ + invalid_stream_lead_with_valid_file_fallback(), + stream_binding(), + ], + concurrent: false, + line: 2, + column: 1, + }, + ]; + + for loop_statement in loops { + let program = with_request_setup(vec![open_out_file(), loop_statement]); + assert!( + typecheck(&program).is_ok(), + "the runtime clears each iteration's local ResponseStream before the next iteration; \ + errors: {:?}", + typecheck(&program).err() + ); + } +} diff --git a/tests/typechecker_response_stream_scope_test.rs b/tests/typechecker_response_stream_scope_test.rs index 62a4587c..553610a6 100644 --- a/tests/typechecker_response_stream_scope_test.rs +++ b/tests/typechecker_response_stream_scope_test.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; -use wfl::parser::ast::{Expression, Literal, Operator, Program, Statement, WsHandlerEvent}; +use wfl::parser::ast::{ + Argument, Expression, Literal, Operator, Program, Statement, WsHandlerEvent, +}; use wfl::typechecker::TypeChecker; fn typecheck(code: &str) -> Result<(), String> { @@ -30,7 +32,7 @@ fn text_literal(value: &str) -> Expression { fn stream_binding() -> Statement { Statement::StartStreamingResponseStatement { - request: text_literal("request"), + request: Expression::Variable("req".to_string(), 2, 1), status: Some(Expression::Literal(Literal::Integer(200), 2, 1)), content_type: None, headers: None, @@ -40,6 +42,34 @@ fn stream_binding() -> Statement { } } +fn request_binding() -> Statement { + Statement::WaitForRequestStatement { + server: text_literal("server"), + request_name: "req".to_string(), + timeout: None, + line: 1, + column: 1, + } +} + +fn dynamic_event_source_binding() -> Statement { + Statement::VariableDeclaration { + name: "source".to_string(), + value: Expression::FunctionCall { + function: Box::new(Expression::Variable("parse_json".to_string(), 1, 1)), + arguments: vec![Argument { + name: None, + value: text_literal("{}"), + }], + line: 1, + column: 1, + }, + is_constant: false, + line: 1, + column: 1, + } +} + fn outer_number_binding() -> Statement { Statement::VariableDeclaration { name: "out".to_string(), @@ -68,21 +98,23 @@ fn subtract_from_outer_out() -> Statement { fn response_stream_bindings_do_not_escape_typechecker_child_scopes() { let scoped_blocks = [ "repeat while false:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ end repeat\n", "try:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ when error:\n\ \x20\x20\x20\x20display \"ignored\"\n\ end try\n", "count from 1 to 1:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ end count\n", ]; for scoped_block in scoped_blocks { let source = format!( - "open file at \"unused.txt\" for writing as out\n\ + "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + open file at \"unused.txt\" for writing as out\n\ {scoped_block}\ store value as \"wrong stream type\"\n\ store line value as 10\n\ @@ -102,24 +134,26 @@ fn response_stream_bindings_do_not_escape_typechecker_child_scopes() { fn response_stream_bindings_are_local_while_outer_text_remains_visible_afterward() { let scoped_blocks = [ "repeat while false:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20flush out\n\ end repeat\n", "try:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20flush out\n\ when error:\n\ \x20\x20\x20\x20display \"ignored\"\n\ end try\n", "count from 1 to 1:\n\ - \x20\x20\x20\x20start streaming response to \"request\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20flush out\n\ end count\n", ]; for scoped_block in scoped_blocks { let source = format!( - "store out as \"outer text\"\n\ + "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + store out as \"outer text\"\n\ {scoped_block}\ store invalid as out minus 1\n" ); @@ -156,9 +190,11 @@ fn default_count_binding_does_not_retype_an_outer_count_variable() { fn event_handler_body_types_do_not_leak_after_registration() { let program = Program { statements: vec![ + request_binding(), + dynamic_event_source_binding(), outer_number_binding(), Statement::EventHandler { - event_source: text_literal("source"), + event_source: Expression::Variable("source".to_string(), 2, 1), event_name: "changed".to_string(), handler_body: vec![stream_binding()], line: 2, @@ -180,6 +216,7 @@ fn event_handler_body_types_do_not_leak_after_registration() { fn websocket_handler_body_types_do_not_leak_after_registration() { let program = Program { statements: vec![ + request_binding(), outer_number_binding(), Statement::WebSocketHandlerStatement { event: WsHandlerEvent::Connect, diff --git a/tests/typechecker_reuse_test.rs b/tests/typechecker_reuse_test.rs new file mode 100644 index 00000000..0955bfb7 --- /dev/null +++ b/tests/typechecker_reuse_test.rs @@ -0,0 +1,77 @@ +use wfl::analyzer::Analyzer; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::{Parser, ast::Program}; +use wfl::typechecker::TypeChecker; + +fn parse(source: &str) -> Program { + let tokens = lex_wfl_with_positions(source); + Parser::new(&tokens) + .parse() + .expect("test program should parse") +} + +#[test] +fn a_failed_run_does_not_poison_the_next_typecheck() { + let mut checker = TypeChecker::new(); + assert!( + checker + .check_types(&parse("store bad as 1 minus \"x\"\n")) + .is_err() + ); + assert!( + checker.check_types(&Program::new()).is_ok(), + "per-run diagnostics must be cleared" + ); +} + +#[test] +fn independent_runs_do_not_reuse_program_symbols() { + let mut checker = TypeChecker::new(); + checker + .check_types(&parse("store value as 1\n")) + .expect("first program should typecheck"); + checker + .check_types(&parse("store value as 2\n")) + .expect("the next program has an independent top-level scope"); +} + +#[test] +fn with_analyzer_is_preanalyzed_for_one_run_only() { + let first = parse("store first_only as 1\n"); + let mut analyzer = Analyzer::new(); + analyzer + .analyze(&first) + .expect("first program should analyze"); + let mut checker = TypeChecker::with_analyzer(analyzer); + checker + .check_types(&first) + .expect("the supplied analyzer belongs to the first run"); + + assert!( + checker.check_types(&parse("display first_only\n")).is_err(), + "a reused with_analyzer checker must analyze the second independent program" + ); +} + +#[test] +fn analyzer_reuse_does_not_reuse_program_symbols() { + let mut analyzer = Analyzer::new(); + analyzer + .analyze(&parse("store stale as 1\n")) + .expect("first program should analyze"); + + assert!( + analyzer.analyze(&parse("display stale\n")).is_err(), + "the next independent program must not resolve a prior binding" + ); +} + +#[test] +fn analyzer_reuse_clears_prior_diagnostics() { + let mut analyzer = Analyzer::new(); + assert!(analyzer.analyze(&parse("display missing_name\n")).is_err()); + assert!( + analyzer.analyze(&Program::new()).is_ok(), + "an empty second program must not inherit the first run's errors" + ); +} diff --git a/tests/typechecker_runtime_binding_test.rs b/tests/typechecker_runtime_binding_test.rs new file mode 100644 index 00000000..3ee36d7b --- /dev/null +++ b/tests/typechecker_runtime_binding_test.rs @@ -0,0 +1,330 @@ +use wfl::interpreter::Interpreter; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; +use wfl::typechecker::TypeChecker; + +fn diagnostics(source: &str) -> Vec { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new() + .check_types(&program) + .expect_err("the concrete runtime binding must make subtraction invalid") + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect() +} + +fn assert_invalid_minus(source: &str, expected_type: &str) { + let errors = diagnostics(source); + assert!( + errors.iter().any(|message| { + message.contains("Cannot perform Minus") && message.contains(expected_type) + }), + "expected subtraction to see the runtime binding as {expected_type}, got: {errors:?}" + ); +} + +#[test] +fn action_local_io_results_keep_their_concrete_types() { + assert_invalid_minus( + r#" +define action called inspect_file: + open file at "unused.txt" and read content as contents + store invalid as contents minus 1 +end action +"#, + "Text", + ); + + assert_invalid_minus( + r#" +define action called inspect_http: + open url at "https://example.com" and read content as reply + store invalid as reply minus 1 +end action +"#, + "Text", + ); + + assert_invalid_minus( + r#" +define action called inspect_execution: + execute wfl file at "unused.wfl" and read output as page_text + store invalid as page_text minus 1 +end action +"#, + "Text", + ); +} + +#[test] +fn action_local_collection_and_calendar_bindings_keep_their_concrete_types() { + assert_invalid_minus( + r#" +define action called inspect_list: + create list items: + add 1 + end list + store invalid as items minus 1 +end action +"#, + "List", + ); + + assert_invalid_minus( + r#" +define action called inspect_map: + create map details: + label is "value" + end map + store invalid as details minus 1 +end action +"#, + "Map", + ); + + assert_invalid_minus( + r#" +define action called inspect_date: + create date due + store invalid as due minus 1 +end action +"#, + "Date", + ); + + assert_invalid_minus( + r#" +define action called inspect_time: + create time started + store invalid as started minus 1 +end action +"#, + "Time", + ); + + // The explicit forms are pass-through bindings at runtime, not coercions. + assert_invalid_minus( + r#" +define action called inspect_explicit_calendar_values: + create date label_date as "not coerced" + store invalid_date as label_date minus 1 +end action +"#, + "Text", + ); + assert_invalid_minus( + r#" +define action called inspect_explicit_clock_values: + create time label_time as "not coerced" + store invalid_time as label_time minus 1 +end action +"#, + "Text", + ); +} + +#[test] +fn request_bindings_are_recreated_in_runtime_loop_scopes() { + assert_invalid_minus( + r#" +listen on port 8080 as srv +main loop: + wait for request comes in on srv as req + store invalid as body minus 1 + break +end loop +"#, + "Text", + ); +} + +#[test] +fn action_local_patterns_keep_their_pattern_type() { + assert_invalid_minus( + r#" +define action called inspect_pattern: + create pattern local_pattern: + "x" + end pattern + store invalid as local_pattern minus 1 +end action +"#, + "Pattern", + ); +} + +#[test] +fn websocket_server_and_event_bindings_keep_their_runtime_types() { + assert_invalid_minus( + r#" +define action called inspect_websocket_server: + listen for websockets on port 0 as ws_server + store invalid as ws_server minus 1 +end action +"#, + "Text", + ); + + assert_invalid_minus( + r#" +listen for websockets on port 0 as ws_server +on websocket message from ws_server as message_event: + store invalid as message_event minus 1 +end on +"#, + "Map", + ); + + for event in ["connect to", "disconnect from"] { + let source = format!( + "listen for websockets on port 0 as ws_server\n\ + on websocket {event} ws_server as connection:\n\ + store invalid as connection[\"id\"] minus 1\n\ + end on\n" + ); + assert_invalid_minus(&source, "Text"); + } +} + +#[tokio::test] +async fn explicit_method_local_binders_shadow_same_named_properties_at_runtime() { + for source in [ + r#" +create container Worker: + property item: Number defaults 0 + + action run: + for each item in ["ok"]: + display touppercase of item + end for + end +end + +create new Worker as worker: +end +store result as worker.run() +"#, + r#" +create container Worker: + property items: Number defaults 0 + + action run: + create list items: + add "ok" + end list + display length of items + end +end + +create new Worker as worker: +end +store result as worker.run() +"#, + ] { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new() + .check_types(&program) + .unwrap_or_else(|failure| { + panic!( + "the explicit local binding should shadow the property statically: {:?}", + failure.into_diagnostics() + ) + }); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| { + panic!("the runtime must create the same local binding: {errors:?}") + }); + } +} + +#[tokio::test] +async fn nested_declarations_shadow_same_named_properties_at_runtime() { + for source in [ + r#" +create container Worker: + property helper: Number defaults 0 + + action run: + define action called helper: + return "ok" + end action + display call helper + end +end + +create new Worker as worker: +end +store result as worker.run() +"#, + r#" +create container Worker: + property LocalType: Number defaults 0 + + action run: + create container LocalType: + end + end +end + +create new Worker as worker: +end +store result as worker.run() +"#, + r#" +create container Worker: + property LocalContract: Number defaults 0 + + action run: + create interface LocalContract + end +end + +create new Worker as worker: +end +store result as worker.run() +"#, + r#" +create container Worker: + static property helper: Number defaults 0 + + static action run: + define action called helper: + return "ok" + end action + display call helper + end +end + +store result as Worker.run() +"#, + ] { + let tokens = lex_wfl_with_positions(source); + let mut parser = Parser::new(&tokens); + let program = parser.parse().expect("test program should parse"); + TypeChecker::new() + .check_types(&program) + .unwrap_or_else(|failure| { + panic!( + "the nested declaration should shadow the property statically: {:?}", + failure.into_diagnostics() + ) + }); + + let mut interpreter = Interpreter::new(); + interpreter + .interpret(&program) + .await + .unwrap_or_else(|errors| { + panic!("the runtime must create the same local declaration: {errors:?}") + }); + } +} diff --git a/tests/typechecker_statement_completion_parity_test.rs b/tests/typechecker_statement_completion_parity_test.rs new file mode 100644 index 00000000..22743f6a --- /dev/null +++ b/tests/typechecker_statement_completion_parity_test.rs @@ -0,0 +1,183 @@ +use wfl::parser::ast::{Expression, Literal, PatternExpression, Program, Statement, Type}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn number(value: i64) -> Expression { + Expression::Literal(Literal::Integer(value), 1, 1) +} + +fn action(name: &str, return_type: Type, body: Vec) -> Statement { + Statement::ActionDefinition { + name: name.to_string(), + parameters: vec![], + body, + return_type: Some(return_type), + line: 1, + column: 1, + } +} + +fn container(name: &str, extends: Option<&str>, methods: Vec) -> Statement { + Statement::ContainerDefinition { + name: name.to_string(), + extends: extends.map(str::to_string), + implements: vec![], + properties: vec![], + methods, + events: vec![], + static_properties: vec![], + static_methods: vec![], + line: 1, + column: 1, + } +} + +fn check(statements: Vec) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(&Program { statements }) +} + +fn assert_exact_completion_type( + prerequisites: Vec, + completion: Statement, + expected: Type, +) { + let mut accepted = prerequisites.clone(); + accepted.push(action( + "returns_expected_type", + expected, + vec![completion.clone()], + )); + check(accepted).expect("the implicit result must have the runtime statement's exact type"); + + let mut rejected = prerequisites; + rejected.push(action("rejects_wrong_type", Type::Text, vec![completion])); + let diagnostics = check(rejected) + .expect_err("an exact statement result must not degrade to Any") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("implicit result")), + "expected an implicit-result mismatch, got {diagnostics:?}" + ); +} + +#[test] +fn container_instantiation_completes_with_the_instance() { + let widget = container("Widget", None, vec![]); + let instantiate = Statement::ContainerInstantiation { + container_type: "Widget".to_string(), + instance_name: "widget".to_string(), + arguments: vec![], + property_initializers: vec![], + line: 1, + column: 1, + }; + + assert_exact_completion_type( + vec![widget], + instantiate, + Type::ContainerInstance("Widget".to_string()), + ); +} + +#[test] +fn container_definition_completes_with_the_definition() { + let definition = container("InnerContainer", None, vec![]); + assert_exact_completion_type( + vec![], + definition, + Type::Container("InnerContainer".to_string()), + ); +} + +#[test] +fn interface_definition_completes_with_the_definition() { + let definition = Statement::InterfaceDefinition { + name: "Renderable".to_string(), + extends: vec![], + required_actions: vec![], + line: 1, + column: 1, + }; + assert_exact_completion_type( + vec![], + definition, + Type::Interface("Renderable".to_string()), + ); +} + +#[test] +fn pattern_definition_completes_with_the_compiled_pattern() { + let definition = Statement::PatternDefinition { + name: "letter_a".to_string(), + pattern: PatternExpression::Literal("a".to_string()), + line: 1, + column: 1, + }; + assert_exact_completion_type(vec![], definition, Type::Pattern); +} + +#[test] +fn parent_method_call_completes_with_the_parent_method_result() { + let parent_method = action( + "value", + Type::Number, + vec![Statement::ReturnStatement { + value: Some(number(7)), + line: 1, + column: 1, + }], + ); + let parent = container("Parent", None, vec![parent_method]); + let parent_call = Statement::ParentMethodCall { + method_name: "value".to_string(), + arguments: vec![], + line: 1, + column: 1, + }; + let child_method = action("child_value", Type::Number, vec![parent_call.clone()]); + let child = container("Child", Some("Parent"), vec![child_method]); + + check(vec![parent.clone(), child]) + .expect("a parent method statement must supply its method's runtime result"); + + let wrong_child = container( + "WrongChild", + Some("Parent"), + vec![action("child_value", Type::Text, vec![parent_call])], + ); + let diagnostics = check(vec![parent, wrong_child]) + .expect_err("a parent method result must not degrade to Any") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("implicit result")), + "expected an implicit-result mismatch, got {diagnostics:?}" + ); +} + +#[test] +fn event_definition_has_a_dynamic_completion_type() { + let definition = Statement::EventDefinition { + name: "updated".to_string(), + parameters: vec![], + line: 1, + column: 1, + }; + + check(vec![action("define_event", Type::Any, vec![definition])]) + .expect("events have runtime values but no dedicated static event type"); +} + +#[test] +fn include_with_a_return_has_a_dynamic_completion_type() { + let include = Statement::IncludeStatement { + path: Expression::Literal(Literal::String("module.wfl".into()), 1, 1), + line: 1, + column: 1, + }; + + check(vec![action("include_value", Type::Any, vec![include])]) + .expect("an included file may return a value of a type unavailable to the caller"); +} diff --git a/tests/typechecker_statement_operand_contract_test.rs b/tests/typechecker_statement_operand_contract_test.rs new file mode 100644 index 00000000..5a0d3da3 --- /dev/null +++ b/tests/typechecker_statement_operand_contract_test.rs @@ -0,0 +1,1027 @@ +use wfl::analyzer::{Analyzer, Symbol, SymbolKind}; +use wfl::parser::ast::{ + Argument, DatabaseQueryKind, ExportType, Expression, Literal, Operator, PatternExpression, + Program, Statement, Type, WriteMode, WsHandlerEvent, +}; +use wfl::typechecker::{TypeCheckError, TypeChecker}; + +fn number(value: i64) -> Expression { + Expression::Literal(Literal::Integer(value), 1, 1) +} + +fn text(value: &str) -> Expression { + Expression::Literal(Literal::String(value.into()), 1, 1) +} + +fn boolean(value: bool) -> Expression { + Expression::Literal(Literal::Boolean(value), 1, 1) +} + +fn list(values: Vec) -> Expression { + Expression::Literal(Literal::List(values), 1, 1) +} + +fn any_expression() -> Expression { + Expression::ActionCall { + name: "parse_json".to_string(), + arguments: vec![argument(text("null"))], + line: 1, + column: 1, + } +} + +fn argument(value: Expression) -> Argument { + Argument { name: None, value } +} + +fn call(name: &str, arguments: Vec) -> Expression { + Expression::ActionCall { + name: name.to_string(), + arguments: arguments.into_iter().map(argument).collect(), + line: 1, + column: 1, + } +} + +fn typecheck(statements: Vec) -> Result<(), TypeCheckError> { + TypeChecker::new().check_types(&Program { statements }) +} + +fn typecheck_with_symbol( + name: &str, + symbol_type: Type, + statements: Vec, +) -> Result<(), TypeCheckError> { + let mut analyzer = Analyzer::new(); + analyzer + .define_symbol(Symbol { + name: name.to_string(), + kind: SymbolKind::Variable { mutable: false }, + symbol_type: Some(symbol_type), + line: 1, + column: 1, + }) + .expect("test symbol should be defined"); + TypeChecker::with_analyzer(analyzer).check_types(&Program { statements }) +} + +fn websocket_send_to(target: &str) -> Statement { + Statement::SendWebSocketMessageStatement { + message: text("hello"), + target: Expression::Variable(target.to_string(), 1, 1), + line: 1, + column: 1, + } +} + +fn diagnostic_messages(statements: Vec) -> Vec { + typecheck(statements) + .expect_err("program should be rejected") + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect() +} + +#[test] +fn file_write_and_close_accept_runtime_supported_text_handles() { + typecheck(vec![ + Statement::WriteFileStatement { + file: text("output.txt"), + content: text("hello"), + mode: WriteMode::Overwrite, + line: 1, + column: 1, + }, + Statement::CloseFileStatement { + file: text("output.txt"), + line: 2, + column: 1, + }, + ]) + .expect("legacy text paths/handles are accepted by the runtime"); +} + +#[test] +fn http_post_requires_text_data() { + let messages = diagnostic_messages(vec![Statement::HttpPostStatement { + url: text("https://example.invalid"), + data: number(42), + variable_name: "response".to_string(), + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("HTTP POST data") && message.contains("text")), + "expected a data-type diagnostic, got {messages:?}" + ); +} + +#[test] +fn streaming_response_requires_a_request_object() { + let messages = diagnostic_messages(vec![Statement::StartStreamingResponseStatement { + request: text("not a request"), + status: None, + content_type: None, + headers: None, + variable_name: "stream".to_string(), + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("request object")), + "expected a request-object diagnostic, got {messages:?}" + ); +} + +#[test] +fn command_and_process_arguments_require_text_or_list() { + for statement in [ + Statement::ExecuteCommandStatement { + command: text("tool"), + arguments: Some(boolean(true)), + variable_name: None, + use_shell: false, + line: 1, + column: 1, + }, + Statement::SpawnProcessStatement { + command: text("tool"), + arguments: Some(boolean(true)), + variable_name: "process".to_string(), + use_shell: false, + line: 1, + column: 1, + }, + ] { + let messages = diagnostic_messages(vec![statement]); + assert!( + messages + .iter() + .any(|message| message.contains("arguments") && message.contains("text or a list")), + "expected an argument-type diagnostic, got {messages:?}" + ); + } + + typecheck(vec![ + Statement::ExecuteCommandStatement { + command: text("tool"), + arguments: Some(text("--version")), + variable_name: None, + use_shell: false, + line: 1, + column: 1, + }, + Statement::SpawnProcessStatement { + command: text("tool"), + arguments: Some(list(vec![text("--version")])), + variable_name: "process".to_string(), + use_shell: false, + line: 1, + column: 1, + }, + ]) + .expect("runtime-supported text and list argument forms should typecheck"); +} + +#[test] +fn execute_file_request_requires_a_request_object() { + let messages = diagnostic_messages(vec![Statement::ExecuteFileStatement { + path: text("child.wfl"), + request: Some(number(42)), + variable_name: None, + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("request object")), + "expected a request-object diagnostic, got {messages:?}" + ); +} + +#[test] +fn websocket_send_validates_payload_and_target() { + let messages = diagnostic_messages(vec![ + Statement::SendWebSocketMessageStatement { + message: list(vec![]), + target: any_expression(), + line: 1, + column: 1, + }, + Statement::SendWebSocketMessageStatement { + message: text("hello"), + target: number(42), + line: 2, + column: 1, + }, + ]); + assert!( + messages + .iter() + .any(|message| message.contains("WebSocket message")), + "expected a WebSocket payload diagnostic, got {messages:?}" + ); + assert!( + messages + .iter() + .any(|message| message.contains("WebSocket connection")), + "expected a WebSocket target diagnostic, got {messages:?}" + ); +} + +#[test] +fn websocket_send_rejects_maps_without_text_connection_values() { + let errors = typecheck_with_symbol( + "connection", + Type::Map(Box::new(Type::Text), Box::new(Type::Number)), + vec![websocket_send_to("connection")], + ) + .expect_err("Map cannot contain a runtime text connection id") + .into_diagnostics(); + + assert!( + errors + .iter() + .any(|error| error.message.contains("WebSocket connection target")), + "expected a WebSocket target diagnostic, got {errors:?}" + ); +} + +#[test] +fn websocket_send_rejects_maps_without_text_keys() { + let errors = typecheck_with_symbol( + "connection", + Type::Map(Box::new(Type::Number), Box::new(Type::Text)), + vec![websocket_send_to("connection")], + ) + .expect_err("a WebSocket connection object must use text keys") + .into_diagnostics(); + + assert!( + errors + .iter() + .any(|error| error.message.contains("WebSocket connection target")), + "expected a WebSocket target diagnostic, got {errors:?}" + ); +} + +#[test] +fn websocket_send_accepts_handler_connection_shapes_and_gradual_map_values() { + typecheck(vec![ + Statement::ListenWebSocketStatement { + port: number(8080), + server_name: "server".to_string(), + line: 1, + column: 1, + }, + Statement::WebSocketHandlerStatement { + event: WsHandlerEvent::Connect, + server: Expression::Variable("server".to_string(), 2, 1), + binding: "connection".to_string(), + body: vec![websocket_send_to("connection")], + line: 2, + column: 1, + }, + Statement::WebSocketHandlerStatement { + event: WsHandlerEvent::Message, + server: Expression::Variable("server".to_string(), 3, 1), + binding: "message_event".to_string(), + body: vec![websocket_send_to("message_event")], + line: 3, + column: 1, + }, + ]) + .expect("connect and message handler objects are valid WebSocket send targets"); + + typecheck_with_symbol( + "gradual_connection", + Type::Map(Box::new(Type::Text), Box::new(Type::Any)), + vec![websocket_send_to("gradual_connection")], + ) + .expect("Map must remain a gradual WebSocket target"); +} + +#[test] +fn websocket_broadcast_validates_payload() { + let messages = diagnostic_messages(vec![ + Statement::ListenWebSocketStatement { + port: number(8080), + server_name: "server".to_string(), + line: 1, + column: 1, + }, + Statement::BroadcastWebSocketMessageStatement { + message: list(vec![]), + server: Expression::Variable("server".to_string(), 2, 1), + line: 2, + column: 1, + }, + ]); + assert!( + messages + .iter() + .any(|message| message.contains("WebSocket message")), + "expected a WebSocket payload diagnostic, got {messages:?}" + ); +} + +#[test] +fn missing_header_result_requires_a_nothing_guard() { + let header = Expression::HeaderAccess { + header_name: "x-missing".to_string(), + request: Box::new(Expression::Variable("request".to_string(), 1, 1)), + line: 1, + column: 1, + }; + let adjusted = Expression::BinaryOperation { + left: Box::new(header), + operator: Operator::Minus, + right: Box::new(number(1)), + line: 1, + column: 1, + }; + + let messages = typecheck_with_symbol( + "request", + Type::Custom("Request".to_string()), + vec![Statement::ExpressionStatement { + expression: adjusted, + line: 1, + column: 1, + }], + ) + .expect_err("a missing request header is still Nothing-capable") + .into_diagnostics() + .into_iter() + .map(|error| error.message) + .collect::>(); + assert!( + messages + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "HeaderAccess is Text or Nothing, not an unrestricted gradual value: {messages:?}" + ); +} + +#[test] +fn header_fallback_preserves_the_map_value_type() { + let mut analyzer = Analyzer::new(); + for (name, symbol_type) in [ + ("request", Type::Number), + ( + "headers", + Type::Map(Box::new(Type::Text), Box::new(Type::Number)), + ), + ] { + analyzer + .define_symbol(Symbol { + name: name.to_string(), + kind: SymbolKind::Variable { mutable: false }, + symbol_type: Some(symbol_type), + line: 1, + column: 1, + }) + .expect("test symbol should be defined"); + } + let header = Expression::HeaderAccess { + header_name: "x-number".to_string(), + request: Box::new(Expression::Variable("request".to_string(), 1, 1)), + line: 1, + column: 1, + }; + let program = Program { + statements: vec![ + Statement::VariableDeclaration { + name: "value".to_string(), + value: header, + is_constant: false, + line: 1, + column: 1, + }, + Statement::IfStatement { + condition: Expression::BinaryOperation { + left: Box::new(Expression::Variable("value".to_string(), 2, 1)), + operator: Operator::NotEquals, + right: Box::new(Expression::Literal(Literal::Nothing, 2, 1)), + line: 2, + column: 1, + }, + then_block: vec![Statement::ExpressionStatement { + expression: call( + "touppercase", + vec![Expression::Variable("value".to_string(), 3, 1)], + ), + line: 3, + column: 1, + }], + else_block: None, + line: 2, + column: 1, + }, + ], + }; + + let diagnostics = TypeChecker::with_analyzer(analyzer) + .check_types(&program) + .expect_err("a Number fallback header must not narrow to Text") + .into_diagnostics(); + assert!( + diagnostics + .iter() + .any(|error| error.found == Some(Type::Number)), + "expected the runtime map value type to survive HeaderAccess: {diagnostics:?}" + ); +} + +#[test] +fn pattern_find_result_requires_a_nothing_guard() { + let result = Expression::PatternFind { + text: Box::new(text("abc")), + pattern: Box::new(Expression::Literal( + Literal::Pattern("letter".to_string()), + 1, + 1, + )), + line: 1, + column: 1, + }; + let messages = diagnostic_messages(vec![Statement::ExpressionStatement { + expression: Expression::BinaryOperation { + left: Box::new(result), + operator: Operator::Minus, + right: Box::new(number(1)), + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "pattern find is a match map or Nothing, not unrestricted Any: {messages:?}" + ); +} + +#[test] +fn method_calls_on_gradual_values_defer_but_still_visit_arguments() { + typecheck(vec![Statement::ExpressionStatement { + expression: Expression::MethodCall { + object: Box::new(any_expression()), + method: "runtime_method".to_string(), + arguments: vec![argument(number(1))], + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]) + .expect("method dispatch on Any must defer to runtime"); + + let invalid_argument = Expression::BinaryOperation { + left: Box::new(number(1)), + operator: Operator::Minus, + right: Box::new(text("wrong")), + line: 2, + column: 1, + }; + let messages = diagnostic_messages(vec![Statement::ExpressionStatement { + expression: Expression::MethodCall { + object: Box::new(any_expression()), + method: "runtime_method".to_string(), + arguments: vec![argument(invalid_argument)], + line: 2, + column: 1, + }, + line: 2, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "gradual dispatch must not hide nested argument errors: {messages:?}" + ); + assert!( + !messages + .iter() + .any(|message| message.contains("Cannot call method")), + "Any itself must not produce a false non-container error: {messages:?}" + ); +} + +#[test] +fn prior_expression_errors_do_not_cascade_through_property_and_method_access() { + let failed_object = Expression::BinaryOperation { + left: Box::new(number(1)), + operator: Operator::Minus, + right: Box::new(text("wrong")), + line: 1, + column: 1, + }; + let property = Expression::PropertyAccess { + object: Box::new(failed_object), + property: "anything".to_string(), + line: 1, + column: 1, + }; + let messages = diagnostic_messages(vec![Statement::ExpressionStatement { + expression: Expression::MethodCall { + object: Box::new(property), + method: "anything".to_string(), + arguments: vec![], + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]); + assert_eq!( + messages + .iter() + .filter(|message| message.contains("Cannot perform Minus")) + .count(), + 1, + "the root expression error should be reported once: {messages:?}" + ); + assert!( + !messages.iter().any(|message| { + message.contains("Cannot access property") || message.contains("Cannot call method") + }), + "Error must propagate without secondary member-access errors: {messages:?}" + ); +} + +#[test] +fn temporal_values_support_the_runtime_comparison_contract() { + for (name, left, right) in [ + ( + "Date", + call("create_date", vec![number(2026), number(7), number(26)]), + call("create_date", vec![number(2026), number(7), number(27)]), + ), + ("Time", call("now", vec![]), call("now", vec![])), + ( + "DateTime", + call("datetime_now", vec![]), + call("datetime_now", vec![]), + ), + ] { + typecheck(vec![Statement::ExpressionStatement { + expression: Expression::BinaryOperation { + left: Box::new(left), + operator: Operator::LessThan, + right: Box::new(right), + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]) + .unwrap_or_else(|failure| { + panic!( + "same-kind {name} ordering is implemented by the runtime: {:?}", + failure.into_diagnostics() + ) + }); + } + + typecheck(vec![Statement::ExpressionStatement { + expression: Expression::BinaryOperation { + left: Box::new(call("today", vec![])), + operator: Operator::Equals, + right: Box::new(call("now", vec![])), + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]) + .expect("runtime equality is total across unlike temporal values"); + + typecheck(vec![ + Statement::CreateListStatement { + name: "dates".to_string(), + initial_values: vec![call("today", vec![])], + line: 1, + column: 1, + }, + Statement::ExpressionStatement { + expression: Expression::BinaryOperation { + left: Box::new(Expression::Variable("dates".to_string(), 2, 1)), + operator: Operator::Contains, + right: Box::new(call("now", vec![])), + line: 2, + column: 1, + }, + line: 2, + column: 1, + }, + ]) + .expect("runtime list membership returns false for an unlike temporal needle"); +} + +#[test] +fn response_statements_reject_ordinary_maps_but_execute_file_defers_shape() { + let make_map = || Statement::MapCreation { + name: "request_like".to_string(), + entries: vec![], + line: 1, + column: 1, + }; + let request_like = || Expression::Variable("request_like".to_string(), 2, 1); + + let messages = diagnostic_messages(vec![ + make_map(), + Statement::RespondStatement { + request: request_like(), + content: text("ok"), + status: None, + content_type: None, + headers: None, + line: 2, + column: 1, + }, + Statement::StartStreamingResponseStatement { + request: request_like(), + status: None, + content_type: None, + headers: None, + variable_name: "stream".to_string(), + line: 3, + column: 1, + }, + ]); + assert!( + messages + .iter() + .filter(|message| message.contains("request object")) + .count() + >= 2, + "ordinary maps have no pending response sender: {messages:?}" + ); + + typecheck(vec![ + make_map(), + Statement::ExecuteFileStatement { + path: text("child.wfl"), + request: Some(request_like()), + variable_name: None, + line: 2, + column: 1, + }, + ]) + .expect("execute-file validates the fields of a map-shaped request at runtime"); +} + +#[test] +fn header_access_rejects_a_scalar_without_request_headers_in_scope() { + let messages = diagnostic_messages(vec![Statement::ExpressionStatement { + expression: Expression::HeaderAccess { + header_name: "x-test".to_string(), + request: Box::new(number(1)), + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("Header access requires")), + "expected a missing request/header-scope diagnostic: {messages:?}" + ); +} + +#[test] +fn binary_writes_require_an_open_file_handle() { + let messages = diagnostic_messages(vec![Statement::WriteBinaryStatement { + content: any_expression(), + target: text("literal-path.bin"), + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("open File handle")), + "a literal path is not a runtime binary handle: {messages:?}" + ); +} + +#[test] +fn database_parameter_lists_reject_known_composite_elements() { + let messages = diagnostic_messages(vec![ + Statement::OpenDatabaseStatement { + url: text("sqlite::memory:"), + variable_name: "db".to_string(), + line: 1, + column: 1, + }, + Statement::MapCreation { + name: "payload".to_string(), + entries: vec![("key".to_string(), text("value"))], + line: 2, + column: 1, + }, + Statement::CreateListStatement { + name: "params".to_string(), + initial_values: vec![Expression::Variable("payload".to_string(), 3, 1)], + line: 3, + column: 1, + }, + Statement::DatabaseQueryStatement { + db: Expression::Variable("db".to_string(), 4, 1), + sql: text("select ?"), + parameters: Some(Expression::Variable("params".to_string(), 4, 1)), + variable_name: "rows".to_string(), + kind: DatabaseQueryKind::Query, + line: 4, + column: 1, + }, + ]); + assert!( + messages + .iter() + .any(|message| message.contains("SQL scalar")), + "known map elements cannot be bound as SQL parameters: {messages:?}" + ); +} + +#[test] +fn database_parameter_lists_accept_optional_scalar_elements() { + typecheck_with_symbol( + "params", + Type::List(Box::new(Type::Optional(Box::new(Type::Text)))), + vec![ + Statement::OpenDatabaseStatement { + url: text("sqlite::memory:"), + variable_name: "db".to_string(), + line: 1, + column: 1, + }, + Statement::DatabaseQueryStatement { + db: Expression::Variable("db".to_string(), 2, 1), + sql: text("select ?"), + parameters: Some(Expression::Variable("params".to_string(), 2, 1)), + variable_name: "rows".to_string(), + kind: DatabaseQueryKind::Query, + line: 2, + column: 1, + }, + ], + ) + .expect("both Text and Nothing are valid SQL parameter values"); +} + +#[test] +fn gradual_function_calls_still_visit_every_argument() { + let invalid_argument = Expression::BinaryOperation { + left: Box::new(number(1)), + operator: Operator::Minus, + right: Box::new(text("wrong")), + line: 1, + column: 1, + }; + let messages = diagnostic_messages(vec![Statement::ExpressionStatement { + expression: Expression::FunctionCall { + function: Box::new(any_expression()), + arguments: vec![argument(invalid_argument)], + line: 1, + column: 1, + }, + line: 1, + column: 1, + }]); + assert!( + messages + .iter() + .any(|message| message.contains("Cannot perform Minus")), + "a gradual callee must not hide argument errors: {messages:?}" + ); + assert!( + !messages + .iter() + .any(|message| message.contains("not a function")), + "Any callability is deferred to runtime: {messages:?}" + ); +} + +#[test] +fn describe_setup_is_shared_but_test_locals_and_describe_locals_do_not_escape() { + let declaration = |name: &str| Statement::VariableDeclaration { + name: name.to_string(), + value: number(1), + is_constant: false, + line: 1, + column: 1, + }; + let display = |name: &str| Statement::DisplayStatement { + value: Expression::Variable(name.to_string(), 1, 1), + line: 1, + column: 1, + }; + + typecheck(vec![Statement::DescribeBlock { + description: "scope".to_string(), + setup: Some(vec![declaration("setup_value")]), + teardown: Some(vec![display("setup_value")]), + tests: vec![ + Statement::TestBlock { + description: "first".to_string(), + body: vec![display("setup_value")], + line: 1, + column: 1, + }, + Statement::TestBlock { + description: "second".to_string(), + body: vec![display("setup_value")], + line: 1, + column: 1, + }, + ], + line: 1, + column: 1, + }]) + .expect("setup bindings are visible to each test and teardown"); + + let messages = diagnostic_messages(vec![ + Statement::DescribeBlock { + description: "scope".to_string(), + setup: Some(vec![declaration("setup_value")]), + teardown: None, + tests: vec![ + Statement::TestBlock { + description: "first".to_string(), + body: vec![declaration("test_only")], + line: 1, + column: 1, + }, + Statement::TestBlock { + description: "second".to_string(), + body: vec![display("test_only")], + line: 1, + column: 1, + }, + ], + line: 1, + column: 1, + }, + display("setup_value"), + ]); + assert!( + messages.iter().any(|message| message.contains("test_only")), + "one test's locals must not leak into another: {messages:?}" + ); + assert!( + messages + .iter() + .any(|message| message.contains("setup_value")), + "describe-level setup locals must not escape the describe: {messages:?}" + ); +} + +#[test] +fn exports_require_a_definition_owned_by_the_current_scope() { + let action = |name: &str, body: Vec| Statement::ActionDefinition { + name: name.to_string(), + parameters: vec![], + body, + return_type: None, + line: 1, + column: 1, + }; + let export = |export_type, name: &str| Statement::ExportStatement { + export_type, + name: name.to_string(), + line: 2, + column: 1, + }; + + let messages = diagnostic_messages(vec![ + action("outer_action", vec![]), + Statement::VariableDeclaration { + name: "OUTER_CONSTANT".to_string(), + value: number(1), + is_constant: true, + line: 1, + column: 1, + }, + Statement::ContainerDefinition { + name: "OuterContainer".to_string(), + extends: None, + implements: vec![], + properties: vec![], + methods: vec![], + events: vec![], + static_properties: vec![], + static_methods: vec![], + line: 1, + column: 1, + }, + action( + "attempt_exports", + vec![ + export(ExportType::Action, "outer_action"), + export(ExportType::Constant, "OUTER_CONSTANT"), + export(ExportType::Container, "OuterContainer"), + ], + ), + ]); + + for name in ["outer_action", "OUTER_CONSTANT", "OuterContainer"] { + assert!( + messages.iter().any(|message| message.contains(name)), + "parent-scope export of {name} must be rejected: {messages:?}" + ); + } +} + +#[test] +fn exports_accept_definitions_owned_by_the_current_scope() { + let action = |name: &str, body: Vec| Statement::ActionDefinition { + name: name.to_string(), + parameters: vec![], + body, + return_type: None, + line: 1, + column: 1, + }; + let export = |export_type, name: &str| Statement::ExportStatement { + export_type, + name: name.to_string(), + line: 2, + column: 1, + }; + + typecheck(vec![action( + "owner", + vec![ + Statement::VariableDeclaration { + name: "LOCAL_CONSTANT".to_string(), + value: number(1), + is_constant: true, + line: 1, + column: 1, + }, + action("local_action", vec![]), + Statement::ContainerDefinition { + name: "LocalContainer".to_string(), + extends: None, + implements: vec![], + properties: vec![], + methods: vec![], + events: vec![], + static_properties: vec![], + static_methods: vec![], + line: 1, + column: 1, + }, + export(ExportType::Constant, "LOCAL_CONSTANT"), + export(ExportType::Action, "local_action"), + export(ExportType::Container, "LocalContainer"), + ], + )]) + .expect("definitions owned by the active scope are exportable"); +} + +#[test] +fn pattern_backreferences_require_an_earlier_capture() { + let valid_pattern = PatternExpression::Sequence(vec![ + PatternExpression::Capture { + name: "word".to_string(), + pattern: Box::new(PatternExpression::Literal("hello".to_string())), + }, + PatternExpression::Backreference("word".to_string()), + ]); + typecheck(vec![Statement::PatternDefinition { + name: "valid".to_string(), + pattern: valid_pattern, + line: 1, + column: 1, + }]) + .expect("a backreference to an earlier capture is valid"); + + let messages = diagnostic_messages(vec![Statement::PatternDefinition { + name: "invalid".to_string(), + pattern: PatternExpression::Backreference("missing".to_string()), + line: 1, + column: 1, + }]); + assert!( + messages.iter().any(|message| { + message.contains("Backreference") + && message.contains("missing") + && message.contains("undefined") + }), + "an undefined backreference must fail before runtime: {messages:?}" + ); +} diff --git a/tests/typechecker_try_finally_join_test.rs b/tests/typechecker_try_finally_join_test.rs index fee6fefe..3b88e65c 100644 --- a/tests/typechecker_try_finally_join_test.rs +++ b/tests/typechecker_try_finally_join_test.rs @@ -2,7 +2,10 @@ //! into `finally`, while keeping `when` error aliases clause-local. use std::sync::Arc; +use wfl::Interpreter; use wfl::analyzer::{Analyzer, Symbol, SymbolKind}; +use wfl::lexer::lex_wfl_with_positions; +use wfl::parser::Parser; use wfl::parser::ast::{ ErrorType, Expression, FileOpenMode, Literal, Operator, Program, Statement, Type, WhenClause, }; @@ -14,7 +17,7 @@ fn text_literal(value: &str) -> Expression { fn stream_binding() -> Statement { Statement::StartStreamingResponseStatement { - request: text_literal("request"), + request: Expression::Variable("request".to_string(), 3, 1), status: Some(Expression::Literal(Literal::Integer(200), 3, 1)), content_type: None, headers: None, @@ -76,6 +79,16 @@ fn display_variable(name: &str, line: usize) -> Statement { #[test] fn handler_response_stream_state_is_joined_before_finally() { + let mut analyzer = Analyzer::new(); + analyzer + .define_symbol(Symbol { + name: "request".to_string(), + kind: SymbolKind::Variable { mutable: false }, + symbol_type: Some(Type::Custom("Request".to_string())), + line: 1, + column: 1, + }) + .expect("define request binding"); let program = Program { statements: vec![ Statement::OpenFileStatement { @@ -100,11 +113,12 @@ fn handler_response_stream_state_is_joined_before_finally() { ], }; + let result = TypeChecker::with_analyzer(analyzer).check_types(&program); assert!( - TypeChecker::new().check_types(&program).is_ok(), + result.is_ok(), "finally must see the gradual join of the successful File path and the handler's \ ResponseStream path; errors: {:?}", - TypeChecker::new().check_types(&program).err() + result.err() ); } @@ -162,7 +176,7 @@ fn handler_error_aliases_remain_clause_local_before_finally() { } #[test] -fn handler_created_binding_is_semantically_visible_in_finally() { +fn handler_only_binding_is_not_definitely_available_in_finally() { let program = Program { statements: vec![Statement::TryStatement { body: vec![display_text("success", 2)], @@ -178,16 +192,16 @@ fn handler_created_binding_is_semantically_visible_in_finally() { }], }; + let outcome = TypeChecker::new().check_types(&program); assert!( - TypeChecker::new().check_types(&program).is_ok(), - "the analyzer and checker must preserve an ordinary handler binding in the shared \ - runtime try scope until finally; errors: {:?}", - TypeChecker::new().check_types(&program).err() + outcome.is_err(), + "a handler-only binding is absent on the successful and unmatched-error paths, so \ + finally must reject it as potentially unbound" ); } #[test] -fn otherwise_created_binding_is_semantically_visible_in_finally() { +fn otherwise_only_binding_is_not_definitely_available_in_finally() { let program = Program { statements: vec![Statement::TryStatement { body: vec![display_text("success", 2)], @@ -199,11 +213,32 @@ fn otherwise_created_binding_is_semantically_visible_in_finally() { }], }; + let outcome = TypeChecker::new().check_types(&program); assert!( - TypeChecker::new().check_types(&program).is_ok(), - "the analyzer and checker must preserve an ordinary otherwise binding in the shared \ - runtime try scope until finally; errors: {:?}", - TypeChecker::new().check_types(&program).err() + outcome.is_err(), + "an otherwise-only binding is absent on the successful path, so finally must reject \ + it as potentially unbound" + ); +} + +#[test] +fn partially_initialized_body_binding_is_not_available_in_handler() { + let program = parse( + r#" +store divisor as 0 +try: + store maybe_value as 1 divided by divisor +when error: + display maybe_value +end try +"#, + ); + + let outcome = TypeChecker::new().check_types(&program); + assert!( + outcome.is_err(), + "a handler can run when a body declaration's initializer fails, so that binding must \ + remain unavailable in the handler" ); } @@ -253,3 +288,163 @@ fn full_pipeline_error_alias_is_clause_local() { TypeChecker::new().check_types(&program).err() ); } + +fn parse(source: &str) -> Program { + Parser::new(&lex_wfl_with_positions(source)) + .parse() + .expect("test program should parse") +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_error_aliases_are_removed_before_finally() { + let program = parse( + r#" +store caught as 10 +store error_message as 20 +try: + store bad as 1 divided by 0 +when error as caught: + display caught +finally: + store caught_result as caught minus 1 + store message_result as error_message minus 1 +end try +"#, + ); + + Interpreter::new() + .interpret(&program) + .await + .expect("finally must resolve the outer numeric bindings"); +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_finally_return_overrides_the_primary_result() { + let program = parse( + r#" +define action called value_from_finally: + try: + display "primary" + finally: + return "from finally" + end try +end action + +store result as call value_from_finally +check if result is not equal to "from finally": + store bad as 1 divided by 0 +end check +"#, + ); + + Interpreter::new() + .interpret(&program) + .await + .expect("return from finally must propagate out of the action"); +} + +#[test] +fn nested_try_error_path_keeps_intermediate_mutation_state() { + let program = parse( + r#" +store values as [1] +store divisor as 0 +try: + check if yes: + store first_fill as fill of values and "text" + store failure as 1 divided by divisor + store second_fill as fill of values and 2 + end check +when error: + store removed as pop of values + store upper as touppercase of removed +end try +"#, + ); + + TypeChecker::new() + .check_types(&program) + .expect("the handler must see a gradual Number/Text element type"); +} + +#[test] +fn nested_try_error_path_rejects_use_that_ignores_intermediate_scalar_mutation() { + let program = parse( + r#" +store value as "start" +store divisor as 0 +try: + check if yes: + change value to nothing + store failure as 1 divided by divisor + change value to "end" + end check +when error: + store upper as touppercase of value +end try +"#, + ); + + let outcome = TypeChecker::new().check_types(&program); + assert!( + outcome.is_err(), + "the handler must retain the intermediate Nothing state instead of treating value as \ + definitely Text from the body's endpoint" + ); +} + +#[test] +fn try_flow_snapshot_traversal_is_charged_to_the_operation_budget() { + use wfl::exec::budget::{BudgetLimits, ExecutionBudget}; + + let declarations = || { + (0..160) + .map(|index| Statement::VariableDeclaration { + name: format!("value_{index}"), + value: Expression::Literal(Literal::Integer(index), 1, 1), + is_constant: false, + line: 1, + column: 1, + }) + .collect::>() + }; + let limits = || BudgetLimits { + max_operations: Some(400), + ..Default::default() + }; + + let mut control_statements = declarations(); + control_statements.push(display_text("no try capture", 2)); + let control_budget = std::sync::Arc::new(ExecutionBudget::new(limits())); + { + let _guard = ExecutionBudget::enter(std::sync::Arc::clone(&control_budget)); + TypeChecker::with_analyzer(Analyzer::new()) + .check_types(&Program { + statements: control_statements, + }) + .expect("ordinary checking must fit beneath the cap used to isolate try traversal"); + } + + let mut try_statements = declarations(); + try_statements.push(Statement::TryStatement { + body: vec![display_text("capture", 2)], + when_clauses: vec![], + otherwise_block: None, + finally_block: None, + line: 2, + column: 1, + }); + let program = Program { + statements: try_statements, + }; + let budget = std::sync::Arc::new(ExecutionBudget::new(limits())); + let _guard = ExecutionBudget::enter(std::sync::Arc::clone(&budget)); + + let outcome = TypeChecker::with_analyzer(Analyzer::new()).check_types(&program); + assert!( + matches!(outcome, Err(wfl::typechecker::TypeCheckError::Budget(_))), + "walking live binding/alias state for a try-flow capture must be charged \ + proportionally instead of bypassing the run budget; charged {}, got: {outcome:?}", + budget.operations_charged() + ); +} From fbfd2d7a9d648628fe5ccfc6f5808a388abe60a5 Mon Sep 17 00:00:00 2001 From: Brad Byrd Date: Sun, 26 Jul 2026 21:39:13 -0500 Subject: [PATCH 2/4] test: add AST baseline for pattern matching documentation examples Adding the expected syntax tree for the pattern matching reference code. This serves as a "blueprint" that allows automated tests to verify the examples shown in the documentation are valid and remain correct as the language evolves. --- .../pattern_examples.wfl.ast.txt | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 TestPrograms/docs_examples/keyword_reference/pattern_examples.wfl.ast.txt diff --git a/TestPrograms/docs_examples/keyword_reference/pattern_examples.wfl.ast.txt b/TestPrograms/docs_examples/keyword_reference/pattern_examples.wfl.ast.txt new file mode 100644 index 00000000..3e23cbfa --- /dev/null +++ b/TestPrograms/docs_examples/keyword_reference/pattern_examples.wfl.ast.txt @@ -0,0 +1,134 @@ +AST output for: C:\Users\ke5cr\OneDrive\Documents\Starnet\Repos\wfl\TestPrograms\docs_examples\keyword_reference\pattern_examples.wfl +============================================== + +Program with 8 statements: + +Statement #1: DisplayStatement { + value: Literal( + String( + "Pattern matching examples", + ), + 6, + 9, + ), + line: 6, + column: 1, +} + +Statement #2: VariableDeclaration { + name: "text_to_match", + value: Literal( + String( + "test123", + ), + 12, + 24, + ), + is_constant: false, + line: 12, + column: 1, +} + +Statement #3: DisplayStatement { + value: Concatenation { + left: Literal( + String( + "Text: ", + ), + 13, + 9, + ), + right: Variable( + "text_to_match", + 13, + 23, + ), + line: 13, + column: 18, + }, + line: 13, + column: 1, +} + +Statement #4: VariableDeclaration { + name: "pattern", + value: Literal( + String( + "email_pattern", + ), + 16, + 18, + ), + is_constant: false, + line: 16, + column: 1, +} + +Statement #5: DisplayStatement { + value: Concatenation { + left: Literal( + String( + "Pattern name: ", + ), + 17, + 9, + ), + right: Variable( + "pattern", + 17, + 31, + ), + line: 17, + column: 26, + }, + line: 17, + column: 1, +} + +Statement #6: VariableDeclaration { + name: "text", + value: Literal( + String( + "sample text", + ), + 19, + 15, + ), + is_constant: false, + line: 19, + column: 1, +} + +Statement #7: DisplayStatement { + value: Concatenation { + left: Literal( + String( + "Text to search: ", + ), + 20, + 9, + ), + right: Variable( + "text", + 20, + 33, + ), + line: 20, + column: 28, + }, + line: 20, + column: 1, +} + +Statement #8: DisplayStatement { + value: Literal( + String( + "Pattern examples complete (simplified)", + ), + 22, + 9, + ), + line: 22, + column: 1, +} + From 158d0e23c55b6f12a651f34d43874d9880d0ea3d Mon Sep 17 00:00:00 2001 From: logbie Date: Mon, 27 Jul 2026 02:58:57 +0000 Subject: [PATCH 3/4] test: bind a real request for repeat-until stream retype after rebase The #642 repeat-until backedge regression tests retype `out` by opening a response stream in the loop body. After rebasing onto main, the gradual type-checking contract added by this PR requires the streaming target to be a request object, so the previous text-literal target ("req") is now a type error that masked the softening behavior under test. Bind `req` via `wait for request comes in on srv as req` and stream `to req`, matching the convention this PR already applies to the sibling response-stream tests. The softening assertions are unchanged and still pass. Co-authored-by: Codesmith --- .../typechecker_repeat_until_backedge_test.rs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/typechecker_repeat_until_backedge_test.rs b/tests/typechecker_repeat_until_backedge_test.rs index 5d89fb22..a714292b 100644 --- a/tests/typechecker_repeat_until_backedge_test.rs +++ b/tests/typechecker_repeat_until_backedge_test.rs @@ -7,6 +7,12 @@ //! evaluating the condition, and evaluates the condition in the same //! environment. Checking the condition first leaves body-introduced bindings //! `Unknown` in the condition, silently missing real type errors. +//! +//! These programs retype `out` by opening a response stream in the loop body. +//! The gradual type-checking contract requires a real request object as the +//! streaming target, so each program first binds `req` via +//! `wait for request comes in on srv as req` rather than passing a text +//! literal; the softening behavior under test is unchanged. use wfl::lexer::lex_wfl_with_positions; use wfl::parser::Parser; @@ -27,9 +33,11 @@ fn repeat_until_condition_sees_body_retyped_binding() { // comparison is a guaranteed runtime type error the checker must // surface. Checking the condition first sees the stale outer Number and // misses it. - let code = "store out as 5\n\ + let code = "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + store out as 5\n\ repeat until out is greater than 3:\n\ - \x20\x20\x20\x20start streaming response to \"req\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ end repeat\n"; assert!( typecheck(code).is_err(), @@ -61,9 +69,11 @@ fn nested_loop_break_does_not_soften_repeat_until_condition() { // condition must keep the precise post-body check and flag the retyped // binding (PR #643 review: over-broad softening would hide this real // runtime type error). - let code = "store out as 5\n\ + let code = "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + store out as 5\n\ repeat until out is greater than 3:\n\ - \x20\x20\x20\x20start streaming response to \"req\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20count from 1 to 3:\n\ \x20\x20\x20\x20\x20\x20\x20\x20break\n\ \x20\x20\x20\x20end count\n\ @@ -81,9 +91,11 @@ fn nested_exit_loop_still_softens_repeat_until_condition() { // `exit loop` propagates out of every enclosing loop (unlike `break`), // so even from a nested loop it skips the outer condition — the softened // joined state applies and the retype-then-exit body stays legal. - let code = "store out as 5\n\ + let code = "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + store out as 5\n\ repeat until out is greater than 3:\n\ - \x20\x20\x20\x20start streaming response to \"req\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20count from 1 to 3:\n\ \x20\x20\x20\x20\x20\x20\x20\x20exit loop\n\ \x20\x20\x20\x20end count\n\ @@ -103,9 +115,11 @@ fn repeat_until_break_path_does_not_force_post_body_condition_types() { // the condition would mistype against the retyped binding. The checker // must not reject it — type errors are fatal inside `load module`, so a // false positive here breaks working modules (PR #643 review). - let code = "store out as 5\n\ + let code = "store srv as \"server\"\n\ + wait for request comes in on srv as req\n\ + store out as 5\n\ repeat until out is greater than 3:\n\ - \x20\x20\x20\x20start streaming response to \"req\" with status 200 as out\n\ + \x20\x20\x20\x20start streaming response to req with status 200 as out\n\ \x20\x20\x20\x20break\n\ end repeat\n"; let result = typecheck(code); From b8acbdf2f0163dfb2dc4b8319cdbe178a23a3fd3 Mon Sep 17 00:00:00 2001 From: logbie Date: Mon, 27 Jul 2026 03:23:13 +0000 Subject: [PATCH 4/4] fix: restore handler test constructor and websocket binding contract Re-add the test-only `RunState::fresh(call_depth)` constructor that an earlier rebase dropped. The inline concurrent-handler unit tests reference it, so `cargo test` failed to compile the lib test target even though a plain `cargo build` (which never compiles that cfg(test) code) succeeded. Stop downgrading WebSocket handler event bindings to `Unknown`. `bind_runtime_value` already recreates the binding in the handler scope with its concrete runtime map type, shadowing any outer same-named symbol (#642), so keeping that map type leaves field/index access on the event object permissive while still rejecting misuse such as arithmetic on it. This restores the runtime-binding contract test. Also broaden the `Type::Optional` doc comment to cover every `T | Nothing` source (find-style lookups, control-flow joins), not just action fall-through, and correct a stale comment in the open-file local-type test. Co-authored-by: Codesmith --- src/interpreter/mod.rs | 13 +++++++++++++ src/parser/ast.rs | 7 +++++-- src/typechecker/mod.rs | 20 +++++++------------- tests/open_file_local_type_test.rs | 3 ++- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index c99151c7..cc32fb78 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -890,6 +890,19 @@ struct RunState { loading_stack: Vec, } +#[cfg(test)] +impl RunState { + /// Build a fresh run state seeded with the given call depth; every other + /// scratch field starts empty/default. Used by the concurrent-handler unit + /// tests to stand up an isolated [`RunState`] without a live handler. + fn fresh(call_depth: usize) -> Self { + RunState { + call_depth, + ..RunState::default() + } + } +} + /// Installs one handler's parked [`RunState`] in the interpreter until drop. /// /// Besides reducing duplicated swap calls, the guard makes restoration diff --git a/src/parser/ast.rs b/src/parser/ast.rs index f2ee8790..ec7786fb 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -1071,8 +1071,11 @@ pub enum Type { Error, // Used to mark expressions that have already failed type checking Async(Box), // For asynchronous operations returning a value of Type Any, // Used for generic types like lists of any type - /// An inferred value that may be `Nothing` because an action can fall - /// through without executing a value-returning `return`. + /// An inferred `T | Nothing` value: the inner type when a value is present, + /// or `Nothing` when it is absent. Produced wherever a value may be missing, + /// e.g. an action falling through without a value-returning `return`, + /// `find`-style lookups that may not match, and control-flow joins where some + /// branch yields `Nothing`. Optional(Box), // Container-related types Container(String), diff --git a/src/typechecker/mod.rs b/src/typechecker/mod.rs index b232aaf2..e32f5427 100644 --- a/src/typechecker/mod.rs +++ b/src/typechecker/mod.rs @@ -7177,20 +7177,14 @@ impl TypeChecker { *line, *column, ); + // `bind_runtime_value` above already recreated the binding in + // this handler scope with its concrete runtime map type, + // deliberately shadowing any outer same-named variable so the + // body is not checked against the outer symbol's type (#642). + // Keeping that map type (rather than downgrading to Unknown) + // leaves field/index access on the event object permissive while + // still rejecting misuse such as arithmetic on it. let outer_type_snapshot = self.analyzer.snapshot_symbol_types(); - // Runtime binds the event object with `define_direct`, - // deliberately shadowing an outer same-named variable. Analyzer - // body scopes are discarded before this pass, so recreate the - // binding here — typed Unknown, keeping event-object access - // permissive instead of checking the body against the outer - // symbol's concrete type (#642). - self.analyzer.define_or_replace_symbol(Symbol { - name: binding.clone(), - kind: SymbolKind::Variable { mutable: true }, - symbol_type: Some(Type::Unknown), - line: *line, - column: *column, - }); let outer_alias_snapshot = self.list_alias_groups.clone(); let outer_refinement_snapshot = self.optional_refinement_origins.clone(); let outer_nonempty_snapshot = self.definitely_nonempty_lists.clone(); diff --git a/tests/open_file_local_type_test.rs b/tests/open_file_local_type_test.rs index 6cd4aa4b..72a7881a 100644 --- a/tests/open_file_local_type_test.rs +++ b/tests/open_file_local_type_test.rs @@ -69,7 +69,8 @@ fn fresh_local_file_handles_are_concrete_in_action_loop_and_method_scopes() { fn reconstructed_local_file_type_does_not_retype_an_outer_visible_binding() { // The type checker must reconstruct `out` as File while checking the loop, // then expose the original outer Number binding after leaving that scope. - // Text is intentionally accepted as an opaque runtime file-handle ID. + // `close out` then operates on the outer Number (42), which is neither a + // File nor a stream handle, so it must be rejected. let source = "store out as 42\n\ main loop:\n\ \x20\x20\x20\x20open file at \"inner.txt\" for writing as out\n\