on master help sign in
Fix 72 verified bugs across all crates: compiler crashes, runtime data corruption, type/effect system soundness, formatter round-trips, LSP sync/state/nav/rename/completion

Runtime (17): race+atomic write-lock deadlock, STM missed-wakeups (numeric-text compare, projection/registration TOCTOU), cancellation-aware waits, all-rows temp-schema inference (Maybe payload corruption), malformed --override Maybe, NULL-coalesced unique indexes, hash canonicalization, fetch_value_to_text constructors, OpenAPI Maybe, 400s for missing body fields, fail-open rate limiting, body-cap overflow, sealed-box KDF + contributory checks, TUI terminal restore + unicode widths. Compiler backend (10): compile_do env hygiene (Cranelift verifier), transitive let substitution in SQL pushdown, view writes run refinement validation, IO do-blocks iterate let-bound relations, SQL-pushdown gates (text collation, payload-ADT equality, text minOn/maxOn), recursive-alias cycle diagnostics, refined-alias fields, O_EXCL temp files. Inference/effects (12): exhaustiveness requires irrefutable sub-patterns, comprehensions typed IO [elem], impl-method skolemisation, rank-2 escape checks, atomic/IO bypasses closed (trait bodies, named lambdas, unannotated HOFs), pass-ordering fix, Ty::Alias substitution, desugar/codegen IO parity, fetchWith typing, opaque-callable false positive. Frontend (9): route rateLimit/headers preservation, parser depth guards, empty trait/impl blocks, scope-aware time-unit sugar, formatter round-trip fixes, dashed import paths. LSP (21): signature+body rename, prelude span leak, alias-cycle/IO infinite-loop guards, code-action edits, lexer-synced validation, deterministic hover, transitive imports, and others. Schema contracts (3): ADT record fields, nested ADT/scalar relations all round-trip via json columns with dedup. ~115 new regression tests; all 1160+ test suite passes; groupby.knot now shows both groups.

commitd4caadcd6d7a2a92814cd65295d8fbf6332dbb13 authoriko <mail@iko.soy> dateThu 11 Jun 2026, 13:37 (4 months ago) parentdb12673
32 files changed+6002−540
Cargo.lock+1 crates/knot-compiler/src/codegen.rs+249−8 crates/knot-compiler/src/desugar.rs+21−1 crates/knot-compiler/src/effects.rs+238−4 crates/knot-compiler/src/infer.rs+319−114 crates/knot-compiler/src/main.rs+64−8 crates/knot-compiler/src/sql_lint.rs+98−74 crates/knot-compiler/src/types.rs+162−15 crates/knot-compiler/tests/regress_backend_fixes.rs+582 crates/knot-compiler/tests/regress_effects_fixes2.rs+284 crates/knot-compiler/tests/regress_infer_fixes2.rs+452 crates/knot-compiler/tests/regress_schema_contract.rs+342 crates/knot-lsp/src/analysis.rs+68 crates/knot-lsp/src/code_action.rs+539−62 crates/knot-lsp/src/completion.rs+172−17 crates/knot-lsp/src/defs.rs+57−3 crates/knot-lsp/src/hover.rs+125−4 crates/knot-lsp/src/inlay_hints.rs+204−73 crates/knot-lsp/src/main.rs+73−3 crates/knot-lsp/src/parsed_type.rs+19 crates/knot-lsp/src/rename.rs+170−9 crates/knot-lsp/src/semantic_tokens.rs+35−2 crates/knot-lsp/src/shared.rs+1−1 crates/knot-lsp/src/state.rs+8−3 crates/knot-lsp/src/utils.rs+17−4 crates/knot-runtime/Cargo.toml+1 crates/knot-runtime/src/lib.rs+858−121 crates/knot-runtime/src/tui.rs+23−6 crates/knot/src/format.rs+22−2 crates/knot/src/parser.rs+173−6 crates/knot/tests/regress_frontend_fixes2.rs+618 unit_skolem.knot+7
Cargo.lock+1
@@ -1073,6 +1073,7 @@ dependencies = [
10731073 "rusqlite",
10741074 "serde_json",
10751075 "tiny_http",
1076+ "unicode-width",
10761077 "ureq",
10771078 "x25519-dalek",
10781079]
crates/knot-compiler/src/codegen.rs+249−8
@@ -96,6 +96,15 @@ pub struct Codegen {
9696 /// `fun_bodies` during inlining.
9797 let_bindings: HashMap<String, ast::Expr>,
9898
99+ /// Names bound inside the current IO do-block whose value is statically
100+ /// known to be a relation (let-bound comprehensions — including groupBy
101+ /// blocks — source reads, list literals, relation-returning stdlib
102+ /// calls). A later `pat <- name` bind in the same IO do-block iterates
103+ /// the relation per row (comprehension semantics) instead of binding
104+ /// the whole relation value. Saved/restored around each IO do-block
105+ /// scope by `compile_io_do_eager`.
106+ io_relation_vars: HashSet<String>,
107+
99108 // Constructor info: ctor_name -> [(field_name, field_type_str)]
100109 constructors: HashMap<String, Vec<(String, String)>>,
101110
@@ -754,6 +763,7 @@ impl Codegen {
754763 source_bind_invalidations: Vec::new(),
755764 fun_bodies: HashMap::new(),
756765 let_bindings: HashMap::new(),
766+ io_relation_vars: HashSet::new(),
757767 constructors: HashMap::new(),
758768 lambda_counter: 0,
759769 pending_lambdas: Vec::new(),
@@ -4988,6 +4998,13 @@ impl Codegen {
49884998 .join(",")
49894999 };
49905000 let (schema_ptr, schema_len) = self.string_ptr(builder, &append_schema);
5001+ // Validate refined types on the rows actually written to the
5002+ // underlying source (post-rename, post-constant-augment) — view
5003+ // writes must not bypass the source's refinement predicates.
5004+ let written_cols = schema_col_names(&append_schema);
5005+ self.emit_refinement_checks_filtered(
5006+ builder, &source_name, augmented, Some(&written_cols), env, db,
5007+ );
49915008 self.call_rt_void(
49925009 builder,
49935010 "knot_source_append",
@@ -5004,6 +5021,13 @@ impl Codegen {
50045021 }
50055022 let (name_ptr, name_len) = self.string_ptr(builder, &source_name);
50065023 let (schema_ptr, schema_len) = self.string_ptr(builder, &view_schema);
5024+ // Validate refined types on the rows written through the view
5025+ // (post-rename) — view writes must not bypass the underlying
5026+ // source's refinement predicates.
5027+ let written_cols = schema_col_names(&view_schema);
5028+ self.emit_refinement_checks_filtered(
5029+ builder, &source_name, val, Some(&written_cols), env, db,
5030+ );
50075031 self.call_rt_void(
50085032 builder,
50095033 "knot_source_diff_write",
@@ -5061,6 +5085,13 @@ impl Codegen {
50615085 let (schema_ptr, schema_len) = self.string_ptr(builder, &write_schema);
50625086 let (filter_ptr, filter_len) = self.string_ptr(builder, &filter_where);
50635087
5088+ // Validate refined types on the rows actually written to the
5089+ // underlying source (post-rename, post-constant-augment) — view
5090+ // writes must not bypass the source's refinement predicates.
5091+ let written_cols = schema_col_names(&write_schema);
5092+ self.emit_refinement_checks_filtered(
5093+ builder, &source_name, augmented, Some(&written_cols), env, db,
5094+ );
50645095 self.call_rt_void(
50655096 builder,
50665097 "knot_view_write",
@@ -5667,7 +5698,11 @@ impl Codegen {
56675698 if !schema.starts_with('#') && !schema.contains('[') {
56685699 if let Some((agg_bind, agg_body)) = extract_single_param_lambda(&args[0], &self.fun_bodies, &self.let_bindings) {
56695700 let agg_body: &ast::Expr = &agg_body;
5670− if let Some(col_sql) = extract_sql_field_access(&agg_bind, agg_body, "", &schema) {
5701+ // MIN/MAX over non-numeric columns must
5702+ // stay in memory (see minmax_pushdown_type_ok).
5703+ let minmax_ok = !matches!(name.as_str(), "minOn" | "maxOn")
5704+ || minmax_pushdown_type_ok(&agg_bind, agg_body, &schema);
5705+ if let (true, Some(col_sql)) = (minmax_ok, extract_sql_field_access(&agg_bind, agg_body, "", &schema)) {
56715706 if let Some(frag) = self.try_compile_sql_expr(&filter_bind, filter_body, &schema) {
56725707 let arg_sql = if matches!(name.as_str(), "minOn" | "maxOn") {
56735708 col_sql_for_minmax(&col_sql, &agg_bind, agg_body, &schema)
@@ -5726,6 +5761,14 @@ impl Codegen {
57265761 }
57275762 _ => None,
57285763 };
5764+ let col_info = col_info.filter(|(_, col_ty)| {
5765+ // MIN/MAX over non-numeric projected columns
5766+ // must stay in memory: the runtime re-parses
5767+ // TEXT results as Int (see minmax_pushdown_type_ok).
5768+ !matches!(name.as_str(), "minOn" | "maxOn")
5769+ || col_ty == "int"
5770+ || col_ty == "float"
5771+ });
57295772 if let Some((col_sql, col_ty)) = col_info {
57305773 let arg_sql = if matches!(name.as_str(), "minOn" | "maxOn")
57315774 && col_ty == "int"
@@ -6721,6 +6764,27 @@ impl Codegen {
67216764 relation_val: Value,
67226765 env: &mut Env,
67236766 db: Value,
6767+ ) {
6768+ self.emit_refinement_checks_filtered(builder, source_name, relation_val, None, env, db);
6769+ }
6770+
6771+ /// Emit refinement validation calls for rows about to be written to
6772+ /// `source_name`, restricted to the columns the rows actually carry.
6773+ /// `written_cols: None` means the rows carry the full source schema.
6774+ /// With `Some(cols)`:
6775+ /// - field-level refinements are emitted only for fields in `cols`
6776+ /// (a projected view that doesn't select the field can't violate it);
6777+ /// - whole-element refinements are emitted only when `cols` covers
6778+ /// every source column (otherwise the predicate could access a
6779+ /// missing field and the check can't be evaluated on partial rows).
6780+ fn emit_refinement_checks_filtered(
6781+ &mut self,
6782+ builder: &mut FunctionBuilder,
6783+ source_name: &str,
6784+ relation_val: Value,
6785+ written_cols: Option<&HashSet<String>>,
6786+ env: &mut Env,
6787+ db: Value,
67246788 ) {
67256789 // Skip refinement checks inside atomic blocks: data entering the
67266790 // system is validated at the boundary (route handlers), so re-running
@@ -6734,7 +6798,25 @@ impl Codegen {
67346798 Some(r) => r.clone(),
67356799 None => return,
67366800 };
6801+ let covers_all = match written_cols {
6802+ None => true,
6803+ Some(cols) => self
6804+ .source_schemas
6805+ .get(source_name)
6806+ .map(|full| {
6807+ split_schema_fields(full).iter().all(|part| {
6808+ let name = part.split(':').next().unwrap_or("");
6809+ cols.contains(name)
6810+ })
6811+ })
6812+ .unwrap_or(false),
6813+ };
67376814 for (field_name, type_name, predicate_expr) in &refinements {
6815+ match (field_name, written_cols) {
6816+ (Some(f), Some(cols)) if !cols.contains(f.as_str()) => continue,
6817+ (None, _) if !covers_all => continue,
6818+ _ => {}
6819+ }
67386820 let pred_fn = self.compile_expr(builder, predicate_expr, env, db);
67396821 let (tn_ptr, tn_len) = self.string_ptr(builder, type_name);
67406822 let field_str = field_name.as_deref().unwrap_or("");
@@ -7512,6 +7594,30 @@ impl Codegen {
75127594 })
75137595 }
75147596
7597+ /// Like `do_block_is_comprehension`, but also admits `groupBy`
7598+ /// statements: a do-block ending in `yield` whose other statements are
7599+ /// all Bind/Where/Let/GroupBy compiles through the relational loop
7600+ /// paths and therefore produces a relation value.
7601+ fn do_block_is_relational_shape(stmts: &[ast::Stmt]) -> bool {
7602+ let Some((last, init)) = stmts.split_last() else {
7603+ return false;
7604+ };
7605+ let last_is_yield = matches!(
7606+ &last.node,
7607+ ast::StmtKind::Expr(e) if e.node.as_yield_arg().is_some()
7608+ );
7609+ last_is_yield
7610+ && init.iter().all(|s| {
7611+ matches!(
7612+ &s.node,
7613+ ast::StmtKind::Bind { .. }
7614+ | ast::StmtKind::Where { .. }
7615+ | ast::StmtKind::Let { .. }
7616+ | ast::StmtKind::GroupBy { .. }
7617+ )
7618+ })
7619+ }
7620+
75157621 /// Check whether an expression's IO-ness involves *external* effects
75167622 /// (console/fs/network/clock/random builtins, fork/race, atomic blocks,
75177623 /// relation writes, user IO functions) rather than plain relation reads.
@@ -7681,6 +7787,8 @@ impl Codegen {
76817787 // (via the saved snapshot) but keep outer-scope entries visible
76827788 // while compiling inner do-blocks.
76837789 let prev_let_bindings = self.let_bindings.clone();
7790+ // Save relation-valued binding names for the same scoping reasons.
7791+ let prev_io_relation_vars = self.io_relation_vars.clone();
76847792 // Mark the invalidation log so writes/rebinds inside this scope are
76857793 // replayed onto the restored snapshots when the scope exits.
76867794 let invalidation_mark = self.source_bind_invalidations.len();
@@ -7705,10 +7813,14 @@ impl Codegen {
77057813 // be relations; other non-IO binds (single-value pattern
77067814 // matches like `InProgress ip <- t.status`) keep
77077815 // bind-the-value semantics.
7708− if !self.expr_is_io(expr)
7816+ let rhs_iterates = !self.expr_is_io(expr)
77097817 && (matches!(&expr.node, ast::ExprKind::List(_))
7710− || self.expr_is_known_relation(expr))
7711− {
7818+ || self.expr_is_known_relation(expr)
7819+ || self.expr_is_relation_var(expr));
7820+ // Names (re)bound by this pattern are rows from here on,
7821+ // not the relation-valued lets they may have shadowed.
7822+ self.io_relation_vars.retain(|n| !pat_binds(pat, n));
7823+ if rhs_iterates {
77127824 last_val = self.compile_io_bind_loop(
77137825 builder,
77147826 pat,
@@ -7776,6 +7888,31 @@ impl Codegen {
77767888 }
77777889 }
77787890 };
7891+ // Track names whose let-bound value is statically a
7892+ // relation (comprehension/groupBy do-blocks, source
7893+ // reads, list literals, relation-returning stdlib calls,
7894+ // or another relation-valued name) so a later
7895+ // `row <- name` bind iterates instead of binding the
7896+ // whole relation value.
7897+ let is_relation_value = match &expr.node {
7898+ ast::ExprKind::Do(do_stmts) => {
7899+ !self.expr_has_external_io(expr)
7900+ && Self::do_block_is_relational_shape(do_stmts)
7901+ }
7902+ ast::ExprKind::List(_)
7903+ | ast::ExprKind::SourceRef(_)
7904+ | ast::ExprKind::DerivedRef(_) => true,
7905+ _ => {
7906+ self.expr_is_known_relation(expr)
7907+ || self.expr_is_relation_var(expr)
7908+ }
7909+ };
7910+ self.io_relation_vars.retain(|n| !pat_binds(pat, n));
7911+ if is_relation_value {
7912+ if let ast::PatKind::Var(var_name) = &pat.node {
7913+ self.io_relation_vars.insert(var_name.clone());
7914+ }
7915+ }
77797916 self.bind_io_pattern(builder, pat, val, env, Some(done_block));
77807917 self.invalidate_after_possible_writes(expr);
77817918 last_val = val;
@@ -7825,6 +7962,7 @@ impl Codegen {
78257962 self.in_io_eager = prev_io_eager;
78267963 self.source_var_binds = prev_source_var_binds;
78277964 self.let_bindings = prev_let_bindings;
7965+ self.io_relation_vars = prev_io_relation_vars;
78287966 // Writes/rebinds inside this scope still invalidate outer entries.
78297967 self.replay_source_bind_invalidations_since(invalidation_mark);
78307968 done_param
@@ -8087,6 +8225,16 @@ impl Codegen {
80878225 // Save let_bindings for restoration on exit; new entries inserted
80888226 // below are visible only inside this do-block's scope.
80898227 let prev_let_bindings = self.let_bindings.clone();
8228+ // Snapshot env bindings: every name bound inside this do-block
8229+ // (bind patterns, lets, the groupBy rebind) refers to an SSA value
8230+ // defined inside a loop body, which does NOT dominate code emitted
8231+ // after the do-block. If such a binding shadowed an outer variable
8232+ // that the caller references after the block, the caller would pick
8233+ // up the loop-local SSA value and the Cranelift verifier rejects the
8234+ // function ("uses value vN from non-dominating inst"). Restore the
8235+ // caller's bindings wholesale on exit — do-block bindings are scoped
8236+ // to the block and never legitimately escape it.
8237+ let prev_env_bindings = env.bindings.clone();
80908238 // Save source_var_binds too: binds in this block shadow outer
80918239 // source-read tracking for the duration of the block.
80928240 let prev_source_var_binds = self.source_var_binds.clone();
@@ -8913,6 +9061,7 @@ impl Codegen {
89139061 // live until the caller returned.
89149062 let promoted = self.call_rt(builder, "knot_arena_pop_frame_promote", &[result]);
89159063 self.atomic_arena_frames -= 1;
9064+ env.bindings = prev_env_bindings;
89169065 self.let_bindings = prev_let_bindings;
89179066 self.source_var_binds = prev_source_var_binds;
89189067 // Writes/rebinds inside this scope still invalidate outer entries.
@@ -9427,6 +9576,13 @@ impl Codegen {
94279576 ))
94289577 }
94299578 "sum" | "avg" | "minOn" | "maxOn" => {
9579+ // MIN/MAX over non-numeric columns must stay in memory
9580+ // (see minmax_pushdown_type_ok).
9581+ if matches!(fn_name, "minOn" | "maxOn")
9582+ && !minmax_pushdown_type_ok(&bind_var, body, schema)
9583+ {
9584+ return None;
9585+ }
94309586 // Use unqualified column names for direct SQL aggregate
94319587 let col_sql = extract_sql_field_access(&bind_var, body, "", schema)?;
94329588 let (func, rt_fn) = aggregate_sql_func_runtime(fn_name)?;
@@ -9595,6 +9751,11 @@ impl Codegen {
95959751 if is_count || aggregate.is_some() || select_override.is_some() {
95969752 return None;
95979753 }
9754+ // MIN over non-numeric columns must stay in memory
9755+ // (see minmax_pushdown_type_ok).
9756+ if !minmax_pushdown_type_ok(bind_var, body, &schema) {
9757+ return None;
9758+ }
95989759 bind_aliases.insert(bind_var.clone(), alias.clone());
95999760 let col_sql = extract_sql_field_access(bind_var, body, &alias, &schema)?;
96009761 let arg_sql = col_sql_for_minmax(&col_sql, bind_var, body, &schema);
@@ -9604,6 +9765,11 @@ impl Codegen {
96049765 if is_count || aggregate.is_some() || select_override.is_some() {
96059766 return None;
96069767 }
9768+ // MAX over non-numeric columns must stay in memory
9769+ // (see minmax_pushdown_type_ok).
9770+ if !minmax_pushdown_type_ok(bind_var, body, &schema) {
9771+ return None;
9772+ }
96079773 bind_aliases.insert(bind_var.clone(), alias.clone());
96089774 let col_sql = extract_sql_field_access(bind_var, body, &alias, &schema)?;
96099775 let arg_sql = col_sql_for_minmax(&col_sql, bind_var, body, &schema);
@@ -9860,7 +10026,25 @@ impl Codegen {
986010026 if bind_to_alias.keys().any(|k| Self::expr_refs_var(expr, k)) {
986110027 return None;
986210028 }
9863− let_binds.insert(var_name, expr.clone());
10029+ // Close the expression over earlier do-local lets: a
10030+ // chained `let a = 5; let b = a + 1` stores `b` as a
10031+ // param expression that is later compiled in the
10032+ // *enclosing* env, where `a` is not bound — substitute
10033+ // previously-collected let bindings so every stored
10034+ // expression only references outer-scope names. Entries
10035+ // in `let_binds` are already closed (each was
10036+ // substituted when stored), so a single pass suffices.
10037+ let mut closed = expr.clone();
10038+ for (k, v) in &let_binds {
10039+ match substitute(&closed, k, v) {
10040+ Some(s) => closed = s,
10041+ // Substitution would capture a free variable —
10042+ // bail out of the SQL plan entirely rather than
10043+ // compile a param expr with unbound names.
10044+ None => return None,
10045+ }
10046+ }
10047+ let_binds.insert(var_name, closed);
986410048 }
986510049 ast::StmtKind::Where { cond } => {
986610050 let frag = Self::try_compile_multi_table_sql_expr(
@@ -10294,7 +10478,7 @@ impl Codegen {
1029410478 };
1029510479 // Try field op value, then value op field (reversed),
1029610480 // then atom-based comparison (handles arithmetic like x.a * x.b > 100)
10297− Self::try_compile_sql_comparison(bind_var, lhs, rhs, sql_op)
10481+ Self::try_compile_sql_comparison(bind_var, lhs, rhs, sql_op, schema)
1029810482 .or_else(|| {
1029910483 let rev = match sql_op {
1030010484 "=" | "!=" => sql_op,
@@ -10304,7 +10488,7 @@ impl Codegen {
1030410488 ">=" => "<=",
1030510489 _ => return None,
1030610490 };
10307− Self::try_compile_sql_comparison(bind_var, rhs, lhs, rev)
10491+ Self::try_compile_sql_comparison(bind_var, rhs, lhs, rev, schema)
1030810492 })
1030910493 .or_else(|| {
1031010494 // Type-witness gate: decide between the KNOT_INT
@@ -10440,6 +10624,7 @@ impl Codegen {
1044010624 field_side: &ast::Expr,
1044110625 value_side: &ast::Expr,
1044210626 op: &str,
10627+ schema: &str,
1044310628 ) -> Option<SqlFragment> {
1044410629 let col_name = if let ast::ExprKind::FieldAccess { expr, field } = &field_side.node {
1044510630 if let ast::ExprKind::Var(name) = &expr.node {
@@ -10455,6 +10640,16 @@ impl Codegen {
1045510640 return None;
1045610641 };
1045710642
10643+ // Payload-bearing ADT fields and nested records are stored as JSON
10644+ // documents, but the runtime encodes the compared Knot value
10645+ // differently when binding it as a SQL parameter (constructor
10646+ // params bind as bare tag text). A pushed-down `col = ?` would
10647+ // silently drop matching rows — fall back to in-memory evaluation.
10648+ // Equality on all-nullary ("tag") ADT columns stays pushable.
10649+ if lookup_col_type_from_schema(schema, &col_name).as_deref() == Some("json") {
10650+ return None;
10651+ }
10652+
1045810653 let param = match &value_side.node {
1045910654 ast::ExprKind::Lit(lit) => SqlParamSource::Literal(lit.clone()),
1046010655 ast::ExprKind::Var(name) => SqlParamSource::Var(name.clone()),
@@ -10906,6 +11101,16 @@ impl Codegen {
1090611101 );
1090711102 }
1090811103
11104+ /// Check if an expression is (possibly through annotation wrappers) a
11105+ /// variable bound earlier in the current IO do-block to a value that is
11106+ /// statically known to be a relation (see `io_relation_vars`).
11107+ fn expr_is_relation_var(&self, expr: &ast::Expr) -> bool {
11108+ match &strip_expr_wrappers(expr).node {
11109+ ast::ExprKind::Var(name) => self.io_relation_vars.contains(name),
11110+ _ => false,
11111+ }
11112+ }
11113+
1090911114 /// Check if an expression is statically known to produce a relation,
1091011115 /// beyond the simple pattern match in compile_do.
1091111116 fn expr_is_known_relation(&self, expr: &ast::Expr) -> bool {
@@ -11542,6 +11747,23 @@ fn aggregate_sql_func_runtime(name: &str) -> Option<(&'static str, &'static str)
1154211747 }
1154311748}
1154411749
11750+/// minOn/maxOn pushdown is only sound when the projected expression's Knot
11751+/// type is numeric (Int or Float): the `knot_source_query_value` runtime
11752+/// re-parses TEXT results as Int when possible (Knot Ints are stored as
11753+/// TEXT in SQLite), which corrupts genuine Text results — `maxOn` over
11754+/// `["007", "01"]` would come back as `Int 1`. Text (and any other or
11755+/// unknown type) must fall back to in-memory evaluation.
11756+pub(crate) fn minmax_pushdown_type_ok(
11757+ bind_var: &str,
11758+ body: &ast::Expr,
11759+ schema: &str,
11760+) -> bool {
11761+ matches!(
11762+ infer_sql_expr_type(bind_var, body, schema).as_deref(),
11763+ Some("int") | Some("float")
11764+ )
11765+}
11766+
1154511767/// Wrap a column SQL expression for use inside MIN/MAX so integer-typed
1154611768/// columns sort numerically rather than lexicographically. Knot stores
1154711769/// `Int` columns as `TEXT COLLATE KNOT_INT`, but SQLite does not propagate
@@ -12046,6 +12268,15 @@ fn parse_schema_columns(schema: &str) -> Vec<(String, String)> {
1204612268 .collect()
1204712269}
1204812270
12271+/// Column names of a schema descriptor (bracket-aware).
12272+fn schema_col_names(schema: &str) -> HashSet<String> {
12273+ split_schema_fields(schema)
12274+ .into_iter()
12275+ .map(|part| part.split(':').next().unwrap_or("").to_string())
12276+ .filter(|n| !n.is_empty())
12277+ .collect()
12278+}
12279+
1204912280/// Split a schema descriptor by commas while respecting `[...]` bracket nesting
1205012281/// for nested relation fields (e.g. `name:text,items:[price:int,qty:int]`).
1205112282fn split_schema_fields(s: &str) -> Vec<&str> {
@@ -13003,7 +13234,7 @@ fn analyze_map_select(
1300313234}
1300413235
1300513236/// Infer the SQL type of an arithmetic expression by examining its leaf types.
13006−fn infer_sql_expr_type(bind_var: &str, expr: &ast::Expr, schema: &str) -> Option<String> {
13237+pub(crate) fn infer_sql_expr_type(bind_var: &str, expr: &ast::Expr, schema: &str) -> Option<String> {
1300713238 match &expr.node {
1300813239 ast::ExprKind::FieldAccess { expr: inner, field: col_name } => {
1300913240 if let ast::ExprKind::Var(name) = &inner.node {
@@ -13861,6 +14092,12 @@ pub(crate) fn sql_scalar_kind(
1386114092 Some("int") => Some(SqlScalarKind::Int),
1386214093 Some("float") => Some(SqlScalarKind::Float),
1386314094 Some("text") => Some(SqlScalarKind::Text),
14095+ // Payload-bearing ADT fields and nested records are
14096+ // stored as JSON documents, but the runtime binds the
14097+ // corresponding Knot values differently (constructor
14098+ // params bind as bare tag text) — SQL comparison would
14099+ // silently mismatch. Never push down json columns.
14100+ Some("json") => return Err(()),
1386414101 Some(_) => Some(SqlScalarKind::Other),
1386514102 None => None, // not a column — runtime param
1386614103 })
@@ -13931,6 +14168,10 @@ pub(crate) fn sql_comparison_cast_mode(
1393114168 let joined = join_sql_kinds(l, r).ok()?;
1393214169 Some(match joined {
1393314170 Some(SqlScalarKind::Float) => SqlCastMode::Plain,
14171+ // Text comparisons must use SQLite's default BINARY (byte-wise)
14172+ // collation, matching Knot's Text semantics. The KNOT_INT collation
14173+ // compares numerically, so e.g. `"0" ++ "7" == "7"` would match.
14174+ Some(SqlScalarKind::Text) => SqlCastMode::Plain,
1393414175 Some(_) => SqlCastMode::CastInt,
1393514176 None => SqlCastMode::NoArith,
1393614177 })
crates/knot-compiler/src/desugar.rs+21−1
@@ -876,7 +876,9 @@ fn is_pure_comprehension(stmts: &[Stmt], io_fns: &IoFns) -> bool {
876876fn expr_is_io(expr: &Expr, io_fns: &HashSet<String>) -> bool {
877877 match &expr.node {
878878 ExprKind::App { func, arg } => {
879− expr_is_io(func, io_fns) || expr_is_io(arg, io_fns)
879+ expr_is_io(func, io_fns)
880+ || expr_is_io(arg, io_fns)
881+ || applied_lambda_body_is_io(func, io_fns)
880882 }
881883 ExprKind::Var(name) => {
882884 // `retry` is in EFFECTFUL_BUILTINS but isn't an IO-producing
@@ -926,6 +928,24 @@ fn expr_is_io(expr: &Expr, io_fns: &HashSet<String>) -> bool {
926928 }
927929}
928930
931+/// Whether the function position of an application is a lambda (possibly
932+/// curried or wrapped in annotations) whose body performs IO. An *applied*
933+/// lambda runs its body immediately, so IO inside the body makes the whole
934+/// application an IO expression — mirroring codegen's `expr_is_io`, which
935+/// recurses into lambda bodies. Bare lambda VALUES (not applied) remain
936+/// non-IO; only the `App { func: Lambda, .. }` shape reaches here.
937+fn applied_lambda_body_is_io(func: &Expr, io_fns: &HashSet<String>) -> bool {
938+ match &func.node {
939+ ExprKind::Lambda { body, .. } => expr_is_io(body, io_fns),
940+ // Curried application: `(\a b -> body) x y` — keep peeling.
941+ ExprKind::App { func, .. } => applied_lambda_body_is_io(func, io_fns),
942+ ExprKind::UnitLit { value, .. }
943+ | ExprKind::Annot { expr: value, .. }
944+ | ExprKind::Refine(value) => applied_lambda_body_is_io(value, io_fns),
945+ _ => false,
946+ }
947+}
948+
929949// ── Core desugaring ──────────────────────────────────────────────
930950
931951/// Counter for generating unique temporary variable names.
crates/knot-compiler/src/effects.rs+238−4
@@ -195,6 +195,17 @@ struct EffectChecker {
195195 /// (e.g. `forEach : (a -> IO {} {}) -> IO {} {}`), the callback's effects
196196 /// are absorbed by the declared row, so we don't propagate.
197197 row_poly_decls: HashSet<String>,
198+ /// Names of declarations whose annotated signature declares a *closed*
199+ /// effect set (an `IO {…}` result with no row variable). Per the
200+ /// documented annotation semantics, such a signature absorbs the
201+ /// effects of callback arguments, so calls to these functions do NOT
202+ /// propagate lambda-argument effects. Every other callee (unannotated
203+ /// user functions, local bindings, unknown names, builtins) may invoke
204+ /// its functional arguments, so lambda-argument effects are
205+ /// conservatively charged to the call — over-approximating for HOFs
206+ /// that never call their argument, which is the safe direction for
207+ /// the atomic gate.
208+ fixed_row_decls: HashSet<String>,
198209 /// Stack of local scopes mapping let-bound (or immediately-applied-
199210 /// lambda-bound) function names to the effects of their bodies. Lets the
200211 /// checker see effects of calls through local bindings, e.g.
@@ -250,6 +261,7 @@ impl EffectChecker {
250261 builtin_effects,
251262 source_names: HashSet::new(),
252263 row_poly_decls: HashSet::new(),
264+ fixed_row_decls: HashSet::new(),
253265 local_fn_effects: Vec::new(),
254266 diagnostics: Vec::new(),
255267 }
@@ -264,8 +276,9 @@ impl EffectChecker {
264276 }
265277
266278 // Collect declarations whose annotated signature uses an effect-row
267− // variable. Knowing this up front lets the App handler propagate
268− // lambda-arg effects through row-polymorphic callees only.
279+ // variable (these propagate lambda-arg effects) and declarations
280+ // with a *closed* declared effect set (these absorb lambda-arg
281+ // effects). Everything else conservatively propagates.
269282 for decl in &module.decls {
270283 match &decl.node {
271284 ast::DeclKind::Fun { name, ty: Some(scheme), .. }
@@ -273,6 +286,64 @@ impl EffectChecker {
273286 | ast::DeclKind::Derived { name, ty: Some(scheme), .. } => {
274287 if type_has_effect_row_var(&scheme.ty) {
275288 self.row_poly_decls.insert(name.clone());
289+ } else if extract_effects(&scheme.ty).is_some() {
290+ self.fixed_row_decls.insert(name.clone());
291+ }
292+ }
293+ _ => {}
294+ }
295+ }
296+
297+ // Collect trait-method bodies: impl methods and trait default
298+ // bodies, grouped by method name. A trait-method call dispatches on
299+ // the runtime type tag, so the only sound static approximation for
300+ // a call site is the UNION of effects across every known impl
301+ // (plus defaults). The union is stored in `decl_effects` under the
302+ // method name so Var/callee lookups see it. (Names that collide
303+ // with a top-level function are skipped — the function wins, which
304+ // matches name-resolution order.) BTreeMap keeps diagnostic order
305+ // deterministic.
306+ let fun_names: HashSet<&str> = module
307+ .decls
308+ .iter()
309+ .filter_map(|d| match &d.node {
310+ ast::DeclKind::Fun { name, .. }
311+ | ast::DeclKind::View { name, .. }
312+ | ast::DeclKind::Derived { name, .. } => Some(name.as_str()),
313+ _ => None,
314+ })
315+ .collect();
316+ let mut method_bodies: std::collections::BTreeMap<String, Vec<&ast::Expr>> =
317+ std::collections::BTreeMap::new();
318+ for decl in &module.decls {
319+ match &decl.node {
320+ ast::DeclKind::Impl { items, .. } => {
321+ for item in items {
322+ if let ast::ImplItem::Method { name, body, .. } = item {
323+ if !fun_names.contains(name.as_str()) {
324+ method_bodies
325+ .entry(name.clone())
326+ .or_default()
327+ .push(body);
328+ }
329+ }
330+ }
331+ }
332+ ast::DeclKind::Trait { items, .. } => {
333+ for item in items {
334+ if let ast::TraitItem::Method {
335+ name,
336+ default_body: Some(body),
337+ ..
338+ } = item
339+ {
340+ if !fun_names.contains(name.as_str()) {
341+ method_bodies
342+ .entry(name.clone())
343+ .or_default()
344+ .push(body);
345+ }
346+ }
276347 }
277348 }
278349 _ => {}
@@ -322,6 +393,21 @@ impl EffectChecker {
322393 changed = true;
323394 }
324395 }
396+ // Trait methods: union of all impl bodies and the trait
397+ // default body, so trait-method call sites are sound for the
398+ // atomic gate and relation reads/writes inside impls are
399+ // visible.
400+ for (name, bodies) in &method_bodies {
401+ let mut effects = EffectSet::empty();
402+ for body in bodies {
403+ effects = effects.union(&self.fun_body_effects(body));
404+ }
405+ let old = self.decl_effects.get(name);
406+ if old.map_or(true, |o| *o != effects) {
407+ self.decl_effects.insert(name.clone(), effects);
408+ changed = true;
409+ }
410+ }
325411 self.diagnostics = saved_diags;
326412 if !changed { break; }
327413 }
@@ -336,6 +422,14 @@ impl EffectChecker {
336422 for decl in &funs {
337423 self.process_decl(decl);
338424 }
425+ // Impl-method and trait-default bodies were only walked with
426+ // diagnostics suppressed during the fixpoint — walk them once more
427+ // so atomic-safety violations inside them are reported.
428+ for bodies in method_bodies.values() {
429+ for body in bodies {
430+ let _ = self.fun_body_effects(body);
431+ }
432+ }
339433 }
340434
341435 fn process_decl(&mut self, decl: &ast::Decl) {
@@ -375,6 +469,17 @@ impl EffectChecker {
375469 if let Some(effects) = self.builtin_effects.get(name) {
376470 return effects.clone();
377471 }
472+ // A reference to a local let-bound lambda carries its
473+ // body's effects: the value may be invoked by whoever
474+ // receives it (e.g. `let cb = \r -> println "x"` passed
475+ // to a higher-order function). Mirrors callee_effects'
476+ // scope lookup — without it, effects laundered through a
477+ // local name bypassed the atomic gate.
478+ for scope in self.local_fn_effects.iter().rev() {
479+ if let Some(effects) = scope.get(name) {
480+ return effects.clone();
481+ }
482+ }
378483 if let Some(effects) = self.decl_effects.get(name) {
379484 return effects.clone();
380485 }
@@ -402,8 +507,21 @@ impl EffectChecker {
402507 // the syntactically last (outermost) argument was considered,
403508 // so `withCb2 (\u -> println "hi") 1` lost the console effect.
404509 let (head, args) = app_spine(expr);
510+ // Lambda-argument effects propagate to the call unless the
511+ // callee's annotation declares a *closed* effect row (the
512+ // documented "absorbing" signature shape). Unannotated
513+ // user functions (`apply = \f x -> f x`), local bindings,
514+ // unknown names, and builtins are all assumed to invoke
515+ // their functional arguments — otherwise an unannotated
516+ // higher-order helper launders IO past the atomic gate.
517+ // Immediately-applied lambda heads keep propagate=false:
518+ // `head_call_effects` analyzes those precisely by binding
519+ // the lambda-valued arguments into a local scope.
405520 let propagate_lambda = head_name(head)
406− .map(|n| self.row_poly_decls.contains(n))
521+ .map(|n| {
522+ self.row_poly_decls.contains(n)
523+ || !self.fixed_row_decls.contains(n)
524+ })
407525 .unwrap_or(false);
408526 let mut effects = EffectSet::empty();
409527 for arg in &args {
@@ -492,11 +610,16 @@ impl EffectChecker {
492610 // conservative: only flag bodies that provably contain no
493611 // relation operations anywhere syntactically (including
494612 // inside local lambdas) — effect inference can miss reads
495− // performed through bindings it cannot resolve.
613+ // performed through bindings it cannot resolve. A call to
614+ // an opaque callee (lambda parameter, field-accessed
615+ // function, any name we cannot analyze) is not provably
616+ // relation-free either, so its presence suppresses the
617+ // error too.
496618 if !inner_effects.has_io()
497619 && inner_effects.reads.is_empty()
498620 && inner_effects.writes.is_empty()
499621 && !touches_relations(inner)
622+ && !self.body_may_call_opaque(inner)
500623 {
501624 self.diagnostics.push(
502625 Diagnostic::error("atomic block must interact with relations")
@@ -602,6 +725,117 @@ impl EffectChecker {
602725 }
603726 }
604727
728+ /// Whether the expression contains a call whose callee cannot be
729+ /// analyzed for relation access: lambda parameters, field-accessed
730+ /// functions, or names that are neither builtins nor declarations
731+ /// with (converged) inferred effects. Such a body is not *provably*
732+ /// relation-free, so the hard "atomic block must interact with
733+ /// relations" error must stay quiet. Trait methods are analyzable
734+ /// (their impl-body union lives in `decl_effects`); constructors and
735+ /// inline lambdas are analyzable too (the surrounding walk recurses
736+ /// into lambda bodies).
737+ fn body_may_call_opaque(&self, expr: &ast::Expr) -> bool {
738+ // Let-bound lambdas inside the body are analyzable: their bodies
739+ // are part of the walked tree, so `touches_relations` and effect
740+ // inference both see through them. (The local_fn_effects scopes
741+ // those bindings lived in are already popped by the time the
742+ // Atomic arm runs this check, so collect them syntactically.)
743+ // The same applies to lambda parameters bound to lambda-literal
744+ // arguments — including the `(\f -> …) (\u -> …)` shape that
745+ // desugared `let f = \u -> …` statements take.
746+ let mut local_lambdas: HashSet<String> = HashSet::new();
747+ walk_expr(expr, &mut |e| {
748+ match &e.node {
749+ ast::ExprKind::Do(stmts) => {
750+ for stmt in stmts {
751+ if let ast::StmtKind::Let { pat, expr } = &stmt.node {
752+ if let ast::PatKind::Var(name) = &pat.node {
753+ if is_lambda_arg(expr) {
754+ local_lambdas.insert(name.clone());
755+ }
756+ }
757+ }
758+ }
759+ }
760+ ast::ExprKind::App { .. } => {
761+ let (head, args) = app_spine(e);
762+ if let ast::ExprKind::Lambda { params, .. } = &head.node {
763+ for (param, arg) in params.iter().zip(args.iter()) {
764+ if let ast::PatKind::Var(name) = &param.node {
765+ if is_lambda_arg(arg) {
766+ local_lambdas.insert(name.clone());
767+ }
768+ }
769+ }
770+ }
771+ }
772+ _ => {}
773+ }
774+ });
775+ let mut found = false;
776+ walk_expr(expr, &mut |e| {
777+ if found {
778+ return;
779+ }
780+ let callee: Option<&ast::Expr> = match &e.node {
781+ ast::ExprKind::App { func, .. } => {
782+ let mut head: &ast::Expr = func;
783+ while let ast::ExprKind::App { func, .. } = &head.node {
784+ head = func;
785+ }
786+ Some(head)
787+ }
788+ ast::ExprKind::BinOp { op: ast::BinOp::Pipe, rhs, .. } => {
789+ Some(rhs.as_ref())
790+ }
791+ _ => None,
792+ };
793+ let Some(mut head) = callee else { return };
794+ // Unwrap annotation-style wrappers around the callee.
795+ loop {
796+ match &head.node {
797+ ast::ExprKind::Annot { expr: inner, .. }
798+ | ast::ExprKind::UnitLit { value: inner, .. }
799+ | ast::ExprKind::Refine(inner) => head = inner,
800+ _ => break,
801+ }
802+ }
803+ match &head.node {
804+ ast::ExprKind::Var(name) => {
805+ // Desugaring helpers (__bind/__yield/__empty) dispatch
806+ // to monad impls whose arguments are walked directly;
807+ // operator-trait fallbacks (eq, compare, add, …) are
808+ // intrinsically pure and user impls of them appear in
809+ // decl_effects.
810+ let known = self.builtin_effects.contains_key(name)
811+ || self.decl_effects.contains_key(name)
812+ || local_lambdas.contains(name)
813+ || crate::builtins::INTERNAL_BUILTINS
814+ .contains(&name.as_str())
815+ || crate::builtins::TRAIT_METHOD_BUILTINS
816+ .contains(&name.as_str())
817+ || self
818+ .local_fn_effects
819+ .iter()
820+ .any(|scope| scope.contains_key(name));
821+ if !known {
822+ found = true;
823+ }
824+ }
825+ // Constructors build data; inline lambdas are walked
826+ // directly; a piped App spine is visited as its own
827+ // App node by the same walk.
828+ ast::ExprKind::Constructor(_)
829+ | ast::ExprKind::Lambda { .. }
830+ | ast::ExprKind::App { .. } => {}
831+ // Field access, computed callees (if/case results), and
832+ // anything else: opaque.
833+ _ => found = true,
834+ }
835+ });
836+ found
837+ }
838+
605839 /// Infer effects of a do-block statement.
606840 fn infer_stmt_effects(&mut self, stmt: &ast::Stmt) -> EffectSet {
607841 match &stmt.node {
crates/knot-compiler/src/infer.rs+319−114
@@ -2190,6 +2190,16 @@ impl Infer {
21902190 Box::new(self.subst_ty(inner, &shadowed)),
21912191 )
21922192 }
2193+ // Aliases must be substituted through: `collect_free_vars`
2194+ // descends into the alias body, so quantified vars can live
2195+ // there (e.g. `type Box = {val: a}`). Skipping the body would
2196+ // share the original var across every instantiation — pinning
2197+ // it at the first use site and falsely rejecting later uses
2198+ // at other types.
2199+ Ty::Alias(name, inner) => Ty::Alias(
2200+ name.clone(),
2201+ Box::new(self.subst_ty(inner, mapping)),
2202+ ),
21932203 _ => ty.clone(),
21942204 }
21952205 }
@@ -2234,6 +2244,11 @@ impl Infer {
22342244 bound.clone(),
22352245 Box::new(self.subst_unit_vars_in_ty(inner, mapping)),
22362246 ),
2247+ // Mirror `subst_ty`: unit vars can occur inside alias bodies.
2248+ Ty::Alias(name, inner) => Ty::Alias(
2249+ name.clone(),
2250+ Box::new(self.subst_unit_vars_in_ty(inner, mapping)),
2251+ ),
22372252 _ => ty.clone(),
22382253 }
22392254 }
@@ -2652,6 +2667,24 @@ impl Infer {
26522667 "[]" => Ty::TyCon("[]".into()),
26532668 _ => {
26542669 if let Some(aliased) = self.aliases.get(name).cloned() {
2670+ // Freshen any free type variables in the alias body
2671+ // (e.g. the `a` in `type Box = {val: a}`): the body
2672+ // was converted ONCE at collection time, so without
2673+ // freshening every reference to the alias shares
2674+ // the same variable — the first use pins it (e.g.
2675+ // to Int) and later uses at other types are falsely
2676+ // rejected.
2677+ let mut fv = HashSet::new();
2678+ self.collect_free_vars(&aliased, &mut fv);
2679+ let aliased = if fv.is_empty() {
2680+ aliased
2681+ } else {
2682+ let mapping: HashMap<TyVar, Ty> = fv
2683+ .into_iter()
2684+ .map(|v| (v, self.fresh()))
2685+ .collect();
2686+ self.subst_ty(&aliased, &mapping)
2687+ };
26552688 // Wrap nullary alias references so the name flows
26562689 // through inference into LSP type hints. Skip the
26572690 // wrapper when the alias already names itself
@@ -3652,6 +3685,24 @@ impl Infer {
36523685 }
36533686 let body_skolemised = self.subst_ty(&body, &mapping);
36543687 self.check_expr(expr, &body_skolemised);
3688+ // Escape check: a skolem must not leak into the enclosing
3689+ // environment. Without this, an outer flexible var (e.g. the
3690+ // type of an unannotated lambda param `h` in
3691+ // `g = \h -> takesPoly h`) can be bound toward a skolem; once
3692+ // the skolem is dropped from `self.skolems` it becomes an
3693+ // ordinary var, gets generalized, and the wrapper accepts
3694+ // monomorphic arguments where a polymorphic one was required.
3695+ let env_vars = self.free_vars_in_env();
3696+ if fresh_skolems.iter().any(|s| env_vars.contains(s)) {
3697+ self.error(
3698+ "polymorphic type escapes its scope: this expression \
3699+ must work for every type, but its type leaked into \
3700+ the surrounding context — add an explicit `forall` \
3701+ annotation to keep the wrapper polymorphic"
3702+ .into(),
3703+ expr.span,
3704+ );
3705+ }
36553706 for s in fresh_skolems {
36563707 self.skolems.remove(&s);
36573708 }
@@ -4006,13 +4057,55 @@ impl Infer {
40064057 return None;
40074058 }
40084059
4060+ // The constructor must come from a `route` declaration — route
4061+ // metadata drives URL construction and response typing, and
4062+ // codegen has no entry to emit for a plain ADT constructor (it
4063+ // would panic at `compile_fetch`). Reject here with a proper
4064+ // diagnostic instead.
4065+ let is_route_ctor = self
4066+ .route_entries_by_api
4067+ .values()
4068+ .flat_map(|entries| entries.iter())
4069+ .any(|e| e.constructor == ctor_name);
4070+ if !is_route_ctor {
4071+ self.error(
4072+ format!(
4073+ "'{}' is not a route constructor — fetch/fetchWith \
4074+ require an endpoint constructor declared in a \
4075+ `route` block",
4076+ ctor_name
4077+ ),
4078+ expr.span,
4079+ );
4080+ return Some(Ty::Error);
4081+ }
4082+
40094083 // Infer URL argument (should be Text)
40104084 let url_ty = self.infer_expr(args[0]);
40114085 self.unify(&url_ty, &Ty::Text, args[0].span);
40124086
4013− // If fetchWith, infer the options record
4087+ // If fetchWith, check the options record. The shape must match
4088+ // what codegen + the runtime consume: `compile_fetch` reads the
4089+ // `headers` field with `knot_record_field` and
4090+ // `knot_http_fetch_io` iterates it as rows of {name, value}
4091+ // Text pairs — anything else compiles but panics at runtime.
40144092 if is_fetch_with {
4015− let _opts_ty = self.infer_expr(args[1]);
4093+ let opts_ty = self.infer_expr(args[1]);
4094+ let header_row = Ty::Record(
4095+ BTreeMap::from([
4096+ ("name".into(), Ty::Text),
4097+ ("value".into(), Ty::Text),
4098+ ]),
4099+ None,
4100+ );
4101+ let expected_opts = Ty::Record(
4102+ BTreeMap::from([(
4103+ "headers".into(),
4104+ Ty::Relation(Box::new(header_row)),
4105+ )]),
4106+ None,
4107+ );
4108+ self.unify(&opts_ty, &expected_opts, args[1].span);
40164109 }
40174110
40184111 // Infer the constructor's record payload (request fields only).
@@ -4353,6 +4446,75 @@ impl Infer {
43534446
43544447 // ── Exhaustiveness checking ────────────────────────────────
43554448
4449+ /// Whether a pattern matches *every* value of its type — i.e. it
4450+ /// contains no refutable sub-pattern. Wildcards and variables are
4451+ /// irrefutable; records are irrefutable when all their field
4452+ /// sub-patterns are. Literals, nested constructors, and list/cons
4453+ /// patterns match only a subset of values. (A nested constructor
4454+ /// position could in principle be exhaustive across several arms;
4455+ /// we conservatively do not attempt that analysis and require a
4456+ /// wildcard or irrefutable pattern instead.)
4457+ fn pattern_is_irrefutable(pat: &ast::Pat) -> bool {
4458+ match &pat.node {
4459+ ast::PatKind::Wildcard | ast::PatKind::Var(_) => true,
4460+ ast::PatKind::Record(fields) => fields.iter().all(|f| match &f.pattern {
4461+ Some(p) => Self::pattern_is_irrefutable(p),
4462+ None => true, // field-name shorthand binds a variable
4463+ }),
4464+ ast::PatKind::Lit(_)
4465+ | ast::PatKind::Constructor { .. }
4466+ | ast::PatKind::List(_)
4467+ | ast::PatKind::Cons { .. } => false,
4468+ }
4469+ }
4470+
4471+ /// Collect the constructors fully covered by `arms` (an arm covers its
4472+ /// constructor only when its payload pattern is irrefutable) and the
4473+ /// constructors that are only *partially* matched (refutable payloads —
4474+ /// e.g. `Circle {radius: 1.0}` — which must not count as coverage).
4475+ fn covered_constructors<'a>(
4476+ arms: &'a [ast::CaseArm],
4477+ ) -> (HashSet<&'a str>, HashSet<&'a str>) {
4478+ let mut covered: HashSet<&str> = HashSet::new();
4479+ let mut partial: HashSet<&str> = HashSet::new();
4480+ for arm in arms {
4481+ match &arm.pat.node {
4482+ ast::PatKind::Constructor { name, payload } => {
4483+ if Self::pattern_is_irrefutable(payload) {
4484+ covered.insert(name.as_str());
4485+ } else {
4486+ partial.insert(name.as_str());
4487+ }
4488+ }
4489+ ast::PatKind::Lit(ast::Literal::Bool(true)) => {
4490+ covered.insert("True");
4491+ }
4492+ ast::PatKind::Lit(ast::Literal::Bool(false)) => {
4493+ covered.insert("False");
4494+ }
4495+ _ => {}
4496+ }
4497+ }
4498+ (covered, partial)
4499+ }
4500+
4501+ /// Format the standard non-exhaustiveness message; when some missing
4502+ /// constructors are matched only with refutable sub-patterns, point
4503+ /// the user toward a wildcard arm.
4504+ fn non_exhaustive_msg(missing: &[&str], partial: &HashSet<&str>) -> String {
4505+ let hint = if missing.iter().any(|c| partial.contains(c)) {
4506+ " (some arms match these constructors only partially — \
4507+ add a wildcard `_` case to cover the remaining values)"
4508+ } else {
4509+ ""
4510+ };
4511+ format!(
4512+ "non-exhaustive pattern match — missing: {}{}",
4513+ missing.join(", "),
4514+ hint
4515+ )
4516+ }
4517+
43564518 /// Check that a case expression covers all constructors of the
43574519 /// scrutinee's type. Emits an error listing missing patterns when
43584520 /// the match is non-exhaustive.
@@ -4385,17 +4547,7 @@ impl Infer {
43854547 None => return,
43864548 };
43874549
4388− let covered: HashSet<&str> = arms
4389− .iter()
4390− .filter_map(|arm| match &arm.pat.node {
4391− ast::PatKind::Constructor { name, .. } => {
4392− Some(name.as_str())
4393− }
4394− ast::PatKind::Lit(ast::Literal::Bool(true)) => Some("True"),
4395− ast::PatKind::Lit(ast::Literal::Bool(false)) => Some("False"),
4396− _ => None,
4397− })
4398− .collect();
4550+ let (covered, partial) = Self::covered_constructors(arms);
43994551
44004552 let missing: Vec<&str> = data_info
44014553 .ctors
@@ -4406,26 +4558,13 @@ impl Infer {
44064558
44074559 if !missing.is_empty() {
44084560 self.error(
4409− format!(
4410− "non-exhaustive pattern match — missing: {}",
4411− missing.join(", "),
4412− ),
4561+ Self::non_exhaustive_msg(&missing, &partial),
44134562 span,
44144563 );
44154564 }
44164565 }
44174566 Ty::Variant(ctors, row) => {
4418− let covered: HashSet<&str> = arms
4419− .iter()
4420− .filter_map(|arm| match &arm.pat.node {
4421− ast::PatKind::Constructor { name, .. } => {
4422− Some(name.as_str())
4423− }
4424− ast::PatKind::Lit(ast::Literal::Bool(true)) => Some("True"),
4425− ast::PatKind::Lit(ast::Literal::Bool(false)) => Some("False"),
4426− _ => None,
4427− })
4428− .collect();
4567+ let (covered, partial) = Self::covered_constructors(arms);
44294568
44304569 if let Some(rv) = row {
44314570 // Open variant — check if the covered constructors
@@ -4507,10 +4646,8 @@ impl Infer {
45074646 .collect();
45084647 if !missing.is_empty() {
45094648 self.error(
4510− format!(
4511− "non-exhaustive pattern \
4512− match — missing: {}",
4513− missing.join(", "),
4649+ Self::non_exhaustive_msg(
4650+ &missing, &partial,
45144651 ),
45154652 span,
45164653 );
@@ -4538,11 +4675,7 @@ impl Infer {
45384675 .collect();
45394676 if !missing.is_empty() {
45404677 self.error(
4541− format!(
4542− "non-exhaustive pattern match \
4543− — missing: {}",
4544− missing.join(", "),
4545− ),
4678+ Self::non_exhaustive_msg(&missing, &partial),
45464679 span,
45474680 );
45484681 }
@@ -4551,17 +4684,7 @@ impl Infer {
45514684 // Bool is Ty::Bool (not Ty::Con), so handle it explicitly.
45524685 Ty::Bool => {
45534686 if let Some(data_info) = self.data_types.get("Bool").cloned() {
4554− let covered: HashSet<&str> = arms
4555− .iter()
4556− .filter_map(|arm| match &arm.pat.node {
4557− ast::PatKind::Constructor { name, .. } => {
4558− Some(name.as_str())
4559− }
4560− ast::PatKind::Lit(ast::Literal::Bool(true)) => Some("True"),
4561− ast::PatKind::Lit(ast::Literal::Bool(false)) => Some("False"),
4562− _ => None,
4563− })
4564− .collect();
4687+ let (covered, partial) = Self::covered_constructors(arms);
45654688
45664689 let missing: Vec<&str> = data_info
45674690 .ctors
@@ -4572,17 +4695,16 @@ impl Infer {
45724695
45734696 if !missing.is_empty() {
45744697 self.error(
4575− format!(
4576− "non-exhaustive pattern match — missing: {}",
4577− missing.join(", "),
4578− ),
4698+ Self::non_exhaustive_msg(&missing, &partial),
45794699 span,
45804700 );
45814701 }
45824702 }
45834703 }
4584− // Relations: exhaustive iff `[]` and `Cons _ _` are both
4585− // covered (or a wildcard is present, handled above).
4704+ // Relations: exhaustive iff `[]` and `Cons h t` (with
4705+ // irrefutable head/tail — `Cons 1 rest` only matches lists
4706+ // starting with 1) are both covered (or a wildcard is
4707+ // present, handled above).
45864708 Ty::Relation(_) => {
45874709 let has_empty = arms.iter().any(|arm| matches!(
45884710 &arm.pat.node,
@@ -4590,7 +4712,9 @@ impl Infer {
45904712 ));
45914713 let has_cons = arms.iter().any(|arm| matches!(
45924714 &arm.pat.node,
4593− ast::PatKind::Cons { .. }
4715+ ast::PatKind::Cons { head, tail }
4716+ if Self::pattern_is_irrefutable(head)
4717+ && Self::pattern_is_irrefutable(tail)
45944718 ));
45954719 let mut missing: Vec<&str> = Vec::new();
45964720 if !has_empty {
@@ -5017,7 +5141,28 @@ impl Infer {
50175141 // When there's no explicit yield, use the last bare expression's type
50185142 // as the result (like Rust's implicit return), falling back to unit.
50195143 let promote_to_io = is_io || (self.in_io_do && !has_relation_bind);
5144+ let has_group_by = stmts
5145+ .iter()
5146+ .any(|s| matches!(&s.node, ast::StmtKind::GroupBy { .. }));
50205147 if promote_to_io {
5148+ // An IO-promoted block that is still a *comprehension* — it
5149+ // iterates plain relation binds (or groupBy groups) and
5150+ // ACCUMULATES its yields — evaluates to the whole relation of
5151+ // yielded values, not a single element. Codegen compiles such
5152+ // blocks with a per-row loop pushing each yield into a result
5153+ // relation (compile_io_bind_loop / the relational groupBy
5154+ // path), so the type must be `IO [yield_ty]`, not
5155+ // `IO yield_ty`. IO blocks without comprehension binds keep
5156+ // `yield = pure` semantics (the yield value IS the result).
5157+ if let Some(ty) = &yield_ty {
5158+ if has_relation_bind || has_group_by {
5159+ return Ty::IO(
5160+ io_effects,
5161+ io_row,
5162+ Box::new(Ty::Relation(Box::new(ty.clone()))),
5163+ );
5164+ }
5165+ }
50215166 let inner = yield_ty.or(last_expr_ty).unwrap_or_else(Ty::unit);
50225167 Ty::IO(io_effects, io_row, Box::new(inner))
50235168 } else {
@@ -7039,6 +7184,9 @@ impl Infer {
70397184 name, params, body, ..
70407185 } = item
70417186 {
7187+ // Constraints deferred while checking this body may land on
7188+ // the method-local skolems below; remember where they start.
7189+ let pre_body_constraints = self.deferred_constraints.len();
70427190 // Type-check each impl method body
70437191 self.push_scope();
70447192 let mut param_types = Vec::new();
@@ -7072,12 +7220,60 @@ impl Infer {
70727220 for (pv, impl_ty) in param_vars.iter().zip(impl_types.iter()) {
70737221 mapping.insert(*pv, impl_ty.clone());
70747222 }
7075− // Give remaining scheme vars fresh variables
7223+ // Skolemise the remaining scheme vars (method-local
7224+ // type variables like the `b` in `conv : a -> b`):
7225+ // the impl must be at least as polymorphic as the
7226+ // trait signature. A flexible fresh var here would
7227+ // let the impl pin a caller-chosen variable to a
7228+ // concrete type (e.g. `conv = \x -> x + 1` against
7229+ // `conv : a -> b`), producing runtime type confusion
7230+ // at call sites that picked a different `b`.
7231+ let mut fresh_skolems: Vec<TyVar> = Vec::new();
70767232 for v in &scheme.vars {
7077− mapping.entry(*v).or_insert_with(|| self.fresh());
7233+ if !mapping.contains_key(v) {
7234+ let s = self.fresh_var();
7235+ self.skolems.insert(s);
7236+ fresh_skolems.push(s);
7237+ mapping.insert(*v, Ty::Var(s));
7238+ }
7239+ }
7240+ // Constraints the trait declared on those vars (e.g.
7241+ // `Ord b =>`) are promises the impl body may rely on.
7242+ for c in &scheme.constraints {
7243+ if let Some(Ty::Var(sv)) = mapping.get(&c.type_var) {
7244+ if fresh_skolems.contains(sv) {
7245+ self.declared_skolem_constraints
7246+ .entry(*sv)
7247+ .or_default()
7248+ .insert(c.trait_name.clone());
7249+ }
7250+ }
70787251 }
70797252 let expected = self.subst_ty(&scheme.ty, &mapping);
7253+ let errors_before = self.errors.len();
70807254 self.unify(&expected, &inferred_method_ty, body.span);
7255+ self.check_skolem_constraints(
7256+ &fresh_skolems,
7257+ pre_body_constraints,
7258+ );
7259+ if !fresh_skolems.is_empty()
7260+ && self.errors.len() > errors_before
7261+ {
7262+ self.error(
7263+ format!(
7264+ "impl method '{}' is less polymorphic than \
7265+ its declaration in trait '{}' — the trait \
7266+ signature's type variables must not be \
7267+ constrained to concrete types by the impl",
7268+ name, trait_name
7269+ ),
7270+ body.span,
7271+ );
7272+ }
7273+ for s in &fresh_skolems {
7274+ self.skolems.remove(s);
7275+ self.declared_skolem_constraints.remove(s);
7276+ }
70817277 } else {
70827278 // Method not found in scope — check if it belongs to a
70837279 // different trait or is simply unknown. Default-body
@@ -7929,44 +8125,20 @@ pub fn check(module: &ast::Module) -> (Vec<Diagnostic>, MonadInfo, TypeInfo, Loc
79298125 // so their result rows get bound to the union of their sources' effects.
79308126 infer.resolve_pending_effect_unions();
79318127
7932− // Phase 4b: Check deferred trait constraints
7933− infer.check_constraints();
7934−
7935− // Phase 4c: Compress substitution chains for faster resolution
7936− infer.compress_substitution();
7937−
7938− // Phase 5: Resolve monad types from desugared do-blocks
7939− let mut monad_info = MonadInfo::new();
7940− for (span, m_var) in &infer.monad_vars {
7941− let resolved = infer.apply(&Ty::Var(*m_var));
7942− let kind = match resolved.peel_alias() {
7943− Ty::TyCon(name) if name == "[]" => MonadKind::Relation,
7944− Ty::TyCon(name) if name == "IO" => MonadKind::IO,
7945− Ty::TyCon(name) => MonadKind::Adt(name.clone()),
7946− Ty::Relation(_) => MonadKind::Relation,
7947− Ty::IO(_, _, _) => MonadKind::IO,
7948− // Partially applied type constructor, e.g. Result e (App(TyCon("Result"), e))
7949− Ty::App(f, _) => match f.as_ref() {
7950− // IO applied to an effect row (App(TyCon("IO"), EffectRow))
7951− // is still the IO monad — classifying it as Adt("IO") would
7952− // dispatch to a nonexistent `Monad_IO_bind`.
7953− Ty::TyCon(name) if name == "IO" => MonadKind::IO,
7954− Ty::TyCon(name) if name == "[]" => MonadKind::Relation,
7955− Ty::TyCon(name) => MonadKind::Adt(name.clone()),
7956− _ => MonadKind::Relation,
7957− },
7958− // Saturated ADT used as monad, e.g. Con("Result", [Text]) from Result Text a
7959− Ty::Con(name, _) => MonadKind::Adt(name.clone()),
7960− _ => MonadKind::Relation, // default unresolved to Relation
7961− };
7962− monad_info.insert(*span, kind);
7963− }
7964−
7965− // Phase 6: Resolve refine expression targets.
7966− // The contextual binding of alpha (from an annotation or call site) wins;
7967− // otherwise fall back to matching the refined expression's type against
7968− // the declared refined types' base types — deterministically (sorted by
7969− // name) and erroring when several refined types share the base.
8128+ // Phase 4b: Resolve refine expression targets.
8129+ // This must run BEFORE deferred-constraint checking (phase 4c) and
8130+ // monad-kind resolution (phase 5): refine-target resolution unifies
8131+ // type variables (the refined value against the target's base type)
8132+ // that constraints and do-block monad vars may resolve through.
8133+ // Running it later meant constraints on such vars were silently
8134+ // skipped (`Ty::Var` skip in check_constraints) and monad kinds were
8135+ // prematurely defaulted to Relation.
8136+ //
8137+ // The contextual binding of alpha (from an annotation or call site)
8138+ // wins; otherwise fall back to matching the refined expression's type
8139+ // against the declared refined types' base types — deterministically
8140+ // (sorted by name) and erroring when several refined types share the
8141+ // base.
79708142 let mut refine_targets = RefineTargets::new();
79718143 let refine_vars = infer.refine_vars.clone();
79728144 for (span, var, inner_ty) in &refine_vars {
@@ -8031,6 +8203,39 @@ pub fn check(module: &ast::Module) -> (Vec<Diagnostic>, MonadInfo, TypeInfo, Loc
80318203 }
80328204 }
80338205
8206+ // Phase 4c: Check deferred trait constraints
8207+ infer.check_constraints();
8208+
8209+ // Phase 4d: Compress substitution chains for faster resolution
8210+ infer.compress_substitution();
8211+
8212+ // Phase 5: Resolve monad types from desugared do-blocks
8213+ let mut monad_info = MonadInfo::new();
8214+ for (span, m_var) in &infer.monad_vars {
8215+ let resolved = infer.apply(&Ty::Var(*m_var));
8216+ let kind = match resolved.peel_alias() {
8217+ Ty::TyCon(name) if name == "[]" => MonadKind::Relation,
8218+ Ty::TyCon(name) if name == "IO" => MonadKind::IO,
8219+ Ty::TyCon(name) => MonadKind::Adt(name.clone()),
8220+ Ty::Relation(_) => MonadKind::Relation,
8221+ Ty::IO(_, _, _) => MonadKind::IO,
8222+ // Partially applied type constructor, e.g. Result e (App(TyCon("Result"), e))
8223+ Ty::App(f, _) => match f.as_ref() {
8224+ // IO applied to an effect row (App(TyCon("IO"), EffectRow))
8225+ // is still the IO monad — classifying it as Adt("IO") would
8226+ // dispatch to a nonexistent `Monad_IO_bind`.
8227+ Ty::TyCon(name) if name == "IO" => MonadKind::IO,
8228+ Ty::TyCon(name) if name == "[]" => MonadKind::Relation,
8229+ Ty::TyCon(name) => MonadKind::Adt(name.clone()),
8230+ _ => MonadKind::Relation,
8231+ },
8232+ // Saturated ADT used as monad, e.g. Con("Result", [Text]) from Result Text a
8233+ Ty::Con(name, _) => MonadKind::Adt(name.clone()),
8234+ _ => MonadKind::Relation, // default unresolved to Relation
8235+ };
8236+ monad_info.insert(*span, kind);
8237+ }
8238+
80348239 // Export refined type predicates for codegen
80358240 let refined_type_info: RefinedTypeInfoMap = infer
80368241 .refined_types
@@ -8323,8 +8528,8 @@ main = applyPred (\\r -> r.x == r.y)\
83238528 let diags = check_src(
83248529 "type P = {name: Text, age: Int}\n\
83258530 *people : [P]\n\
8326− insert = \\name age -> do\n\
8327− ps <- *people\n\
8531+ insert = \\name age -> do\n \
8532+ ps <- *people\n \
83288533 *people = union ps [{name: name, age: age}]"
83298534 );
83308535 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -8338,8 +8543,8 @@ main = applyPred (\\r -> r.x == r.y)\
83388543 "type P = {name: Text, age: Int}\n\
83398544 *people : [P]\n\
83408545 *other : [P]\n\
8341− copy = do\n\
8342− os <- *other\n\
8546+ copy = do\n \
8547+ os <- *other\n \
83438548 *people = os"
83448549 );
83458550 assert!(has_error(&diags, "replace *people"));
@@ -8352,10 +8557,10 @@ main = applyPred (\\r -> r.x == r.y)\
83528557 let diags = check_src(
83538558 "type P = {name: Text, age: Int}\n\
83548559 *people : [P]\n\
8355− birthday = do\n\
8356− replace *people = do\n\
8357− p <- *people\n\
8358− yield {p | age: p.age + 1}\n\
8560+ birthday = do\n \
8561+ replace *people = do\n \
8562+ p <- *people\n \
8563+ yield {p | age: p.age + 1}\n \
83598564 yield {}"
83608565 );
83618566 assert!(has_error(&diags, "`replace *people = ...` is unnecessary"));
@@ -8368,9 +8573,9 @@ main = applyPred (\\r -> r.x == r.y)\
83688573 let diags = check_src(
83698574 "type P = {name: Text, age: Int}\n\
83708575 *people : [P]\n\
8371− insert = \\name age -> do\n\
8372− ps <- *people\n\
8373− replace *people = union ps [{name: name, age: age}]\n\
8576+ insert = \\name age -> do\n \
8577+ ps <- *people\n \
8578+ replace *people = union ps [{name: name, age: age}]\n \
83748579 yield {}"
83758580 );
83768581 assert!(has_error(&diags, "`replace *people = ...` is unnecessary"));
@@ -8383,8 +8588,8 @@ main = applyPred (\\r -> r.x == r.y)\
83838588 let diags = check_src(
83848589 "type P = {name: Text, age: Int}\n\
83858590 *people : [P]\n\
8386− main = do\n\
8387− replace *people = [{name: \"Alice\", age: 30}]\n\
8591+ main = do\n \
8592+ replace *people = [{name: \"Alice\", age: 30}]\n \
83888593 yield {}"
83898594 );
83908595 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -9154,9 +9359,9 @@ main = applyPred (\\r -> r.x == r.y)\
91549359 "unit M\nunit S\n\
91559360 double : Float<u> -> Float<u>\n\
91569361 double = \\x -> x + x\n\
9157− main = do\n\
9158− let a = double 5.0<M>\n\
9159− let b = double 3.0<S>\n\
9362+ main = do\n \
9363+ let a = double 5.0<M>\n \
9364+ let b = double 3.0<S>\n \
91609365 yield {}"
91619366 );
91629367 assert!(diags.is_empty(), "errors: {:?}", diags.iter().map(|d| &d.message).collect::<Vec<_>>());
@@ -9472,9 +9677,9 @@ main = applyPred (\\r -> r.x == r.y)\
94729677 let diags = check_src(
94739678 "type Person = {name: Text, age: Int}\n\
94749679 *people : [Person]\n\
9475− main = do\n\
9476− replace *people = [{name: \"A\", age: 1}]\n\
9477− p <- *people\n\
9680+ main = do\n \
9681+ replace *people = [{name: \"A\", age: 1}]\n \
9682+ p <- *people\n \
94789683 yield p.name"
94799684 );
94809685 assert!(diags.is_empty(), "diagnostics: {:?}", diags);
crates/knot-compiler/src/main.rs+64−8
@@ -317,6 +317,17 @@ fn cmd_build(source_file: &str, output_override: Option<&std::path::Path>, overr
317317 // Desugar monadic do blocks into trait method calls
318318 desugar::desugar(&mut module);
319319
320+ // Detect recursive type aliases before resolution — a cyclic alias
321+ // (`type A = {x: A}`, mutual cycles) can never be resolved, so report
322+ // a diagnostic instead of letting resolution chase the cycle.
323+ let cycle_diags = types::check_alias_cycles(&module);
324+ if !cycle_diags.is_empty() {
325+ for diag in &cycle_diags {
326+ eprintln!("{}", diag.render(&source, &filename));
327+ }
328+ process::exit(1);
329+ }
330+
320331 // Resolve types
321332 let type_env = types::TypeEnv::from_module(&module);
322333
@@ -407,8 +418,7 @@ fn cmd_build(source_file: &str, output_override: Option<&std::path::Path>, overr
407418 if let Err(e) = linker::link(&obj_path, &runtime_path, &output_path) {
408419 eprintln!("Link error: {}", e);
409420 let _ = std::fs::remove_file(&obj_path);
410− let temp_runtime = std::env::temp_dir().join(format!("libknot_runtime_{}.a", std::process::id()));
411− if runtime_path == temp_runtime {
421+ if is_extracted_temp_runtime(&runtime_path) {
412422 let _ = std::fs::remove_file(&runtime_path);
413423 }
414424 process::exit(1);
@@ -417,8 +427,7 @@ fn cmd_build(source_file: &str, output_override: Option<&std::path::Path>, overr
417427 // Clean up
418428 let _ = std::fs::remove_file(&obj_path);
419429 // Remove temp runtime if it was extracted from embedded bytes
420− let temp_runtime = std::env::temp_dir().join(format!("libknot_runtime_{}.a", std::process::id()));
421− if runtime_path == temp_runtime {
430+ if is_extracted_temp_runtime(&runtime_path) {
422431 let _ = std::fs::remove_file(&runtime_path);
423432 }
424433
@@ -444,6 +453,19 @@ const EMBEDDED_RUNTIME: Option<&[u8]> =
444453#[cfg(not(has_embedded_runtime))]
445454const EMBEDDED_RUNTIME: Option<&[u8]> = None;
446455
456+/// True if `p` is a runtime archive that `find_runtime` extracted into the
457+/// temp directory for this process (and which is therefore ours to delete).
458+fn is_extracted_temp_runtime(p: &std::path::Path) -> bool {
459+ let tmp_dir = std::env::temp_dir();
460+ p.parent() == Some(tmp_dir.as_path())
461+ && p.file_name()
462+ .and_then(|n| n.to_str())
463+ .is_some_and(|n| {
464+ n.starts_with(&format!("libknot_runtime_{}_", std::process::id()))
465+ && n.ends_with(".a")
466+ })
467+}
468+
447469fn find_runtime() -> PathBuf {
448470 // 1. Environment variable override
449471 if let Ok(path) = std::env::var("KNOT_RUNTIME_LIB") {
@@ -463,11 +485,45 @@ fn find_runtime() -> PathBuf {
463485 }
464486 }
465487
466− // 3. Extract embedded runtime to a temp file
488+ // 3. Extract embedded runtime to a temp file. The name includes the
489+ // pid plus a nanosecond nonce and attempt counter, and the file is
490+ // opened with `create_new` (O_CREAT|O_EXCL — fails instead of
491+ // following an attacker-planted symlink or reusing an existing
492+ // file) and owner-only permissions on unix; collisions retry with
493+ // a fresh name.
467494 if let Some(bytes) = EMBEDDED_RUNTIME {
468− let tmp = std::env::temp_dir().join(format!("libknot_runtime_{}.a", std::process::id()));
469− if std::fs::write(&tmp, bytes).is_ok() {
470− return tmp;
495+ use std::io::Write;
496+ for attempt in 0..32u32 {
497+ let nonce = std::time::SystemTime::now()
498+ .duration_since(std::time::UNIX_EPOCH)
499+ .map(|d| d.subsec_nanos())
500+ .unwrap_or(0);
501+ let tmp = std::env::temp_dir().join(format!(
502+ "libknot_runtime_{}_{}_{:08x}.a",
503+ std::process::id(),
504+ attempt,
505+ nonce
506+ ));
507+ let mut opts = std::fs::OpenOptions::new();
508+ opts.write(true).create_new(true);
509+ #[cfg(unix)]
510+ {
511+ use std::os::unix::fs::OpenOptionsExt;
512+ opts.mode(0o600);
513+ }
514+ match opts.open(&tmp) {
515+ Ok(mut f) => {
516+ if f.write_all(bytes).is_ok() {
517+ return tmp;
518+ }
519+ let _ = std::fs::remove_file(&tmp);
520+ break;
521+ }
522+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
523+ continue; // name collision — retry with a fresh nonce
524+ }
525+ Err(_) => break,
526+ }
471527 }
472528 }
473529
crates/knot-compiler/src/sql_lint.rs+98−74
@@ -11,7 +11,7 @@ use knot::diagnostic::Diagnostic;
1111
1212use crate::codegen::{
1313 divisor_is_nonzero_int_literal, divisor_is_nonzero_literal, expr_refs_var,
14− sql_comparison_cast_mode, SqlCastMode,
14+ minmax_pushdown_type_ok, sql_comparison_cast_mode, SqlCastMode,
1515};
1616use crate::types::TypeEnv;
1717
@@ -452,7 +452,7 @@ fn lint_pipe_chain(
452452 }
453453 }
454454 LintPipeOp::Min { bind_var, body, span } => {
455− if try_sql_column_expr(bind_var, body).is_none() {
455+ if try_sql_minmax_expr(bind_var, body, schema).is_none() {
456456 diags.push(
457457 Diagnostic::info(
458458 "pipe minOn will be evaluated at runtime instead of SQL MIN",
@@ -462,7 +462,7 @@ fn lint_pipe_chain(
462462 }
463463 }
464464 LintPipeOp::Max { bind_var, body, span } => {
465− if try_sql_column_expr(bind_var, body).is_none() {
465+ if try_sql_minmax_expr(bind_var, body, schema).is_none() {
466466 diags.push(
467467 Diagnostic::info(
468468 "pipe maxOn will be evaluated at runtime instead of SQL MAX",
@@ -585,7 +585,7 @@ fn lint_app_form(
585585 }
586586 }
587587 "minOn" => {
588− if try_sql_column_expr(&bind_var, body).is_none() {
588+ if try_sql_minmax_expr(&bind_var, body, schema).is_none() {
589589 diags.push(
590590 Diagnostic::info(
591591 "minOn will be evaluated at runtime instead of SQL MIN",
@@ -598,7 +598,7 @@ fn lint_app_form(
598598 }
599599 }
600600 "maxOn" => {
601− if try_sql_column_expr(&bind_var, body).is_none() {
601+ if try_sql_minmax_expr(&bind_var, body, schema).is_none() {
602602 diags.push(
603603 Diagnostic::info(
604604 "maxOn will be evaluated at runtime instead of SQL MAX",
@@ -642,8 +642,8 @@ fn try_compile_sql_expr(bind_var: &str, expr: &Expr, schema: &str) -> Option<()>
642642 }
643643 BinOp::Eq | BinOp::Neq | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
644644 // Try simple field op value, then atom-based (handles arithmetic)
645− try_sql_comparison(bind_var, lhs, rhs)
646− .or_else(|| try_sql_comparison(bind_var, rhs, lhs))
645+ try_sql_comparison(bind_var, lhs, rhs, schema)
646+ .or_else(|| try_sql_comparison(bind_var, rhs, lhs, schema))
647647 .or_else(|| {
648648 // Mirror codegen's type-witness gate: arithmetic
649649 // comparisons only push down when they can be typed
@@ -718,13 +718,24 @@ fn try_compile_sql_expr(bind_var: &str, expr: &Expr, schema: &str) -> Option<()>
718718}
719719
720720/// Check if `field_side op value_side` can be a SQL comparison.
721−fn try_sql_comparison(bind_var: &str, field_side: &Expr, value_side: &Expr) -> Option<()> {
721+fn try_sql_comparison(
722+ bind_var: &str,
723+ field_side: &Expr,
724+ value_side: &Expr,
725+ schema: &str,
726+) -> Option<()> {
722727 // field_side must be bind_var.field
723− if let ExprKind::FieldAccess { expr, .. } = &field_side.node {
728+ if let ExprKind::FieldAccess { expr, field } = &field_side.node {
724729 if let ExprKind::Var(name) = &expr.node {
725730 if name != bind_var {
726731 return None;
727732 }
733+ // Mirror codegen: json columns (payload-bearing ADTs, nested
734+ // records) are never pushed down — the runtime binds the
735+ // compared value differently than it is stored.
736+ if lookup_col_type(schema, field).as_deref() == Some("json") {
737+ return None;
738+ }
728739 } else {
729740 return None;
730741 }
@@ -1169,6 +1180,19 @@ fn extract_single_param_lambda(expr: &Expr) -> Option<(String, &Expr)> {
11691180 None
11701181}
11711182
1183+/// Mirror of codegen's minOn/maxOn pushdown approval: the lambda body must
1184+/// compile to a SQL column expression AND its Knot type must be numeric
1185+/// (Int/Float) — the MIN/MAX runtime re-parses TEXT results as Int, so
1186+/// Text projections fall back to in-memory evaluation.
1187+fn try_sql_minmax_expr(bind_var: &str, body: &Expr, schema: &str) -> Option<()> {
1188+ try_sql_column_expr(bind_var, body)?;
1189+ if minmax_pushdown_type_ok(bind_var, body, schema) {
1190+ Some(())
1191+ } else {
1192+ None
1193+ }
1194+}
1195+
11721196/// Check if a lambda body can be compiled to a SQL expression.
11731197/// Mirrors codegen's `extract_sql_field_access` which handles simple field access,
11741198/// arithmetic expressions (including ++), CASE WHEN, and built-in functions.
@@ -1344,9 +1368,9 @@ mod tests {
13441368 let diags = lint(
13451369 "type T = {name: Text, age: Int}\n\
13461370 *people : [T]\n\
1347− main = do\n\
1348− p <- *people\n\
1349− where p.age > 30\n\
1371+ main = do\n \
1372+ p <- *people\n \
1373+ where p.age > 30\n \
13501374 yield p\n",
13511375 );
13521376 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1358,9 +1382,9 @@ mod tests {
13581382 let diags = lint(
13591383 "type T = {name: Text, age: Int}\n\
13601384 *people : [T]\n\
1361− main = do\n\
1362− p <- *people\n\
1363− where length p.name > 3\n\
1385+ main = do\n \
1386+ p <- *people\n \
1387+ where length p.name > 3\n \
13641388 yield p\n",
13651389 );
13661390 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1372,11 +1396,11 @@ mod tests {
13721396 let diags = lint(
13731397 "type T = {name: Text, active: Int}\n\
13741398 *items : [T]\n\
1375− process = \\target -> do\n\
1376− *items = do\n\
1377− i <- *items\n\
1378− yield (if contains target i.name\n\
1379− then {i | active: 0}\n\
1399+ process = \\target -> do\n \
1400+ *items = do\n \
1401+ i <- *items\n \
1402+ yield (if contains target i.name\n \
1403+ then {i | active: 0}\n \
13801404 else i)\n",
13811405 );
13821406 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1389,9 +1413,9 @@ mod tests {
13891413 "type T = {name: Text, age: Int}\n\
13901414 *people : [T]\n\
13911415 isLong = \\t -> length t > 10\n\
1392− main = do\n\
1393− p <- *people\n\
1394− where isLong p.name\n\
1416+ main = do\n \
1417+ p <- *people\n \
1418+ where isLong p.name\n \
13951419 yield p\n",
13961420 );
13971421 assert_eq!(diags.len(), 1);
@@ -1405,11 +1429,11 @@ mod tests {
14051429 let diags = lint(
14061430 "type T = {name: Text, active: Int}\n\
14071431 *items : [T]\n\
1408− process = \\target -> do\n\
1409− *items = do\n\
1410− i <- *items\n\
1411− yield (if i.name == target\n\
1412− then {i | active: 0}\n\
1432+ process = \\target -> do\n \
1433+ *items = do\n \
1434+ i <- *items\n \
1435+ yield (if i.name == target\n \
1436+ then {i | active: 0}\n \
14131437 else i)\n",
14141438 );
14151439 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1422,10 +1446,10 @@ mod tests {
14221446 "type T = {name: Text, score: Int}\n\
14231447 isGood = \\x -> x > 50\n\
14241448 *items : [T]\n\
1425− cleanup = do\n\
1426− *items = do\n\
1427− i <- *items\n\
1428− where isGood i.score\n\
1449+ cleanup = do\n \
1450+ *items = do\n \
1451+ i <- *items\n \
1452+ where isGood i.score\n \
14291453 yield i\n",
14301454 );
14311455 assert_eq!(diags.len(), 1);
@@ -1439,10 +1463,10 @@ mod tests {
14391463 let diags = lint(
14401464 "type T = {name: Text, score: Int}\n\
14411465 *items : [T]\n\
1442− cleanup = do\n\
1443− *items = do\n\
1444− i <- *items\n\
1445− where i.score > 50\n\
1466+ cleanup = do\n \
1467+ *items = do\n \
1468+ i <- *items\n \
1469+ where i.score > 50\n \
14461470 yield i\n",
14471471 );
14481472 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1458,7 +1482,7 @@ mod tests {
14581482 isGood = \\x -> x.score > 50\n\
14591483 *a : [T]\n\
14601484 *b : [T]\n\
1461− sync = do\n\
1485+ sync = do\n \
14621486 *a = (*b |> filter (\\i -> isGood i))\n",
14631487 );
14641488 assert!(
@@ -1477,10 +1501,10 @@ mod tests {
14771501 isGood = \\x -> x > 50\n\
14781502 *a : [T]\n\
14791503 *b : [T]\n\
1480− move = do\n\
1481− *a = do\n\
1482− x <- *b\n\
1483− where isGood x.score\n\
1504+ move = do\n \
1505+ *a = do\n \
1506+ x <- *b\n \
1507+ where isGood x.score\n \
14841508 yield x\n",
14851509 );
14861510 assert_eq!(
@@ -1499,7 +1523,7 @@ mod tests {
14991523 "type T = {name: Text, score: Int}\n\
15001524 isGood = \\x -> x.score > 50\n\
15011525 *items : [T]\n\
1502− main = do\n\
1526+ main = do\n \
15031527 yield (*items |> filter (\\i -> isGood i))\n",
15041528 );
15051529 assert!(!diags.is_empty(), "expected diagnostics for complex pipe filter");
@@ -1512,7 +1536,7 @@ mod tests {
15121536 let diags = lint(
15131537 "type T = {name: Text, score: Int}\n\
15141538 *items : [T]\n\
1515− main = do\n\
1539+ main = do\n \
15161540 yield (*items |> filter (\\i -> i.score > 50))\n",
15171541 );
15181542 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1524,9 +1548,9 @@ mod tests {
15241548 let diags = lint(
15251549 "type T = {name: Text, age: Int}\n\
15261550 *people : [T]\n\
1527− main = do\n\
1528− p <- *people\n\
1529− where contains \"test\" p.name\n\
1551+ main = do\n \
1552+ p <- *people\n \
1553+ where contains \"test\" p.name\n \
15301554 yield p\n",
15311555 );
15321556 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1538,9 +1562,9 @@ mod tests {
15381562 let diags = lint(
15391563 "type T = {name: Text, status: Text}\n\
15401564 *items : [T]\n\
1541− main = do\n\
1542− i <- *items\n\
1543− where elem i.status [\"open\", \"pending\"]\n\
1565+ main = do\n \
1566+ i <- *items\n \
1567+ where elem i.status [\"open\", \"pending\"]\n \
15441568 yield i\n",
15451569 );
15461570 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1555,9 +1579,9 @@ mod tests {
15551579 "type T = {name: Text, status: Text}\n\
15561580 *items : [T]\n\
15571581 allowed = [\"open\", \"pending\"]\n\
1558− main = do\n\
1559− i <- *items\n\
1560− where elem i.status allowed\n\
1582+ main = do\n \
1583+ i <- *items\n \
1584+ where elem i.status allowed\n \
15611585 yield i\n",
15621586 );
15631587 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1572,9 +1596,9 @@ mod tests {
15721596 "type T = {name: Text, status: Text}\n\
15731597 pick = \\x -> [x]\n\
15741598 *items : [T]\n\
1575− main = do\n\
1576− i <- *items\n\
1577− where elem i.status (pick i.name)\n\
1599+ main = do\n \
1600+ i <- *items\n \
1601+ where elem i.status (pick i.name)\n \
15781602 yield i\n",
15791603 );
15801604 assert!(
@@ -1592,9 +1616,9 @@ mod tests {
15921616 let diags = lint(
15931617 "type T = {name: Text, age: Int}\n\
15941618 *people : [T]\n\
1595− main = do\n\
1596− p <- *people\n\
1597− where toUpper p.name == \"ALICE\"\n\
1619+ main = do\n \
1620+ p <- *people\n \
1621+ where toUpper p.name == \"ALICE\"\n \
15981622 yield p\n",
15991623 );
16001624 assert_eq!(diags.len(), 1, "expected runtime-fallback diagnostic, got: {:?}", diags);
@@ -1607,9 +1631,9 @@ mod tests {
16071631 let diags = lint(
16081632 "type T = {name: Text, active: Int}\n\
16091633 *items : [T]\n\
1610− main = do\n\
1611− i <- *items\n\
1612− where not (i.active == 1)\n\
1634+ main = do\n \
1635+ i <- *items\n \
1636+ where not (i.active == 1)\n \
16131637 yield i\n",
16141638 );
16151639 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1621,8 +1645,8 @@ mod tests {
16211645 let diags = lint(
16221646 "type T = {price: Int, qty: Int}\n\
16231647 *items : [T]\n\
1624− main = do\n\
1625− items <- *items\n\
1648+ main = do\n \
1649+ items <- *items\n \
16261650 yield (sum (\\i -> i.price * i.qty) items)\n",
16271651 );
16281652 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1634,8 +1658,8 @@ mod tests {
16341658 let diags = lint(
16351659 "type T = {salary: Int}\n\
16361660 *items : [T]\n\
1637− main = do\n\
1638− items <- *items\n\
1661+ main = do\n \
1662+ items <- *items\n \
16391663 yield (minOn (\\i -> i.salary) items)\n",
16401664 );
16411665 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1648,7 +1672,7 @@ mod tests {
16481672 "type T = {salary: Int}\n\
16491673 classify = \\x -> x + 100\n\
16501674 *items : [T]\n\
1651− main = do\n\
1675+ main = do\n \
16521676 yield (*items |> minOn (\\i -> classify i.salary))\n",
16531677 );
16541678 assert!(!diags.is_empty(), "expected diagnostic for non-SQL minOn");
@@ -1661,8 +1685,8 @@ mod tests {
16611685 let diags = lint(
16621686 "type T = {salary: Int, dept: Text}\n\
16631687 *items : [T]\n\
1664− main = do\n\
1665− items <- *items\n\
1688+ main = do\n \
1689+ items <- *items\n \
16661690 yield (countWhere (\\i -> i.salary > 75) items)\n",
16671691 );
16681692 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1675,7 +1699,7 @@ mod tests {
16751699 "type T = {salary: Int}\n\
16761700 isHigh = \\x -> x.salary > 50\n\
16771701 *items : [T]\n\
1678− main = do\n\
1702+ main = do\n \
16791703 yield (*items |> countWhere (\\i -> isHigh i))\n",
16801704 );
16811705 assert!(!diags.is_empty(), "expected diagnostic for non-SQL countWhere");
@@ -1688,7 +1712,7 @@ mod tests {
16881712 let diags = lint(
16891713 "type T = {salary: Int}\n\
16901714 *items : [T]\n\
1691− main = do\n\
1715+ main = do\n \
16921716 yield (*items |> maxOn (\\i -> i.salary))\n",
16931717 );
16941718 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1700,7 +1724,7 @@ mod tests {
17001724 let diags = lint(
17011725 "type T = {salary: Int}\n\
17021726 *items : [T]\n\
1703− main = do\n\
1727+ main = do\n \
17041728 yield (*items |> countWhere (\\i -> i.salary > 75))\n",
17051729 );
17061730 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
@@ -1712,9 +1736,9 @@ mod tests {
17121736 let diags = lint(
17131737 "type T = {first: Text, last: Text}\n\
17141738 *people : [T]\n\
1715− main = do\n\
1716− p <- *people\n\
1717− where p.first ++ \" \" ++ p.last == \"Alice Smith\"\n\
1739+ main = do\n \
1740+ p <- *people\n \
1741+ where p.first ++ \" \" ++ p.last == \"Alice Smith\"\n \
17181742 yield p\n",
17191743 );
17201744 assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags);
crates/knot-compiler/src/types.rs+162−15
@@ -162,20 +162,22 @@ impl TypeEnv {
162162 // Re-resolve pass: fix forward references in type aliases.
163163 // After the first pass, some aliases may contain Named("X") where X
164164 // was defined later. Re-resolve those now that all aliases are known.
165− // Run until stable so chained forward refs (A→B→C) all resolve
166− // regardless of HashMap iteration order.
167− loop {
168− let mut changed = false;
169− let alias_keys: Vec<String> = aliases.keys().cloned().collect();
170− for name in &alias_keys {
171− let resolved = aliases[name].clone();
172− let fixed = re_resolve_type(&resolved, &aliases);
173− if fixed != resolved {
174− aliases.insert(name.clone(), fixed);
175− changed = true;
176− }
165+ // A single pass suffices: `re_resolve_inner` recurses into each
166+ // replacement (with a seen-set for cycle protection), so chained
167+ // forward refs (A→B→C) resolve transitively regardless of HashMap
168+ // iteration order. Do NOT loop until stable — a recursive type
169+ // (`type A = {x: A}`, mutual cycles, self-referential data) makes
170+ // every pass expand the structure one more level, so a
171+ // run-until-stable loop grows it without bound until the stack
172+ // overflows. Cyclic aliases are reported as diagnostics by
173+ // `check_alias_cycles` / type inference before codegen runs.
174+ let alias_keys: Vec<String> = aliases.keys().cloned().collect();
175+ for name in &alias_keys {
176+ let resolved = aliases[name].clone();
177+ let fixed = re_resolve_type(&resolved, &aliases);
178+ if fixed != resolved {
179+ aliases.insert(name.clone(), fixed);
177180 }
178− if !changed { break; }
179181 }
180182 // Also re-resolve constructor fields
181183 let ctor_keys: Vec<String> = constructors.keys().cloned().collect();
@@ -247,6 +249,110 @@ impl TypeEnv {
247249 }
248250}
249251
252+/// Detect cyclic type-alias definitions: direct (`type A = {x: A}`),
253+/// mutual (`type A = B; type B = A`), and through any type structure
254+/// (records, relations, functions, refined bases, ...). Returns one error
255+/// diagnostic per cyclic alias.
256+///
257+/// Must run before `TypeEnv::from_module`'s resolution: a cyclic alias can
258+/// never be fully resolved, and downstream consumers (schema generation,
259+/// codegen) have no representation for infinite types.
260+pub fn check_alias_cycles(module: &Module) -> Vec<knot::diagnostic::Diagnostic> {
261+ let mut alias_decls: Vec<(String, &Type, Span)> = Vec::new();
262+ for decl in &module.decls {
263+ if let DeclKind::TypeAlias { name, params, ty } = &decl.node {
264+ if params.is_empty() {
265+ alias_decls.push((name.clone(), ty, decl.span));
266+ }
267+ }
268+ }
269+ let alias_names: HashSet<String> =
270+ alias_decls.iter().map(|(n, _, _)| n.clone()).collect();
271+ let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
272+ for (name, ty, _) in &alias_decls {
273+ let mut refs = HashSet::new();
274+ collect_named_alias_refs(ty, &alias_names, &mut refs);
275+ deps.entry(name.clone()).or_default().extend(refs);
276+ }
277+ let mut diags = Vec::new();
278+ for (name, _, span) in &alias_decls {
279+ // The alias is cyclic when it can reach itself through alias refs.
280+ let mut stack: Vec<String> = deps[name].iter().cloned().collect();
281+ let mut visited: HashSet<String> = HashSet::new();
282+ let mut found = false;
283+ while let Some(n) = stack.pop() {
284+ if &n == name {
285+ found = true;
286+ break;
287+ }
288+ if visited.insert(n.clone()) {
289+ if let Some(ds) = deps.get(&n) {
290+ stack.extend(ds.iter().cloned());
291+ }
292+ }
293+ }
294+ if found {
295+ diags.push(
296+ knot::diagnostic::Diagnostic::error(format!(
297+ "recursive type alias '{}' — a type alias cannot refer \
298+ to itself, directly or through other aliases",
299+ name
300+ ))
301+ .label(*span, "cycle detected in this type alias"),
302+ );
303+ }
304+ }
305+ diags
306+}
307+
308+/// Collect the alias names referenced anywhere inside a type AST.
309+fn collect_named_alias_refs(
310+ ty: &Type,
311+ alias_names: &HashSet<String>,
312+ out: &mut HashSet<String>,
313+) {
314+ match &ty.node {
315+ TypeKind::Named(name) => {
316+ if alias_names.contains(name) {
317+ out.insert(name.clone());
318+ }
319+ }
320+ TypeKind::Var(_) | TypeKind::Hole => {}
321+ TypeKind::App { func, arg } => {
322+ collect_named_alias_refs(func, alias_names, out);
323+ collect_named_alias_refs(arg, alias_names, out);
324+ }
325+ TypeKind::Record { fields, .. } => {
326+ for f in fields {
327+ collect_named_alias_refs(&f.value, alias_names, out);
328+ }
329+ }
330+ TypeKind::Relation(inner) => {
331+ collect_named_alias_refs(inner, alias_names, out);
332+ }
333+ TypeKind::Function { param, result } => {
334+ collect_named_alias_refs(param, alias_names, out);
335+ collect_named_alias_refs(result, alias_names, out);
336+ }
337+ TypeKind::Variant { constructors, .. } => {
338+ for c in constructors {
339+ for f in &c.fields {
340+ collect_named_alias_refs(&f.value, alias_names, out);
341+ }
342+ }
343+ }
344+ TypeKind::Effectful { ty, .. } | TypeKind::IO { ty, .. } => {
345+ collect_named_alias_refs(ty, alias_names, out);
346+ }
347+ TypeKind::UnitAnnotated { base, .. } | TypeKind::Refined { base, .. } => {
348+ collect_named_alias_refs(base, alias_names, out);
349+ }
350+ TypeKind::Forall { ty, .. } => {
351+ collect_named_alias_refs(ty, alias_names, out);
352+ }
353+ }
354+}
355+
250356/// Walk a source's type (e.g., `[{name: NonEmptyText, age: Nat}]`) and collect
251357/// refinement info: (field_name_or_none, refined_type_name, predicate_expr).
252358fn collect_source_refinements(
@@ -273,6 +379,29 @@ fn collect_source_refinements_inner(
273379 // Element type is a refined type alias: *scores : [Nat]
274380 TypeKind::Named(name) if refined_types.contains_key(name) => {
275381 result.push((None, name.clone(), refined_types[name].clone()));
382+ // The alias base may itself carry field-level refinements
383+ // (`type P = {age: Int where ...} where ...`) — recurse into it
384+ // so those aren't dropped (mirrors the inline Refined branch).
385+ if seen_aliases.insert(name.clone()) {
386+ if let Some(alias_ty) = alias_ast_types.get(name) {
387+ let base: &Type = match &alias_ty.node {
388+ TypeKind::Refined { base, .. } => base.as_ref(),
389+ _ => alias_ty,
390+ };
391+ let inner_ty = if matches!(&base.node, TypeKind::Relation(_)) {
392+ base.clone()
393+ } else {
394+ Spanned::new(TypeKind::Relation(Box::new(base.clone())), ty.span)
395+ };
396+ result.extend(collect_source_refinements_inner(
397+ &inner_ty,
398+ refined_types,
399+ alias_ast_types,
400+ seen_aliases,
401+ ));
402+ }
403+ seen_aliases.remove(name);
404+ }
276405 }
277406 // Element type is a type alias: *people : [Person]
278407 // Resolve through the alias to find refined fields in the underlying record.
@@ -590,10 +719,28 @@ fn col_type_str(ty: &ResolvedType) -> &'static str {
590719}
591720
592721/// Format a single field for a schema descriptor.
593−/// Nested relations are inlined as `field:[child_schema]`.
722+///
723+/// Contract with the runtime's `parse_record_schema`:
724+/// - Nested relations of *record* element type are inlined as
725+/// `field:[child_schema]` and stored in child tables.
726+/// - Nested relations of any other element type (ADTs, scalars) are stored
727+/// as a `json` column holding the whole relation. Child tables only
728+/// support record-shaped rows (`_parent_id` + scalar columns), so the
729+/// `[...]` form must not be emitted for non-record elements — the runtime
730+/// used to panic at table init on descriptors like `tags:[text]` or
731+/// `shapes:[#Circle:...|Dot]`. The Json column round-trip
732+/// (`value_to_json`/`json_to_value` with the `__knot_ctor` marker)
733+/// reconstructs relations of constructors and scalars faithfully, and
734+/// `m <- t.field` binds iterate the in-memory relation read back from
735+/// the column.
594736fn format_schema_field(name: &str, ty: &ResolvedType) -> String {
595737 if let ResolvedType::Relation(inner) = ty {
596− format!("{}:[{}]", name, schema_descriptor(inner))
738+ match inner.as_ref() {
739+ ResolvedType::Record(_) => {
740+ format!("{}:[{}]", name, schema_descriptor(inner))
741+ }
742+ _ => format!("{}:json", name),
743+ }
597744 } else {
598745 format!("{}:{}", name, col_type_str(ty))
599746 }
crates/knot-compiler/tests/regress_backend_fixes.rsnew file+582
@@ -0,0 +1,582 @@
1+//! Regression tests for compiler-backend bugs:
2+//!
3+//! 1. `compile_do` leaking loop-body bind variables into the caller's env —
4+//! a do-bind variable shadowing an outer variable used after the block
5+//! crashed the Cranelift verifier ("uses value vN from non-dominating
6+//! inst").
7+//! 2. Chained do-local lets in a pushed-down WHERE (`let a = 5; let b =
8+//! a + 1; where m.x == b`) panicking "codegen: undefined variable 'a'".
9+//! 3. View writes bypassing refined-type validation (invalid rows persisted
10+//! through a view of a refined source).
11+//! 4. Binding from a local relation variable in an IO do-block not
12+//! iterating (only the first row was visible; examples/groupby.knot lost
13+//! all groups after the first).
14+//! 5. Text comparisons pushed down under COLLATE KNOT_INT comparing
15+//! numerically ("07" == "7" matched in SQL but not in Knot).
16+//! 6. `==` on payload-bearing ADT columns pushing a tag-only SQL param —
17+//! matching rows were silently dropped.
18+//! 7. minOn/maxOn pushdown over Text columns returning Int (runtime
19+//! re-parses TEXT results as Int).
20+//! 8. Recursive type aliases overflowing the stack during type resolution
21+//! instead of producing a diagnostic.
22+//! 9. Field-level refinements dropped when a refined record alias is the
23+//! source element type.
24+//!
25+//! Each test compiles a small Knot program with the real `knot` binary into
26+//! its own scratch directory (so `knot.db` lands there) and asserts on the
27+//! program's output / exit status.
28+
29+use std::fs;
30+use std::path::PathBuf;
31+use std::process::Command;
32+
33+struct Compiled {
34+ dir: PathBuf,
35+ exe: PathBuf,
36+}
37+
38+impl Drop for Compiled {
39+ fn drop(&mut self) {
40+ let _ = fs::remove_dir_all(&self.dir);
41+ }
42+}
43+
44+fn scratch_dir(test_name: &str) -> PathBuf {
45+ let dir = std::env::temp_dir().join(format!(
46+ "knot_regress_backend_{}_{}",
47+ test_name,
48+ std::process::id()
49+ ));
50+ let _ = fs::remove_dir_all(&dir);
51+ fs::create_dir_all(&dir).unwrap();
52+ dir
53+}
54+
55+/// Compile `source` into a fresh scratch directory and return paths.
56+fn compile(test_name: &str, source: &str) -> Compiled {
57+ let dir = scratch_dir(test_name);
58+ let src_path = dir.join("prog.knot");
59+ fs::write(&src_path, source).unwrap();
60+
61+ let knot = env!("CARGO_BIN_EXE_knot");
62+ let out = Command::new(knot)
63+ .arg("build")
64+ .arg(&src_path)
65+ .current_dir(&dir)
66+ .output()
67+ .expect("failed to spawn knot compiler");
68+ assert!(
69+ out.status.success(),
70+ "knot build failed for {test_name}:\nstdout: {}\nstderr: {}",
71+ String::from_utf8_lossy(&out.stdout),
72+ String::from_utf8_lossy(&out.stderr),
73+ );
74+ let exe = dir.join("prog");
75+ Compiled { dir, exe }
76+}
77+
78+/// Compile and run; returns (stdout, stderr, success).
79+fn compile_and_run(test_name: &str, source: &str) -> (String, String, bool) {
80+ let c = compile(test_name, source);
81+ let out = Command::new(&c.exe)
82+ .current_dir(&c.dir)
83+ .output()
84+ .expect("failed to run compiled program");
85+ (
86+ String::from_utf8_lossy(&out.stdout).into_owned(),
87+ String::from_utf8_lossy(&out.stderr).into_owned(),
88+ out.status.success(),
89+ )
90+}
91+
92+/// Compile a program that is expected to FAIL to build; returns the
93+/// compiler's stderr.
94+fn compile_expect_error(test_name: &str, source: &str) -> String {
95+ let dir = scratch_dir(test_name);
96+ let src_path = dir.join("prog.knot");
97+ fs::write(&src_path, source).unwrap();
98+
99+ let knot = env!("CARGO_BIN_EXE_knot");
100+ let out = Command::new(knot)
101+ .arg("build")
102+ .arg(&src_path)
103+ .current_dir(&dir)
104+ .output()
105+ .expect("failed to spawn knot compiler");
106+ let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
107+ assert!(
108+ !out.status.success(),
109+ "expected knot build to fail for {test_name}, but it succeeded:\nstderr: {stderr}",
110+ );
111+ let _ = fs::remove_dir_all(&dir);
112+ stderr
113+}
114+
115+// ── Finding 1: do-bind variable leaking into the caller's env ──────
116+
117+#[test]
118+fn do_bind_shadowing_outer_var_compiles_and_runs() {
119+ // The groupBy comprehension's bind var `t` shadows the outer `t <- now`.
120+ // compile_do used to leave the loop-local SSA binding for `t` in the
121+ // caller's env, so `show t` after the block referenced a value from a
122+ // non-dominating block and the Cranelift verifier panicked.
123+ let (stdout, stderr, ok) = compile_and_run(
124+ "do_bind_shadow",
125+ r#"*todos : [{title: Text, owner: Text, done: Int}]
126+
127+main = do
128+ replace *todos = [{title: "a", owner: "Alice", done: 0}]
129+ t <- now
130+ let workload = do
131+ t <- *todos
132+ where t.done == 0
133+ groupBy {t.owner}
134+ yield {owner: t.owner, count: count t}
135+ println (show t)
136+ yield {}
137+"#,
138+ );
139+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
140+ // The outer `t` is a clock timestamp (a large integer), not a row.
141+ let first = stdout.lines().next().unwrap_or("");
142+ assert!(
143+ first.trim_matches('"').chars().all(|c| c.is_ascii_digit())
144+ && !first.is_empty(),
145+ "expected the outer `t` (timestamp) to be printed, got:\n{stdout}"
146+ );
147+}
148+
149+// ── Finding 2: chained do-local lets in pushed-down WHERE ──────────
150+
151+#[test]
152+fn chained_do_local_lets_in_pushed_where() {
153+ // `let b = a + 1` references the earlier do-local `let a = 5`; the SQL
154+ // plan stored `a + 1` as a param expression and compiled it in the
155+ // enclosing env where `a` is unbound — "codegen: undefined variable 'a'".
156+ let (stdout, stderr, ok) = compile_and_run(
157+ "chained_lets_where",
158+ r#"*items : [{x: Int}]
159+
160+main = do
161+ replace *items = [{x: 5}, {x: 6}]
162+ let r = do
163+ m <- *items
164+ let a = 5
165+ let b = a + 1
166+ where m.x == b
167+ yield m
168+ println (show (count r))
169+"#,
170+ );
171+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
172+ assert!(
173+ stdout.contains("\"1\""),
174+ "expected exactly the x == 6 row to match (b = a + 1 = 6), got:\n{stdout}"
175+ );
176+}
177+
178+// ── Finding 3: view writes bypassing refined-type validation ───────
179+
180+const REFINED_VIEW_PROG_HEADER: &str = r#"type Nat = Int where \x -> x >= 0
181+
182+*accounts : [{owner: Text, balance: Nat}]
183+
184+*aliceAccounts = do
185+ a <- *accounts
186+ where a.owner == "alice"
187+ yield {balance: a.balance}
188+"#;
189+
190+#[test]
191+fn direct_write_of_refined_source_rejected() {
192+ let src = format!(
193+ "{REFINED_VIEW_PROG_HEADER}
194+main = do
195+ replace *accounts = [{{owner: \"bob\", balance: 0 - 5}}]
196+ rows <- *accounts
197+ println (show (count rows))
198+"
199+ );
200+ let (stdout, stderr, ok) = compile_and_run("direct_refined_write", &src);
201+ assert!(
202+ !ok && stderr.contains("refinement violation"),
203+ "expected direct write of balance -5 to be rejected:\nstdout: {stdout}\nstderr: {stderr}"
204+ );
205+}
206+
207+#[test]
208+fn view_write_of_refined_source_rejected() {
209+ // Writing through the view used to bypass the underlying source's
210+ // refinement predicates entirely and persist the invalid row.
211+ let src = format!(
212+ "{REFINED_VIEW_PROG_HEADER}
213+main = do
214+ *aliceAccounts = [{{balance: 0 - 5}}]
215+ rows <- *accounts
216+ println (show (count rows))
217+"
218+ );
219+ let (stdout, stderr, ok) = compile_and_run("view_refined_write", &src);
220+ assert!(
221+ !ok && stderr.contains("refinement violation"),
222+ "expected view write of balance -5 to be rejected:\nstdout: {stdout}\nstderr: {stderr}"
223+ );
224+}
225+
226+#[test]
227+fn view_write_of_valid_rows_accepted() {
228+ let src = format!(
229+ "{REFINED_VIEW_PROG_HEADER}
230+main = do
231+ *aliceAccounts = [{{balance: 7}}]
232+ rows <- *accounts
233+ println (show (count rows))
234+"
235+ );
236+ let (stdout, stderr, ok) = compile_and_run("view_valid_write", &src);
237+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
238+ assert!(
239+ stdout.contains("\"1\""),
240+ "expected the valid row to persist through the view, got:\n{stdout}"
241+ );
242+}
243+
244+// ── Finding 4: bind from a local relation var in an IO do-block ────
245+
246+#[test]
247+fn io_bind_from_let_bound_groupby_iterates_all_rows() {
248+ // `w <- workload` where `workload` is a let-bound groupBy comprehension
249+ // used to bind only once (the whole relation / first row) — the second
250+ // group ("Bob") never printed.
251+ let (stdout, stderr, ok) = compile_and_run(
252+ "io_bind_relation_var",
253+ r#"*todos : [{title: Text, owner: Text, done: Int}]
254+
255+main = do
256+ replace *todos = [
257+ {title: "a", owner: "Alice", done: 0},
258+ {title: "b", owner: "Alice", done: 0},
259+ {title: "c", owner: "Bob", done: 0},
260+ {title: "d", owner: "Alice", done: 1}
261+ ]
262+ let workload = do
263+ t <- *todos
264+ where t.done == 0
265+ groupBy {t.owner}
266+ yield {owner: t.owner, count: count t}
267+ w <- workload
268+ println (w.owner ++ ": " ++ show w.count)
269+ yield {}
270+"#,
271+ );
272+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
273+ assert!(
274+ stdout.contains("Alice: 2") && stdout.contains("Bob: 1"),
275+ "expected BOTH groups to print, got:\n{stdout}"
276+ );
277+}
278+
279+#[test]
280+fn io_bind_from_let_bound_filter_comprehension_iterates() {
281+ // Same shape without groupBy: a plain let-bound comprehension.
282+ let (stdout, stderr, ok) = compile_and_run(
283+ "io_bind_relation_var_plain",
284+ r#"*nums : [{n: Int}]
285+
286+main = do
287+ replace *nums = [{n: 1}, {n: 2}, {n: 3}]
288+ let bigs = do
289+ x <- *nums
290+ where x.n > 1
291+ yield x
292+ b <- bigs
293+ println (show b.n)
294+ yield {}
295+"#,
296+ );
297+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
298+ assert!(
299+ stdout.contains("\"2\"") && stdout.contains("\"3\""),
300+ "expected both matching rows to be visited, got:\n{stdout}"
301+ );
302+}
303+
304+// ── Finding 5: Text comparisons under COLLATE KNOT_INT ─────────────
305+
306+#[test]
307+fn text_concat_equality_is_not_numeric() {
308+ // "0" ++ "7" == "7" compared numerically in SQL (CAST ... COLLATE
309+ // KNOT_INT made "07" == "7" match); Knot's in-memory semantics say no.
310+ let (stdout, stderr, ok) = compile_and_run(
311+ "text_concat_eq",
312+ r#"*items : [{a: Text, b: Text}]
313+
314+main = do
315+ replace *items = [{a: "0", b: "7"}]
316+ let r = do
317+ i <- *items
318+ where i.a ++ i.b == "7"
319+ yield i
320+ println (show (count r))
321+"#,
322+ );
323+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
324+ assert!(
325+ stdout.contains("\"0\""),
326+ "expected no match (\"07\" != \"7\" as Text), got:\n{stdout}"
327+ );
328+}
329+
330+#[test]
331+fn text_concat_ordering_is_bytewise() {
332+ // "07" < "1" byte-wise (Knot semantics) but 7 < 1 is false numerically.
333+ let (stdout, stderr, ok) = compile_and_run(
334+ "text_concat_ord",
335+ r#"*items : [{a: Text, b: Text}]
336+
337+main = do
338+ replace *items = [{a: "0", b: "7"}]
339+ let r = do
340+ i <- *items
341+ where i.a ++ i.b < "1"
342+ yield i
343+ println (show (count r))
344+"#,
345+ );
346+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
347+ assert!(
348+ stdout.contains("\"1\""),
349+ "expected \"07\" < \"1\" byte-wise to match, got:\n{stdout}"
350+ );
351+}
352+
353+// ── Finding 6: equality on payload-bearing ADT columns ─────────────
354+
355+#[test]
356+fn adt_payload_column_equality_finds_matching_row() {
357+ // `u.st == Active {}` used to push `st = ?` with a bare-tag param while
358+ // the column stores JSON — the matching row was silently dropped.
359+ let (stdout, stderr, ok) = compile_and_run(
360+ "adt_payload_eq",
361+ r#"data Status = Active {} | Banned {reason: Text}
362+
363+impl Eq Status where
364+ eq = \a b -> show a == show b
365+
366+*users : [{name: Text, st: Status}]
367+
368+main = do
369+ replace *users = [
370+ {name: "a", st: Active {}},
371+ {name: "b", st: Banned {reason: "spam"}}
372+ ]
373+ let r = do
374+ u <- *users
375+ where u.st == Active {}
376+ yield u
377+ println (show (count r))
378+"#,
379+ );
380+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
381+ assert!(
382+ stdout.contains("\"1\""),
383+ "expected the Active user to match, got:\n{stdout}"
384+ );
385+}
386+
387+#[test]
388+fn nullary_adt_tag_column_equality_still_works() {
389+ // All-nullary ("tag") ADT columns remain pushable and must keep working.
390+ let (stdout, stderr, ok) = compile_and_run(
391+ "adt_tag_eq",
392+ r#"data Color = Red {} | Blue {}
393+
394+impl Eq Color where
395+ eq = \a b -> show a == show b
396+
397+*marbles : [{n: Int, c: Color}]
398+
399+main = do
400+ replace *marbles = [{n: 1, c: Red {}}, {n: 2, c: Blue {}}, {n: 3, c: Red {}}]
401+ let r = do
402+ m <- *marbles
403+ where m.c == Red {}
404+ yield m
405+ println (show (count r))
406+"#,
407+ );
408+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
409+ assert!(
410+ stdout.contains("\"2\""),
411+ "expected the two Red marbles to match, got:\n{stdout}"
412+ );
413+}
414+
415+// ── Finding 7: minOn/maxOn pushdown over Text columns ──────────────
416+
417+#[test]
418+fn maxon_over_text_column_returns_text() {
419+ // The SQL MIN/MAX runtime re-parses TEXT results as Int (Knot Ints are
420+ // stored as TEXT), so maxOn over ["007", "01"] came back as Int 1.
421+ let (stdout, stderr, ok) = compile_and_run(
422+ "maxon_text",
423+ r#"*codes : [{c: Text}]
424+
425+main = do
426+ replace *codes = [{c: "007"}, {c: "01"}]
427+ let m = maxOn (\x -> x.c) *codes
428+ println (show m)
429+"#,
430+ );
431+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
432+ assert!(
433+ stdout.contains("\"01\""),
434+ "expected Text maximum \"01\", got:\n{stdout}"
435+ );
436+}
437+
438+#[test]
439+fn minon_over_text_column_returns_text() {
440+ let (stdout, stderr, ok) = compile_and_run(
441+ "minon_text",
442+ r#"*codes : [{c: Text}]
443+
444+main = do
445+ replace *codes = [{c: "007"}, {c: "01"}]
446+ let m = minOn (\x -> x.c) *codes
447+ println (show m)
448+"#,
449+ );
450+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
451+ assert!(
452+ stdout.contains("\"007\""),
453+ "expected Text minimum \"007\", got:\n{stdout}"
454+ );
455+}
456+
457+#[test]
458+fn maxon_over_int_column_still_pushes_and_is_numeric() {
459+ // Int columns keep numeric MIN/MAX semantics (9 > 10 byte-wise!).
460+ let (stdout, stderr, ok) = compile_and_run(
461+ "maxon_int",
462+ r#"*scores : [{s: Int}]
463+
464+main = do
465+ replace *scores = [{s: 9}, {s: 10}]
466+ let m = maxOn (\x -> x.s) *scores
467+ println (show m)
468+"#,
469+ );
470+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
471+ assert!(
472+ stdout.contains("\"10\""),
473+ "expected numeric maximum 10, got:\n{stdout}"
474+ );
475+}
476+
477+// ── Finding 8: recursive type alias → diagnostic, not crash ────────
478+
479+#[test]
480+fn recursive_type_alias_through_record_is_diagnosed() {
481+ // `type A = {x: A}` used to double the resolved type's depth on every
482+ // fixpoint pass and abort with a stack overflow before any diagnostic.
483+ let stderr = compile_expect_error(
484+ "recursive_alias",
485+ r#"type A = {x: A}
486+
487+main = do
488+ println "hi"
489+"#,
490+ );
491+ assert!(
492+ stderr.contains("recursive type alias 'A'"),
493+ "expected a recursive-alias diagnostic, got:\n{stderr}"
494+ );
495+ assert!(
496+ !stderr.contains("stack overflow"),
497+ "compiler must not crash:\n{stderr}"
498+ );
499+}
500+
501+#[test]
502+fn mutually_recursive_type_aliases_are_diagnosed() {
503+ let stderr = compile_expect_error(
504+ "mutual_recursive_alias",
505+ r#"type A = {x: B}
506+type B = {y: A}
507+
508+main = do
509+ println "hi"
510+"#,
511+ );
512+ assert!(
513+ stderr.contains("recursive type alias"),
514+ "expected a recursive-alias diagnostic, got:\n{stderr}"
515+ );
516+ assert!(
517+ !stderr.contains("stack overflow"),
518+ "compiler must not crash:\n{stderr}"
519+ );
520+}
521+
522+// ── Finding 9: refined record alias as source element type ─────────
523+
524+const REFINED_ALIAS_HEADER: &str =
525+ r#"type P = {age: Int where \x -> x >= 0} where \p -> p.age <= 200
526+
527+*people : [P]
528+"#;
529+
530+#[test]
531+fn refined_alias_field_refinement_enforced() {
532+ // Field-level refinements inside the alias base were dropped — only the
533+ // whole-element predicate ran, so {age: -5} persisted.
534+ let src = format!(
535+ "{REFINED_ALIAS_HEADER}
536+main = do
537+ replace *people = [{{age: 0 - 5}}]
538+ rows <- *people
539+ println (show (count rows))
540+"
541+ );
542+ let (stdout, stderr, ok) = compile_and_run("refined_alias_field", &src);
543+ assert!(
544+ !ok && stderr.contains("refinement violation"),
545+ "expected age -5 to violate the field refinement:\nstdout: {stdout}\nstderr: {stderr}"
546+ );
547+}
548+
549+#[test]
550+fn refined_alias_whole_element_refinement_still_enforced() {
551+ let src = format!(
552+ "{REFINED_ALIAS_HEADER}
553+main = do
554+ replace *people = [{{age: 300}}]
555+ rows <- *people
556+ println (show (count rows))
557+"
558+ );
559+ let (stdout, stderr, ok) = compile_and_run("refined_alias_elem", &src);
560+ assert!(
561+ !ok && stderr.contains("refinement violation"),
562+ "expected age 300 to violate the whole-element refinement:\nstdout: {stdout}\nstderr: {stderr}"
563+ );
564+}
565+
566+#[test]
567+fn refined_alias_valid_row_accepted() {
568+ let src = format!(
569+ "{REFINED_ALIAS_HEADER}
570+main = do
571+ replace *people = [{{age: 42}}]
572+ rows <- *people
573+ println (show (count rows))
574+"
575+ );
576+ let (stdout, stderr, ok) = compile_and_run("refined_alias_ok", &src);
577+ assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}");
578+ assert!(
579+ stdout.contains("\"1\""),
580+ "expected the valid row to persist, got:\n{stdout}"
581+ );
582+}
crates/knot-compiler/tests/regress_effects_fixes2.rsnew file+284
@@ -0,0 +1,284 @@
1+//! Regression tests for effect-inference fixes (second batch):
2+//!
3+//! 5. Impl method bodies and trait default bodies are visible to effect
4+//! inference: a trait-method call site carries the union of effects
5+//! across all known impls, so IO inside an impl can't run inside
6+//! `atomic`, and relation access through a trait method satisfies the
7+//! "atomic must interact with relations" validation.
8+//! 6. A let-bound lambda referenced *by name* (not as a lambda literal)
9+//! carries its body's effects.
10+//! 7. Unannotated higher-order helpers propagate the effects of their
11+//! lambda arguments (only signatures declaring a closed effect row
12+//! absorb them).
13+//! 12. The "atomic block must interact with relations" hard error stays
14+//! quiet when the body calls something that is not provably
15+//! relation-free (e.g. a parameter-typed callable).
16+
17+use knot::diagnostic::{Diagnostic, Severity};
18+
19+fn effect_diags(src: &str) -> Vec<Diagnostic> {
20+ let lexer = knot::lexer::Lexer::new(src);
21+ let (tokens, lex_diags) = lexer.tokenize();
22+ assert!(
23+ !lex_diags.iter().any(|d| d.severity == Severity::Error),
24+ "lex errors: {:?}",
25+ lex_diags
26+ );
27+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
28+ let (mut module, parse_diags) = parser.parse_module();
29+ assert!(
30+ !parse_diags.iter().any(|d| d.severity == Severity::Error),
31+ "parse errors: {:?}",
32+ parse_diags
33+ );
34+ knot_compiler::base::inject_prelude(&mut module);
35+ knot_compiler::desugar::desugar(&mut module);
36+ knot_compiler::effects::check(&module)
37+}
38+
39+fn assert_no_diags(diags: &[Diagnostic]) {
40+ assert!(
41+ diags.is_empty(),
42+ "expected no effect diagnostics, got: {:?}",
43+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
44+ );
45+}
46+
47+fn assert_has_error(diags: &[Diagnostic], message_contains: &str) {
48+ assert!(
49+ diags
50+ .iter()
51+ .any(|d| d.severity == Severity::Error
52+ && d.message.contains(message_contains)),
53+ "expected error containing {:?}, got: {:?}",
54+ message_contains,
55+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
56+ );
57+}
58+
59+fn assert_no_error(diags: &[Diagnostic], message_contains: &str) {
60+ assert!(
61+ !diags.iter().any(|d| d.message.contains(message_contains)),
62+ "expected NO diagnostic containing {:?}, got: {:?}",
63+ message_contains,
64+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
65+ );
66+}
67+
68+// ── 5. Trait/impl bodies visible to effect inference ────────────────
69+
70+#[test]
71+fn impl_method_console_io_inside_atomic_rejected() {
72+ // `tick`'s impl calls println; the trait-method call inside the
73+ // atomic block must surface that console effect.
74+ let diags = effect_diags(
75+ r#"*items : [{n: Int}]
76+
77+trait Ticker a where
78+ tick : a -> IO _ {}
79+
80+impl Ticker Int where
81+ tick = \x -> println "tick"
82+
83+main = do
84+ r <- atomic (do
85+ rows <- *items
86+ t <- tick 1
87+ yield {})
88+ yield {}
89+"#,
90+ );
91+ assert_has_error(&diags, "IO effects are not allowed inside atomic blocks");
92+}
93+
94+#[test]
95+fn trait_default_body_io_inside_atomic_rejected() {
96+ let diags = effect_diags(
97+ r#"*items : [{n: Int}]
98+
99+trait Ticker a where
100+ tick : a -> IO _ {}
101+ tick = \x -> println "default tick"
102+
103+impl Ticker Int where
104+ tick = \x -> println "impl tick"
105+
106+main = do
107+ r <- atomic (do
108+ rows <- *items
109+ t <- tick 1
110+ yield {})
111+ yield {}
112+"#,
113+ );
114+ assert_has_error(&diags, "IO effects are not allowed inside atomic blocks");
115+}
116+
117+#[test]
118+fn atomic_relation_access_via_trait_method_accepted() {
119+ // The atomic block's only relation interaction goes through a trait
120+ // method whose impl reads *items — pre-fix this was a false
121+ // "atomic block must interact with relations" error.
122+ let diags = effect_diags(
123+ r#"*items : [{n: Int}]
124+
125+trait Reader a where
126+ readAll : a -> IO _ [{n: Int}]
127+
128+impl Reader Int where
129+ readAll = \x -> *items
130+
131+main = do
132+ r <- atomic (do
133+ t <- readAll 1
134+ yield {})
135+ yield {}
136+"#,
137+ );
138+ assert_no_error(&diags, "atomic block must interact with relations");
139+ assert_no_diags(&diags);
140+}
141+
142+#[test]
143+fn trait_method_union_includes_all_impls() {
144+ // Two impls with different effects: a call site must carry the UNION
145+ // (here, the console effect of the Text impl) even when only the Int
146+ // impl could be the dynamic target — conservative and sound.
147+ let diags = effect_diags(
148+ r#"*items : [{n: Int}]
149+
150+trait Ticker a where
151+ tick : a -> IO _ {}
152+
153+impl Ticker Int where
154+ tick = \x -> yield {}
155+
156+impl Ticker Text where
157+ tick = \x -> println "noisy"
158+
159+main = do
160+ r <- atomic (do
161+ rows <- *items
162+ t <- tick 1
163+ yield {})
164+ yield {}
165+"#,
166+ );
167+ assert_has_error(&diags, "IO effects are not allowed inside atomic blocks");
168+}
169+
170+// ── 6. Let-bound lambda referenced by name carries effects ──────────
171+
172+#[test]
173+fn let_bound_lambda_passed_by_name_carries_console_effect() {
174+ let diags = effect_diags(
175+ r#"*items : [{n: Int}]
176+
177+runIt : (Int -> IO {| e} {}) -> IO {| e} {}
178+runIt = \f -> f 1
179+
180+main = do
181+ r <- atomic (do
182+ rows <- *items
183+ let cb = \u -> println "x"
184+ q <- runIt cb
185+ yield {})
186+ yield {}
187+"#,
188+ );
189+ assert_has_error(&diags, "IO effects are not allowed inside atomic blocks");
190+}
191+
192+// ── 7. Unannotated higher-order helpers propagate lambda effects ─────
193+
194+#[test]
195+fn unannotated_hof_propagates_lambda_io_into_atomic_check() {
196+ let diags = effect_diags(
197+ r#"*items : [{n: Int}]
198+
199+apply = \f x -> f x
200+
201+main = do
202+ r <- atomic (do
203+ rows <- *items
204+ q <- apply (\u -> println "x") 1
205+ yield {})
206+ yield {}
207+"#,
208+ );
209+ assert_has_error(&diags, "IO effects are not allowed inside atomic blocks");
210+}
211+
212+#[test]
213+fn closed_row_annotation_still_absorbs_callback_effects() {
214+ // `quiet` declares a closed effect row (`IO {r *items} {}`): per the
215+ // documented annotation semantics it absorbs its callback's effects,
216+ // so the caller must NOT be charged with console.
217+ let diags = effect_diags(
218+ r#"*items : [{n: Int}]
219+
220+quiet : (Int -> IO {console} {}) -> IO {r *items} {}
221+quiet = \f -> *items
222+
223+caller : IO {r *items} {}
224+caller = quiet (\x -> println "boo")
225+
226+main = do
227+ c <- caller
228+ yield {}
229+"#,
230+ );
231+ assert_no_error(&diags, "inferred effects exceed declared effects");
232+}
233+
234+#[test]
235+fn pure_lambda_args_add_no_effects() {
236+ // Conservative propagation must not invent effects for pure lambdas.
237+ let diags = effect_diags(
238+ r#"*items : [{n: Int}]
239+
240+apply = \f x -> f x
241+
242+main : IO {console} {}
243+main = do
244+ n <- apply (\u -> u + 1) 1
245+ p <- println (show n)
246+ yield {}
247+"#,
248+ );
249+ assert_no_diags(&diags);
250+}
251+
252+// ── 12. Opaque callables suppress the must-interact hard error ───────
253+
254+#[test]
255+fn atomic_calling_parameter_typed_callable_not_flagged() {
256+ // `action` is a lambda parameter — the body is not provably
257+ // relation-free, so the hard error must stay quiet.
258+ let diags = effect_diags(
259+ r#"*items : [{n: Int}]
260+
261+helper = \action -> atomic (do
262+ r <- action {}
263+ yield {})
264+
265+main = do
266+ q <- helper (\u -> *items)
267+ yield {}
268+"#,
269+ );
270+ assert_no_error(&diags, "atomic block must interact with relations");
271+}
272+
273+#[test]
274+fn provably_relation_free_atomic_still_rejected() {
275+ let diags = effect_diags(
276+ r#"main = do
277+ n <- atomic (do
278+ let x = 1 + 2
279+ yield x)
280+ yield {}
281+"#,
282+ );
283+ assert_has_error(&diags, "atomic block must interact with relations");
284+}
crates/knot-compiler/tests/regress_infer_fixes2.rsnew file+452
@@ -0,0 +1,452 @@
1+//! Regression tests for analysis-pass fixes (second batch):
2+//!
3+//! 1. Exhaustiveness checking must not count constructor arms with
4+//! refutable sub-patterns (literals, nested constructors) as covering
5+//! their constructor.
6+//! 2. IO-promoted comprehensions (plain relation binds or groupBy plus a
7+//! final yield) evaluate to the whole relation of yielded values, so
8+//! they must be typed `IO [elem]`, not `IO elem`.
9+//! 3. Impl method bodies are checked against the trait signature with
10+//! skolemised method-local type variables — an impl pinning a
11+//! caller-chosen variable to a concrete type is rejected.
12+//! 4. Rank-2 skolems must not escape through unannotated wrappers.
13+//! 8. Refine-target unification runs before deferred-constraint checking,
14+//! so constraints on variables concretized by refine resolution are
15+//! actually validated.
16+//! 9. `subst_ty` recurses into `Ty::Alias` bodies (and alias references
17+//! freshen their free vars), so an alias like `type Box = {val: a}`
18+//! can be used at two different types.
19+//! 10. A do-block whose IO lives inside an *applied* lambda routes through
20+//! the IO path instead of being desugared as a pure comprehension.
21+//! 11. `fetchWith` options are type-checked against the runtime's expected
22+//! `{headers: [{name, value}]}` shape, and `fetch` on a non-route
23+//! constructor is a type error instead of a codegen panic.
24+
25+use knot::diagnostic::Diagnostic;
26+
27+fn parse(src: &str) -> knot::ast::Module {
28+ let lexer = knot::lexer::Lexer::new(src);
29+ let (tokens, _) = lexer.tokenize();
30+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
31+ let (module, parse_diags) = parser.parse_module();
32+ assert!(
33+ parse_diags.is_empty(),
34+ "unexpected parse diagnostics: {:?}",
35+ parse_diags
36+ );
37+ module
38+}
39+
40+fn check_src(src: &str) -> Vec<Diagnostic> {
41+ let mut module = parse(src);
42+ knot_compiler::base::inject_prelude(&mut module);
43+ knot_compiler::desugar::desugar(&mut module);
44+ let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem) =
45+ knot_compiler::infer::check(&module);
46+ diags
47+}
48+
49+fn has_error(diags: &[Diagnostic], needle: &str) -> bool {
50+ diags.iter().any(|d| d.message.contains(needle))
51+}
52+
53+fn assert_clean(diags: &[Diagnostic]) {
54+ assert!(
55+ diags.is_empty(),
56+ "expected no diagnostics, got: {:?}",
57+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
58+ );
59+}
60+
61+// ── 1. Exhaustiveness with refutable sub-patterns ───────────────────
62+
63+#[test]
64+fn literal_payload_does_not_cover_constructor() {
65+ // `Circle {radius: 1.0}` matches only one radius — Circle is NOT
66+ // covered, so the match must be rejected as non-exhaustive.
67+ let diags = check_src(
68+ r#"data Shape = Circle {radius: Float} | Rect {width: Float, height: Float}
69+
70+describe = \s -> case s of
71+ Circle {radius: 1.0} -> "unit circle"
72+ Rect r -> "rect"
73+
74+main = println (describe (Circle {radius: 2.0}))
75+"#,
76+ );
77+ assert!(
78+ has_error(&diags, "non-exhaustive pattern match"),
79+ "expected non-exhaustive error, got: {:?}",
80+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
81+ );
82+ assert!(
83+ has_error(&diags, "Circle"),
84+ "the missing constructor should be named: {:?}",
85+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
86+ );
87+}
88+
89+#[test]
90+fn wildcard_fixes_literal_payload_match() {
91+ let diags = check_src(
92+ r#"data Shape = Circle {radius: Float} | Rect {width: Float, height: Float}
93+
94+describe = \s -> case s of
95+ Circle {radius: 1.0} -> "unit circle"
96+ Rect r -> "rect"
97+ _ -> "other"
98+
99+main = println (describe (Circle {radius: 2.0}))
100+"#,
101+ );
102+ assert_clean(&diags);
103+}
104+
105+#[test]
106+fn irrefutable_payloads_still_exhaustive() {
107+ let diags = check_src(
108+ r#"data Shape = Circle {radius: Float} | Rect {width: Float, height: Float}
109+
110+describe = \s -> case s of
111+ Circle c -> "circle"
112+ Rect {width: w, height: h} -> "rect"
113+
114+main = println (describe (Circle {radius: 2.0}))
115+"#,
116+ );
117+ assert_clean(&diags);
118+}
119+
120+#[test]
121+fn nested_constructor_payload_does_not_cover() {
122+ // `Wrap {s: Circle c}` only matches Wrap values holding a Circle —
123+ // a Rect payload would panic at runtime, so a wildcard is required.
124+ let diags = check_src(
125+ r#"data Shape = Circle {radius: Float} | Rect {width: Float}
126+data Wrapper = Wrap {s: Shape}
127+
128+unwrap = \w -> case w of
129+ Wrap {s: Circle c} -> c.radius
130+
131+main = println (show (unwrap (Wrap {s: Circle {radius: 1.0}})))
132+"#,
133+ );
134+ assert!(
135+ has_error(&diags, "non-exhaustive pattern match"),
136+ "expected non-exhaustive error, got: {:?}",
137+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
138+ );
139+}
140+
141+#[test]
142+fn cons_with_literal_head_does_not_cover_nonempty_lists() {
143+ let diags = check_src(
144+ r#"f = \xs -> case xs of
145+ [] -> 0
146+ Cons 1 rest -> 1
147+
148+main = println (show (f [2, 3]))
149+"#,
150+ );
151+ assert!(
152+ has_error(&diags, "non-exhaustive pattern match"),
153+ "expected non-exhaustive error, got: {:?}",
154+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
155+ );
156+}
157+
158+#[test]
159+fn cons_with_irrefutable_head_and_tail_is_exhaustive() {
160+ let diags = check_src(
161+ r#"f = \xs -> case xs of
162+ [] -> 0
163+ Cons h t -> h
164+
165+main = println (show (f [2, 3]))
166+"#,
167+ );
168+ assert_clean(&diags);
169+}
170+
171+// ── 2. IO-promoted comprehensions type as IO [elem] ─────────────────
172+
173+#[test]
174+fn groupby_comprehension_types_as_io_relation() {
175+ // `workload` accumulates one row per group, so its type must be
176+ // `IO [{owner, n}]` — `count workload` only typechecks as a list.
177+ let diags = check_src(
178+ r#"*todos : [{title: Text, owner: Text, done: Int}]
179+
180+main = do
181+ replace *todos = [{title: "a", owner: "Alice", done: 0}]
182+ let workload = do
183+ t <- *todos
184+ where t.done == 0
185+ groupBy {t.owner}
186+ yield {owner: t.owner, n: count t}
187+ c <- println (show (count workload))
188+ w <- workload
189+ p <- println (w.owner ++ ": " ++ show w.n)
190+ yield {}
191+"#,
192+ );
193+ assert_clean(&diags);
194+}
195+
196+#[test]
197+fn io_block_with_plain_relation_bind_yields_list() {
198+ // The plain bind `x <- [1, 2]` makes the block a comprehension that
199+ // loops and accumulates its yields, so `xs : IO [Int]` and `count ys`
200+ // must typecheck.
201+ let diags = check_src(
202+ r#"xs = do
203+ x <- [1, 2]
204+ p <- println (show x)
205+ yield x
206+
207+main = do
208+ ys <- xs
209+ q <- println (show (count ys))
210+ yield {}
211+"#,
212+ );
213+ assert_clean(&diags);
214+}
215+
216+#[test]
217+fn io_block_without_comprehension_binds_keeps_pure_yield() {
218+ // No relation binds: `yield 42` is the block's result value, so the
219+ // block types as `IO Int` (not `IO [Int]`).
220+ let diags = check_src(
221+ r#"answer = do
222+ p <- println "computing"
223+ yield 42
224+
225+main = do
226+ n <- answer
227+ q <- println (show (n + 1))
228+ yield {}
229+"#,
230+ );
231+ assert_clean(&diags);
232+}
233+
234+// ── 3. Impl methods checked against skolemised trait signature ──────
235+
236+#[test]
237+fn impl_pinning_method_type_var_rejected() {
238+ // `conv : a -> b` promises a caller-chosen `b`; the impl returning
239+ // Int pins it and must be rejected.
240+ let diags = check_src(
241+ r#"trait Conv a where
242+ conv : a -> b
243+
244+impl Conv Int where
245+ conv = \x -> x + 1
246+
247+main = println "x"
248+"#,
249+ );
250+ assert!(
251+ has_error(&diags, "less polymorphic"),
252+ "expected 'less polymorphic' error, got: {:?}",
253+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
254+ );
255+}
256+
257+#[test]
258+fn impl_leaving_method_type_var_polymorphic_ok() {
259+ let diags = check_src(
260+ r#"trait Pick a where
261+ pick : a -> b -> a
262+
263+impl Pick Int where
264+ pick = \x y -> x
265+
266+main = println (show (pick 1 "anything"))
267+"#,
268+ );
269+ assert_clean(&diags);
270+}
271+
272+// ── 4. Rank-2 skolem escape through unannotated wrappers ────────────
273+
274+#[test]
275+fn rank2_skolem_escape_through_wrapper_rejected() {
276+ // `g`'s param type gets bound toward the skolem of `takesPoly`'s
277+ // argument; generalizing it would let `g (\x -> x + 1)` pass a
278+ // monomorphic function where a polymorphic one is required.
279+ let diags = check_src(
280+ r#"takesPoly : (forall a. a -> a) -> Int
281+takesPoly = \f -> f 1
282+
283+g = \h -> takesPoly h
284+
285+main = do
286+ let r = g (\x -> x + 1)
287+ println (show r)
288+"#,
289+ );
290+ assert!(
291+ has_error(&diags, "escape"),
292+ "expected escape error, got: {:?}",
293+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
294+ );
295+}
296+
297+#[test]
298+fn direct_polymorphic_argument_ok() {
299+ let diags = check_src(
300+ r#"takesPoly : (forall a. a -> a) -> Int
301+takesPoly = \f -> f 1
302+
303+main = println (show (takesPoly (\x -> x)))
304+"#,
305+ );
306+ assert_clean(&diags);
307+}
308+
309+// ── 8. Refine-target unification before constraint checking ─────────
310+
311+#[test]
312+fn constraint_on_refine_concretized_var_is_checked() {
313+ // `foo x` defers a `Foo` constraint on x's type variable (which stays
314+ // out of main's generalized type — `x <- []` keeps it monomorphic and
315+ // local); the refine annotation resolves x to Int (Nat's base) only
316+ // during refine-target unification. Pre-fix, check_constraints ran
317+ // BEFORE that unification, hit the Ty::Var skip, and the missing
318+ // `Foo Int` impl was silently not reported.
319+ let diags = check_src(
320+ r#"trait Foo a where
321+ foo : a -> Int
322+
323+type Nat = Int where \x -> x >= 0
324+
325+main = do
326+ x <- []
327+ let n = foo x
328+ let r = ((refine x) : Result RefinementError Nat)
329+ yield n
330+"#,
331+ );
332+ assert!(
333+ has_error(&diags, "no implementation of trait 'Foo'"),
334+ "expected missing-impl error, got: {:?}",
335+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
336+ );
337+}
338+
339+// ── 9. Alias bodies with free type variables ─────────────────────────
340+
341+#[test]
342+fn alias_with_free_var_usable_at_two_types() {
343+ let diags = check_src(
344+ r#"type Box = {val: a}
345+
346+b1 : Box
347+b1 = {val: 1}
348+
349+b2 : Box
350+b2 = {val: "s"}
351+
352+main = println "ok"
353+"#,
354+ );
355+ assert_clean(&diags);
356+}
357+
358+// ── 10. Applied lambdas with IO bodies route through the IO path ────
359+
360+#[test]
361+fn applied_lambda_io_do_block_typechecks() {
362+ // Pre-fix, the desugarer treated `(\u -> println (show u)) x` as
363+ // pure, desugared the block as a comprehension, and inference
364+ // produced a confusing IO-vs-relation mismatch.
365+ let diags = check_src(
366+ r#"main = do
367+ x <- [1, 2]
368+ (\u -> println (show u)) x
369+"#,
370+ );
371+ assert_clean(&diags);
372+}
373+
374+#[test]
375+fn applied_lambda_io_do_block_not_desugared_as_pure() {
376+ use knot::ast::{DeclKind, ExprKind};
377+ let mut module = parse(
378+ r#"main = do
379+ x <- [1, 2]
380+ (\u -> println (show u)) x
381+"#,
382+ );
383+ knot_compiler::desugar::desugar(&mut module);
384+ let main_body = module
385+ .decls
386+ .iter()
387+ .find_map(|d| match &d.node {
388+ DeclKind::Fun { name, body: Some(body), .. } if name == "main" => {
389+ Some(body)
390+ }
391+ _ => None,
392+ })
393+ .expect("main decl");
394+ assert!(
395+ matches!(&main_body.node, ExprKind::Do(_)),
396+ "IO do-block must remain a Do node for the IO codegen path, got: {:?}",
397+ main_body.node
398+ );
399+}
400+
401+// ── 11. fetchWith options / non-route constructors ──────────────────
402+
403+#[test]
404+fn fetchwith_bad_options_rejected() {
405+ let diags = check_src(
406+ r#"route Api where
407+ GET /users/{id: Int} -> {name: Text} = GetUser
408+
409+main = do
410+ r <- fetchWith "http://localhost:1" 42 (GetUser {id: 1})
411+ yield {}
412+"#,
413+ );
414+ assert!(
415+ has_error(&diags, "type mismatch"),
416+ "expected type mismatch on options, got: {:?}",
417+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
418+ );
419+}
420+
421+#[test]
422+fn fetchwith_headers_options_accepted() {
423+ let diags = check_src(
424+ r#"route Api where
425+ GET /users/{id: Int} -> {name: Text} = GetUser
426+
427+main = do
428+ r <- fetchWith "http://localhost:1" {headers: [{name: "X-A", value: "b"}]} (GetUser {id: 1})
429+ yield {}
430+"#,
431+ );
432+ assert_clean(&diags);
433+}
434+
435+#[test]
436+fn fetch_on_non_route_constructor_rejected() {
437+ // Pre-fix this passed inference and PANICKED the compiler in codegen
438+ // ("fetch: no route entry found for constructor 'Mk'").
439+ let diags = check_src(
440+ r#"data Foo = Mk {x: Int}
441+
442+main = do
443+ r <- fetch "http://localhost:1" (Mk {x: 1})
444+ yield {}
445+"#,
446+ );
447+ assert!(
448+ has_error(&diags, "not a route constructor"),
449+ "expected route-constructor error, got: {:?}",
450+ diags.iter().map(|d| &d.message).collect::<Vec<_>>()
451+ );
452+}
crates/knot-compiler/tests/regress_schema_contract.rsnew file+342
@@ -0,0 +1,342 @@
1+//! Regression tests for compiler↔runtime schema-descriptor contract bugs.
2+//! All three used to compile cleanly and then panic at table init on every
3+//! run:
4+//!
5+//! 1. ADT relation with a record (or payload-ADT) constructor field:
6+//! `col_type_str` emits "json" for the field but `parse_adt_schema` had
7+//! no "json" arm → panic `unknown ADT field type 'json'`.
8+//! 2. Nested relation of ADT element type inside a record
9+//! (`shapes: [Shape]`): emission produced
10+//! `shapes:[#Circle:radius=float|Dot]` but `parse_record_schema` routes
11+//! every `[...]` into nested-RECORD child-table parsing → panic.
12+//! Contract chosen: non-record-element nested relations are stored as a
13+//! single `json` column holding the whole relation (descriptor
14+//! `shapes:json`); only record-element nested relations keep the
15+//! `field:[child_schema]` child-table form.
16+//! 3. Nested relation of scalar element type (`tags: [Text]`): emission
17+//! produced `tags:[text]` → runtime panic on `text` having no `:`.
18+//! Same contract as 2: stored as a `json` column (`tags:json`), with
19+//! rows deduped on write so set semantics hold.
20+//!
21+//! Each test compiles a small Knot program with the real `knot` binary into
22+//! its own scratch directory (so the program's `.db` lands there) and
23+//! asserts on the program's output / exit status. Programs are run twice to
24+//! exercise cross-session re-init against an existing database and reading
25+//! back persisted values written by the previous process.
26+
27+use std::fs;
28+use std::path::PathBuf;
29+use std::process::Command;
30+
31+struct Compiled {
32+ dir: PathBuf,
33+ exe: PathBuf,
34+}
35+
36+impl Drop for Compiled {
37+ fn drop(&mut self) {
38+ let _ = fs::remove_dir_all(&self.dir);
39+ }
40+}
41+
42+fn scratch_dir(test_name: &str) -> PathBuf {
43+ let dir = std::env::temp_dir().join(format!(
44+ "knot_regress_schema_contract_{}_{}",
45+ test_name,
46+ std::process::id()
47+ ));
48+ let _ = fs::remove_dir_all(&dir);
49+ fs::create_dir_all(&dir).unwrap();
50+ dir
51+}
52+
53+/// Compile `source` into a fresh scratch directory and return paths.
54+fn compile(test_name: &str, source: &str) -> Compiled {
55+ let dir = scratch_dir(test_name);
56+ let src_path = dir.join("prog.knot");
57+ fs::write(&src_path, source).unwrap();
58+
59+ let knot = env!("CARGO_BIN_EXE_knot");
60+ let out = Command::new(knot)
61+ .arg("build")
62+ .arg(&src_path)
63+ .current_dir(&dir)
64+ .output()
65+ .expect("failed to spawn knot compiler");
66+ assert!(
67+ out.status.success(),
68+ "knot build failed for {test_name}:\nstdout: {}\nstderr: {}",
69+ String::from_utf8_lossy(&out.stdout),
70+ String::from_utf8_lossy(&out.stderr),
71+ );
72+ let exe = dir.join("prog");
73+ Compiled { dir, exe }
74+}
75+
76+/// Run an already-compiled program once; returns (stdout, stderr, success).
77+fn run(c: &Compiled) -> (String, String, bool) {
78+ let out = Command::new(&c.exe)
79+ .current_dir(&c.dir)
80+ .output()
81+ .expect("failed to run compiled program");
82+ (
83+ String::from_utf8_lossy(&out.stdout).into_owned(),
84+ String::from_utf8_lossy(&out.stderr).into_owned(),
85+ out.status.success(),
86+ )
87+}
88+
89+// ── Bug 1: ADT relation with record / payload-ADT constructor fields ──
90+
91+#[test]
92+fn adt_relation_with_record_field_round_trips() {
93+ // `center` is a record field inside a constructor of a direct ADT
94+ // relation. Its wide-table column descriptor is `center=json`; the
95+ // runtime used to panic at init with "unknown ADT field type 'json'".
96+ let c = compile(
97+ "adt_record_field",
98+ r#"data Shape = Circle {center: {x: Float, y: Float}, radius: Float} | Dot {}
99+
100+*shapes : [Shape]
101+
102+main = do
103+ pre <- *shapes
104+ println ("pre: " ++ show (count pre))
105+ replace *shapes = [Circle {center: {x: 1.5, y: 2.5}, radius: 3.0}, Dot {}]
106+ ss <- *shapes
107+ forEach ss (\s -> case s of
108+ Circle k -> println ("circle " ++ show k.center.x ++ " " ++ show k.center.y ++ " r=" ++ show k.radius)
109+ Dot d -> println "dot")
110+"#,
111+ );
112+
113+ let (out1, err1, ok1) = run(&c);
114+ assert!(ok1, "first run failed:\nstdout: {out1}\nstderr: {err1}");
115+ assert!(out1.contains("pre: 0"), "unexpected first-run output: {out1}");
116+ assert!(out1.contains("circle 1.5 2.5 r=3.0"), "record payload did not round-trip: {out1}");
117+ assert!(out1.contains("dot"), "nullary constructor missing: {out1}");
118+
119+ // Second process: re-init against the existing DB must not panic, and
120+ // the initial read must see the row persisted by the first process
121+ // (i.e. the json column reconstructs across sessions).
122+ let (out2, err2, ok2) = run(&c);
123+ assert!(ok2, "second run failed:\nstdout: {out2}\nstderr: {err2}");
124+ assert!(out2.contains("pre: 2"), "persisted rows not visible across sessions: {out2}");
125+ assert!(out2.contains("circle 1.5 2.5 r=3.0"), "second-run read failed: {out2}");
126+}
127+
128+#[test]
129+fn adt_relation_with_payload_adt_field_round_trips() {
130+ // A payload-bearing ADT as a constructor field also maps to a json
131+ // column (`inner=json`) in the wide table.
132+ let c = compile(
133+ "adt_payload_adt_field",
134+ r#"data Inner = A {n: Int} | B {}
135+data Outer = W {label: Text, inner: Inner} | E {}
136+
137+*outers : [Outer]
138+
139+main = do
140+ replace *outers = [W {label: "w1", inner: A {n: 42}}, W {label: "w2", inner: B {}}, E {}]
141+ os <- *outers
142+ forEach os (\o -> case o of
143+ W w -> case w.inner of
144+ A a -> println (w.label ++ " A " ++ show a.n)
145+ B b -> println (w.label ++ " B")
146+ E e -> println "E")
147+"#,
148+ );
149+
150+ for pass in 1..=2 {
151+ let (out, err, ok) = run(&c);
152+ assert!(ok, "run {pass} failed:\nstdout: {out}\nstderr: {err}");
153+ assert!(out.contains("w1 A 42"), "run {pass}: payload ADT field lost: {out}");
154+ assert!(out.contains("w2 B"), "run {pass}: nullary inner ctor lost: {out}");
155+ assert!(out.contains("E"), "run {pass}: nullary outer ctor lost: {out}");
156+ }
157+}
158+
159+// ── Bug 2: nested relation of ADT element type inside a record ──────
160+
161+#[test]
162+fn nested_relation_of_adt_elements_round_trips() {
163+ let c = compile(
164+ "nested_adt_relation",
165+ r#"data Shape = Circle {radius: Float} | Dot {}
166+
167+*drawings : [{name: Text, shapes: [Shape]}]
168+
169+main = do
170+ pre <- *drawings
171+ println ("pre: " ++ show (count pre))
172+ replace *drawings = [{name: "d1", shapes: [Circle {radius: 2.0}, Dot {}]}]
173+ ds <- *drawings
174+ forEach ds (\d -> do
175+ println d.name
176+ println ("n=" ++ show (count d.shapes))
177+ forEach d.shapes (\s -> case s of
178+ Circle k -> println ("circle " ++ show k.radius)
179+ Dot x -> println "dot"))
180+"#,
181+ );
182+
183+ let (out1, err1, ok1) = run(&c);
184+ assert!(ok1, "first run failed:\nstdout: {out1}\nstderr: {err1}");
185+ assert!(out1.contains("pre: 0"), "unexpected first-run output: {out1}");
186+ assert!(out1.contains("d1"), "parent row missing: {out1}");
187+ assert!(out1.contains("n=2"), "nested relation count wrong: {out1}");
188+ assert!(out1.contains("circle 2.0"), "payload constructor lost: {out1}");
189+ assert!(out1.contains("dot"), "nullary constructor lost: {out1}");
190+
191+ let (out2, err2, ok2) = run(&c);
192+ assert!(ok2, "second run failed:\nstdout: {out2}\nstderr: {err2}");
193+ assert!(out2.contains("pre: 1"), "persisted parent row not visible across sessions: {out2}");
194+ assert!(out2.contains("circle 2.0") && out2.contains("dot"), "second-run read failed: {out2}");
195+}
196+
197+#[test]
198+fn nested_adt_relation_dedups_on_write() {
199+ // Set semantics: duplicate constructors inside the nested relation are
200+ // deduped when the relation is serialized into the json column.
201+ let c = compile(
202+ "nested_adt_dedup",
203+ r#"data Shape = Circle {radius: Float} | Dot {}
204+
205+*drawings : [{name: Text, shapes: [Shape]}]
206+
207+main = do
208+ replace *drawings = [{name: "d", shapes: [Dot {}, Dot {}, Circle {radius: 1.0}, Dot {}, Circle {radius: 1.0}]}]
209+ ds <- *drawings
210+ forEach ds (\d -> println ("n=" ++ show (count d.shapes)))
211+"#,
212+ );
213+
214+ let (out, err, ok) = run(&c);
215+ assert!(ok, "run failed:\nstdout: {out}\nstderr: {err}");
216+ assert!(out.contains("n=2"), "nested ADT relation not deduped: {out}");
217+}
218+
219+// ── Bug 3: nested relation of scalar element type inside a record ───
220+
221+#[test]
222+fn nested_relation_of_scalar_elements_round_trips() {
223+ let c = compile(
224+ "nested_scalar_relation",
225+ r#"*posts : [{title: Text, tags: [Text]}]
226+
227+main = do
228+ pre <- *posts
229+ println ("pre: " ++ show (count pre))
230+ replace *posts = [{title: "a", tags: ["x", "y"]}]
231+ ps <- *posts
232+ forEach ps (\p -> do
233+ println p.title
234+ forEach p.tags (\t -> println ("tag " ++ t)))
235+"#,
236+ );
237+
238+ let (out1, err1, ok1) = run(&c);
239+ assert!(ok1, "first run failed:\nstdout: {out1}\nstderr: {err1}");
240+ assert!(out1.contains("pre: 0"), "unexpected first-run output: {out1}");
241+ assert!(out1.contains("tag x") && out1.contains("tag y"), "bare scalars did not round-trip: {out1}");
242+
243+ let (out2, err2, ok2) = run(&c);
244+ assert!(ok2, "second run failed:\nstdout: {out2}\nstderr: {err2}");
245+ assert!(out2.contains("pre: 1"), "persisted row not visible across sessions: {out2}");
246+ assert!(out2.contains("tag x") && out2.contains("tag y"), "second-run read failed: {out2}");
247+}
248+
249+#[test]
250+fn nested_scalar_relation_dedups_on_write() {
251+ let c = compile(
252+ "nested_scalar_dedup",
253+ r#"*posts : [{title: Text, tags: [Text]}]
254+
255+main = do
256+ replace *posts = [{title: "a", tags: ["x", "y", "x", "y", "x"]}]
257+ ps <- *posts
258+ forEach ps (\p -> do
259+ println ("n=" ++ show (count p.tags))
260+ forEach p.tags (\t -> println ("tag " ++ t)))
261+"#,
262+ );
263+
264+ let (out, err, ok) = run(&c);
265+ assert!(ok, "run failed:\nstdout: {out}\nstderr: {err}");
266+ assert!(out.contains("n=2"), "nested scalar relation not deduped: {out}");
267+ assert!(out.contains("tag x") && out.contains("tag y"), "deduped values wrong: {out}");
268+}
269+
270+// ── Comprehension binds over json-column nested fields ──────────────
271+
272+#[test]
273+fn comprehension_binds_over_json_nested_fields() {
274+ // `m <- t.field` binds in pure comprehensions must iterate the
275+ // in-memory relation read back from the json column, including
276+ // constructor-pattern binds that filter by tag.
277+ let c = compile(
278+ "comprehension_json_fields",
279+ r#"data Shape = Circle {radius: Float} | Dot {}
280+
281+*drawings : [{name: Text, shapes: [Shape]}]
282+*posts : [{title: Text, tags: [Text]}]
283+
284+main = do
285+ replace *drawings = [{name: "d1", shapes: [Circle {radius: 2.0}, Dot {}]}]
286+ replace *posts = [{title: "a", tags: ["x", "y"]}]
287+ let circles = do
288+ d <- *drawings
289+ Circle k <- d.shapes
290+ yield {r: k.radius}
291+ forEach circles (\c -> println ("r=" ++ show c.r))
292+ let allTags = do
293+ p <- *posts
294+ t <- p.tags
295+ yield {tag: t}
296+ forEach allTags (\t -> println ("tag=" ++ t.tag))
297+"#,
298+ );
299+
300+ let (out, err, ok) = run(&c);
301+ assert!(ok, "run failed:\nstdout: {out}\nstderr: {err}");
302+ assert!(out.contains("r=2.0"), "constructor-pattern bind over json field failed: {out}");
303+ assert!(
304+ out.contains("tag=x") && out.contains("tag=y"),
305+ "scalar bind over json field failed: {out}"
306+ );
307+}
308+
309+// ── Mixed: record-element child tables still work alongside json cols ──
310+
311+#[test]
312+fn record_child_table_with_inner_scalar_relation_round_trips() {
313+ // A record-element nested relation (child table, unchanged contract)
314+ // whose rows themselves contain a scalar-element nested relation
315+ // (json column inside the child table).
316+ let c = compile(
317+ "child_table_with_json_col",
318+ r#"*teams : [{name: Text, members: [{handle: Text, skills: [Text]}]}]
319+
320+main = do
321+ replace *teams = [{name: "t1", members: [{handle: "ana", skills: ["rust", "sql"]}, {handle: "bo", skills: []}]}]
322+ ts <- *teams
323+ forEach ts (\t -> do
324+ println t.name
325+ forEach t.members (\m -> do
326+ println (m.handle ++ " n=" ++ show (count m.skills))
327+ forEach m.skills (\s -> println ("skill " ++ s))))
328+"#,
329+ );
330+
331+ for pass in 1..=2 {
332+ let (out, err, ok) = run(&c);
333+ assert!(ok, "run {pass} failed:\nstdout: {out}\nstderr: {err}");
334+ assert!(out.contains("t1"), "run {pass}: parent missing: {out}");
335+ assert!(out.contains("ana n=2"), "run {pass}: child row / inner count wrong: {out}");
336+ assert!(out.contains("bo n=0"), "run {pass}: empty inner relation wrong: {out}");
337+ assert!(
338+ out.contains("skill rust") && out.contains("skill sql"),
339+ "run {pass}: inner scalars lost: {out}"
340+ );
341+ }
342+}
crates/knot-lsp/src/analysis.rs+68
@@ -537,6 +537,23 @@ pub fn analyze_document(
537537 refine_targets = rt;
538538 monad_info = mi;
539539
540+ // Inference ran on `analysis_module` — the prelude-injected,
541+ // import-inlined module. Binding spans recorded for prelude and
542+ // imported decls are byte offsets into OTHER sources; treating them
543+ // as offsets into this document produces ghost inlay hints and wrong
544+ // hover types at unrelated positions (comments, mid-token, …).
545+ // Keep only span-keyed entries that fall inside one of the *user's*
546+ // own decl spans (`module` is the pre-injection parse of this file).
547+ let user_decl_spans: Vec<Span> = module.decls.iter().map(|d| d.span).collect();
548+ let in_user_decl = |s: &Span| {
549+ user_decl_spans
550+ .iter()
551+ .any(|d| d.start <= s.start && s.end <= d.end)
552+ };
553+ local_type_info.retain(|s, _| in_user_decl(s));
554+ refine_targets.retain(|s, _| in_user_decl(s));
555+ monad_info.retain(|s, _| in_user_decl(s));
556+
540557 // Populate `unit_info` from the formatted local type strings — every
541558 // binding whose type carries a `<unit>` annotation gets an entry. The
542559 // inlay-hint handler reads this directly to surface unit annotations
@@ -1759,4 +1776,55 @@ main = do
17591776 assert!(state.type_info.is_empty());
17601777 assert!(state.local_type_info.is_empty());
17611778 }
1779+
1780+ /// Inference runs on the prelude-injected, import-inlined module, so it
1781+ /// records binding spans for prelude/imported binders — byte offsets
1782+ /// into OTHER sources. Those used to flow unfiltered into
1783+ /// `local_type_info` and render as ghost inlay hints / wrong hover types
1784+ /// at arbitrary positions in the user's document (reproduced inside
1785+ /// comment blocks). Every surviving span must now fall inside one of the
1786+ /// user's own decl spans.
1787+ #[test]
1788+ fn local_type_info_excludes_prelude_and_import_spans() {
1789+ use crate::test_support::TestWorkspace;
1790+ // A large leading comment block (> 1.1KB) guarantees that any
1791+ // leaked prelude span (small byte offsets into PRELUDE_SOURCE)
1792+ // would land inside the comment region — outside every decl span.
1793+ let comment_block = "-- padding comment line to push decls far into the file\n".repeat(24);
1794+ let src = format!("{comment_block}\nf = \\x -> x + 1\n\nmain = f 2\n");
1795+ let mut ws = TestWorkspace::new();
1796+ let uri = ws.open("main", &src);
1797+ let doc = ws.doc(&uri);
1798+ assert!(
1799+ !doc.local_type_info.is_empty(),
1800+ "user bindings should still be recorded"
1801+ );
1802+ let decl_spans: Vec<Span> = doc.module.decls.iter().map(|d| d.span).collect();
1803+ for span in doc.local_type_info.keys() {
1804+ assert!(
1805+ decl_spans
1806+ .iter()
1807+ .any(|d| d.start <= span.start && span.end <= d.end),
1808+ "leaked binding span {span:?} (text {:?}) outside every user decl",
1809+ doc.source.get(span.start..span.end)
1810+ );
1811+ }
1812+ // Specifically: nothing inside the leading comment block.
1813+ let first_decl_start = decl_spans.iter().map(|s| s.start).min().unwrap_or(0);
1814+ assert!(
1815+ doc.local_type_info
1816+ .keys()
1817+ .all(|s| s.start >= first_decl_start),
1818+ "ghost hint span inside the comment block"
1819+ );
1820+ // monad_info spans are filtered by the same provenance rule.
1821+ for span in doc.monad_info.keys() {
1822+ assert!(
1823+ decl_spans
1824+ .iter()
1825+ .any(|d| d.start <= span.start && span.end <= d.end),
1826+ "leaked monad_info span {span:?}"
1827+ );
1828+ }
1829+ }
17621830}
crates/knot-lsp/src/code_action.rs+539−62
@@ -192,10 +192,17 @@ pub(crate) fn handle_code_action(
192192 .collect();
193193
194194 if !missing.is_empty() {
195− let insert_pos = offset_to_position(&doc.source, decl.span.end);
195+ // Insert after the last non-whitespace byte of the impl —
196+ // the decl span includes the trailing newline run, and the
197+ // stubs must attach to the impl block, not the next decl.
198+ let insert_pos =
199+ offset_to_position(&doc.source, decl_text_end(&doc.source, decl.span));
200+ // Match the impl block's existing indentation instead of
201+ // hardcoding two spaces.
202+ let indent = impl_block_indent(&doc.source, decl.span);
196203 let stubs: String = missing
197204 .iter()
198− .map(|item| build_trait_method_stub(item))
205+ .map(|item| build_trait_method_stub(item, &indent))
199206 .collect();
200207
201208 let mut changes = HashMap::new();
@@ -605,12 +612,15 @@ pub(crate) fn handle_code_action(
605612
606613 // Extract function: wrap selected expression in a named function
607614 let mut fn_changes = HashMap::new();
608− // Find free variables in the selected text that are bound in scope
615+ // Find free variables in the selected text that are bound in scope.
616+ // Top-level Knot functions take parameters via lambdas
617+ // (`name = \x y -> body`), NOT via parameters on the left-hand
618+ // side — `name x = body` is a parse error at top level.
609619 let free_vars = find_free_vars_in_selection(doc, range_start, range_end);
610− let params_str = if free_vars.is_empty() {
611− String::new()
620+ let helper_decl = if free_vars.is_empty() {
621+ format!("{fn_name} = {trimmed}\n\n")
612622 } else {
613− format!(" {}", free_vars.join(" "))
623+ format!("{fn_name} = \\{} -> {trimmed}\n\n", free_vars.join(" "))
614624 };
615625 let call_args = if free_vars.is_empty() {
616626 String::new()
@@ -618,13 +628,17 @@ pub(crate) fn handle_code_action(
618628 format!(" {}", free_vars.join(" "))
619629 };
620630
621− // Find the enclosing top-level declaration to place the function before it
631+ // Find the enclosing top-level declaration to place the function
632+ // before it. An exported decl's span EXCLUDES the consumed
633+ // `export` keyword — inserting at span.start would splice the
634+ // helper between `export` and its decl, so scan back over a
635+ // preceding `export` token.
622636 let fn_insert_offset = doc
623637 .module
624638 .decls
625639 .iter()
626640 .find(|d| d.span.start <= range_start && range_end <= d.span.end)
627− .map(|d| d.span.start)
641+ .map(|d| include_export_prefix(&doc.source, d.span.start))
628642 .unwrap_or(0);
629643 let fn_insert_pos = offset_to_position(&doc.source, fn_insert_offset);
630644
@@ -637,9 +651,7 @@ pub(crate) fn handle_code_action(
637651 start: fn_insert_pos,
638652 end: fn_insert_pos,
639653 },
640− new_text: format!(
641− "{fn_name}{params_str} = {trimmed}\n\n"
642− ),
654+ new_text: helper_decl,
643655 },
644656 // Replace the selected expression with a call
645657 TextEdit {
@@ -739,7 +751,7 @@ pub(crate) fn handle_code_action(
739751 end: offset_to_position(&doc.source, last_import.span.end),
740752 };
741753
742− let new_text = merged
754+ let sorted_imports = merged
743755 .iter()
744756 .map(|(path, items)| match items {
745757 None => format!("import {path}"),
@@ -755,6 +767,20 @@ pub(crate) fn handle_code_action(
755767 // only appears when the rewrite actually changes something.
756768 let original_text =
757769 safe_slice(&doc.source, Span::new(first_import.span.start, last_import.span.end));
770+
771+ // Comment lines interleaved between imports would be wiped by the
772+ // block rewrite. Preserve them (in original order) above the
773+ // sorted import block — the simplest placement that never loses
774+ // user text.
775+ let comment_lines: Vec<&str> = original_text
776+ .lines()
777+ .filter(|l| l.trim_start().starts_with("--"))
778+ .collect();
779+ let new_text = if comment_lines.is_empty() {
780+ sorted_imports
781+ } else {
782+ format!("{}\n{sorted_imports}", comment_lines.join("\n"))
783+ };
758784 if new_text != original_text {
759785 let mut changes = HashMap::new();
760786 changes.insert(uri.clone(), vec![TextEdit { range: import_range, new_text }]);
@@ -1263,6 +1289,15 @@ fn find_if_negate_at(
12631289 None
12641290}
12651291
1292+/// Byte offset just past the last non-whitespace character of a declaration.
1293+/// Decl spans include the trailing newline run the parser consumed, so
1294+/// "insert at the end of the decl" anchors must trim that whitespace first —
1295+/// otherwise inserted text glues onto the start of the next declaration.
1296+fn decl_text_end(source: &str, span: Span) -> usize {
1297+ let text = safe_slice(source, span);
1298+ span.start.min(source.len()) + text.trim_end().len()
1299+}
1300+
12661301/// Locate a `data Name = ...` declaration at the cursor that has no `deriving`
12671302/// clause. Returns the decl span, the data type name, and the position to
12681303/// insert the deriving clause (immediately after the last constructor).
@@ -1286,10 +1321,11 @@ fn find_deriving_insertion_at(
12861321 return None;
12871322 }
12881323 let _ = constructors;
1289− // Insert at the very end of the data declaration's span. Cleanest
1290− // anchor that doesn't require constructor-level spans, and matches
1291− // the syntax `data Foo = A | B deriving (Eq, Show)`.
1292− let pos = offset_to_position(source, decl.span.end);
1324+ // Insert at the end of the declaration's TEXT, not its span —
1325+ // data decl spans include the trailing newline run (the parser's
1326+ // skip_newlines collapses them into the decl), so inserting at
1327+ // `span.end` would glue the clause onto the NEXT declaration.
1328+ let pos = offset_to_position(source, decl_text_end(source, decl.span));
12931329 return Some((decl.span, name.clone(), pos));
12941330 }
12951331 }
@@ -1945,39 +1981,44 @@ fn build_effect_widen_edit(decl: &ast::Decl, source: &str, target_effects: &str)
19451981 let decl_text = source.get(decl.span.start..decl.span.end.min(source.len()))?;
19461982 // Find `: ` after the function name to locate the start of the type signature
19471983 let colon = decl_text.find(": ")?;
1948− let after_colon = &decl_text[colon + 2..];
1949− // Find an existing IO effect set: `IO {...}`
1950− let abs_after_colon = decl.span.start + colon + 2;
1951− if let Some(io_pos) = after_colon.find("IO {") {
1952− let abs_io = abs_after_colon + io_pos;
1953− // Find the matching `}`
1954− let depth_start = abs_io + 3; // position of `{`
1955− let bytes = source.as_bytes();
1956− let mut depth = 0i32;
1957− for i in depth_start..source.len() {
1958− match bytes[i] {
1959− b'{' => depth += 1,
1960− b'}' => {
1961− depth -= 1;
1962− if depth == 0 {
1963− // Replace `{...}` with target effects (which already
1964− // includes braces).
1965− return Some(TextEdit {
1966− range: Range {
1967− start: offset_to_position(source, depth_start),
1968− end: offset_to_position(source, i + 1),
1969− },
1970− new_text: target_effects.to_string(),
1971− });
1972− }
1973− }
1974− _ => {}
1984+ let after_colon_off = colon + 2;
1985+ // Bound the search to the SIGNATURE text: the signature ends where the
1986+ // next column-0 line begins (the body line `name = …`). Continuation
1987+ // lines of a multi-line signature are indented and stay included.
1988+ let sig_end = {
1989+ let bytes = decl_text.as_bytes();
1990+ let mut end = decl_text.len();
1991+ let mut i = after_colon_off;
1992+ while i < bytes.len() {
1993+ if bytes[i] == b'\n'
1994+ && bytes
1995+ .get(i + 1)
1996+ .map_or(true, |b| *b != b' ' && *b != b'\t' && *b != b'\r')
1997+ {
1998+ end = i;
1999+ break;
19752000 }
2001+ i += 1;
19762002 }
1977− }
1978− // No existing IO effects: insert IO before the result type. We just append
1979− // a comment hint at the end of the signature line so the user can review.
1980− None
2003+ end
2004+ };
2005+ let sig = &decl_text[after_colon_off..sig_end];
2006+ // The declared row to widen is the RESULT row — the outermost top-level
2007+ // `IO { … }` along the arrow spine. Taking the first textual `IO {`
2008+ // would mutate a callback parameter's row in signatures like
2009+ // `(Int -> IO {} {}) -> IO {} {}`. `find_outermost_io_row` already
2010+ // implements the depth-aware spine walk.
2011+ let (row_start, row_end) = crate::shared::find_outermost_io_row(sig)?;
2012+ // Replace the row INCLUDING braces (target_effects carries its own).
2013+ let abs_open = decl.span.start + after_colon_off + row_start - 1;
2014+ let abs_close = decl.span.start + after_colon_off + row_end + 1;
2015+ Some(TextEdit {
2016+ range: Range {
2017+ start: offset_to_position(source, abs_open),
2018+ end: offset_to_position(source, abs_close),
2019+ },
2020+ new_text: target_effects.to_string(),
2021+ })
19812022}
19822023
19832024/// A wrap-in-constructor suggestion derived from a type mismatch.
@@ -2169,9 +2210,10 @@ fn build_deriving_action(
21692210 };
21702211 // `data_decl.span.end` covers everything up to (but not including) the
21712212 // start of the next decl — including any existing `deriving (...)`
2172− // clause, which is why the non-empty case below edits the clause in
2173− // place instead of appending at the end.
2174− let data_decl_end = data_decl.span.end;
2213+ // clause (which is why the non-empty case below edits the clause in
2214+ // place) AND the trailing newline run, which must be trimmed so the
2215+ // inserted clause attaches to the data decl rather than the next one.
2216+ let data_decl_end = decl_text_end(&doc.source, data_decl.span);
21752217 if existing_deriving.iter().any(|n| n == trait_name) {
21762218 return None;
21772219 }
@@ -2287,10 +2329,31 @@ fn build_deriving_action(
22872329 }))
22882330}
22892331
2290−/// Build a trait method stub `name p1 p2 = todo` from a trait method declaration.
2332+/// Leading indentation for method lines inside an impl block. Prefers the
2333+/// indentation of an existing item line (any indented non-empty line after
2334+/// the header); falls back to two spaces for empty impls.
2335+fn impl_block_indent(source: &str, decl_span: Span) -> String {
2336+ let text = safe_slice(source, decl_span);
2337+ for line in text.lines().skip(1) {
2338+ let trimmed = line.trim_start();
2339+ if trimmed.is_empty() {
2340+ continue;
2341+ }
2342+ let indent = &line[..line.len() - trimmed.len()];
2343+ if !indent.is_empty() {
2344+ return indent.to_string();
2345+ }
2346+ }
2347+ " ".to_string()
2348+}
2349+
2350+/// Build a trait method stub `name p1 p2 = todo` from a trait method
2351+/// declaration, indented with `indent` to match the impl block. Impl methods
2352+/// take parameters on the left-hand side (`method x = body` — see
2353+/// `ImplItem::Method { params, .. }`), unlike top-level functions.
22912354/// Counts arrows in the type signature to determine arity, then synthesizes
22922355/// fresh `a`, `b`, `c`... parameter names.
2293−fn build_trait_method_stub(item: &ast::TraitItem) -> String {
2356+fn build_trait_method_stub(item: &ast::TraitItem, indent: &str) -> String {
22942357 let (name, arity) = match item {
22952358 ast::TraitItem::Method { name, ty, .. } => {
22962359 let arity = count_function_arity(&ty.ty);
@@ -2319,7 +2382,7 @@ fn build_trait_method_stub(item: &ast::TraitItem) -> String {
23192382 } else {
23202383 format!(" {}", params.join(" "))
23212384 };
2322− format!("\n {name}{params_str} = todo")
2385+ format!("\n{indent}{name}{params_str} = todo")
23232386}
23242387
23252388/// Count the arity of a function type by walking the arrow spine.
@@ -2449,6 +2512,30 @@ fn fresh_extract_name(doc: &DocumentState, base: &str) -> String {
24492512 base.to_string()
24502513}
24512514
2515+/// If the declaration starting at `decl_start` is preceded by an `export`
2516+/// keyword (which the parser consumes — decl spans start AFTER it), return
2517+/// the offset of the start of that `export` line instead, so insertions land
2518+/// before the whole exported declaration.
2519+fn include_export_prefix(source: &str, decl_start: usize) -> usize {
2520+ let before = &source[..decl_start.min(source.len())];
2521+ let trimmed = before.trim_end();
2522+ if !trimmed.ends_with("export") {
2523+ return decl_start;
2524+ }
2525+ let kw_start = trimmed.len() - "export".len();
2526+ // Whole-word check: `myexport` must not match.
2527+ let boundary_ok = kw_start == 0
2528+ || !{
2529+ let b = trimmed.as_bytes()[kw_start - 1];
2530+ b.is_ascii_alphanumeric() || b == b'_' || b == b'\''
2531+ };
2532+ if boundary_ok {
2533+ kw_start
2534+ } else {
2535+ decl_start
2536+ }
2537+}
2538+
24522539fn find_free_vars_in_selection(
24532540 doc: &DocumentState,
24542541 start: usize,
@@ -2480,6 +2567,66 @@ fn find_free_vars_in_selection(
24802567 free_vars
24812568}
24822569
2570+/// Conservative syntactic check: is `s` an ATOMIC expression that can be
2571+/// spliced into any operand position without parentheses? Accepts single
2572+/// identifiers, numeric literals, simple string literals, and fully-wrapped
2573+/// `( … )` / `{ … }` / `[ … ]` forms whose outer delimiters actually match.
2574+/// Anything else (operators, applications, field accesses with arguments…)
2575+/// reports `false` so the caller parenthesizes — over-parenthesizing is
2576+/// safe, under-parenthesizing changes semantics.
2577+fn is_atomic_expr_text(s: &str) -> bool {
2578+ let t = s.trim();
2579+ if t.is_empty() {
2580+ return false;
2581+ }
2582+ let bytes = t.as_bytes();
2583+ // Single identifier (incl. primes) — also covers `true`, `Nothing`, etc.
2584+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
2585+ if (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') && bytes.iter().all(|b| is_ident(*b)) {
2586+ return true;
2587+ }
2588+ // Numeric literal: digits with optional `.`, `_` separators, and a
2589+ // trailing `<Unit>` annotation (`42.0<M>`).
2590+ if bytes[0].is_ascii_digit() {
2591+ let (num, unit) = match t.find('<') {
2592+ Some(p) if t.ends_with('>') => (&t[..p], Some(&t[p + 1..t.len() - 1])),
2593+ _ => (t, None),
2594+ };
2595+ let num_ok = num
2596+ .bytes()
2597+ .all(|b| b.is_ascii_digit() || b == b'.' || b == b'_');
2598+ let unit_ok = unit.map_or(true, |u| {
2599+ !u.is_empty() && u.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
2600+ });
2601+ if num_ok && unit_ok {
2602+ return true;
2603+ }
2604+ }
2605+ // Simple string literal with no internal quotes.
2606+ if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') && !t[1..t.len() - 1].contains('"') {
2607+ return true;
2608+ }
2609+ // Fully-delimited: the opening bracket must match the LAST closing one.
2610+ let pairs: &[(u8, u8)] = &[(b'(', b')'), (b'{', b'}'), (b'[', b']')];
2611+ for (open, close) in pairs {
2612+ if bytes[0] == *open && bytes[bytes.len() - 1] == *close {
2613+ let mut depth = 0i32;
2614+ for (i, b) in bytes.iter().enumerate() {
2615+ if *b == *open {
2616+ depth += 1;
2617+ } else if *b == *close {
2618+ depth -= 1;
2619+ if depth == 0 && i != bytes.len() - 1 {
2620+ return false; // `(a) (b)` — outer delimiters don't wrap
2621+ }
2622+ }
2623+ }
2624+ return depth == 0;
2625+ }
2626+ }
2627+ false
2628+}
2629+
24832630/// Find inline variable opportunities in do-block let bindings.
24842631fn find_inline_actions(
24852632 expr: &ast::Expr,
@@ -2534,11 +2681,16 @@ fn find_inline_actions(
25342681 new_text: String::new(),
25352682 });
25362683
2537− // Replace each usage with the value (parenthesized if complex)
2538− let replacement = if value_text.contains(' ') && use_count > 0 {
2539− format!("({value_text})")
2540− } else {
2684+ // Replace each usage with the value. Parenthesize
2685+ // unless the text is syntactically atomic — a
2686+ // whitespace check misses operators without
2687+ // spaces (`n-1` inlined into `2 * y` becomes
2688+ // `2 * n-1`, changing semantics). Over-
2689+ // parenthesizing is harmless; under is not.
2690+ let replacement = if is_atomic_expr_text(value_text) {
25412691 value_text.to_string()
2692+ } else {
2693+ format!("({value_text})")
25422694 };
25432695
25442696 for (usage_span, def_span) in &doc.references {
@@ -2981,11 +3133,21 @@ fn find_if_to_case_at(
29813133 let cond_text = safe_slice(source, cond.span);
29823134 let then_text = safe_slice(source, then_branch.span);
29833135 let else_text = safe_slice(source, else_branch.span);
2984− let indent = indent_for_expr_start(source, expr.span.start);
2985− let replacement = format!(
2986− "case {cond_text} of{indent}True {{}} -> {then_text}{indent}False {{}} -> {else_text}"
2987− );
2988− *best = Some((expr.span, replacement));
3136+ // Multi-line branch/condition text carries indentation tied
3137+ // to its original column; splicing it after a case arm's
3138+ // `->` at a new column breaks the layout-sensitive parse.
3139+ // Same guard as `find_if_negate_at`. (Recursion below may
3140+ // still find a single-line inner `if`.)
3141+ let multi_line = cond_text.contains('\n')
3142+ || then_text.contains('\n')
3143+ || else_text.contains('\n');
3144+ if !multi_line {
3145+ let indent = indent_for_expr_start(source, expr.span.start);
3146+ let replacement = format!(
3147+ "case {cond_text} of{indent}True {{}} -> {then_text}{indent}False {{}} -> {else_text}"
3148+ );
3149+ *best = Some((expr.span, replacement));
3150+ }
29893151 }
29903152 }
29913153 crate::utils::recurse_expr(expr, |e| walk(e, source, offset, best));
@@ -4020,6 +4182,321 @@ mod regress_fixes_tests {
40204182 "wildcard arm must be indented past column 0, got {new_text:?}"
40214183 );
40224184 }
4185+
4186+ /// Apply `TextEdit`s to `source`, back-to-front so offsets stay valid.
4187+ fn apply_edits_to(source: &str, edits: &[TextEdit]) -> String {
4188+ let mut spans: Vec<(usize, usize, &str)> = edits
4189+ .iter()
4190+ .map(|e| {
4191+ (
4192+ crate::utils::position_to_offset(source, e.range.start),
4193+ crate::utils::position_to_offset(source, e.range.end),
4194+ e.new_text.as_str(),
4195+ )
4196+ })
4197+ .collect();
4198+ spans.sort_by_key(|(s, _, _)| std::cmp::Reverse(*s));
4199+ let mut out = source.to_string();
4200+ for (start, end, text) in spans {
4201+ out.replace_range(start..end, text);
4202+ }
4203+ out
4204+ }
4205+
4206+ fn selection(source: &str, needle: &str) -> Range {
4207+ let off = source.find(needle).expect("needle found");
4208+ Range {
4209+ start: crate::utils::offset_to_position(source, off),
4210+ end: crate::utils::offset_to_position(source, off + needle.len()),
4211+ }
4212+ }
4213+
4214+ /// "Extract to function" must emit the lambda form for parameters —
4215+ /// `helper = \x -> body` — since `helper x = body` doesn't parse at
4216+ /// top level.
4217+ #[test]
4218+ fn extract_function_emits_lambda_form_for_free_vars() {
4219+ let mut tw = TestWorkspace::new();
4220+ let src = "f = \\n -> n * 2 + 1\n";
4221+ let uri = tw.open("main", src);
4222+ let range = selection(src, "n * 2");
4223+ let actions = handle_code_action(&tw.state, &params_for(&uri, range))
4224+ .unwrap_or_default();
4225+ let action = action_titled(&actions, |t| t.starts_with("Extract to function"))
4226+ .expect("extract-to-function offered");
4227+ let edits = edits_for(action, &uri);
4228+ let helper = edits
4229+ .iter()
4230+ .find(|e| e.new_text.contains("= "))
4231+ .expect("helper insertion edit");
4232+ assert!(
4233+ helper.new_text.contains("= \\n -> n * 2"),
4234+ "helper must use the lambda form, got: {:?}",
4235+ helper.new_text
4236+ );
4237+ // The whole result must round-trip through the parser cleanly.
4238+ let out = apply_edits_to(src, edits);
4239+ let lexer = knot::lexer::Lexer::new(&out);
4240+ let (tokens, _) = lexer.tokenize();
4241+ let parser = knot::parser::Parser::new(out.clone(), tokens);
4242+ let (_, diags) = parser.parse_module();
4243+ assert!(
4244+ diags.iter().all(|d| !matches!(d.severity, knot::diagnostic::Severity::Error)),
4245+ "extracted result must parse; got {diags:?}\nsource:\n{out}"
4246+ );
4247+ }
4248+
4249+ /// The helper must be inserted BEFORE the `export` keyword of an
4250+ /// exported declaration, not between `export` and its decl.
4251+ #[test]
4252+ fn extract_function_inserts_before_export_keyword() {
4253+ let mut tw = TestWorkspace::new();
4254+ let src = "export f = \\n -> n * 2 + 1\n";
4255+ let uri = tw.open("main", src);
4256+ let range = selection(src, "n * 2");
4257+ let actions = handle_code_action(&tw.state, &params_for(&uri, range))
4258+ .unwrap_or_default();
4259+ let action = action_titled(&actions, |t| t.starts_with("Extract to function"))
4260+ .expect("extract-to-function offered");
4261+ let edits = edits_for(action, &uri);
4262+ let out = apply_edits_to(src, edits);
4263+ assert!(
4264+ !out.contains("export extracted"),
4265+ "helper spliced between `export` and its decl:\n{out}"
4266+ );
4267+ assert!(
4268+ out.contains("\nexport f"),
4269+ "exported decl must keep its `export` prefix:\n{out}"
4270+ );
4271+ }
4272+
4273+ /// "Add deriving" must attach to the data decl itself — decl spans
4274+ /// include the trailing newline run, so inserting at span.end used to
4275+ /// glue the clause onto the NEXT declaration.
4276+ #[test]
4277+ fn add_deriving_attaches_to_data_decl_not_next_decl() {
4278+ let mut tw = TestWorkspace::new();
4279+ let src = "data Color = Red {} | Blue {}\n\nnext = 1\n";
4280+ let uri = tw.open("main", src);
4281+ let pos = tw.position_of(&uri, "Color");
4282+ let actions = handle_code_action(
4283+ &tw.state,
4284+ &params_for(&uri, Range { start: pos, end: pos }),
4285+ )
4286+ .unwrap_or_default();
4287+ let action = action_titled(&actions, |t| t.starts_with("Add `deriving"))
4288+ .expect("add-deriving offered");
4289+ let edits = edits_for(action, &uri);
4290+ let out = apply_edits_to(src, edits);
4291+ assert_eq!(
4292+ out, "data Color = Red {} | Blue {} deriving (Eq, Show)\n\nnext = 1\n",
4293+ "deriving clause must end the data decl line"
4294+ );
4295+ }
4296+
4297+ /// "Convert to deriving" has the same span-end pitfall.
4298+ #[test]
4299+ fn convert_to_deriving_attaches_to_data_decl_not_next_decl() {
4300+ let mut tw = TestWorkspace::new();
4301+ let src = "trait Greet a where\n greet : a -> Text\n greet x = \"hi\"\n\ndata Color = Red {} | Blue {}\n\nimpl Greet Color where\n\nnext = 1\n";
4302+ let uri = tw.open("main", src);
4303+ let pos = tw.position_of(&uri, "impl Greet");
4304+ let actions = handle_code_action(
4305+ &tw.state,
4306+ &params_for(&uri, Range { start: pos, end: pos }),
4307+ )
4308+ .unwrap_or_default();
4309+ let action = action_titled(&actions, |t| t.starts_with("Convert to `deriving"))
4310+ .expect("convert-to-deriving offered for empty impl of defaulted trait");
4311+ let edits = edits_for(action, &uri);
4312+ let deriving_edit = edits
4313+ .iter()
4314+ .find(|e| e.new_text.contains("deriving"))
4315+ .expect("deriving edit");
4316+ // The insertion must land at the end of the data decl's TEXT
4317+ // (line 4, right after `Blue {}`), not at the start of a later decl.
4318+ assert_eq!(
4319+ deriving_edit.range.start.line, 4,
4320+ "deriving must attach to the data decl line; edits: {edits:?}"
4321+ );
4322+ }
4323+
4324+ /// "Widen declared effects" must rewrite the RESULT row, not the first
4325+ /// textual `IO {` (which can be a callback parameter's row).
4326+ #[test]
4327+ fn widen_effects_targets_result_row_not_callback_param() {
4328+ let src = "runCb : (Int -> IO {} {}) -> IO {} {}\nrunCb = \\cb -> cb 1\n";
4329+ let lexer = knot::lexer::Lexer::new(src);
4330+ let (tokens, _) = lexer.tokenize();
4331+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
4332+ let (module, _) = parser.parse_module();
4333+ let decl = module
4334+ .decls
4335+ .iter()
4336+ .find(|d| matches!(&d.node, DeclKind::Fun { name, .. } if name == "runCb"))
4337+ .expect("runCb decl");
4338+ let edit = build_effect_widen_edit(decl, src, "{console}")
4339+ .expect("widen edit produced");
4340+ let start = crate::utils::position_to_offset(src, edit.range.start);
4341+ let result_row = src.find("-> IO {} {}\n").expect("result row") + 3 + 3;
4342+ assert_eq!(
4343+ start, result_row,
4344+ "edit must target the result row's braces; got offset {start} \
4345+ (text {:?})",
4346+ &src[start..start + 4.min(src.len() - start)]
4347+ );
4348+ let end = crate::utils::position_to_offset(src, edit.range.end);
4349+ let mut out = src.to_string();
4350+ out.replace_range(start..end, &edit.new_text);
4351+ assert_eq!(
4352+ out,
4353+ "runCb : (Int -> IO {} {}) -> IO {console} {}\nrunCb = \\cb -> cb 1\n"
4354+ );
4355+ }
4356+
4357+ /// "Inline variable" must parenthesize non-atomic values even without
4358+ /// spaces — `let y = n-1` into `2 * y` is `2 * (n-1)`, not `2 * n-1`.
4359+ #[test]
4360+ fn inline_variable_parenthesizes_operator_value_without_spaces() {
4361+ let mut tw = TestWorkspace::new();
4362+ let src = "f = \\n -> do\n let y = n-1\n yield (2 * y)\n";
4363+ let uri = tw.open("main", src);
4364+ let pos = tw.position_of(&uri, "y = n-1");
4365+ let actions = handle_code_action(
4366+ &tw.state,
4367+ &params_for(&uri, Range { start: pos, end: pos }),
4368+ )
4369+ .unwrap_or_default();
4370+ let action = action_titled(&actions, |t| t.starts_with("Inline"))
4371+ .expect("inline action offered");
4372+ let edits = edits_for(action, &uri);
4373+ let out = apply_edits_to(src, edits);
4374+ assert!(
4375+ out.contains("2 * (n-1)"),
4376+ "inlined operator expression must be parenthesized:\n{out}"
4377+ );
4378+ }
4379+
4380+ /// Atomic values (bare identifiers/literals) stay unparenthesized.
4381+ #[test]
4382+ fn inline_variable_leaves_atomic_values_bare() {
4383+ let mut tw = TestWorkspace::new();
4384+ let src = "f = \\n -> do\n let y = n\n yield (2 * y)\n";
4385+ let uri = tw.open("main", src);
4386+ let pos = tw.position_of(&uri, "y = n");
4387+ let actions = handle_code_action(
4388+ &tw.state,
4389+ &params_for(&uri, Range { start: pos, end: pos }),
4390+ )
4391+ .unwrap_or_default();
4392+ let action = action_titled(&actions, |t| t.starts_with("Inline"))
4393+ .expect("inline action offered");
4394+ let edits = edits_for(action, &uri);
4395+ let out = apply_edits_to(src, edits);
4396+ assert!(
4397+ out.contains("2 * n") && !out.contains("(n)"),
4398+ "atomic value must inline without parens:\n{out}"
4399+ );
4400+ }
4401+
4402+ #[test]
4403+ fn is_atomic_expr_text_classification() {
4404+ assert!(is_atomic_expr_text("n"));
4405+ assert!(is_atomic_expr_text("x'"));
4406+ assert!(is_atomic_expr_text("42"));
4407+ assert!(is_atomic_expr_text("1_000.5"));
4408+ assert!(is_atomic_expr_text("\"hello\""));
4409+ assert!(is_atomic_expr_text("(a + b)"));
4410+ assert!(is_atomic_expr_text("{a: 1, b: 2}"));
4411+ assert!(is_atomic_expr_text("[1, 2]"));
4412+ assert!(!is_atomic_expr_text("n-1"));
4413+ assert!(!is_atomic_expr_text("n - 1"));
4414+ assert!(!is_atomic_expr_text("f x"));
4415+ assert!(!is_atomic_expr_text("(a) (b)"));
4416+ assert!(!is_atomic_expr_text("p.x"));
4417+ assert!(!is_atomic_expr_text(""));
4418+ }
4419+
4420+ /// "Convert if to case" must refuse multi-line branches — splicing
4421+ /// indented branch text at a new column breaks the layout parse.
4422+ #[test]
4423+ fn convert_if_to_case_refuses_multiline_branches() {
4424+ let mut tw = TestWorkspace::new();
4425+ let src = "f = \\x -> if x > 1\n then do\n yield 1\n else do\n yield 2\n";
4426+ let uri = tw.open("main", src);
4427+ let pos = tw.position_of(&uri, "if x > 1");
4428+ let actions = handle_code_action(
4429+ &tw.state,
4430+ &params_for(&uri, Range { start: pos, end: pos }),
4431+ )
4432+ .unwrap_or_default();
4433+ assert!(
4434+ action_titled(&actions, |t| t.contains("if") && t.contains("case")).is_none(),
4435+ "if-to-case must not be offered for multi-line branches"
4436+ );
4437+ }
4438+
4439+ /// "Organize imports" must not delete comment lines interleaved between
4440+ /// imports — they're preserved above the sorted block.
4441+ #[test]
4442+ fn organize_imports_preserves_interleaved_comments() {
4443+ let mut tw = TempWorkspace::new();
4444+ tw.write_and_open("b.knot", "helperB = 1\n");
4445+ tw.write_and_open("a.knot", "helperA = 1\n");
4446+ let src = "import ./b\n-- keep me\nimport ./a\n\nmain = helperA + helperB\n";
4447+ let uri = tw.write_and_open("main.knot", src);
4448+ let pos = tw.workspace.position_of(&uri, "import ./b");
4449+ let actions = handle_code_action(
4450+ &tw.workspace.state,
4451+ &params_for(&uri, Range { start: pos, end: pos }),
4452+ )
4453+ .unwrap_or_default();
4454+ let Some(action) = action_titled(&actions, |t| t.starts_with("Organize imports")) else {
4455+ // Already organized → no action → nothing can be lost. But the
4456+ // imports above are unsorted, so the action should exist.
4457+ panic!("organize-imports action expected for unsorted imports");
4458+ };
4459+ let edits = edits_for(action, &uri);
4460+ let out = apply_edits_to(src, edits);
4461+ assert!(
4462+ out.contains("-- keep me"),
4463+ "comment between imports was deleted:\n{out}"
4464+ );
4465+ assert!(
4466+ out.contains("import ./a\nimport ./b"),
4467+ "imports must be sorted:\n{out}"
4468+ );
4469+ }
4470+
4471+ /// "Add missing methods" must match the impl block's actual indentation
4472+ /// and attach to the impl text, not after its trailing newline run.
4473+ #[test]
4474+ fn add_missing_methods_matches_impl_indentation() {
4475+ let mut tw = TestWorkspace::new();
4476+ let src = "trait Shape a where\n area : a -> Int\n name : a -> Text\n\ndata Sq = Sq {s: Int}\n\nimpl Shape Sq where\n area x = 1\n\nnext = 1\n";
4477+ let uri = tw.open("main", src);
4478+ let pos = tw.position_of(&uri, "impl Shape");
4479+ let actions = handle_code_action(
4480+ &tw.state,
4481+ &params_for(&uri, Range { start: pos, end: pos }),
4482+ )
4483+ .unwrap_or_default();
4484+ let action = action_titled(&actions, |t| t.starts_with("Add missing methods"))
4485+ .expect("add-missing-methods offered");
4486+ let edits = edits_for(action, &uri);
4487+ assert_eq!(edits.len(), 1);
4488+ assert!(
4489+ edits[0].new_text.starts_with("\n name"),
4490+ "stub must reuse the impl's 4-space indent; got: {:?}",
4491+ edits[0].new_text
4492+ );
4493+ // Insertion anchors at the end of `area x = 1` (line 7), before the
4494+ // blank line — not glued onto `next = 1`.
4495+ assert_eq!(
4496+ edits[0].range.start.line, 7,
4497+ "stub must attach to the impl block; edits: {edits:?}"
4498+ );
4499+ }
40234500}
40244501
40254502#[cfg(test)]
crates/knot-lsp/src/completion.rs+172−17
@@ -69,7 +69,13 @@ pub(crate) fn handle_completion(
6969 // the runtime builtins, lambdas, or do-block scaffolding belongs here.
7070 // Returning a tightly-scoped completion list keeps the user from typing
7171 // expression-level snippets that would never parse.
72− if find_enclosing_route_decl_span(&doc.module, offset).is_some() {
72+ //
73+ // Exception: `rateLimit <expr>` clauses hold ordinary EXPRESSIONS
74+ // (`{key: \inp ctx -> ..., limit: ...}`), so a cursor inside one needs
75+ // the normal expression completions, not the method/type gate.
76+ if find_enclosing_route_decl_span(&doc.module, offset).is_some()
77+ && !offset_in_route_rate_limit(&doc.module, offset)
78+ {
7379 return Some(CompletionResponse::Array(route_completions(doc)));
7480 }
7581
@@ -515,7 +521,7 @@ pub(crate) fn handle_completion(
515521 // the source is a partial expression (e.g. mid-typing inside a `<-` bind),
516522 // so the bias kicks in continuously as the user types.
517523 if let Some(do_span) = find_enclosing_do_span(&doc.module, offset) {
518− if let Some(monad) = monad_for_do_span(&doc.monad_info, do_span) {
524+ if let Some(monad) = monad_for_do_span(&doc.monad_info, do_span, offset) {
519525 for item in items.iter_mut() {
520526 let label = item.label.trim_start_matches(['*', '&']);
521527 if let Some(ty) = doc.type_info.get(label) {
@@ -790,6 +796,24 @@ fn find_enclosing_route_decl_span(module: &Module, offset: usize) -> Option<Span
790796 None
791797}
792798
799+/// True when `offset` sits inside the expression of a `rateLimit <expr>`
800+/// clause of some route entry. Those expressions are ordinary value-level
801+/// code and must receive normal expression completions.
802+fn offset_in_route_rate_limit(module: &Module, offset: usize) -> bool {
803+ for decl in &module.decls {
804+ if let DeclKind::Route { entries, .. } = &decl.node {
805+ for entry in entries {
806+ if let Some(rl) = &entry.rate_limit {
807+ if rl.span.start <= offset && offset <= rl.span.end {
808+ return true;
809+ }
810+ }
811+ }
812+ }
813+ }
814+ false
815+}
816+
793817/// Build the completion list for a position inside a `route … where` block.
794818/// Surfaces HTTP method keywords, the soft `headers` keyword, and types that
795819/// are valid in field-of-type positions; everything else (functions, builtins,
@@ -945,19 +969,33 @@ fn snippet_context_matches(decl: SnippetContext, cursor: SnippetContext) -> bool
945969 }
946970}
947971
948−/// Return the resolved monad kind for the do-block at `do_span`, if any. The
949−/// type checker registers monad-vars with spans tied to the desugared
972+/// Return the resolved monad kind for the do-block at `do_span`. The type
973+/// checker registers monad-vars with spans tied to the desugared
950974/// `__bind`/`__yield`/`__empty` callsites, which sit inside the original
951−/// do-block, so any `monad_info` entry whose span is contained in `do_span`
952−/// is a valid sample of that block's monad.
975+/// do-block — but NESTED do-blocks contribute entries inside `do_span` too,
976+/// so an arbitrary `.find()` over the HashMap sampled them
977+/// nondeterministically. Prefer the INNERMOST entry containing the cursor
978+/// (that's the block the user is typing in); when no entry covers the
979+/// cursor, fall back to the first contained entry in deterministic
980+/// `(start, end)` order.
953981fn monad_for_do_span(
954982 monad_info: &HashMap<Span, MonadKind>,
955983 do_span: Span,
984+ offset: usize,
956985) -> Option<MonadKind> {
957− monad_info
986+ let mut contained: Vec<(&Span, &MonadKind)> = monad_info
987+ .iter()
988+ .filter(|(s, _)| s.start >= do_span.start && s.end <= do_span.end)
989+ .collect();
990+ if let Some((_, kind)) = contained
958991 .iter()
959− .find(|(s, _)| s.start >= do_span.start && s.end <= do_span.end)
960− .map(|(_, k)| k.clone())
992+ .filter(|(s, _)| s.start <= offset && offset <= s.end)
993+ .min_by_key(|(s, _)| (s.end - s.start, s.start, s.end))
994+ {
995+ return Some((*kind).clone());
996+ }
997+ contained.sort_by_key(|(s, _)| (s.start, s.end));
998+ contained.first().map(|(_, k)| (*k).clone())
961999}
9621000
9631001/// True if the rendered type of a completion candidate is a value in the
@@ -1060,7 +1098,7 @@ fn cursor_in_type_context(before: &str) -> bool {
10601098 // In `type`/`data` declarations the RHS of `=` is type-level.
10611099 let eq_introduces_type = head.starts_with("type ") || head.starts_with("data ");
10621100
1063− let is_ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
1101+ let is_ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'\'';
10641102 let mut ty = false; // current position is type-level?
10651103 let mut lambda_arrows = 0usize; // `\`s whose `->` hasn't been seen yet
10661104 let mut stack: Vec<(u8, bool)> = Vec::new(); // (bracket, ty at open)
@@ -1163,7 +1201,7 @@ fn cursor_in_type_context(before: &str) -> bool {
11631201/// dot-completion items when the variable was bound from a `*source`.
11641202fn receiver_ident_before_dot(source: &str, dot_pos: usize) -> Option<String> {
11651203 let bytes = source.as_bytes();
1166− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1204+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
11671205 let mut end = dot_pos;
11681206 while end > 0 && bytes[end - 1] == b' ' {
11691207 end -= 1;
@@ -1189,7 +1227,7 @@ fn receiver_ident_before_dot(source: &str, dot_pos: usize) -> Option<String> {
11891227/// sent (it may be newer than `doc.source`); `dot_pos` is an offset into it.
11901228fn resolve_dot_fields(doc: &DocumentState, source: &str, dot_pos: usize) -> Vec<String> {
11911229 let bytes = source.as_bytes();
1192− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
1230+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
11931231
11941232 // Find the identifier immediately before the dot
11951233 let mut end = dot_pos.min(bytes.len());
@@ -1241,6 +1279,18 @@ fn find_type_for_name(doc: &DocumentState, name: &str, offset: usize) -> Option<
12411279
12421280/// Extract field names from a type string like `{name: Text, age: Int}` or a named type.
12431281fn extract_fields_from_type_str(type_str: &str, module: &Module) -> Vec<String> {
1282+ let mut visited = HashSet::new();
1283+ extract_fields_from_type_str_inner(type_str, module, &mut visited)
1284+}
1285+
1286+/// Recursive worker for `extract_fields_from_type_str`. Carries a visited
1287+/// set of alias names so cyclic type aliases (`type A = B` ⏎ `type B = A`)
1288+/// terminate instead of overflowing the stack.
1289+fn extract_fields_from_type_str_inner(
1290+ type_str: &str,
1291+ module: &Module,
1292+ visited: &mut HashSet<String>,
1293+) -> Vec<String> {
12441294 let type_str = type_str.trim();
12451295
12461296 // Direct record type: `{name: Text, age: Int}`
@@ -1251,7 +1301,7 @@ fn extract_fields_from_type_str(type_str: &str, module: &Module) -> Vec<String>
12511301 // Relation type: `[{name: Text}]` or `[Person]` — extract inner type
12521302 if type_str.starts_with('[') && type_str.ends_with(']') {
12531303 let inner = &type_str[1..type_str.len() - 1];
1254− return extract_fields_from_type_str(inner, module);
1304+ return extract_fields_from_type_str_inner(inner, module, visited);
12551305 }
12561306
12571307 // IO type: `IO {...} [T]` or `IO {...} {fields}` — skip to the value type
@@ -1261,7 +1311,7 @@ fn extract_fields_from_type_str(type_str: &str, module: &Module) -> Vec<String>
12611311 if rest.starts_with('{') {
12621312 if let Some(close) = rest.find('}') {
12631313 let value_type = rest[close + 1..].trim();
1264− return extract_fields_from_type_str(value_type, module);
1314+ return extract_fields_from_type_str_inner(value_type, module, visited);
12651315 }
12661316 }
12671317 }
@@ -1269,10 +1319,14 @@ fn extract_fields_from_type_str(type_str: &str, module: &Module) -> Vec<String>
12691319 // Maybe type: `Maybe T` — unwrap to inner type
12701320 if type_str.starts_with("Maybe ") {
12711321 let inner = type_str[6..].trim();
1272− return extract_fields_from_type_str(inner, module);
1322+ return extract_fields_from_type_str_inner(inner, module, visited);
12731323 }
12741324
1275− // Named type: look up in the module's declarations
1325+ // Named type: look up in the module's declarations. Cycle guard: a name
1326+ // we've already followed resolves to nothing rather than recursing.
1327+ if !visited.insert(type_str.to_string()) {
1328+ return Vec::new();
1329+ }
12761330 for decl in &module.decls {
12771331 match &decl.node {
12781332 DeclKind::TypeAlias { name, ty, .. } if name == type_str => {
@@ -1282,7 +1336,7 @@ fn extract_fields_from_type_str(type_str: &str, module: &Module) -> Vec<String>
12821336 }
12831337 // Follow alias to another named type
12841338 TypeKind::Named(target) => {
1285− return extract_fields_from_type_str(target, module);
1339+ return extract_fields_from_type_str_inner(target, module, visited);
12861340 }
12871341 _ => {}
12881342 }
@@ -2066,4 +2120,105 @@ mod regress_fixes_tests {
20662120 assert!(!monad_head_matches("Map Text [Int]", &rel));
20672121 assert!(!monad_head_matches("Text", &rel));
20682122 }
2123+
2124+ fn parse_module_src(src: &str) -> Module {
2125+ let lexer = knot::lexer::Lexer::new(src);
2126+ let (tokens, _) = lexer.tokenize();
2127+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
2128+ parser.parse_module().0
2129+ }
2130+
2131+ /// Cyclic type aliases must not overflow the stack during
2132+ /// dot-completion field extraction.
2133+ #[test]
2134+ fn extract_fields_terminates_on_cyclic_type_aliases() {
2135+ // Previously: infinite mutual recursion A → B → A → … killing the
2136+ // whole LSP process. Now: terminates with no fields.
2137+ let module = parse_module_src("type A = B\ntype B = A\nx = 1\n");
2138+ assert!(extract_fields_from_type_str("A", &module).is_empty());
2139+ assert!(extract_fields_from_type_str("B", &module).is_empty());
2140+ // Self-cycle too.
2141+ let module2 = parse_module_src("type C = C\n");
2142+ assert!(extract_fields_from_type_str("C", &module2).is_empty());
2143+ }
2144+
2145+ /// Non-cyclic alias chains still resolve through the guard.
2146+ #[test]
2147+ fn extract_fields_follows_acyclic_alias_chain() {
2148+ let module = parse_module_src("type Person = {name: Text}\ntype Alias = Person\n");
2149+ assert_eq!(extract_fields_from_type_str("Alias", &module), vec!["name"]);
2150+ }
2151+
2152+ /// Monad-aware ranking must sample the do-block deterministically:
2153+ /// the INNERMOST monad_info span containing the cursor wins; without a
2154+ /// containing span, the first contained entry in (start, end) order.
2155+ #[test]
2156+ fn monad_for_do_span_prefers_innermost_containing_cursor() {
2157+ let mut info: HashMap<Span, MonadKind> = HashMap::new();
2158+ let do_span = Span::new(0, 100);
2159+ // Outer block's callsite entry.
2160+ info.insert(Span::new(5, 90), MonadKind::Relation);
2161+ // Nested do-block's entry.
2162+ info.insert(Span::new(40, 60), MonadKind::IO);
2163+ // Cursor inside the nested block → IO.
2164+ assert!(matches!(
2165+ monad_for_do_span(&info, do_span, 50),
2166+ Some(MonadKind::IO)
2167+ ));
2168+ // Cursor inside the outer block only → Relation.
2169+ assert!(matches!(
2170+ monad_for_do_span(&info, do_span, 10),
2171+ Some(MonadKind::Relation)
2172+ ));
2173+ // Cursor outside both → deterministic fallback (first by start).
2174+ assert!(matches!(
2175+ monad_for_do_span(&info, do_span, 95),
2176+ Some(MonadKind::Relation)
2177+ ));
2178+ }
2179+
2180+ /// `rateLimit <expr>` clauses inside route declarations hold ordinary
2181+ /// expressions — the route-block completion gate must not swallow them.
2182+ #[test]
2183+ fn rate_limit_expression_gets_normal_expression_completions() {
2184+ use crate::test_support::TestWorkspace;
2185+ let mut ws = TestWorkspace::new();
2186+ let src = "route Api where\n GET /things -> Text rateLimit {key: \\i -> \\c -> Nothing, limit: {requests: 10, window: 1000}} = GetThings\n";
2187+ let uri = ws.open("main", src);
2188+ let doc = ws.doc(&uri);
2189+ // Sanity: the parser recorded the rateLimit expression.
2190+ let has_rl = doc.module.decls.iter().any(|d| {
2191+ matches!(&d.node, DeclKind::Route { entries, .. }
2192+ if entries.iter().any(|e| e.rate_limit.is_some()))
2193+ });
2194+ assert!(has_rl, "parser should record the rateLimit clause");
2195+ let off = doc.source.find("\\i ->").expect("rateLimit lambda");
2196+ assert!(
2197+ offset_in_route_rate_limit(&doc.module, off),
2198+ "cursor inside the rateLimit expression must be detected"
2199+ );
2200+ let pos = crate::utils::offset_to_position(&doc.source, off);
2201+ let params = CompletionParams {
2202+ text_document_position: TextDocumentPositionParams {
2203+ text_document: TextDocumentIdentifier { uri: uri.clone() },
2204+ position: pos,
2205+ },
2206+ context: None,
2207+ work_done_progress_params: Default::default(),
2208+ partial_result_params: Default::default(),
2209+ };
2210+ let resp = handle_completion(&ws.state, &params).expect("completion response");
2211+ let items = match resp {
2212+ CompletionResponse::Array(items) => items,
2213+ CompletionResponse::List(l) => l.items,
2214+ };
2215+ // Expression completions include builtins like `show`; the gated
2216+ // route list contains only methods/types/headers.
2217+ assert!(
2218+ items.iter().any(|i| i.label == "show"),
2219+ "expected expression completions inside rateLimit; got {} items: {:?}",
2220+ items.len(),
2221+ items.iter().map(|i| &i.label).take(20).collect::<Vec<_>>()
2222+ );
2223+ }
20692224}
crates/knot-lsp/src/defs.rs+57−3
@@ -43,13 +43,27 @@ pub fn resolve_definitions(
4343 resolver.define(name, name_span(name));
4444 }
4545 DeclKind::Source { name, .. } | DeclKind::View { name, .. } => {
46− resolver.define(name, name_span(name));
46+ let span = name_span(name);
47+ resolver.define(name, span);
48+ resolver.register_extra_definition_tokens(decl.span, name, span);
4749 }
4850 DeclKind::Derived { name, .. } => {
49− resolver.define(name, name_span(name));
51+ let span = name_span(name);
52+ resolver.define(name, span);
53+ resolver.register_extra_definition_tokens(decl.span, name, span);
5054 }
5155 DeclKind::Fun { name, .. } => {
52− resolver.define(name, name_span(name));
56+ let span = name_span(name);
57+ resolver.define(name, span);
58+ // The parser merges a separate type signature and the body
59+ // line (`f : T` ⏎ `f = body`) into ONE `DeclKind::Fun`
60+ // spanning both lines. `name_span` finds only the FIRST
61+ // whole-word occurrence (the signature line), so the
62+ // body-line definition token would otherwise be invisible to
63+ // rename/references/highlight — register every additional
64+ // line-start occurrence as a self-reference to the canonical
65+ // definition span.
66+ resolver.register_extra_definition_tokens(decl.span, name, span);
5367 }
5468 DeclKind::Trait { name, items, .. } => {
5569 resolver.define(name, name_span(name));
@@ -231,6 +245,46 @@ impl<'a> DefResolver<'a> {
231245 }
232246 }
233247
248+ /// Register every *additional* definition-name token of a top-level
249+ /// declaration as a self-reference to its canonical (first) name span.
250+ ///
251+ /// Multi-line declarations repeat their name at the start of each
252+ /// defining line — most importantly `f : T` ⏎ `f = body`, which the
253+ /// parser merges into a single decl. Top-level decls start at column 0,
254+ /// so any line inside the decl span that begins with the name (after an
255+ /// optional `*`/`&` relation sigil) is a definition token, never a body
256+ /// expression (body lines are layout-indented).
257+ fn register_extra_definition_tokens(&mut self, decl_span: Span, name: &str, primary: Span) {
258+ let end = decl_span.end.min(self.source.len());
259+ let start = decl_span.start.min(end);
260+ let text = &self.source[start..end];
261+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
262+ let mut rel = 0usize;
263+ for line in text.split('\n') {
264+ // Optional relation sigil before the name (views/derived).
265+ let cand = if line.starts_with('*') || line.starts_with('&') {
266+ 1
267+ } else {
268+ 0
269+ };
270+ let rest = &line[cand.min(line.len())..];
271+ if rest.starts_with(name) {
272+ let tok_end = cand + name.len();
273+ let boundary_ok = line
274+ .as_bytes()
275+ .get(tok_end)
276+ .map_or(true, |b| !is_ident(*b));
277+ if boundary_ok {
278+ let span = Span::new(start + rel + cand, start + rel + tok_end);
279+ if span != primary {
280+ self.refs.push((span, primary));
281+ }
282+ }
283+ }
284+ rel += line.len() + 1;
285+ }
286+ }
287+
234288 /// Walk a type expression, registering goto-references for each named
235289 /// type. The recorded usage span is just the name (not the surrounding
236290 /// type construction), so the goto-on-cursor lookup matches identifier
crates/knot-lsp/src/hover.rs+125−4
@@ -46,19 +46,33 @@ pub(crate) fn handle_hover(state: &ServerState, params: &HoverParams) -> Option<
4646 let word = word_at_position(&doc.source, pos)?;
4747 let word_span = word_span_at_offset(&doc.source, offset);
4848
49+ // Span containment must use the same leftward-resolved offset that
50+ // `word_at_position` used: a caret immediately AFTER an identifier
51+ // resolves the identifier to its left, so the binding lookup has to be
52+ // nudged back inside that word too — otherwise hover at `total|` falls
53+ // back to a same-named global instead of the local binding.
54+ let lookup_offset = crate::utils::ident_lookup_offset(&doc.source, offset);
55+
4956 // Try local binding types (let, bind, lambda params, case patterns).
5057 // Check if cursor is on a binding site or on a usage that references one.
58+ // Several recorded spans can overlap (a destructuring pattern contains
59+ // its binders); `local_type_info` is a HashMap, so a bare `.find()` is
60+ // nondeterministic across runs — always pick the SMALLEST containing
61+ // span (the innermost binding), tie-broken by start for determinism.
5162 let local_type = doc
5263 .local_type_info
5364 .iter()
54− .find(|(span, _)| span.start <= offset && offset < span.end)
65+ .filter(|(span, _)| span.start <= lookup_offset && lookup_offset < span.end)
66+ .min_by_key(|(span, _)| (span.end - span.start, span.start, span.end))
5567 .map(|(_, ty)| ty.clone())
5668 .or_else(|| {
57− // Cursor is on a usage — find the definition span and look up its type
69+ // Cursor is on a usage — find the definition span and look up its
70+ // type. References overlap the same way, so prefer the innermost.
5871 let (_, def_span) = doc
5972 .references
6073 .iter()
61− .find(|(usage, _)| usage.start <= offset && offset < usage.end)?;
74+ .filter(|(usage, _)| usage.start <= lookup_offset && lookup_offset < usage.end)
75+ .min_by_key(|(usage, _)| (usage.end - usage.start, usage.start, usage.end))?;
6276 doc.local_type_info.get(def_span).cloned()
6377 });
6478
@@ -353,6 +367,13 @@ pub(crate) fn handle_hover(state: &ServerState, params: &HoverParams) -> Option<
353367 value.push_str(doc_comment);
354368 }
355369
370+ // Every section above is conditional — e.g. `field_at_cursor` can be
371+ // `Some` without any refinement metadata to render. Don't ship an empty
372+ // popup in that case; `None` lets the editor show nothing.
373+ if value.trim().is_empty() {
374+ return None;
375+ }
376+
356377 Some(Hover {
357378 contents: HoverContents::Markup(MarkupContent {
358379 kind: MarkupKind::Markdown,
@@ -571,7 +592,7 @@ fn type_contains_name(haystack: &str, name: &str) -> bool {
571592 if needle.is_empty() || bytes.len() < needle.len() {
572593 return false;
573594 }
574− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
595+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
575596 let mut i = 0;
576597 while i + needle.len() <= bytes.len() {
577598 if &bytes[i..i + needle.len()] == needle {
@@ -882,4 +903,104 @@ mod regress_fixes_tests {
882903 "row after function-typed field was merged/lost: {table}"
883904 );
884905 }
906+
907+ use crate::test_support::TestWorkspace;
908+ use crate::utils::offset_to_position;
909+
910+ fn hover_params(uri: &Uri, position: Position) -> HoverParams {
911+ HoverParams {
912+ text_document_position_params: TextDocumentPositionParams {
913+ text_document: TextDocumentIdentifier { uri: uri.clone() },
914+ position,
915+ },
916+ work_done_progress_params: Default::default(),
917+ }
918+ }
919+
920+ fn hover_text(hover: Hover) -> String {
921+ match hover.contents {
922+ HoverContents::Markup(m) => m.value,
923+ other => format!("{other:?}"),
924+ }
925+ }
926+
927+ /// Hover at a caret sitting immediately AFTER an identifier (standard
928+ /// post-typing position) must resolve the LOCAL binding to its left —
929+ /// not fall back to a same-named global. `word_at_position` resolves the
930+ /// word to the left; span containment has to use the same nudged offset.
931+ #[test]
932+ fn hover_after_identifier_prefers_local_over_same_named_global() {
933+ let mut ws = TestWorkspace::new();
934+ let uri = ws.open(
935+ "main",
936+ "total : Text\ntotal = \"label\"\nf = \\total -> total + 1\n",
937+ );
938+ let doc = ws.doc(&uri);
939+ // Caret right after the body USAGE of the lambda param `total`.
940+ let usage = doc.source.rfind("total +").expect("usage");
941+ let pos = offset_to_position(&doc.source, usage + "total".len());
942+ let hover = handle_hover(&ws.state, &hover_params(&uri, pos)).expect("hover");
943+ let text = hover_text(hover);
944+ assert!(
945+ text.contains("Int"),
946+ "expected the local param's Int type, got: {text}"
947+ );
948+ assert!(
949+ !text.lines().nth(1).unwrap_or("").contains("Text"),
950+ "hover headline leaked the same-named global's type: {text}"
951+ );
952+ }
953+
954+ /// Overlapping local_type_info spans (a destructuring pattern containing
955+ /// its binders) must resolve to the SMALLEST containing span. The old
956+ /// `.find()` over a HashMap was nondeterministic across process runs.
957+ #[test]
958+ fn hover_picks_innermost_binding_span() {
959+ let mut ws = TestWorkspace::new();
960+ let uri = ws.open(
961+ "main",
962+ "data P = P {a: Int, b: Text}\nf = \\p -> case p of\n P {a, b} -> b\n",
963+ );
964+ let doc = ws.doc(&uri);
965+ let off = doc.source.find("a, b}").expect("binder a");
966+ let pos = offset_to_position(&doc.source, off);
967+ // Run the lookup many times — with the smallest-span rule the result
968+ // is stable; the old behavior depended on HashMap iteration order.
969+ let mut seen: Option<String> = None;
970+ for _ in 0..16 {
971+ let hover = handle_hover(&ws.state, &hover_params(&uri, pos)).expect("hover");
972+ let text = hover_text(hover);
973+ let headline = text.lines().nth(1).unwrap_or("").to_string();
974+ match &seen {
975+ None => seen = Some(headline),
976+ Some(prev) => assert_eq!(prev, &headline, "hover result not stable"),
977+ }
978+ }
979+ // The binder `a` is an Int; the whole-pattern span would render the
980+ // record/variant type instead.
981+ let headline = seen.unwrap_or_default();
982+ assert!(
983+ headline.contains("Int") || headline.contains("a :"),
984+ "expected the innermost binder's type, got: {headline}"
985+ );
986+ }
987+
988+ /// A field-access position with no refinement metadata and no symbol
989+ /// info must return None — not an empty popup.
990+ #[test]
991+ fn hover_returns_none_instead_of_empty_popup() {
992+ let mut ws = TestWorkspace::new();
993+ let uri = ws.open("main", "f = \\p -> p.unknownField\n");
994+ let doc = ws.doc(&uri);
995+ let off = doc.source.find("unknownField").expect("field");
996+ let pos = offset_to_position(&doc.source, off + 2);
997+ let resp = handle_hover(&ws.state, &hover_params(&uri, pos));
998+ if let Some(h) = resp {
999+ let text = hover_text(h);
1000+ assert!(
1001+ !text.trim().is_empty(),
1002+ "hover returned an EMPTY popup; should have been None"
1003+ );
1004+ }
1005+ }
8851006}
crates/knot-lsp/src/inlay_hints.rs+204−73
@@ -83,7 +83,15 @@ pub(crate) fn handle_inlay_hint(
8383 .map(|ty| !type_str_mentions_effects(ty, effects))
8484 .unwrap_or(true);
8585 if needs_hint {
86− let hint_offset = name_end_offset(&doc.source, decl.span, name);
86+ // Anchor at the END of the signature line. Anchoring
87+ // right after the name would visually split `name`
88+ // and `:` on annotated declarations.
89+ let span_end = decl.span.end.min(doc.source.len());
90+ let hint_offset = doc.source
91+ [decl.span.start.min(span_end)..span_end]
92+ .find('\n')
93+ .map(|p| decl.span.start + p)
94+ .unwrap_or(span_end);
8795 let hint_pos = offset_to_position(&doc.source, hint_offset);
8896 hints.push(InlayHint {
8997 position: hint_pos,
@@ -356,17 +364,70 @@ fn add_record_pattern_field_hints(
356364 range_end: usize,
357365 hints: &mut Vec<InlayHint>,
358366) {
359− /// Find each pattern that destructures a record. Tracks both the span
360− /// and an optional constructor name for ADT cases like `Person {name}`.
361− fn walk_pat_for_records(pat: &ast::Pat, out: &mut Vec<(Span, Option<String>)>) {
367+ /// Field-name token positions parsed structurally from a record pattern.
368+ /// The AST stores field names as bare strings, so each token is recovered
369+ /// from source text — but confined to its syntactic slot: the window
370+ /// between the previous field's sub-pattern (or the record opener) and
371+ /// this field's sub-pattern. A whole-pattern first-occurrence search
372+ /// would anchor field `b`'s hint on the BINDER `b` of an earlier field
373+ /// (`P {a: b, b: c}`).
374+ fn record_field_name_spans(
375+ fields: &[ast::FieldPat],
376+ window: Span,
377+ source: &str,
378+ ) -> Vec<(String, Span)> {
379+ let mut out = Vec::new();
380+ let mut search_start = window.start;
381+ for f in fields {
382+ match &f.pattern {
383+ Some(p) => {
384+ if let Some(s) = crate::utils::find_word_in_source(
385+ source,
386+ &f.name,
387+ search_start,
388+ p.span.start,
389+ ) {
390+ out.push((f.name.clone(), s));
391+ }
392+ search_start = p.span.end;
393+ }
394+ None => {
395+ // Pun: the token both names the field and binds the var.
396+ if let Some(s) = crate::utils::find_word_in_source(
397+ source,
398+ &f.name,
399+ search_start,
400+ window.end,
401+ ) {
402+ out.push((f.name.clone(), s));
403+ search_start = s.end;
404+ }
405+ }
406+ }
407+ }
408+ out
409+ }
410+
411+ /// Find each pattern that destructures a record. Tracks the span, an
412+ /// optional constructor name for ADT cases like `Person {name}`, and the
413+ /// structurally-parsed field-name token spans.
414+ fn walk_pat_for_records(
415+ pat: &ast::Pat,
416+ source: &str,
417+ out: &mut Vec<(Span, Option<String>, Vec<(String, Span)>)>,
418+ ) {
362419 match &pat.node {
363420 ast::PatKind::Record(fields) => {
364− out.push((pat.span, None));
421+ out.push((
422+ pat.span,
423+ None,
424+ record_field_name_spans(fields, pat.span, source),
425+ ));
365426 // Recurse into field sub-patterns so nested record
366427 // destructures (`{addr: {city}}`) get hints too.
367428 for f in fields {
368429 if let Some(p) = &f.pattern {
369− walk_pat_for_records(p, out);
430+ walk_pat_for_records(p, source, out);
370431 }
371432 }
372433 }
@@ -376,41 +437,49 @@ fn add_record_pattern_field_hints(
376437 // push the same record again and duplicate every per-field
377438 // hint. Only the payload's field SUB-patterns are recursed.
378439 if let ast::PatKind::Record(fields) = &payload.node {
379− out.push((pat.span, Some(name.clone())));
440+ out.push((
441+ pat.span,
442+ Some(name.clone()),
443+ record_field_name_spans(fields, payload.span, source),
444+ ));
380445 for f in fields {
381446 if let Some(p) = &f.pattern {
382− walk_pat_for_records(p, out);
447+ walk_pat_for_records(p, source, out);
383448 }
384449 }
385450 } else {
386− walk_pat_for_records(payload, out);
451+ walk_pat_for_records(payload, source, out);
387452 }
388453 }
389454 ast::PatKind::List(pats) => {
390455 for p in pats {
391− walk_pat_for_records(p, out);
456+ walk_pat_for_records(p, source, out);
392457 }
393458 }
394459 ast::PatKind::Cons { head, tail } => {
395− walk_pat_for_records(head, out);
396− walk_pat_for_records(tail, out);
460+ walk_pat_for_records(head, source, out);
461+ walk_pat_for_records(tail, source, out);
397462 }
398463 _ => {}
399464 }
400465 }
401− fn walk_expr(expr: &ast::Expr, out: &mut Vec<(Span, Option<String>)>) {
466+ fn walk_expr(
467+ expr: &ast::Expr,
468+ source: &str,
469+ out: &mut Vec<(Span, Option<String>, Vec<(String, Span)>)>,
470+ ) {
402471 match &expr.node {
403472 ast::ExprKind::Lambda { params, body } => {
404473 for p in params {
405− walk_pat_for_records(p, out);
474+ walk_pat_for_records(p, source, out);
406475 }
407− walk_expr(body, out);
476+ walk_expr(body, source, out);
408477 }
409478 ast::ExprKind::Case { scrutinee, arms } => {
410− walk_expr(scrutinee, out);
479+ walk_expr(scrutinee, source, out);
411480 for arm in arms {
412− walk_pat_for_records(&arm.pat, out);
413− walk_expr(&arm.body, out);
481+ walk_pat_for_records(&arm.pat, source, out);
482+ walk_expr(&arm.body, source, out);
414483 }
415484 }
416485 ast::ExprKind::Do(stmts) => {
@@ -418,33 +487,33 @@ fn add_record_pattern_field_hints(
418487 match &stmt.node {
419488 ast::StmtKind::Bind { pat, expr }
420489 | ast::StmtKind::Let { pat, expr } => {
421− walk_pat_for_records(pat, out);
422− walk_expr(expr, out);
490+ walk_pat_for_records(pat, source, out);
491+ walk_expr(expr, source, out);
423492 }
424493 ast::StmtKind::Where { cond } | ast::StmtKind::Expr(cond) => {
425− walk_expr(cond, out);
494+ walk_expr(cond, source, out);
426495 }
427− ast::StmtKind::GroupBy { key } => walk_expr(key, out),
496+ ast::StmtKind::GroupBy { key } => walk_expr(key, source, out),
428497 }
429498 }
430499 }
431− _ => recurse_expr(expr, |e| walk_expr(e, out)),
500+ _ => recurse_expr(expr, |e| walk_expr(e, source, out)),
432501 }
433502 }
434503
435− let mut record_pats: Vec<(Span, Option<String>)> = Vec::new();
504+ let mut record_pats: Vec<(Span, Option<String>, Vec<(String, Span)>)> = Vec::new();
436505 for decl in &doc.module.decls {
437506 match &decl.node {
438507 ast::DeclKind::Fun { body: Some(body), .. }
439508 | ast::DeclKind::View { body, .. }
440− | ast::DeclKind::Derived { body, .. } => walk_expr(body, &mut record_pats),
509+ | ast::DeclKind::Derived { body, .. } => walk_expr(body, &doc.source, &mut record_pats),
441510 ast::DeclKind::Impl { items, .. } => {
442511 for item in items {
443512 if let ast::ImplItem::Method { params, body, .. } = item {
444513 for p in params {
445− walk_pat_for_records(p, &mut record_pats);
514+ walk_pat_for_records(p, &doc.source, &mut record_pats);
446515 }
447− walk_expr(body, &mut record_pats);
516+ walk_expr(body, &doc.source, &mut record_pats);
448517 }
449518 }
450519 }
@@ -452,18 +521,20 @@ fn add_record_pattern_field_hints(
452521 }
453522 }
454523
455− for (span, ctor_opt) in record_pats {
524+ for (span, ctor_opt, field_name_spans) in record_pats {
456525 if span.end < range_start || span.start > range_end {
457526 continue;
458527 }
459528 // Resolve the field set. Prefer the AST-driven constructor lookup —
460529 // the inferencer's local_type_info often doesn't carry an entry at
461530 // the constructor pattern's outer span, but the data decl always
462− // does.
531+ // does. Take the FIRST data type declaring a constructor with this
532+ // name and stop — scanning on would accumulate fields from every ADT
533+ // that happens to share the constructor name.
463534 let mut fields_str: Vec<(String, String)> = Vec::new();
464535 let mut tooltip_source = String::from("destructured record");
465536 if let Some(ctor_name) = ctor_opt.as_deref() {
466− for d in &doc.module.decls {
537+ 'ctor_lookup: for d in &doc.module.decls {
467538 if let ast::DeclKind::Data { constructors, name: data_name, .. } = &d.node {
468539 for c in constructors {
469540 if c.name == ctor_name {
@@ -474,6 +545,7 @@ fn add_record_pattern_field_hints(
474545 crate::type_format::format_type_kind(&f.value.node),
475546 ));
476547 }
548+ break 'ctor_lookup;
477549 }
478550 }
479551 }
@@ -507,17 +579,15 @@ fn add_record_pattern_field_hints(
507579 if fields_str.is_empty() {
508580 continue;
509581 }
510− // Slice once; we need the pattern source to find each field's
511− // position via word-boundary scan.
512− let pat_text = match doc.source.get(span.start..span.end) {
513− Some(s) => s,
514− None => continue,
515− };
516582 for (field_name, ty_str) in fields_str {
517− // The field name appears as a whole-word identifier inside the
518− // pattern. Search for it with simple boundary checks.
519− if let Some(rel_pos) = find_word_boundary(pat_text, &field_name) {
520− let abs_end = span.start + rel_pos + field_name.len();
583+ // Anchor each hint on the field-NAME token position parsed
584+ // structurally from the pattern — not on the first same-named
585+ // token, which could be an earlier field's binder.
586+ if let Some((_, name_span)) = field_name_spans
587+ .iter()
588+ .find(|(n, _)| *n == field_name)
589+ {
590+ let abs_end = name_span.end;
521591 if abs_end > span.end {
522592 continue;
523593 }
@@ -563,7 +633,7 @@ fn extract_variant_ctor_fields(
563633 }
564634 let mut j = i;
565635 while j < bytes.len()
566− && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_')
636+ && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'\'')
567637 {
568638 j += 1;
569639 }
@@ -580,29 +650,6 @@ fn extract_variant_ctor_fields(
580650 None
581651}
582652
583−/// Locate `word` as a whole-word match in `text`. Returns the byte offset of
584−/// its first occurrence, or `None`. Avoids matching `name` inside `nameish`.
585−fn find_word_boundary(text: &str, word: &str) -> Option<usize> {
586− let bytes = text.as_bytes();
587− let needle = word.as_bytes();
588− if needle.is_empty() || bytes.len() < needle.len() {
589− return None;
590− }
591− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
592− let mut i = 0;
593− while i + needle.len() <= bytes.len() {
594− if &bytes[i..i + needle.len()] == needle {
595− let left_ok = i == 0 || !is_ident(bytes[i - 1]);
596− let right_ok = i + needle.len() >= bytes.len() || !is_ident(bytes[i + needle.len()]);
597− if left_ok && right_ok {
598− return Some(i);
599− }
600− }
601− i += 1;
602− }
603− None
604−}
605−
606653/// Extract the unit annotation `<...>` from a formatted type string.
607654/// Returns the unit text without the angle brackets, or `None` if the type
608655/// has no unit annotation. Skips trivial dimensionless `<1>` annotations.
@@ -716,15 +763,6 @@ fn add_unit_literal_hints(
716763 }
717764}
718765
719−/// Find the byte offset just after the function name within its declaration span.
720−fn name_end_offset(source: &str, decl_span: Span, _name: &str) -> usize {
721− let decl_text = safe_slice(source, decl_span);
722− let name_end = decl_text
723− .find(|c: char| !c.is_alphanumeric() && c != '_')
724− .unwrap_or(decl_text.len());
725− decl_span.start + name_end
726−}
727−
728766/// Heuristic: does the rendered type string already mention all of the given
729767/// effects? Used to suppress redundant effect inlay hints.
730768fn type_str_mentions_effects(ty: &str, effects: &str) -> bool {
@@ -1388,4 +1426,97 @@ checkGlobalRate = \t -> atomic do
13881426 "expected `[Monad]` hint; got: {labels:?}"
13891427 );
13901428 }
1429+
1430+ /// Record-destructure field hints must anchor on the FIELD-NAME token,
1431+ /// not the first same-named token: in `P {a: b, b: c}`, field `b`'s hint
1432+ /// belongs on the second `b` (the field name), not on the binder `b`
1433+ /// of field `a`.
1434+ #[test]
1435+ fn record_field_hints_anchor_on_field_name_not_binder() {
1436+ let mut ws = TestWorkspace::new();
1437+ let src = "data P = P {a: Int, b: Text}\n\nf = \\p -> case p of\n P {a: b, b: c} -> c\n";
1438+ let uri = ws.open("main", src);
1439+ let doc = ws.doc(&uri);
1440+ let range = ws.whole_file_range(&uri);
1441+ let hints = handle_inlay_hint(&ws.state, &hint_params(&uri, range)).unwrap_or_default();
1442+
1443+ // Expected anchor for field `b`: just after the SECOND `b` in the
1444+ // pattern (the field-name token of `b: c`).
1445+ let pat_off = doc.source.find("P {a: b, b: c}").expect("pattern");
1446+ let field_b_off = doc.source[pat_off..].find(", b:").map(|p| pat_off + p + 2).unwrap();
1447+ let expected_b_pos = offset_to_position(&doc.source, field_b_off + 1);
1448+ // And the WRONG anchor (the binder b of field a).
1449+ let binder_b_off = doc.source[pat_off..].find("a: b").map(|p| pat_off + p + 3).unwrap();
1450+ let wrong_b_pos = offset_to_position(&doc.source, binder_b_off + 1);
1451+
1452+ let text_hints: Vec<(&Position, String)> = hints
1453+ .iter()
1454+ .filter_map(|h| match &h.label {
1455+ InlayHintLabel::String(s) if s == ": Text" => Some((&h.position, s.clone())),
1456+ _ => None,
1457+ })
1458+ .collect();
1459+ assert!(
1460+ text_hints.iter().any(|(p, _)| **p == expected_b_pos),
1461+ "`: Text` hint must anchor on the field-name token of `b`; got: {text_hints:?} (expected {expected_b_pos:?})"
1462+ );
1463+ assert!(
1464+ text_hints.iter().all(|(p, _)| **p != wrong_b_pos),
1465+ "`: Text` hint anchored on the binder of field `a`: {text_hints:?}"
1466+ );
1467+ }
1468+
1469+ /// The constructor lookup must take the FIRST ADT declaring the
1470+ /// constructor — not accumulate fields from every ADT sharing the name.
1471+ #[test]
1472+ fn record_field_hints_do_not_accumulate_across_same_named_ctors() {
1473+ let mut ws = TestWorkspace::new();
1474+ let src = "data A = Mk {x: Int}\ndata B = Mk {y: Text}\n\nf = \\v -> case v of\n Mk {x} -> x\n";
1475+ let uri = ws.open("main", src);
1476+ let range = ws.whole_file_range(&uri);
1477+ let hints = handle_inlay_hint(&ws.state, &hint_params(&uri, range)).unwrap_or_default();
1478+ // The pattern destructures `x` only; no `: Text` hint may appear on
1479+ // the pattern (which would come from B's same-named constructor).
1480+ let doc = ws.doc(&uri);
1481+ let pat_off = doc.source.find("Mk {x}").expect("pattern");
1482+ let pat_line = offset_to_position(&doc.source, pat_off).line;
1483+ let leaked = hints.iter().any(|h| {
1484+ h.position.line == pat_line
1485+ && matches!(&h.label, InlayHintLabel::String(s) if s == ": Text")
1486+ });
1487+ assert!(!leaked, "fields from a second same-named ctor leaked: {hints:?}");
1488+ }
1489+
1490+ /// Effects hint for ANNOTATED functions must anchor at the end of the
1491+ /// signature line — anchoring right after the name splits `name` and `:`.
1492+ #[test]
1493+ fn effects_hint_anchors_at_end_of_signature_line() {
1494+ let mut ws = TestWorkspace::new();
1495+ let src = "*t : [{a: Int}]\n\nf : Int -> IO {} [{a: Int}]\nf = \\x -> *t\n";
1496+ let uri = ws.open("main", src);
1497+ let doc = ws.doc(&uri);
1498+ let range = ws.whole_file_range(&uri);
1499+ let hints = handle_inlay_hint(&ws.state, &hint_params(&uri, range)).unwrap_or_default();
1500+ let sig_line_start = doc.source.find("f : Int").expect("sig");
1501+ let sig_line = offset_to_position(&doc.source, sig_line_start).line;
1502+ let sig_text_len = "f : Int -> IO {} [{a: Int}]".len() as u32;
1503+ for h in &hints {
1504+ if let InlayHintLabel::String(s) = &h.label {
1505+ if s.starts_with("-- effects:") {
1506+ assert_eq!(h.position.line, sig_line, "hint on wrong line: {h:?}");
1507+ assert_eq!(
1508+ h.position.character, sig_text_len,
1509+ "effects hint must sit at END of the signature line, \
1510+ not after the name: {h:?}"
1511+ );
1512+ return;
1513+ }
1514+ }
1515+ }
1516+ panic!(
1517+ "expected an `-- effects:` hint for the annotated function; \
1518+ effect_info: {:?}, hints: {hints:?}",
1519+ ws.doc(&uri).effect_info
1520+ );
1521+ }
13911522}
crates/knot-lsp/src/main.rs+73−3
@@ -1240,13 +1240,32 @@ fn handle_notification(state: &mut ServerState, conn: &Connection, not: Notifica
12401240 .collect();
12411241
12421242 if !changed_paths.is_empty() || !deleted_paths.is_empty() {
1243+ // Staleness propagates TRANSITIVELY through the import graph of
1244+ // open documents: when C changes on disk and open B imports C
1245+ // while open A imports B, A's analysis is just as stale as B's.
1246+ // Compute the affected-path closure over the open docs' direct
1247+ // import edges, then requeue every open doc whose imports touch
1248+ // that closure.
1249+ let open_imports: Vec<(PathBuf, Vec<PathBuf>)> = state
1250+ .documents
1251+ .iter()
1252+ .filter_map(|(uri, doc)| {
1253+ let path = uri_to_path(uri).and_then(|p| p.canonicalize().ok())?;
1254+ Some((path, doc.imported_files.keys().cloned().collect()))
1255+ })
1256+ .collect();
1257+ let seed: HashSet<PathBuf> = changed_paths
1258+ .iter()
1259+ .chain(deleted_paths.iter())
1260+ .cloned()
1261+ .collect();
1262+ let affected = transitively_affected_paths(&open_imports, &seed);
1263+
12431264 let dependents: Vec<(Uri, PathBuf, String)> = state
12441265 .documents
12451266 .iter()
12461267 .filter(|(_, doc)| {
1247− doc.imported_files
1248− .keys()
1249− .any(|p| changed_paths.contains(p) || deleted_paths.contains(p))
1268+ doc.imported_files.keys().any(|p| affected.contains(p))
12501269 })
12511270 .filter_map(|(uri, doc)| {
12521271 let path = uri_to_path(uri).and_then(|p| p.canonicalize().ok())?;
@@ -1538,6 +1557,34 @@ fn shift_byte_ranges_for_edit(
15381557/// hover freeze until the next edit.
15391558/// - `Disconnected`: the worker thread has died. Other features still work
15401559/// against the last good analysis, so log and continue rather than crash.
1560+/// Fixpoint closure of staleness over the open documents' import edges.
1561+/// `open_imports` maps each open doc's canonical path to its DIRECT imports;
1562+/// `seed` is the set of paths that changed (or were deleted) on disk. A doc
1563+/// becomes affected when any of its direct imports is affected — iterating
1564+/// until stable captures transitive chains (A imports B imports changed C).
1565+fn transitively_affected_paths(
1566+ open_imports: &[(PathBuf, Vec<PathBuf>)],
1567+ seed: &HashSet<PathBuf>,
1568+) -> HashSet<PathBuf> {
1569+ let mut affected: HashSet<PathBuf> = seed.clone();
1570+ loop {
1571+ let mut grew = false;
1572+ for (path, imports) in open_imports {
1573+ if affected.contains(path) {
1574+ continue;
1575+ }
1576+ if imports.iter().any(|p| affected.contains(p)) {
1577+ affected.insert(path.clone());
1578+ grew = true;
1579+ }
1580+ }
1581+ if !grew {
1582+ break;
1583+ }
1584+ }
1585+ affected
1586+}
1587+
15411588fn queue_analysis(state: &mut ServerState, uri: Uri, source: String, version: Option<i32>) {
15421589 use crossbeam_channel::TrySendError;
15431590 // Anything parked from an earlier overflow is superseded by the fresher
@@ -2161,5 +2208,28 @@ main = do
21612208 pubs
21622209 );
21632210 }
2211+
2212+ /// Watched-file staleness must propagate TRANSITIVELY through open
2213+ /// documents: C changes on disk; open B imports C; open A imports B —
2214+ /// A must be in the affected closure, not just B.
2215+ #[test]
2216+ fn transitively_affected_paths_walks_open_import_graph() {
2217+ let a = PathBuf::from("/w/a.knot");
2218+ let b = PathBuf::from("/w/b.knot");
2219+ let c = PathBuf::from("/w/c.knot");
2220+ let d = PathBuf::from("/w/unrelated.knot");
2221+ let open_imports = vec![
2222+ (a.clone(), vec![b.clone()]),
2223+ (b.clone(), vec![c.clone()]),
2224+ (d.clone(), vec![]),
2225+ ];
2226+ let mut seed = HashSet::new();
2227+ seed.insert(c.clone());
2228+ let affected = transitively_affected_paths(&open_imports, &seed);
2229+ assert!(affected.contains(&c), "seed retained");
2230+ assert!(affected.contains(&b), "direct importer affected");
2231+ assert!(affected.contains(&a), "TRANSITIVE importer affected");
2232+ assert!(!affected.contains(&d), "unrelated doc untouched");
2233+ }
21642234}
21652235
crates/knot-lsp/src/parsed_type.rs+19
@@ -516,6 +516,12 @@ impl<'a> Parser<'a> {
516516 let mut rest: Option<String> = None;
517517 loop {
518518 self.skip_ws();
519+ // Unterminated `IO {…` (truncated/malformed type string): EOF
520+ // before the closing `}`. Bail instead of spinning forever — no
521+ // arm below can make progress on a `None` peek.
522+ if self.peek().is_none() {
523+ return None;
524+ }
519525 if self.peek() == Some('}') {
520526 self.eat_char('}');
521527 break;
@@ -851,4 +857,17 @@ mod regress_fixes_tests {
851857 assert_eq!(ParsedType::parse("IO {fs} Text").render(), "IO {fs} Text");
852858 assert_eq!(ParsedType::parse("IO {} Text").render(), "IO {} Text");
853859 }
860+
861+ /// Unterminated `IO {…` (truncated type string) used to spin forever in
862+ /// `parse_effects_braces` — every loop iteration must consume input or
863+ /// bail at EOF. The assertion is simply that parse RETURNS.
864+ #[test]
865+ fn unterminated_io_effect_row_terminates() {
866+ let _ = ParsedType::parse("IO {");
867+ let _ = ParsedType::parse("IO {console");
868+ let _ = ParsedType::parse("IO {console, r *foo");
869+ let _ = ParsedType::parse("IO {|");
870+ let _ = ParsedType::parse("IO {| r");
871+ let _ = ParsedType::parse("Int -> IO {fs");
872+ }
854873}
crates/knot-lsp/src/rename.rs+170−9
@@ -81,7 +81,10 @@ pub(crate) fn handle_prepare_rename(
8181 // Return the word range
8282 let word_offset = position_to_offset(&doc.source, pos);
8383 let bytes = doc.source.as_bytes();
84− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
84+ // `'` continues identifiers in the lexer (`x'`), so it's part of the
85+ // rename range too — otherwise renaming `x'` edits only `x` and leaves
86+ // a stray prime behind.
87+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
8588 let start = (0..word_offset)
8689 .rev()
8790 .find(|&i| !is_ident(bytes[i]))
@@ -99,19 +102,25 @@ pub(crate) fn handle_prepare_rename(
99102}
100103
101104/// Validate that `name` is a syntactically valid Knot identifier:
102−/// starts with letter or underscore, contains only ident chars, and isn't a
103−/// reserved keyword. Used by `handle_rename` to reject malformed renames
105+/// starts with an ASCII letter or underscore, continues with ASCII
106+/// alphanumerics, `_`, or `'`, and isn't a reserved keyword. This matches
107+/// the lexer's `is_ident_continue` rules exactly — the lexer is ASCII-only,
108+/// so a Unicode-letter name like `naïve` would lex as garbage and corrupt
109+/// every edited file. Used by `handle_rename` to reject malformed renames
104110/// before scanning the workspace.
105111fn is_valid_identifier(name: &str) -> bool {
106− let mut chars = name.chars();
107− let first = match chars.next() {
108− Some(c) => c,
112+ let bytes = name.as_bytes();
113+ let first = match bytes.first() {
114+ Some(b) => *b,
109115 None => return false,
110116 };
111− if !(first.is_alphabetic() || first == '_') {
117+ if !(first.is_ascii_alphabetic() || first == b'_') {
112118 return false;
113119 }
114− if !chars.all(|c| c.is_alphanumeric() || c == '_') {
120+ if !bytes[1..]
121+ .iter()
122+ .all(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'\'')
123+ {
115124 return false;
116125 }
117126 !KEYWORDS.iter().any(|kw| *kw == name)
@@ -318,7 +327,7 @@ fn name_span_within(source: &str, decl_span: Span, name: &str) -> Option<Span> {
318327 if needle.is_empty() {
319328 return None;
320329 }
321− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
330+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
322331 let mut i = 0;
323332 while i + needle.len() <= bytes.len() {
324333 if &bytes[i..i + needle.len()] == needle {
@@ -2372,4 +2381,156 @@ mod regress_rename_fixes_tests {
23722381 "the importer's type annotation must be rewritten; edits: {consumer_edits:?}"
23732382 );
23742383 }
2384+
2385+ // ── Body-line definition token of typed functions ────────────────
2386+ //
2387+ // The parser merges `f : T` ⏎ `f = body` into ONE DeclKind::Fun. Rename
2388+ // used to edit only the FIRST whole-word occurrence (the signature line)
2389+ // plus references, leaving the body-line `f` behind — silent corruption.
2390+
2391+ #[test]
2392+ fn rename_typed_function_edits_both_definition_lines() {
2393+ let mut ws = TestWorkspace::new();
2394+ let uri = ws.open(
2395+ "main",
2396+ "double : Int -> Int\ndouble = \\x -> x * 2\nmain = double 2\n",
2397+ );
2398+ let doc = ws.doc(&uri);
2399+ let off = doc.source.find("double").expect("sig line");
2400+ let pos = offset_to_position(&doc.source, off);
2401+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, "triple"))
2402+ .expect("rename emits edit");
2403+ let changes = edit.changes.expect("changes present");
2404+ let edits = changes.get(&uri).expect("edits for main");
2405+ let out = apply_edits(&doc.source, edits);
2406+ assert_eq!(
2407+ out,
2408+ "triple : Int -> Int\ntriple = \\x -> x * 2\nmain = triple 2\n",
2409+ "the body-line definition token must be renamed too; edits: {edits:?}"
2410+ );
2411+ }
2412+
2413+ #[test]
2414+ fn rename_typed_function_initiated_from_body_line_token() {
2415+ let mut ws = TestWorkspace::new();
2416+ let uri = ws.open(
2417+ "main",
2418+ "double : Int -> Int\ndouble = \\x -> x * 2\nmain = double 2\n",
2419+ );
2420+ let doc = ws.doc(&uri);
2421+ // Cursor on the BODY-line `double` (second occurrence).
2422+ let off = doc.source.find("double =").expect("body line");
2423+ let pos = offset_to_position(&doc.source, off);
2424+ let prep = handle_prepare_rename(&ws.state, &prepare_params(&uri, pos));
2425+ assert!(prep.is_some(), "prepare must accept the body-line token");
2426+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, "triple"))
2427+ .expect("rename emits edit");
2428+ let edits = edit.changes.expect("changes").remove(&uri).expect("edits");
2429+ let out = apply_edits(&doc.source, &edits);
2430+ assert_eq!(
2431+ out,
2432+ "triple : Int -> Int\ntriple = \\x -> x * 2\nmain = triple 2\n"
2433+ );
2434+ }
2435+
2436+ #[test]
2437+ fn rename_typed_function_in_unopened_disk_file() {
2438+ use crate::test_support::TempWorkspace;
2439+ let mut tw = TempWorkspace::new();
2440+ // Owner exists ONLY on disk — never opened — and has a separate
2441+ // type signature, exercising `apply_owner_disk_edits`.
2442+ let owner_src = "parse : Int -> Int\nparse = \\x -> x\n";
2443+ std::fs::write(tw.root.join("owner.knot"), owner_src).expect("write owner");
2444+ let consumer_uri = tw.write_and_open(
2445+ "consumer.knot",
2446+ "import ./owner\n\nmain = parse 5\n",
2447+ );
2448+ let consumer_doc = tw.workspace.doc(&consumer_uri);
2449+ let off = consumer_doc.source.find("parse 5").expect("call site");
2450+ let pos = offset_to_position(&consumer_doc.source, off);
2451+ let edit = handle_rename(
2452+ &tw.workspace.state,
2453+ &rename_params(&consumer_uri, pos, "parsed"),
2454+ )
2455+ .expect("rename emits edit");
2456+ let changes = edit.changes.expect("changes present");
2457+ let owner_uri_entry = changes
2458+ .iter()
2459+ .find(|(u, _)| u.as_str().contains("owner.knot"))
2460+ .expect("owner file must be edited");
2461+ let out = apply_edits(owner_src, owner_uri_entry.1);
2462+ assert_eq!(
2463+ out, "parsed : Int -> Int\nparsed = \\x -> x\n",
2464+ "disk-path rename must edit BOTH definition lines; edits: {:?}",
2465+ owner_uri_entry.1
2466+ );
2467+ }
2468+
2469+ // ── Primed identifiers (`x'`) ────────────────────────────────────
2470+
2471+ #[test]
2472+ fn rename_primed_identifier_covers_whole_token() {
2473+ let mut ws = TestWorkspace::new();
2474+ let uri = ws.open("main", "f = \\x' -> x' + 1\n");
2475+ let doc = ws.doc(&uri);
2476+ let off = doc.source.find("x' +").expect("usage");
2477+ let pos = offset_to_position(&doc.source, off);
2478+ let prep = handle_prepare_rename(&ws.state, &prepare_params(&uri, pos))
2479+ .expect("prepare accepts primed identifier");
2480+ match prep {
2481+ PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. } => {
2482+ assert_eq!(placeholder, "x'", "placeholder must include the prime");
2483+ }
2484+ other => panic!("unexpected response: {other:?}"),
2485+ }
2486+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, "y'"))
2487+ .expect("rename emits edit");
2488+ let edits = edit.changes.expect("changes").remove(&uri).expect("edits");
2489+ let out = apply_edits(&doc.source, &edits);
2490+ assert_eq!(
2491+ out, "f = \\y' -> y' + 1\n",
2492+ "renaming `x'` must not leave stray primes; edits: {edits:?}"
2493+ );
2494+ }
2495+
2496+ // ── New-name validation must match the (ASCII-only) lexer ────────
2497+
2498+ #[test]
2499+ fn rename_rejects_non_ascii_new_name() {
2500+ let mut ws = TestWorkspace::new();
2501+ let uri = ws.open("main", "double = \\x -> x * 2\n");
2502+ let doc = ws.doc(&uri);
2503+ let off = doc.source.find("double").expect("def");
2504+ let pos = offset_to_position(&doc.source, off);
2505+ // `naïve` passes Unicode is_alphabetic but the lexer is ASCII-only —
2506+ // accepting it would corrupt every edited file.
2507+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, "naïve"));
2508+ assert!(edit.is_none(), "non-ASCII name must be rejected: {edit:?}");
2509+ }
2510+
2511+ #[test]
2512+ fn rename_rejects_all_lexer_keywords() {
2513+ let mut ws = TestWorkspace::new();
2514+ let uri = ws.open("main", "double = \\x -> x * 2\n");
2515+ let doc = ws.doc(&uri);
2516+ let off = doc.source.find("double").expect("def");
2517+ let pos = offset_to_position(&doc.source, off);
2518+ // Previously missing from KEYWORDS: serve/unit/refine/forall/true/false.
2519+ for kw in ["serve", "unit", "refine", "forall", "true", "false"] {
2520+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, kw));
2521+ assert!(edit.is_none(), "rename to keyword `{kw}` must be rejected");
2522+ }
2523+ }
2524+
2525+ #[test]
2526+ fn rename_accepts_primed_new_name() {
2527+ let mut ws = TestWorkspace::new();
2528+ let uri = ws.open("main", "double = \\x -> x * 2\nmain = double 1\n");
2529+ let doc = ws.doc(&uri);
2530+ let off = doc.source.find("double").expect("def");
2531+ let pos = offset_to_position(&doc.source, off);
2532+ // `'` is a legal identifier-continue char in the lexer.
2533+ let edit = handle_rename(&ws.state, &rename_params(&uri, pos, "double'"));
2534+ assert!(edit.is_some(), "primed new name must be accepted");
2535+ }
23752536}
crates/knot-lsp/src/semantic_tokens.rs+35−2
@@ -734,8 +734,13 @@ fn delta_encode_tokens(tokens: &[RawToken], source: &str) -> Vec<SemanticToken>
734734 byte_cursor += 1;
735735 line_start_byte = byte_cursor;
736736 col_utf16 = 0;
737− } else if bytes[byte_cursor] == b'\r' {
738− // Skip \r in CRLF — it doesn't contribute a UTF-16 column.
737+ } else if bytes[byte_cursor] == b'\r'
738+ && bytes.get(byte_cursor + 1) == Some(&b'\n')
739+ {
740+ // Skip the \r of a CRLF pair — it's part of the line break
741+ // and doesn't contribute a UTF-16 column. A stray mid-line
742+ // \r is an ordinary character and must count as a column,
743+ // matching `utils::offset_to_position`.
739744 byte_cursor += 1;
740745 } else {
741746 let mut next = byte_cursor + 1;
@@ -840,4 +845,32 @@ mod regress_fixes_tests {
840845 assert_eq!(tokens[1].start, 5);
841846 assert_eq!(tokens[1].length, 3);
842847 }
848+
849+ /// A stray mid-line `\r` (not part of CRLF) is an ordinary character:
850+ /// `delta_encode_tokens` must count it as a column, exactly like
851+ /// `utils::offset_to_position` does — otherwise token columns drift by
852+ /// one for every stray \r earlier in the line.
853+ #[test]
854+ fn delta_encode_counts_stray_cr_as_column() {
855+ let source = "ab\rcd\r\nxy\n";
856+ // Token on `cd` (byte 3..5) and on `xy` (byte 7..9).
857+ let tokens = vec![
858+ RawToken { start: 3, length: 2, token_type: 0, modifiers: 0 },
859+ RawToken { start: 7, length: 2, token_type: 0, modifiers: 0 },
860+ ];
861+ let encoded = delta_encode_tokens(&tokens, source);
862+ assert_eq!(encoded.len(), 2);
863+ // `cd` starts at UTF-16 column 3 (a, b, stray \r all count) —
864+ // matching offset_to_position.
865+ let expected = crate::utils::offset_to_position(source, 3);
866+ assert_eq!(encoded[0].delta_line, expected.line);
867+ assert_eq!(
868+ encoded[0].delta_start, expected.character,
869+ "stray \\r must count as a column"
870+ );
871+ // `xy` is on the next line, column 0 — the CRLF \r is still part of
872+ // the line break and contributes nothing.
873+ assert_eq!(encoded[1].delta_line, 1);
874+ assert_eq!(encoded[1].delta_start, 0);
875+ }
843876}
crates/knot-lsp/src/shared.rs+1−1
@@ -50,7 +50,7 @@ pub(crate) fn render_signature_with_effects(inferred: &str, effects: &EffectSet)
5050/// outermost row is the FIRST). Among depth-0 rows, the last along the arrow
5151/// spine (`IO {a} Int -> IO {b} Text`) is the result row. Returns `None`
5252/// when no top-level IO row is present.
53−fn find_outermost_io_row(ty: &str) -> Option<(usize, usize)> {
53+pub(crate) fn find_outermost_io_row(ty: &str) -> Option<(usize, usize)> {
5454 let bytes = ty.as_bytes();
5555 let mut last: Option<(usize, usize)> = None;
5656 let mut depth: i32 = 0;
crates/knot-lsp/src/state.rs+8−3
@@ -540,10 +540,15 @@ impl ServerConfig {
540540
541541// ── Completion / token grab-bag constants ───────────────────────────
542542
543+/// Reserved words that can never be user identifiers. Mirrors the lexer's
544+/// keyword table in `knot::lexer::lex_identifier` exactly (plus the
545+/// contextual `yield`, which the parser treats specially inside do-blocks):
546+/// `true`/`false` lex as `Bool` literals and are included because renaming a
547+/// symbol *to* them would re-lex as a literal.
543548pub const KEYWORDS: &[&str] = &[
544− "import", "data", "type", "trait", "impl", "route", "migrate", "where", "do", "yield",
545− "if", "then", "else", "case", "of", "let", "in", "not", "replace", "atomic", "deriving", "with",
546− "export",
549+ "import", "data", "type", "trait", "impl", "route", "serve", "migrate", "where", "do",
550+ "yield", "if", "then", "else", "case", "of", "let", "in", "not", "replace", "atomic",
551+ "deriving", "with", "export", "unit", "refine", "forall", "true", "false",
547552];
548553
549554/// Context tag for a snippet — used by `handle_completion` to filter snippets
crates/knot-lsp/src/utils.rs+17−4
@@ -94,7 +94,10 @@ pub fn word_span_at_offset(source: &str, offset: usize) -> Option<Span> {
9494
9595fn word_bounds_at_offset(source: &str, offset: usize) -> Option<(usize, usize)> {
9696 let bytes = source.as_bytes();
97− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
97+ // `'` is an identifier-continue character in the lexer (`x'` is one
98+ // identifier), so word boundaries must treat it the same — otherwise
99+ // rename/hover on `x'` resolve only `x` and corrupt primed identifiers.
100+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
98101
99102 // The caret immediately after the last char of an identifier (the
100103 // standard post-typing cursor position) should still resolve that
@@ -109,16 +112,24 @@ fn word_bounds_at_offset(source: &str, offset: usize) -> Option<(usize, usize)>
109112 return None;
110113 };
111114
112− let start = (0..offset)
115+ let mut start = (0..offset)
113116 .rev()
114117 .find(|&i| !is_ident(bytes[i]))
115118 .map(|i| i + 1)
116119 .unwrap_or(0);
120+ // `'` cannot START an identifier — skip any leading primes (e.g. when
121+ // the scan landed inside a string literal like "don't").
122+ while start < bytes.len() && bytes[start] == b'\'' && start <= offset {
123+ start += 1;
124+ }
117125
118− let end = (offset..bytes.len())
126+ let end = (offset.max(start)..bytes.len())
119127 .find(|&i| !is_ident(bytes[i]))
120128 .unwrap_or(bytes.len());
121129
130+ if start >= end {
131+ return None;
132+ }
122133 Some((start, end))
123134}
124135
@@ -253,7 +264,9 @@ pub fn find_word_in_source(source: &str, name: &str, start: usize, end: usize) -
253264 let end = end.min(source.len());
254265 let text = source.get(start..end)?;
255266 let bytes = source.as_bytes();
256− let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
267+ // `'` continues identifiers in the lexer (`x'`), so it's a word char
268+ // here too — `x` must not whole-word-match the prefix of `x'`.
269+ let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'';
257270
258271 let mut search_start = 0;
259272 while search_start <= text.len() {
crates/knot-runtime/Cargo.toml+1
@@ -18,6 +18,7 @@ rusqlite = { workspace = true }
1818tiny_http = { workspace = true }
1919ratatui = "0.30"
2020crossterm = "0.29"
21+unicode-width = "0.2"
2122getrandom = "0.4"
2223ed25519-dalek = { version = "2", default-features = false }
2324x25519-dalek = { version = "2", features = ["static_secrets"] }
crates/knot-runtime/src/lib.rs+858−121
@@ -1229,6 +1229,11 @@ static ACTIVE_FORKS_CVAR: Condvar = Condvar::new();
12291229pub struct CancelToken {
12301230 flag: AtomicBool,
12311231 notify: (Mutex<()>, Condvar),
1232+ /// `WakeSlot` registered by `knot_stm_wait` while this thread is parked
1233+ /// in an STM retry, so `cancel()` can cut that park short too — without
1234+ /// this, a cancelled `race` loser blocked in `retry` would sleep out the
1235+ /// full `STM_WAIT_TIMEOUT` and stall `knot_threads_join` at program exit.
1236+ stm_slot: Mutex<Option<Weak<WakeSlot>>>,
12321237}
12331238
12341239impl CancelToken {
@@ -1236,6 +1241,7 @@ impl CancelToken {
12361241 Self {
12371242 flag: AtomicBool::new(false),
12381243 notify: (Mutex::new(()), Condvar::new()),
1244+ stm_slot: Mutex::new(None),
12391245 }
12401246 }
12411247
@@ -1248,9 +1254,30 @@ impl CancelToken {
12481254 /// just checked the flag and is about to wait cannot miss the
12491255 /// wake-up.
12501256 fn cancel(&self) {
1251− let _g = self.notify.0.lock().unwrap();
1252− self.flag.store(true, Ordering::Release);
1253− self.notify.1.notify_all();
1257+ {
1258+ let _g = self.notify.0.lock().unwrap();
1259+ self.flag.store(true, Ordering::Release);
1260+ self.notify.1.notify_all();
1261+ }
1262+ // Also wake an STM wait slot if the thread is parked in retry.
1263+ // Race-free pairing with `knot_stm_wait`: the waiter registers its
1264+ // slot here BEFORE checking `is_cancelled()` — so either we see the
1265+ // registered slot (and wake it), or the waiter's check sees the flag
1266+ // (the `stm_slot` mutex orders the two paths).
1267+ let slot = self.stm_slot.lock().unwrap().take();
1268+ if let Some(slot) = slot.and_then(|w| w.upgrade()) {
1269+ slot.wake();
1270+ }
1271+ }
1272+
1273+ /// Register the current STM wake slot so `cancel()` can wake it.
1274+ fn set_stm_wake_slot(&self, slot: &Arc<WakeSlot>) {
1275+ *self.stm_slot.lock().unwrap() = Some(Arc::downgrade(slot));
1276+ }
1277+
1278+ /// Clear the registered STM wake slot after the wait completes.
1279+ fn clear_stm_wake_slot(&self) {
1280+ *self.stm_slot.lock().unwrap() = None;
12541281 }
12551282
12561283 /// Sleep for `duration`, returning early as soon as `cancel()` is
@@ -1674,7 +1701,22 @@ fn sql_partial_cmp(a: &SqlVal, b: &SqlVal) -> Option<std::cmp::Ordering> {
16741701 (SqlVal::Real(x), SqlVal::Real(y)) => x.partial_cmp(y),
16751702 (SqlVal::Int(x), SqlVal::Real(y)) => (*x as f64).partial_cmp(y),
16761703 (SqlVal::Real(x), SqlVal::Int(y)) => x.partial_cmp(&(*y as f64)),
1677− (SqlVal::Text(x), SqlVal::Text(y)) => Some(x.cmp(y)),
1704+ (SqlVal::Text(x), SqlVal::Text(y)) => {
1705+ // Int columns are stored as TEXT (`SqlVal::from_knot` serializes
1706+ // Int as Text), so numeric texts must compare numerically —
1707+ // "5" < "10" — not lexicographically. Mirrors `SqlVal::to_key`'s
1708+ // i64-parse collapse: i64 first, then f64; plain string order
1709+ // only when NEITHER side parses as a number. Mixed numeric /
1710+ // non-numeric is unknown → None (callers wake conservatively).
1711+ match (x.parse::<i64>(), y.parse::<i64>()) {
1712+ (Ok(a), Ok(b)) => Some(a.cmp(&b)),
1713+ _ => match (x.parse::<f64>(), y.parse::<f64>()) {
1714+ (Ok(a), Ok(b)) => a.partial_cmp(&b),
1715+ (Err(_), Err(_)) => Some(x.cmp(y)),
1716+ _ => None,
1717+ },
1718+ }
1719+ }
16781720 // BigInts are stored as TEXT in SQLite — handle Int ↔ Text via numeric parse.
16791721 (SqlVal::Int(x), SqlVal::Text(y)) => y.parse::<i64>().ok().map(|n| x.cmp(&n)),
16801722 (SqlVal::Text(x), SqlVal::Int(y)) => x.parse::<i64>().ok().map(|n| n.cmp(y)),
@@ -2402,12 +2444,19 @@ fn wait_for_any_write(timeout: Duration) {
24022444fn bump_table_version(name: &str) {
24032445 if let Some(v) = TABLE_VERSIONS.get(name) {
24042446 v.fetch_add(1, Ordering::Release);
2405− return;
2447+ } else {
2448+ TABLE_VERSIONS
2449+ .entry(name.to_string())
2450+ .or_insert_with(|| Arc::new(AtomicU64::new(0)))
2451+ .fetch_add(1, Ordering::Release);
24062452 }
2407− TABLE_VERSIONS
2408− .entry(name.to_string())
2409− .or_insert_with(|| Arc::new(AtomicU64::new(0)))
2410− .fetch_add(1, Ordering::Release);
2453+ // TOCTOU guard (Dekker pattern), paired with the fence in
2454+ // `knot_stm_wait`: order the version bump before this writer's
2455+ // subsequent reads of watcher-registration state (`table_has_watchers`,
2456+ // `TABLE_FILTER_COLS` in `wake_matching_watchers`, the watcher indices).
2457+ // A watcher registering concurrently either gets seen by those reads or
2458+ // observes this bump in its post-registration re-check.
2459+ std::sync::atomic::fence(Ordering::SeqCst);
24112460}
24122461
24132462/// Walk the broad watcher list for `name`, then for `WriteEvent::Rows` the EQ
@@ -2424,6 +2473,23 @@ fn bump_table_version(name: &str) {
24242473/// Reads only — dead `Weak`s linger here and are reclaimed by `sweep_dead_watchers`
24252474/// on a counter trip via `maybe_sweep_dead_watchers`.
24262475fn wake_matching_watchers(name: &str, event: &WriteEvent) {
2476+ // TOCTOU guard: a `Rows` payload was projected to the filter-column
2477+ // union read at WRITE time (`filter_cols_for`); a watcher that
2478+ // registered a filter on a NEW column between that projection and this
2479+ // notify would never be probed — indexed slots are only discovered via
2480+ // payload columns, and `row_matches_preds_indexed`'s missing-column
2481+ // fallback only helps slots that get probed at all. Re-read the current
2482+ // filter-column set here: if it references a column absent from the
2483+ // payload, fall back to a conservative `Bulk` wake. Paired with the
2484+ // SeqCst fences in `bump_table_version` (runs just before this on every
2485+ // notify path) and `knot_stm_wait` (between registration and the version
2486+ // re-check), at least one side always observes the other.
2487+ let event: &WriteEvent = match event {
2488+ WriteEvent::Rows(rows) if filter_cols_missing_from_payload(name, rows) => {
2489+ &WriteEvent::Bulk
2490+ }
2491+ e => e,
2492+ };
24272493 // 1) Broad watchers — All filters and non-indexable Cols.
24282494 if let Some(slots) = TABLE_WATCHERS.get(name) {
24292495 for weak in slots.iter() {
@@ -2472,6 +2538,17 @@ fn wake_matching_watchers(name: &str, event: &WriteEvent) {
24722538 maybe_sweep_dead_watchers();
24732539}
24742540
2541+/// True if any column currently registered in `TABLE_FILTER_COLS` for
2542+/// `table` is missing from the event payload — i.e. the payload's projection
2543+/// is stale relative to the live watcher set and per-row filtering can no
2544+/// longer be trusted. See the TOCTOU comment in `wake_matching_watchers`.
2545+fn filter_cols_missing_from_payload(table: &str, rows: &EventRows) -> bool {
2546+ match TABLE_FILTER_COLS.get(table) {
2547+ Some(state) => state.cols.keys().any(|c| rows.col_index(c).is_none()),
2548+ None => false,
2549+ }
2550+}
2551+
24752552/// Conservatively wake every EQ- and Range-indexed watcher registered for
24762553/// `table`. Used for `WriteEvent::Bulk` events, which carry no row payload:
24772554/// any value may have changed, so every indexed predicate may now hold.
@@ -3119,8 +3196,10 @@ pub extern "C-unwind" fn knot_override_lookup(
31193196 };
31203197
31213198 if wrap_maybe {
3122− let tag = intern_str("Just");
3123− alloc(Value::Constructor(tag, inner))
3199+ // `Just`'s payload is always the record `{value: ...}` — a bare
3200+ // scalar payload would not match the shape pattern-matching and
3201+ // `unwrap_maybe` expect.
3202+ make_just(inner)
31243203 } else {
31253204 inner
31263205 }
@@ -4789,17 +4868,63 @@ fn infer_temp_schema(rows: &[*mut Value]) -> Option<TempSchema> {
47894868 return Some(TempSchema::Scalar(ColType::Text));
47904869 }
47914870 match unsafe { as_ref(first) } {
4792− Value::Record(fields) => {
4793− let mut cols = Vec::with_capacity(fields.len());
4794− for f in fields {
4795− if !f.value.is_null() {
4871+ Value::Record(first_fields) => {
4872+ // Scan ALL rows, not just the first: a first-row-only guess
4873+ // corrupts data when later rows disagree — e.g. a Maybe field
4874+ // whose first row is `Nothing` (nullary → Tag column) silently
4875+ // drops the payload of a later `Just {value: ...}` row, and a
4876+ // first-row NULL types the column Text, mis-reading later Ints.
4877+ // Any inconsistency (mixed nullary/payload constructors, NULL vs
4878+ // typed, conflicting scalar types, differing field sets) returns
4879+ // None so callers take the safe in-memory fallback.
4880+ let names: Vec<Arc<str>> = first_fields.iter().map(|f| f.name.clone()).collect();
4881+ // Per-field type, refined across rows. `None` = only NULLs so far.
4882+ let mut types: Vec<Option<ColType>> = vec![None; names.len()];
4883+ let mut saw_null: Vec<bool> = vec![false; names.len()];
4884+ for row in rows {
4885+ if row.is_null() {
4886+ return None;
4887+ }
4888+ let fields = match unsafe { as_ref(*row) } {
4889+ Value::Record(fields) => fields,
4890+ _ => return None,
4891+ };
4892+ if fields.len() != names.len() {
4893+ return None;
4894+ }
4895+ for (i, f) in fields.iter().enumerate() {
4896+ if f.name != names[i] {
4897+ return None;
4898+ }
4899+ if f.value.is_null() {
4900+ saw_null[i] = true;
4901+ continue;
4902+ }
47964903 match unsafe { as_ref(f.value) } {
47974904 Value::Relation(_) | Value::Function(_) => return None,
47984905 _ => {}
47994906 }
4907+ let ty = infer_col_type(f.value)?;
4908+ match types[i] {
4909+ None => types[i] = Some(ty),
4910+ Some(prev) if prev == ty => {}
4911+ Some(_) => return None,
4912+ }
48004913 }
4801− let ty = infer_col_type(f.value)?;
4802− cols.push((f.name.to_string(), ty));
4914+ }
4915+ let mut cols = Vec::with_capacity(names.len());
4916+ for (i, name) in names.iter().enumerate() {
4917+ let ty = match types[i] {
4918+ // NULL and typed values mixed in one column: SQLite
4919+ // would conflate "no value" with the column's affinity
4920+ // games — bail to the in-memory path.
4921+ Some(_) if saw_null[i] => return None,
4922+ Some(ty) => ty,
4923+ // Every row NULL: Text (matches `infer_col_type`'s
4924+ // historical null handling).
4925+ None => ColType::Text,
4926+ };
4927+ cols.push((name.to_string(), ty));
48034928 }
48044929 Some(TempSchema::Record(cols))
48054930 }
@@ -5367,16 +5492,20 @@ pub extern "C-unwind" fn knot_relation_singleton(v: *mut Value) -> *mut Value {
53675492}
53685493
53695494/// Unwrap a scalar source relation: extract the `_value` field from the first row.
5370−/// Returns a default (Int 0) if the relation is empty.
5495+/// Panics if the relation is empty — a scalar source must hold exactly one
5496+/// row, so an empty backing table means the database is corrupted (returning
5497+/// a wrong-typed default like Int 0 would silently corrupt downstream data).
53715498#[unsafe(no_mangle)]
53725499pub extern "C-unwind" fn knot_scalar_source_unwrap(rel: *mut Value) -> *mut Value {
53735500 match unsafe { as_ref(rel) } {
53745501 Value::Relation(rows) => {
53755502 if rows.is_empty() {
5376− alloc_int(0)
5377− } else {
5378− knot_record_field(rows[0], "_value".as_ptr(), 6)
5503+ panic!(
5504+ "knot runtime: scalar source has no row — database corrupted? \
5505+ (expected exactly one row holding the scalar value)"
5506+ );
53795507 }
5508+ knot_record_field(rows[0], "_value".as_ptr(), 6)
53805509 }
53815510 _ => rel,
53825511 }
@@ -6160,11 +6289,19 @@ pub extern "C-unwind" fn knot_value_neq_i32(a: *mut Value, b: *mut Value) -> i32
61606289 !values_equal(a, b) as i32
61616290}
61626291
6292+/// Diagnostic for null operands in ordered comparisons. Shared by the
6293+/// `<`/`>` paths (via `compare_lt`/`compare_gt`) and the `<=`/`>=` wrappers,
6294+/// which must short-circuit BEFORE the negated compare call (a silent
6295+/// `!compare_gt(null, ..)` would otherwise return a wrong `true`).
6296+fn warn_null_comparison(a: *mut Value, b: *mut Value) {
6297+ eprintln!("knot runtime: comparison with null value (a={}, b={})",
6298+ if a.is_null() { "null".to_string() } else { brief_value(a) },
6299+ if b.is_null() { "null".to_string() } else { brief_value(b) });
6300+}
6301+
61636302fn compare_lt(a: *mut Value, b: *mut Value) -> bool {
61646303 if a.is_null() || b.is_null() {
6165− eprintln!("knot runtime: comparison with null value (a={}, b={})",
6166− if a.is_null() { "null".to_string() } else { brief_value(a) },
6167− if b.is_null() { "null".to_string() } else { brief_value(b) });
6304+ warn_null_comparison(a, b);
61686305 return false;
61696306 }
61706307 let av = unsafe { as_ref(a) };
@@ -6183,9 +6320,7 @@ fn compare_lt(a: *mut Value, b: *mut Value) -> bool {
61836320
61846321fn compare_gt(a: *mut Value, b: *mut Value) -> bool {
61856322 if a.is_null() || b.is_null() {
6186− eprintln!("knot runtime: comparison with null value (a={}, b={})",
6187− if a.is_null() { "null".to_string() } else { brief_value(a) },
6188− if b.is_null() { "null".to_string() } else { brief_value(b) });
6323+ warn_null_comparison(a, b);
61896324 return false;
61906325 }
61916326 let av = unsafe { as_ref(a) };
@@ -6216,13 +6351,19 @@ pub extern "C-unwind" fn knot_value_gt(a: *mut Value, b: *mut Value) -> *mut Val
62166351
62176352#[unsafe(no_mangle)]
62186353pub extern "C-unwind" fn knot_value_le(a: *mut Value, b: *mut Value) -> *mut Value {
6219− if a.is_null() || b.is_null() { return alloc_bool(false); }
6354+ if a.is_null() || b.is_null() {
6355+ warn_null_comparison(a, b);
6356+ return alloc_bool(false);
6357+ }
62206358 alloc_bool(!compare_gt(a, b))
62216359}
62226360
62236361#[unsafe(no_mangle)]
62246362pub extern "C-unwind" fn knot_value_ge(a: *mut Value, b: *mut Value) -> *mut Value {
6225− if a.is_null() || b.is_null() { return alloc_bool(false); }
6363+ if a.is_null() || b.is_null() {
6364+ warn_null_comparison(a, b);
6365+ return alloc_bool(false);
6366+ }
62266367 alloc_bool(!compare_lt(a, b))
62276368}
62286369
@@ -6240,13 +6381,19 @@ pub extern "C-unwind" fn knot_value_gt_i32(a: *mut Value, b: *mut Value) -> i32
62406381
62416382#[unsafe(no_mangle)]
62426383pub extern "C-unwind" fn knot_value_le_i32(a: *mut Value, b: *mut Value) -> i32 {
6243− if a.is_null() || b.is_null() { return 0; }
6384+ if a.is_null() || b.is_null() {
6385+ warn_null_comparison(a, b);
6386+ return 0;
6387+ }
62446388 !compare_gt(a, b) as i32
62456389}
62466390
62476391#[unsafe(no_mangle)]
62486392pub extern "C-unwind" fn knot_value_ge_i32(a: *mut Value, b: *mut Value) -> i32 {
6249− if a.is_null() || b.is_null() { return 0; }
6393+ if a.is_null() || b.is_null() {
6394+ warn_null_comparison(a, b);
6395+ return 0;
6396+ }
62506397 !compare_lt(a, b) as i32
62516398}
62526399
@@ -6980,8 +7127,17 @@ pub extern "C-unwind" fn knot_fork_io(io_val: *mut Value) -> *mut Value {
69807127 }
69817128 let _guard = CleanupGuard { db, io };
69827129
6983− // Run the IO action
6984− knot_io_run(db, io);
7130+ // Run the IO action under catch_unwind so a panicking body (e.g.
7131+ // inside an `atomic` block, which holds the global write lock
7132+ // across begin/commit) releases the write lock instead of leaking
7133+ // it and deadlocking every other writer in the process. The panic
7134+ // hook has already reported the panic; the thread then exits.
7135+ let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7136+ knot_io_run(db, io);
7137+ }));
7138+ if run.is_err() {
7139+ write_lock_force_release();
7140+ }
69857141 });
69867142
69877143 alloc(Value::Unit)
@@ -7097,6 +7253,16 @@ pub extern "C-unwind" fn knot_race_io(io_a: *mut Value, io_b: *mut Value) -> *mu
70977253 let result = match run {
70987254 Ok(v) => v,
70997255 Err(payload) => {
7256+ // Release any write locks the unwinding body may have
7257+ // held so other threads aren't deadlocked (no-op when
7258+ // none are held). This MUST run for `Cancelled`
7259+ // unwinds too: a loser cancelled inside an `atomic`
7260+ // block still holds the global write lock acquired by
7261+ // `knot_atomic_begin` (the guard was `mem::forget`-en)
7262+ // — skipping the release would deadlock every writer
7263+ // in the process. The connection (and its open
7264+ // savepoints) is discarded by `CleanupGuard` below.
7265+ write_lock_force_release();
71007266 // Cooperative cancellation surfaces as a `Cancelled`
71017267 // panic raised by `knot_io_run`. It is not an error:
71027268 // the peer already produced the outcome, so this side
@@ -7104,10 +7270,6 @@ pub extern "C-unwind" fn knot_race_io(io_a: *mut Value, io_b: *mut Value) -> *mu
71047270 if payload.downcast_ref::<Cancelled>().is_some() {
71057271 return;
71067272 }
7107− // Release any write locks the panicking body may have
7108− // held so other threads aren't deadlocked (no-op when
7109− // none are held).
7110− write_lock_force_release();
71117273 let msg = if let Some(s) = payload.downcast_ref::<&str>() {
71127274 s.to_string()
71137275 } else if let Some(s) = payload.downcast_ref::<String>() {
@@ -7466,13 +7628,37 @@ pub extern "C-unwind" fn knot_stm_wait(_snapshot: i64) {
74667628 bucket.entry(threshold.to_key()).or_default().push(weak);
74677629 }
74687630
7631+ // Cancellation-awareness: register the slot with this thread's
7632+ // CancelToken (if it is a `race` worker) BEFORE checking `is_cancelled`
7633+ // below — a `cancel()` arriving at any point either finds the registered
7634+ // slot (and wakes it) or is observed by the check; no ordering window
7635+ // loses the wakeup.
7636+ let cancel_token = current_cancel_token();
7637+ if let Some(tok) = &cancel_token {
7638+ tok.set_stm_wake_slot(&slot);
7639+ }
7640+
7641+ // TOCTOU guard (Dekker pattern), paired with the fence in
7642+ // `bump_table_version`: order this thread's registration stores
7643+ // (watcher counts, TABLE_FILTER_COLS, watcher indices) before the
7644+ // version re-check load below. Together the fences guarantee that a
7645+ // concurrent writer either sees our registration when it projects /
7646+ // notifies, or we see its version bump here — a wakeup cannot be lost
7647+ // in both directions.
7648+ std::sync::atomic::fence(Ordering::SeqCst);
7649+
74697650 // Re-check after registration to prevent lost wakeups. Uses cached Arcs —
74707651 // one atomic load per table, no DashMap traffic.
74717652 let changed_after_register = read_versions
74727653 .iter()
74737654 .any(|(_, arc, ver)| arc.load(Ordering::Acquire) > *ver);
74747655
7475− if !changed_after_register {
7656+ let cancelled = cancel_token
7657+ .as_ref()
7658+ .map(|t| t.is_cancelled())
7659+ .unwrap_or(false);
7660+
7661+ if !changed_after_register && !cancelled {
74767662 slot.wait(STM_WAIT_TIMEOUT);
74777663 // slot drops → Weak refs become invalid, cleaned up lazily in notify
74787664 } else {
@@ -7481,6 +7667,10 @@ pub extern "C-unwind" fn knot_stm_wait(_snapshot: i64) {
74817667 std::thread::yield_now();
74827668 }
74837669
7670+ if let Some(tok) = &cancel_token {
7671+ tok.clear_stm_wake_slot();
7672+ }
7673+
74847674 // Re-acquire write lock if we held it before waiting
74857675 write_lock_reacquire_at_depth(saved_lock_depth);
74867676}
@@ -9297,10 +9487,13 @@ enum ColType {
92979487 Bytes,
92989488 /// Stored as TEXT, reconstructed as Constructor on read
92999489 Tag,
9300− /// Nested relation stored as JSON text in SQLite. In practice this variant
9301− /// never appears in `RecordSchema::columns`/`NestedField::columns` because
9302− /// `parse_record_schema` routes `[...]` types into the `nested` channel
9303− /// instead — but it remains for ADT field schemas (`parse_adt_schema`).
9490+ /// Value stored as JSON text in SQLite. Used for:
9491+ /// - record-valued fields (`{x: Float, y: Float}` inside a row),
9492+ /// - payload-bearing ADT fields,
9493+ /// - nested relations of *non-record* element type (e.g. `tags: [Text]`,
9494+ /// `shapes: [Shape]`) — the whole relation is serialized as one JSON
9495+ /// column (descriptor `field:json`); only record-element nested
9496+ /// relations take the `field:[child_schema]` child-table form.
93049497 Json,
93059498}
93069499
@@ -9362,6 +9555,10 @@ fn parse_adt_schema(spec: &str) -> AdtSpec {
93629555 "bool" => ColType::Bool,
93639556 "bytes" => ColType::Bytes,
93649557 "tag" => ColType::Tag,
9558+ // Record / payload-ADT constructor fields round-trip
9559+ // through JSON (the compiler's `col_type_str` emits
9560+ // "json" for them).
9561+ "json" => ColType::Json,
93659562 s if s.starts_with('[') => ColType::Json,
93669563 other => panic!("knot runtime: unknown ADT field type '{}'", other),
93679564 };
@@ -9568,7 +9765,13 @@ fn init_record_table(conn: &rusqlite::Connection, table_name: &str, schema: &Rec
95689765
95699766 for c in &schema.columns {
95709767 col_defs.push(format!("{} {}", quote_ident(&c.name), sql_type(c.ty)));
9571− unique_cols.push(quote_ident(&c.name));
9768+ // NULL-coalesced so rows containing NULLs still dedupe under
9769+ // INSERT OR IGNORE (SQLite treats raw NULLs as distinct in UNIQUE
9770+ // indexes) — same sentinel scheme as the ADT path. Existing DBs
9771+ // keep their old raw-column index under the same name (IF NOT
9772+ // EXISTS no-ops), matching how the ADT init path handles upgrades;
9773+ // schema migrations (`auto_apply_record_change`) drop + recreate.
9774+ unique_cols.push(null_safe_coalesce(&quote_ident(&c.name), c.ty));
95729775 }
95739776
95749777 if col_defs.is_empty() {
@@ -9613,7 +9816,10 @@ fn init_child_table(conn: &rusqlite::Connection, parent_table: &str, nf: &Nested
96139816
96149817 for c in &nf.columns {
96159818 col_defs.push(format!("{} {}", quote_ident(&c.name), sql_type(c.ty)));
9616− unique_cols.push(quote_ident(&c.name));
9819+ // NULL-coalesced for set semantics with NULL-bearing rows — see
9820+ // the matching comment in `init_record_table`. `_parent_id` stays
9821+ // raw (NOT NULL by definition).
9822+ unique_cols.push(null_safe_coalesce(&quote_ident(&c.name), c.ty));
96179823 }
96189824
96199825 let sql = format!("CREATE TABLE IF NOT EXISTS {} ({});", child_table, col_defs.join(", "));
@@ -9795,7 +10001,8 @@ fn auto_apply_child_change(
979510001
979610002 let mut unique_cols = vec![quote_ident("_parent_id")];
979710003 for c in &new_nf.columns {
9798− unique_cols.push(quote_ident(&c.name));
10004+ // NULL-coalesced — same scheme as `init_child_table`.
10005+ unique_cols.push(null_safe_coalesce(&quote_ident(&c.name), c.ty));
979910006 }
980010007 if unique_cols.len() > 1 {
980110008 let idx_sql = format!(
@@ -9890,7 +10097,12 @@ fn auto_apply_record_change(
989010097 debug_sql(&drop_idx);
989110098 let _ = conn.execute_batch(&drop_idx);
989210099
9893− let unique_cols: Vec<String> = new_rec.columns.iter().map(|c| quote_ident(&c.name)).collect();
10100+ // NULL-coalesced — same scheme as `init_record_table` / the ADT path.
10101+ let unique_cols: Vec<String> = new_rec
10102+ .columns
10103+ .iter()
10104+ .map(|c| null_safe_coalesce(&quote_ident(&c.name), c.ty))
10105+ .collect();
989410106 if !unique_cols.is_empty() {
989510107 let idx_sql = format!(
989610108 "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {} ({});",
@@ -11997,8 +12209,12 @@ fn value_to_sqlite(v: *mut Value, ty: ColType) -> rusqlite::types::Value {
1199712209 rusqlite::types::Value::Text(value_to_json(v))
1199812210 }
1199912211 (Value::Constructor(tag, _), _) => rusqlite::types::Value::Text(tag.to_string()),
12000− (Value::Relation(_), ColType::Json) => {
12001− rusqlite::types::Value::Text(value_to_json(v))
12212+ (Value::Relation(rows), ColType::Json) => {
12213+ // Nested relations stored as JSON columns keep set semantics:
12214+ // dedup rows before serializing, mirroring the INSERT OR IGNORE
12215+ // dedup that child-table storage gets from its UNIQUE index.
12216+ let deduped = in_memory_dedup(rows.clone());
12217+ rusqlite::types::Value::Text(value_to_json(alloc(Value::Relation(deduped))))
1200212218 }
1200312219 (Value::Record(_), ColType::Json) => {
1200412220 rusqlite::types::Value::Text(value_to_json(v))
@@ -12435,15 +12651,39 @@ fn check_rate_limit(
1243512651 }
1243612652 ensure_rate_limit_table(db);
1243712653 let now = current_unix_ms();
12438− let _ = db.conn.execute_batch("BEGIN IMMEDIATE;");
12439− let row: Option<(f64, i64)> = db
12440− .conn
12441− .query_row(
12442− "SELECT tokens, last_refill FROM _knot_rate_limits WHERE route = ?1 AND key = ?2",
12443− rusqlite::params![route, key],
12444− |r| Ok((r.get::<_, f64>(0)?, r.get::<_, i64>(1)?)),
12445− )
12446− .ok();
12654+ // BEGIN IMMEDIATE serializes concurrent same-key checks. If it fails
12655+ // (e.g. SQLITE_BUSY under contention), fail OPEN without touching the
12656+ // bucket: allowing a request through under transient DB pressure beats
12657+ // 429ing legitimate traffic, and skipping the accounting write keeps
12658+ // the stored token counts intact.
12659+ if let Err(e) = db.conn.execute_batch("BEGIN IMMEDIATE;") {
12660+ log_warn!(
12661+ "[HTTP] rate limiter: BEGIN failed ({}); allowing request without accounting",
12662+ e
12663+ );
12664+ return Ok(());
12665+ }
12666+ let row: Option<(f64, i64)> = match db.conn.query_row(
12667+ "SELECT tokens, last_refill FROM _knot_rate_limits WHERE route = ?1 AND key = ?2",
12668+ rusqlite::params![route, key],
12669+ |r| Ok((r.get::<_, f64>(0)?, r.get::<_, i64>(1)?)),
12670+ ) {
12671+ Ok(r) => Some(r),
12672+ // Genuinely no bucket yet — start a fresh one at full capacity.
12673+ Err(rusqlite::Error::QueryReturnedNoRows) => None,
12674+ // Real error (I/O, corruption, busy): do NOT conflate with "no
12675+ // bucket" — that would reset the bucket to full capacity and let
12676+ // an attacker leverage DB pressure to bypass the limit. Fail open
12677+ // for this one request and leave the stored bucket untouched.
12678+ Err(e) => {
12679+ log_warn!(
12680+ "[HTTP] rate limiter: bucket read failed ({}); allowing request without accounting",
12681+ e
12682+ );
12683+ let _ = db.conn.execute_batch("ROLLBACK;");
12684+ return Ok(());
12685+ }
12686+ };
1244712687 let capacity = requests as f64;
1244812688 let tokens_before = match row {
1244912689 Some((tokens, last_refill)) => {
@@ -12453,23 +12693,27 @@ fn check_rate_limit(
1245312693 }
1245412694 None => capacity,
1245512695 };
12456− if tokens_before >= 1.0 {
12457− let new_tokens = tokens_before - 1.0;
12458− let _ = db.conn.execute(
12696+ // Persist the updated bucket. Write failures are logged and rolled
12697+ // back; the allow/deny decision (computed from the read above) stands.
12698+ let write_bucket = |tokens: f64| {
12699+ let write = db.conn.execute(
1245912700 "INSERT OR REPLACE INTO _knot_rate_limits (route, key, tokens, last_refill) VALUES (?1, ?2, ?3, ?4)",
12460− rusqlite::params![route, key, new_tokens, now],
12701+ rusqlite::params![route, key, tokens, now],
1246112702 );
12462− let _ = db.conn.execute_batch("COMMIT;");
12703+ let result = write.and_then(|_| db.conn.execute_batch("COMMIT;"));
12704+ if let Err(e) = result {
12705+ log_warn!("[HTTP] rate limiter: bucket write failed ({})", e);
12706+ let _ = db.conn.execute_batch("ROLLBACK;");
12707+ }
12708+ };
12709+ if tokens_before >= 1.0 {
12710+ write_bucket(tokens_before - 1.0);
1246312711 Ok(())
1246412712 } else {
1246512713 let needed = 1.0 - tokens_before;
1246612714 let retry_after_ms =
1246712715 (needed * (window_ms as f64) / capacity).ceil() as i64;
12468− let _ = db.conn.execute(
12469− "INSERT OR REPLACE INTO _knot_rate_limits (route, key, tokens, last_refill) VALUES (?1, ?2, ?3, ?4)",
12470− rusqlite::params![route, key, tokens_before, now],
12471− );
12472− let _ = db.conn.execute_batch("COMMIT;");
12716+ write_bucket(tokens_before);
1247312717 Err(retry_after_ms.max(1))
1247412718 }
1247512719}
@@ -14114,7 +14358,8 @@ fn http_serve_loop(
1411414358 use std::io::Read;
1411514359 let mut buf = Vec::new();
1411614360 let max = http_max_body_bytes();
14117− let mut limited = request.as_reader().take(max + 1);
14361+ // saturating: `max` may be u64::MAX (env override).
14362+ let mut limited = request.as_reader().take(max.saturating_add(1));
1411814363 let _ = limited.read_to_end(&mut buf);
1411914364 if buf.len() as u64 > max {
1412014365 eprintln!(
@@ -14150,13 +14395,20 @@ fn http_serve_loop(
1415014395 _ => None,
1415114396 })
1415214397 .unwrap_or("text");
14153− let value = match try_string_to_value(val, ty) {
14398+ // `?`-typed (Maybe) path params: a matched path
14399+ // segment is always present, so absence (Nothing)
14400+ // can't occur — but the handler's declared type is
14401+ // `Maybe T`, so the value must be Just-wrapped.
14402+ let is_maybe = ty.starts_with('?');
14403+ let inner_ty = if is_maybe { &ty[1..] } else { ty };
14404+ let value = match try_string_to_value(val, inner_ty) {
1415414405 Some(v) => v,
1415514406 None => return Err((400, format!(
1415614407 "invalid path parameter '{}': '{}' is not a valid {}",
14157− name, val, ty
14408+ name, val, inner_ty
1415814409 ), None)),
1415914410 };
14411+ let value = if is_maybe { make_just(value) } else { value };
1416014412 fields.push(RecordField {
1416114413 name: intern_str(name),
1416214414 value,
@@ -14248,7 +14500,15 @@ fn http_serve_loop(
1424814500 } else {
1424914501 match raw_val {
1425014502 Some(v) => coerce_json_field(v, inner_ty),
14251− None => string_to_value("", inner_ty),
14503+ // A missing required field is a
14504+ // client error — defaulting to
14505+ // 0/""/false silently corrupts
14506+ // data (path/query/headers
14507+ // already 400 on bad input).
14508+ None => return Err((400, format!(
14509+ "missing required body field '{}'",
14510+ bname
14511+ ), None)),
1425214512 }
1425314513 };
1425414514 fields.push(RecordField {
@@ -14258,12 +14518,17 @@ fn http_serve_loop(
1425814518 }
1425914519 }
1426014520 _ => {
14521+ // Body parsed but isn't a JSON object: every
14522+ // required (non-Maybe) field is missing.
1426114523 for (bname, bty) in &entry_body_fields {
1426214524 let is_maybe = bty.starts_with('?');
1426314525 let value = if is_maybe {
1426414526 alloc(Value::Constructor("Nothing".into(), alloc(Value::Unit)))
1426514527 } else {
14266− string_to_value("", bty)
14528+ return Err((400, format!(
14529+ "missing required body field '{}' (request body must be a JSON object)",
14530+ bname
14531+ ), None));
1426714532 };
1426814533 fields.push(RecordField {
1426914534 name: intern_str(bname),
@@ -14455,7 +14720,29 @@ fn http_serve_loop(
1445514720 if let Value::Record(hdr_fields) = unsafe { as_ref(hdrs_val) } {
1445614721 for hf in hdr_fields {
1445714722 let http_name = camel_to_header_case(&hf.name);
14458− let hdr_value = fetch_value_to_text(hf.value);
14723+ // This loop runs OUTSIDE the worker's
14724+ // catch_unwind — a conversion panic here
14725+ // would kill the response path entirely.
14726+ // Catch it and drop the offending header
14727+ // (the body still goes out); `Nothing`
14728+ // header values are omitted, mirroring
14729+ // how fetch skips Nothing request headers.
14730+ let converted = std::panic::catch_unwind(
14731+ std::panic::AssertUnwindSafe(|| {
14732+ fetch_value_to_text_opt(hf.value)
14733+ }),
14734+ );
14735+ let hdr_value = match converted {
14736+ Ok(Some(s)) => s,
14737+ Ok(None) => continue, // Nothing → omit header
14738+ Err(_) => {
14739+ log_warn!(
14740+ "[HTTP] dropping response header '{}': value not convertible to text",
14741+ http_name
14742+ );
14743+ continue;
14744+ }
14745+ };
1445914746 if let Ok(header) = format!("{}: {}", http_name, hdr_value)
1446014747 .parse::<tiny_http::Header>()
1446114748 {
@@ -14708,25 +14995,18 @@ pub extern "C-unwind" fn knot_http_fetch_io(
1470814995 if !req_hdrs_str.is_empty() {
1470914996 for field_desc in req_hdrs_str.split(',') {
1471014997 if field_desc.is_empty() { continue; }
14711− let (name, ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
14712− let is_maybe = ty.starts_with('?');
14998+ let (name, _ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
1471314999 let http_name = camel_to_header_case(name);
1471415000 if http_name.eq_ignore_ascii_case("Content-Type") {
1471515001 has_content_type = true;
1471615002 }
1471715003 let field_val = knot_record_field(payload, name.as_ptr(), name.len());
14718− if is_maybe {
14719− // Maybe type: skip Nothing, extract Just value
14720− if !field_val.is_null() {
14721− if let Value::Constructor(tag, inner) = unsafe { as_ref(field_val) } {
14722− if &**tag == "Just" {
14723− let v = knot_record_field(*inner, "value".as_ptr(), 5);
14724− req_headers.push((http_name, fetch_value_to_text(v)));
14725− }
14726− }
14727− }
14728− } else {
14729− req_headers.push((http_name, fetch_value_to_text(field_val)));
15004+ // `fetch_value_to_text_opt` unwraps Just and returns None
15005+ // for Nothing — covers both Maybe (`?`-typed) and plain
15006+ // header fields (a `None` on a non-Maybe field can't occur
15007+ // by typing, and omitting it is safe regardless).
15008+ if let Some(text) = fetch_value_to_text_opt(field_val) {
15009+ req_headers.push((http_name, text));
1473015010 }
1473115011 }
1473215012 }
@@ -14946,7 +15226,8 @@ fn fetch_read_capped_body(body: &mut ureq::Body) -> Result<String, String> {
1494615226 let max = http_max_body_bytes();
1494715227 let mut buf = Vec::new();
1494815228 body.as_reader()
14949− .take(max + 1)
15229+ // saturating: `max` may be u64::MAX (env override).
15230+ .take(max.saturating_add(1))
1495015231 .read_to_end(&mut buf)
1495115232 .map_err(|e| format!("response read error: {}", e))?;
1495215233 if buf.len() as u64 > max {
@@ -14973,7 +15254,15 @@ fn fetch_build_url(base: &str, path_pattern: &str, payload: *mut Value) -> Strin
1497315254 let param = &remaining[start + 1..end];
1497415255 let (name, _ty) = param.split_once(':').unwrap_or((param, "text"));
1497515256 let field_val = knot_record_field(payload, name.as_ptr(), name.len());
14976− url.push_str(&percent_encode(&fetch_value_to_text(field_val)));
15257+ let text = fetch_value_to_text_opt(field_val).unwrap_or_else(|| {
15258+ // Path segments cannot be omitted — a Nothing here is a caller
15259+ // bug the type checker should have prevented.
15260+ panic!(
15261+ "knot runtime: path parameter '{}' is Nothing — path parameters cannot be omitted",
15262+ name
15263+ )
15264+ });
15265+ url.push_str(&percent_encode(&text));
1497715266 remaining = &remaining[end + 1..];
1497815267 }
1497915268 url.push_str(remaining);
@@ -15013,35 +15302,62 @@ fn fetch_build_query(query_desc: &str, payload: *mut Value) -> String {
1501315302 if field_desc.is_empty() {
1501415303 continue;
1501515304 }
15016− let (name, ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
15017− let is_maybe = ty.starts_with('?');
15305+ let (name, _ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
1501815306 let field_val = knot_record_field(payload, name.as_ptr(), name.len());
15019− let val = if is_maybe {
15020− match unwrap_maybe(field_val) {
15021− Some(inner) => inner,
15022− None => continue, // Skip Nothing query params
15023− }
15024− } else {
15025− field_val
15307+ // `fetch_value_to_text_opt` unwraps Just and returns None for
15308+ // Nothing — Nothing query params are omitted from the URL.
15309+ let val_str = match fetch_value_to_text_opt(field_val) {
15310+ Some(s) => s,
15311+ None => continue,
1502615312 };
15027− let val_str = fetch_value_to_text(val);
1502815313 parts.push(format!("{}={}", name, percent_encode(&val_str)));
1502915314 }
1503015315 parts.join("&")
1503115316}
1503215317
15033−/// Convert a Knot value to its text representation for URL params.
15034−fn fetch_value_to_text(v: *mut Value) -> String {
15318+/// Convert a Knot value to its text representation for URL params and
15319+/// HTTP headers. Returns `None` for `Nothing` — callers must OMIT the
15320+/// parameter/header entirely (mirroring how fetch skips `Nothing` request
15321+/// headers). `Just {value: v}` unwraps and recurses on `v`; nullary
15322+/// constructors (Unit or empty-record payload) render as their bare tag,
15323+/// matching the server-side `tag` column decode for enum-like ADTs.
15324+fn fetch_value_to_text_opt(v: *mut Value) -> Option<String> {
1503515325 if v.is_null() {
15036− return String::new();
15326+ return Some(String::new());
1503715327 }
1503815328 match unsafe { as_ref(v) } {
15039− Value::Int(n) => n.to_string(),
15040− Value::Float(n) => n.to_string(),
15041− Value::Text(s) => (**s).to_string(),
15042− Value::Bool(b) => b.to_string(),
15329+ Value::Int(n) => Some(n.to_string()),
15330+ Value::Float(n) => Some(n.to_string()),
15331+ Value::Text(s) => Some((**s).to_string()),
15332+ Value::Bool(b) => Some(b.to_string()),
15333+ Value::Constructor(tag, payload) => {
15334+ if &**tag == "Nothing" {
15335+ return None;
15336+ }
15337+ if &**tag == "Just" {
15338+ return fetch_value_to_text_opt(knot_record_field(
15339+ *payload,
15340+ "value".as_ptr(),
15341+ 5,
15342+ ));
15343+ }
15344+ let nullary = (*payload).is_null()
15345+ || match unsafe { as_ref(*payload) } {
15346+ Value::Unit => true,
15347+ Value::Record(fs) => fs.is_empty(),
15348+ _ => false,
15349+ };
15350+ if nullary {
15351+ Some(tag.to_string())
15352+ } else {
15353+ panic!(
15354+ "knot runtime: cannot convert constructor '{}' (with fields) to text for URL parameter or header",
15355+ tag
15356+ )
15357+ }
15358+ }
1504315359 _ => panic!(
15044− "knot runtime: cannot convert {} to text for URL parameter",
15360+ "knot runtime: cannot convert {} to text for URL parameter or header",
1504515361 type_name(v)
1504615362 ),
1504715363 }
@@ -15207,6 +15523,14 @@ fn generate_openapi(name: &str, table: &RouteTable) -> String {
1520715523
1520815524 for (i, (path, entries)) in paths.iter().enumerate() {
1520915525 out.push_str(&format!(" \"{}\": {{\n", json_escape(path)));
15526+ // Methods are JSON keys within a path object — two route entries
15527+ // sharing method+path would emit duplicate keys (invalid JSON).
15528+ // Keep the first entry per method.
15529+ let mut seen_methods: HashSet<String> = HashSet::new();
15530+ let entries: Vec<&&RouteTableEntry> = entries
15531+ .iter()
15532+ .filter(|e| seen_methods.insert(e.method.to_lowercase()))
15533+ .collect();
1521015534 for (j, entry) in entries.iter().enumerate() {
1521115535 let method = entry.method.to_lowercase();
1521215536 out.push_str(&format!(" \"{}\": {{\n", method));
@@ -15227,9 +15551,12 @@ fn generate_openapi(name: &str, table: &RouteTable) -> String {
1522715551 }
1522815552 }
1522915553 for (qname, qty) in &entry.query_fields {
15554+ // `?`-typed (Maybe) query params are optional; all others
15555+ // are required (the server 400s when they're absent).
1523015556 params.push(format!(
15231− "{{ \"name\": \"{}\", \"in\": \"query\", \"required\": false, \"schema\": {} }}",
15557+ "{{ \"name\": \"{}\", \"in\": \"query\", \"required\": {}, \"schema\": {} }}",
1523215558 json_escape(qname),
15559+ !qty.starts_with('?'),
1523315560 type_to_openapi_schema(qty)
1523415561 ));
1523515562 }
@@ -15274,14 +15601,18 @@ fn generate_openapi(name: &str, table: &RouteTable) -> String {
1527415601 out.push('\n');
1527515602 }
1527615603 out.push_str(" },\n");
15277− out.push_str(" \"required\": [");
15278− for (k, (fname, _)) in entry.body_fields.iter().enumerate() {
15279− out.push_str(&format!("\"{}\"", json_escape(fname)));
15280− if k + 1 < entry.body_fields.len() {
15281− out.push_str(", ");
15282− }
15283− }
15284− out.push_str("]\n");
15604+ // `?`-typed (Maybe) body fields are optional — exclude them
15605+ // from the `required` array.
15606+ let required: Vec<String> = entry
15607+ .body_fields
15608+ .iter()
15609+ .filter(|(_, fty)| !fty.starts_with('?'))
15610+ .map(|(fname, _)| format!("\"{}\"", json_escape(fname)))
15611+ .collect();
15612+ out.push_str(&format!(
15613+ " \"required\": [{}]\n",
15614+ required.join(", ")
15615+ ));
1528515616 out.push_str(" }\n");
1528615617 out.push_str(" }\n");
1528715618 out.push_str(" }\n");
@@ -15350,6 +15681,9 @@ fn openapi_path(parts: &[PathPart]) -> String {
1535015681}
1535115682
1535215683fn type_to_openapi_schema(ty: &str) -> &'static str {
15684+ // `?` marks Maybe-typed (optional) fields — optionality is expressed via
15685+ // the `required` flag/array, not the schema, so strip it before mapping.
15686+ let ty = ty.strip_prefix('?').unwrap_or(ty);
1535315687 match ty {
1535415688 "int" => "{ \"type\": \"integer\" }",
1535515689 "float" => "{ \"type\": \"number\" }",
@@ -15507,7 +15841,10 @@ fn serialize_value_for_hash_into(v: *mut Value, buf: &mut Vec<u8>) {
1550715841 }
1550815842 Value::Relation(rows) => {
1550915843 buf.push(8);
15510− buf.extend_from_slice(&(rows.len() as u32).to_le_bytes());
15844+ // Canonical *set* form: sort + dedup, then write the DEDUPED
15845+ // count — must match `value_to_hash_bytes` exactly, otherwise a
15846+ // relation key containing duplicate rows hashes differently
15847+ // across the two paths and equal join keys miss each other.
1551115848 let mut row_bytes: Vec<Vec<u8>> = rows
1551215849 .iter()
1551315850 .map(|r| {
@@ -15517,6 +15854,8 @@ fn serialize_value_for_hash_into(v: *mut Value, buf: &mut Vec<u8>) {
1551715854 })
1551815855 .collect();
1551915856 row_bytes.sort_unstable();
15857+ row_bytes.dedup();
15858+ buf.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
1552015859 for rb in &row_bytes {
1552115860 buf.extend_from_slice(&(rb.len() as u32).to_le_bytes());
1552215861 buf.extend_from_slice(rb);
@@ -15636,7 +15975,30 @@ pub extern "C-unwind" fn knot_crypto_generate_signing_key_pair() -> *mut Value {
1563615975 record
1563715976}
1563815977
15639−/// Sealed-box encryption: X25519 ECDH + ChaCha20-Poly1305.
15978+/// Derive the sealed-box AEAD key: BLAKE3 `derive_key` over
15979+/// `shared_secret || ephemeral_pub || recipient_pub` with a fixed,
15980+/// domain-separating context string. Using a KDF instead of the raw ECDH
15981+/// output (a) binds both public keys into the key, preventing cross-keypair
15982+/// ciphertext malleability, and (b) hides any structure of the raw curve
15983+/// point from the cipher.
15984+///
15985+/// NOTE: ciphertexts produced by builds that used the raw shared secret as
15986+/// the key cannot be decrypted by current builds (accepted pre-1.0 format
15987+/// change).
15988+fn derive_sealed_box_key(
15989+ shared: &[u8; 32],
15990+ eph_pub: &[u8; 32],
15991+ recipient_pub: &[u8; 32],
15992+) -> [u8; 32] {
15993+ const CONTEXT: &str = "knot-runtime sealed-box v2 ChaCha20-Poly1305 key";
15994+ let mut material = [0u8; 96];
15995+ material[..32].copy_from_slice(shared);
15996+ material[32..64].copy_from_slice(eph_pub);
15997+ material[64..].copy_from_slice(recipient_pub);
15998+ blake3::derive_key(CONTEXT, &material)
15999+}
16000+
16001+/// Sealed-box encryption: X25519 ECDH + BLAKE3 KDF + ChaCha20-Poly1305.
1564016002/// Takes (publicKey: Bytes, plaintext: Bytes), returns ciphertext Bytes.
1564116003/// Format: [ephemeral_pub: 32][nonce: 12][encrypted + tag: len+16]
1564216004#[unsafe(no_mangle)]
@@ -15663,9 +16025,15 @@ pub extern "C-unwind" fn knot_crypto_encrypt(public_key: *mut Value, plaintext:
1566316025 let eph_secret = x25519_dalek::StaticSecret::from(eph_secret_bytes);
1566416026 let eph_public = x25519_dalek::PublicKey::from(&eph_secret);
1566516027
15666− // ECDH shared secret
16028+ // ECDH shared secret. Reject non-contributory exchanges (low-order /
16029+ // identity points): an attacker-chosen public key would otherwise force
16030+ // a predictable all-zero secret.
1566716031 let shared = eph_secret.diffie_hellman(&recipient_public);
15668− let key = chacha20poly1305::Key::from_slice(shared.as_bytes());
16032+ if !shared.was_contributory() {
16033+ panic!("knot runtime: encrypt publicKey is a low-order point (non-contributory X25519 exchange)");
16034+ }
16035+ let key_bytes = derive_sealed_box_key(shared.as_bytes(), eph_public.as_bytes(), &recipient_pub);
16036+ let key = chacha20poly1305::Key::from_slice(&key_bytes);
1566916037 let cipher = ChaCha20Poly1305::new(key);
1567016038
1567116039 // Random nonce
@@ -15714,8 +16082,19 @@ pub extern "C-unwind" fn knot_crypto_decrypt(private_key: *mut Value, ciphertext
1571416082 let encrypted = &ct[44..];
1571516083
1571616084 let eph_public = x25519_dalek::PublicKey::from(eph_pub_bytes);
16085+ // Reject non-contributory exchanges — a low-order ephemeral point in the
16086+ // ciphertext header would force a predictable key (see encrypt).
1571716087 let shared = secret.diffie_hellman(&eph_public);
15718− let key = chacha20poly1305::Key::from_slice(shared.as_bytes());
16088+ if !shared.was_contributory() {
16089+ panic!("knot runtime: decryption failed (non-contributory X25519 exchange — low-order ephemeral point)");
16090+ }
16091+ let recipient_public = x25519_dalek::PublicKey::from(&secret);
16092+ let key_bytes = derive_sealed_box_key(
16093+ shared.as_bytes(),
16094+ &eph_pub_bytes,
16095+ recipient_public.as_bytes(),
16096+ );
16097+ let key = chacha20poly1305::Key::from_slice(&key_bytes);
1571916098 let cipher = ChaCha20Poly1305::new(key);
1572016099 let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
1572116100
@@ -16627,3 +17006,361 @@ mod _regress_runtime_fixes_tests {
1662717006 assert!(!values_equal(r1, r2));
1662817007 }
1662917008}
17009+
17010+#[cfg(test)]
17011+mod _bugfix_batch_tests {
17012+ use super::*;
17013+
17014+ fn int(n: i64) -> *mut Value {
17015+ alloc(Value::Int(n))
17016+ }
17017+
17018+ fn text(s: &str) -> *mut Value {
17019+ alloc(Value::Text(Arc::from(s)))
17020+ }
17021+
17022+ fn rec(fields: &[(&str, *mut Value)]) -> *mut Value {
17023+ let mut fs: Vec<RecordField> = fields
17024+ .iter()
17025+ .map(|(n, v)| RecordField {
17026+ name: intern_str(n),
17027+ value: *v,
17028+ })
17029+ .collect();
17030+ fs.sort_by(|a, b| a.name.cmp(&b.name));
17031+ alloc(Value::Record(fs))
17032+ }
17033+
17034+ fn nothing() -> *mut Value {
17035+ // Unit-payload shape, as produced by the DB `tag` column decode —
17036+ // this is the shape `infer_col_type` classifies as a Tag column.
17037+ alloc(Value::Constructor(intern_str("Nothing"), alloc(Value::Unit)))
17038+ }
17039+
17040+ fn just(v: *mut Value) -> *mut Value {
17041+ make_just(v)
17042+ }
17043+
17044+ // ── Fix: sql_partial_cmp must compare numeric texts numerically ──
17045+
17046+ #[test]
17047+ fn sql_partial_cmp_numeric_texts_compare_numerically() {
17048+ use std::cmp::Ordering;
17049+ // "5" < "10" numerically (lexicographic order would say Greater).
17050+ assert_eq!(
17051+ sql_partial_cmp(&SqlVal::Text("5".into()), &SqlVal::Text("10".into())),
17052+ Some(Ordering::Less)
17053+ );
17054+ assert_eq!(
17055+ sql_partial_cmp(&SqlVal::Text("100".into()), &SqlVal::Text("99".into())),
17056+ Some(Ordering::Greater)
17057+ );
17058+ // Float-as-text compares numerically too.
17059+ assert_eq!(
17060+ sql_partial_cmp(&SqlVal::Text("2.5".into()), &SqlVal::Text("10".into())),
17061+ Some(Ordering::Less)
17062+ );
17063+ // Non-numeric texts keep lexicographic order.
17064+ assert_eq!(
17065+ sql_partial_cmp(&SqlVal::Text("apple".into()), &SqlVal::Text("banana".into())),
17066+ Some(Ordering::Less)
17067+ );
17068+ // Mixed numeric / non-numeric is unknown → None (wake conservatively).
17069+ assert_eq!(
17070+ sql_partial_cmp(&SqlVal::Text("5".into()), &SqlVal::Text("apple".into())),
17071+ None
17072+ );
17073+ }
17074+
17075+ #[test]
17076+ fn stm_ordered_pred_on_int_as_text_row() {
17077+ // STM watcher on `qty < 10`: write payload carries Int-as-TEXT "5"
17078+ // (SqlVal::from_knot serializes Int that way) — must match.
17079+ let pred = ColPred::Cmp(CmpOp::Lt, SqlVal::Text("10".into()));
17080+ assert!(col_pred_matches(&SqlVal::Text("5".into()), &pred));
17081+ assert!(!col_pred_matches(&SqlVal::Text("50".into()), &pred));
17082+ }
17083+
17084+ // ── Fix: infer_temp_schema must scan all rows ──
17085+
17086+ #[test]
17087+ fn temp_schema_mixed_maybe_returns_none() {
17088+ // First row `Nothing` (nullary → Tag column), later row carries a
17089+ // `Just {value: 1}` payload — a Tag column would silently drop it.
17090+ let rows = vec![
17091+ rec(&[("m", nothing()), ("x", int(1))]),
17092+ rec(&[("m", just(int(2))), ("x", int(3))]),
17093+ ];
17094+ assert!(infer_temp_schema(&rows).is_none());
17095+ // Same in the opposite order.
17096+ let rows_rev = vec![
17097+ rec(&[("m", just(int(2))), ("x", int(3))]),
17098+ rec(&[("m", nothing()), ("x", int(1))]),
17099+ ];
17100+ assert!(infer_temp_schema(&rows_rev).is_none());
17101+ }
17102+
17103+ #[test]
17104+ fn temp_schema_null_then_typed_returns_none() {
17105+ // First-row SQL NULL used to type the column Text, mis-typing the
17106+ // later Int rows.
17107+ let rows = vec![
17108+ rec(&[("x", std::ptr::null_mut())]),
17109+ rec(&[("x", int(7))]),
17110+ ];
17111+ assert!(infer_temp_schema(&rows).is_none());
17112+ }
17113+
17114+ #[test]
17115+ fn temp_schema_conflicting_scalar_types_returns_none() {
17116+ let rows = vec![rec(&[("x", int(1))]), rec(&[("x", text("a"))])];
17117+ assert!(infer_temp_schema(&rows).is_none());
17118+ }
17119+
17120+ #[test]
17121+ fn temp_schema_consistent_rows_still_infer() {
17122+ let rows = vec![
17123+ rec(&[("name", text("a")), ("x", int(1))]),
17124+ rec(&[("name", text("b")), ("x", int(2))]),
17125+ ];
17126+ match infer_temp_schema(&rows) {
17127+ Some(TempSchema::Record(cols)) => {
17128+ assert_eq!(cols.len(), 2);
17129+ assert!(cols.contains(&("name".to_string(), ColType::Text)));
17130+ assert!(cols.contains(&("x".to_string(), ColType::Int)));
17131+ }
17132+ _ => panic!("expected Record schema"),
17133+ }
17134+ // All-nullary Maybe column stays a Tag column (consistent rows).
17135+ let tags = vec![rec(&[("m", nothing())]), rec(&[("m", nothing())])];
17136+ match infer_temp_schema(&tags) {
17137+ Some(TempSchema::Record(cols)) => {
17138+ assert_eq!(cols, vec![("m".to_string(), ColType::Tag)]);
17139+ }
17140+ _ => panic!("expected Record schema"),
17141+ }
17142+ }
17143+
17144+ #[test]
17145+ fn sql_dedup_falls_back_for_mixed_maybe_rows() {
17146+ // End-to-end through sql_dedup: mixed-arity Maybe rows must return
17147+ // None so the caller's in-memory dedup keeps all three distinct rows
17148+ // (the old Tag-typed column collapsed Just{1} and Just{2}).
17149+ let conn = Connection::open_in_memory().unwrap();
17150+ // `Nothing` FIRST: the old first-row-only inference typed the column
17151+ // Tag and `SELECT DISTINCT` collapsed the two Just rows.
17152+ let rows = vec![
17153+ rec(&[("m", nothing())]),
17154+ rec(&[("m", just(int(1)))]),
17155+ rec(&[("m", just(int(2)))]),
17156+ ];
17157+ assert!(sql_dedup(&conn, &rows).is_none());
17158+ assert_eq!(in_memory_dedup(rows).len(), 3);
17159+ }
17160+
17161+ // ── Fix: hash serializer divergence on duplicate relation rows ──
17162+
17163+ #[test]
17164+ fn hash_serializers_agree_on_relations_with_duplicates() {
17165+ let dup = alloc(Value::Relation(vec![
17166+ rec(&[("id", int(1))]),
17167+ rec(&[("id", int(1))]),
17168+ rec(&[("id", int(2))]),
17169+ ]));
17170+ let nodup = alloc(Value::Relation(vec![
17171+ rec(&[("id", int(2))]),
17172+ rec(&[("id", int(1))]),
17173+ ]));
17174+ assert!(values_equal(dup, nodup));
17175+
17176+ let mut a = Vec::new();
17177+ value_to_hash_bytes(dup, &mut a);
17178+ let mut b = Vec::new();
17179+ value_to_hash_bytes(nodup, &mut b);
17180+ assert_eq!(a, b, "value_to_hash_bytes must canonicalize duplicates");
17181+
17182+ // The join-index serializer must produce the SAME canonical form.
17183+ assert_eq!(
17184+ serialize_value_for_hash(dup),
17185+ serialize_value_for_hash(nodup),
17186+ "serialize_value_for_hash must dedup like value_to_hash_bytes"
17187+ );
17188+ assert_eq!(a, serialize_value_for_hash(dup), "the two paths must match");
17189+ }
17190+
17191+ // ── Fix: fetch_value_to_text constructors / Maybe ──
17192+
17193+ #[test]
17194+ fn fetch_text_opt_handles_maybe_and_enums() {
17195+ // Nothing → None (callers omit the param/header).
17196+ assert_eq!(fetch_value_to_text_opt(nothing()), None);
17197+ // Just {value: v} unwraps.
17198+ assert_eq!(fetch_value_to_text_opt(just(int(7))), Some("7".to_string()));
17199+ assert_eq!(
17200+ fetch_value_to_text_opt(just(text("hi"))),
17201+ Some("hi".to_string())
17202+ );
17203+ // Nullary constructor (enum ADT) renders as its tag — both the
17204+ // Unit-payload shape (DB tag decode) and the empty-record shape
17205+ // (user-written `Red {}`).
17206+ let unit_ctor = alloc(Value::Constructor(intern_str("Red"), alloc(Value::Unit)));
17207+ assert_eq!(fetch_value_to_text_opt(unit_ctor), Some("Red".to_string()));
17208+ let rec_ctor = alloc(Value::Constructor(
17209+ intern_str("Red"),
17210+ alloc(Value::Record(Vec::new())),
17211+ ));
17212+ assert_eq!(fetch_value_to_text_opt(rec_ctor), Some("Red".to_string()));
17213+ }
17214+
17215+ #[test]
17216+ fn fetch_build_query_omits_nothing_params() {
17217+ let payload = rec(&[("limit", just(int(5))), ("q", nothing())]);
17218+ let qs = fetch_build_query("q:?text,limit:?int", payload);
17219+ assert_eq!(qs, "limit=5");
17220+ }
17221+
17222+ // ── Fix: OpenAPI `?` (Maybe) type mapping ──
17223+
17224+ #[test]
17225+ fn openapi_schema_strips_maybe_prefix() {
17226+ assert_eq!(type_to_openapi_schema("?int"), "{ \"type\": \"integer\" }");
17227+ assert_eq!(type_to_openapi_schema("?bool"), "{ \"type\": \"boolean\" }");
17228+ assert_eq!(type_to_openapi_schema("int"), "{ \"type\": \"integer\" }");
17229+ }
17230+
17231+ // ── Fix: scalar source unwrap must not fabricate Int 0 ──
17232+
17233+ #[test]
17234+ #[should_panic(expected = "scalar source has no row")]
17235+ fn scalar_source_unwrap_panics_on_empty_relation() {
17236+ let empty = alloc(Value::Relation(Vec::new()));
17237+ let _ = knot_scalar_source_unwrap(empty);
17238+ }
17239+
17240+ // ── Fix: cancel() must wake a registered STM slot ──
17241+
17242+ #[test]
17243+ fn cancel_wakes_registered_stm_slot() {
17244+ let tok = CancelToken::new();
17245+ let slot = Arc::new(WakeSlot::new());
17246+ tok.set_stm_wake_slot(&slot);
17247+ tok.cancel();
17248+ assert!(tok.is_cancelled());
17249+ assert!(
17250+ *slot.woken.lock().unwrap(),
17251+ "cancel() must wake the slot registered by knot_stm_wait"
17252+ );
17253+ }
17254+
17255+ // ── Fix: stale Rows payloads upgrade to Bulk at notify time ──
17256+
17257+ #[test]
17258+ fn stale_rows_payload_upgrades_to_bulk_wake() {
17259+ let table = "t_toctou_upgrade_unique";
17260+ // Watcher indexed on col "a", registered AFTER a writer projected
17261+ // its payload (which only carries col "b").
17262+ let slot = Arc::new(WakeSlot::new());
17263+ EQ_WATCHERS
17264+ .entry((table.to_string(), "a".to_string(), SqlVal::Int(1).to_key()))
17265+ .or_default()
17266+ .push(Arc::downgrade(&slot));
17267+ TABLE_FILTER_COLS
17268+ .entry(table.to_string())
17269+ .or_default()
17270+ .cols
17271+ .insert("a".to_string(), 1);
17272+
17273+ let cols: Arc<[ColName]> = Arc::from(vec![intern_col("b")]);
17274+ let mut rows = EventRows::new(cols);
17275+ rows.push(vec![SqlVal::Int(9)]);
17276+ // Payload lacks "a": without the staleness re-check the "a" index is
17277+ // never probed and the watcher sleeps until the timeout.
17278+ wake_matching_watchers(table, &WriteEvent::Rows(rows));
17279+ assert!(
17280+ *slot.woken.lock().unwrap(),
17281+ "stale payload must fall back to a conservative Bulk wake"
17282+ );
17283+ TABLE_FILTER_COLS.remove(table);
17284+ }
17285+
17286+ #[test]
17287+ fn fresh_rows_payload_stays_precise() {
17288+ let table = "t_toctou_fresh_unique";
17289+ let slot = Arc::new(WakeSlot::new());
17290+ EQ_WATCHERS
17291+ .entry((table.to_string(), "a".to_string(), SqlVal::Int(1).to_key()))
17292+ .or_default()
17293+ .push(Arc::downgrade(&slot));
17294+ TABLE_FILTER_COLS
17295+ .entry(table.to_string())
17296+ .or_default()
17297+ .cols
17298+ .insert("a".to_string(), 1);
17299+
17300+ let cols: Arc<[ColName]> = Arc::from(vec![intern_col("a")]);
17301+ let mut rows = EventRows::new(cols);
17302+ rows.push(vec![SqlVal::Int(2)]); // ≠ 1 → indexed watcher must NOT wake
17303+ wake_matching_watchers(table, &WriteEvent::Rows(rows));
17304+ assert!(
17305+ !*slot.woken.lock().unwrap(),
17306+ "payload covering all filter cols must keep per-row precision"
17307+ );
17308+ TABLE_FILTER_COLS.remove(table);
17309+ }
17310+
17311+ // ── Fix: sealed-box KDF + contributory check ──
17312+
17313+ #[test]
17314+ fn crypto_sealed_box_round_trip() {
17315+ let pair = knot_crypto_generate_key_pair();
17316+ let private_key = knot_record_field(pair, b"privateKey".as_ptr(), 10);
17317+ let public_key = knot_record_field(pair, b"publicKey".as_ptr(), 9);
17318+
17319+ let plaintext = alloc(Value::Bytes(Arc::from(b"attack at dawn".to_vec())));
17320+ let ct = knot_crypto_encrypt(public_key, plaintext);
17321+ let decrypted = knot_crypto_decrypt(private_key, ct);
17322+ match unsafe { as_ref(decrypted) } {
17323+ Value::Bytes(b) => assert_eq!(&**b, b"attack at dawn" as &[u8]),
17324+ _ => panic!("expected Bytes from decrypt"),
17325+ }
17326+ }
17327+
17328+ #[test]
17329+ #[should_panic(expected = "non-contributory")]
17330+ fn crypto_encrypt_rejects_low_order_public_key() {
17331+ // The identity point (all zeros) is the canonical low-order input:
17332+ // DH with it yields an all-zero shared secret for ANY ephemeral key.
17333+ let zero_pub = alloc(Value::Bytes(Arc::from(vec![0u8; 32])));
17334+ let plaintext = alloc(Value::Bytes(Arc::from(b"x".to_vec())));
17335+ let _ = knot_crypto_encrypt(zero_pub, plaintext);
17336+ }
17337+
17338+ #[test]
17339+ #[should_panic(expected = "non-contributory")]
17340+ fn crypto_decrypt_rejects_low_order_ephemeral() {
17341+ let pair = knot_crypto_generate_key_pair();
17342+ let private_key = knot_record_field(pair, b"privateKey".as_ptr(), 10);
17343+ // Forged ciphertext with an all-zero (low-order) ephemeral point.
17344+ let ct = vec![0u8; 32 + 12 + 16];
17345+ let ct_val = alloc(Value::Bytes(Arc::from(ct)));
17346+ let _ = knot_crypto_decrypt(private_key, ct_val);
17347+ }
17348+
17349+ // ── Fix: HTTP body cap take() overflow ──
17350+
17351+ #[test]
17352+ fn body_cap_take_saturates_at_u64_max() {
17353+ use std::io::Read;
17354+ knot_set_http_max_body_bytes(u64::MAX);
17355+ let data = b"hello".to_vec();
17356+ let mut out = Vec::new();
17357+ // Mirrors the listen/fetch read paths: take(max.saturating_add(1)).
17358+ let max = http_max_body_bytes();
17359+ (&data[..])
17360+ .take(max.saturating_add(1))
17361+ .read_to_end(&mut out)
17362+ .unwrap();
17363+ assert_eq!(out, data);
17364+ knot_set_http_max_body_bytes(0); // restore env-or-default resolution
17365+ }
17366+}
crates/knot-runtime/src/tui.rs+23−6
@@ -15,6 +15,7 @@ use ratatui::{
1515 widgets::{Block, Borders, Cell, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Wrap},
1616};
1717use rusqlite::Connection;
18+use unicode_width::UnicodeWidthStr;
1819
1920// ── Schema types (mirrors lib.rs internal types) ─────────────────
2021
@@ -495,15 +496,17 @@ fn ui(frame: &mut Frame, app: &mut App) {
495496 let w: Vec<Constraint> = hdrs
496497 .iter()
497498 .map(|h| {
498− // Calculate width from header + data
499+ // Calculate width from header + data. Use display width
500+ // (terminal columns), not byte length — multi-byte UTF-8 and
501+ // wide (CJK/emoji) characters would otherwise mis-size columns.
499502 let max_data = app
500503 .data_rows
501504 .iter()
502505 .filter_map(|row| row.get(hdrs.iter().position(|x| x == h)?))
503− .map(|s| s.len())
506+ .map(|s| s.width())
504507 .max()
505508 .unwrap_or(0);
506− let w = h.len().max(max_data).min(40) + 2;
509+ let w = h.width().max(max_data).min(40) + 2;
507510 Constraint::Length(w as u16)
508511 })
509512 .collect();
@@ -616,6 +619,18 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
616619
617620// ── Entry point ──────────────────────────────────────────────────
618621
622+/// Restores the terminal on drop, so panics and early returns inside the
623+/// event loop can't leave the user's shell stuck in raw mode / the
624+/// alternate screen. Created immediately after entering raw mode below.
625+struct TerminalRestoreGuard;
626+
627+impl Drop for TerminalRestoreGuard {
628+ fn drop(&mut self) {
629+ let _ = disable_raw_mode();
630+ let _ = stdout().execute(LeaveAlternateScreen);
631+ }
632+}
633+
619634pub fn run_db_explorer(db_path: &str) -> io::Result<()> {
620635 let conn = Connection::open(db_path).map_err(|e| {
621636 io::Error::new(io::ErrorKind::Other, format!("Failed to open database: {}", e))
@@ -626,6 +641,9 @@ pub fn run_db_explorer(db_path: &str) -> io::Result<()> {
626641
627642 enable_raw_mode()?;
628643 stdout().execute(EnterAlternateScreen)?;
644+ // From here on the terminal is in raw mode + alternate screen; the guard
645+ // restores it on every exit path, including panics inside the loop.
646+ let _restore = TerminalRestoreGuard;
629647 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
630648
631649 let result = (|| -> io::Result<()> {
@@ -698,8 +716,7 @@ pub fn run_db_explorer(db_path: &str) -> io::Result<()> {
698716 Ok(())
699717 })();
700718
701− // Always restore terminal, even if the loop returned an error
702− let _ = disable_raw_mode();
703− let _ = stdout().execute(LeaveAlternateScreen);
719+ // Terminal restoration is handled by `_restore`'s Drop (covers the
720+ // normal return, the `?` error paths, and panics alike).
704721 result
705722}
crates/knot/src/format.rs+22−2
@@ -1135,19 +1135,27 @@ fn render_unit_expr(u: &UnitExpr) -> String {
11351135 render_unit_expr_prec(u, 0)
11361136}
11371137
1138+/// Contexts (`ctx`): 0 = top level, 1 = left operand of `*`/`/` (left-assoc,
1139+/// so same-precedence children need no parens), 2 = right operand of `*`/`/`
1140+/// (a nested `*`/`/` must keep its parens to preserve associativity), 3 =
1141+/// base of `^` (the grammar's `parse_unit_power` allows one `^` per atom, so
1142+/// any non-atom base must be parenthesized).
11381143fn render_unit_expr_prec(u: &UnitExpr, ctx: u8) -> String {
11391144 match u {
11401145 UnitExpr::Dimensionless => "1".into(),
11411146 UnitExpr::Named(n) => n.clone(),
11421147 UnitExpr::Mul(a, b) => {
1143− let s = format!("{} * {}", render_unit_expr_prec(a, 1), render_unit_expr_prec(b, 1));
1148+ let s = format!("{} * {}", render_unit_expr_prec(a, 1), render_unit_expr_prec(b, 2));
11441149 if ctx > 1 { format!("({})", s) } else { s }
11451150 }
11461151 UnitExpr::Div(a, b) => {
11471152 let s = format!("{} / {}", render_unit_expr_prec(a, 1), render_unit_expr_prec(b, 2));
11481153 if ctx > 1 { format!("({})", s) } else { s }
11491154 }
1150− UnitExpr::Pow(a, n) => format!("{}^{}", render_unit_expr_prec(a, 2), n),
1155+ UnitExpr::Pow(a, n) => {
1156+ let s = format!("{}^{}", render_unit_expr_prec(a, 3), n);
1157+ if ctx > 2 { format!("({})", s) } else { s }
1158+ }
11511159 }
11521160}
11531161
@@ -1276,6 +1284,12 @@ fn forces_multiline(e: &Expr) -> bool {
12761284fn render_expr_inline(e: &Expr, parent: Prec) -> String {
12771285 match &e.node {
12781286 ExprKind::Lit(l) => render_literal(l),
1287+ // `yield` is refused by the parser's `can_start_atom` in application
1288+ // argument position (it would be ambiguous with do-block yields), so
1289+ // a Var named `yield` must keep its parens there: `f (yield)`.
1290+ // Head position (Prec::App) must stay bare — `yield x` do-statements
1291+ // are represented as `App(Var("yield"), x)`.
1292+ ExprKind::Var(n) if n == "yield" && parent == Prec::Atom => format!("({})", n),
12791293 ExprKind::Var(n) => n.clone(),
12801294 ExprKind::Constructor(n) => n.clone(),
12811295 ExprKind::SourceRef(n) => format!("*{}", n),
@@ -1948,6 +1962,12 @@ fn render_pat(p: &Pat) -> String {
19481962 PatKind::Constructor { name, payload } => {
19491963 // `Ctor {}` for empty record; otherwise `Ctor {fields}` or `Ctor pat`.
19501964 match &payload.node {
1965+ // A constructor named `Cons` must print WITHOUT a payload
1966+ // atom: `Cons {}` would reparse via the reserved
1967+ // `Cons head tail` path ('{}' becomes the head pattern and
1968+ // the parse fails on a missing tail). A bare `Cons` reparses
1969+ // to Constructor("Cons", Record([])) — exactly this AST.
1970+ PatKind::Record(fields) if fields.is_empty() && name == "Cons" => name.clone(),
19511971 PatKind::Record(fields) if fields.is_empty() => format!("{} {{}}", name),
19521972 _ => format!("{} {}", name, render_pat_atom(payload)),
19531973 }
crates/knot/src/parser.rs+173−6
@@ -34,6 +34,15 @@ pub struct Parser {
3434 /// (unary operators, constructor chaining, type arrows) to prevent
3535 /// stack overflow on pathological input.
3636 recursion_depth: usize,
37+ /// Stack of locally-bound identifiers (lambda params, do-bind and
38+ /// do-let names, `let ... in` names, case pattern binders). Used by
39+ /// `maybe_time_unit` to suppress the `2 ms`/`5 seconds` literal sugar
40+ /// when the would-be unit name is actually a bound variable, so
41+ /// `\ms -> g 2 ms` applies `g` to `2` and `ms` rather than desugaring
42+ /// to `g (2 * 1)`. Top-level declaration names are not tracked (the
43+ /// parser has no module-wide scope), so a top-level `ms = 5` still
44+ /// triggers the sugar.
45+ bound_vars: Vec<Name>,
3746}
3847
3948// ── Public API ──────────────────────────────────────────────────────
@@ -51,6 +60,7 @@ impl Parser {
5160 block_indent: usize::MAX,
5261 delimiter_depth: 0,
5362 recursion_depth: 0,
63+ bound_vars: Vec::new(),
5464 }
5565 }
5666
@@ -104,14 +114,27 @@ impl Parser {
104114
105115const MAX_RECURSION_DEPTH: usize = 256;
106116
117+/// Cost charged by `parse_atom`/`parse_type_atom` per nesting level. The
118+/// expression delimiter cycle (`parse_atom` → `parse_expr` → … → `parse_atom`)
119+/// burns ~10 stack frames per level — far more than the cheap cycles (unary
120+/// chains, type arrows) — so it is charged more of the shared budget to stay
121+/// well within thread stack limits before the guard fires.
122+const DELIMITER_RECURSION_COST: usize = 4;
123+
107124impl Parser {
108125 /// Increment recursion depth and return `true`, or emit an error and
109126 /// return `false` if the limit is exceeded. Callers must decrement
110127 /// `self.recursion_depth` when the recursive call returns.
111128 fn enter_recursion(&mut self) -> bool {
112− self.recursion_depth += 1;
129+ self.enter_recursion_cost(1)
130+ }
131+
132+ /// Like `enter_recursion`, but charges `cost` units of the depth budget.
133+ /// Callers must subtract the same `cost` when the recursive call returns.
134+ fn enter_recursion_cost(&mut self, cost: usize) -> bool {
135+ self.recursion_depth += cost;
113136 if self.recursion_depth > MAX_RECURSION_DEPTH {
114− self.recursion_depth -= 1;
137+ self.recursion_depth -= cost;
115138 let span = self.span();
116139 self.diagnostics.push(
117140 Diagnostic::error("nesting depth limit exceeded")
@@ -123,6 +146,43 @@ impl Parser {
123146 }
124147}
125148
149+// ── Local binding scope tracking ────────────────────────────────────
150+
151+impl Parser {
152+ /// Push every variable bound by `pat` onto the `bound_vars` stack.
153+ /// Callers record `bound_vars.len()` before pushing and truncate back
154+ /// to it when the binder's scope ends.
155+ fn push_pat_vars(&mut self, pat: &Pat) {
156+ match &pat.node {
157+ PatKind::Var(n) => self.bound_vars.push(n.clone()),
158+ PatKind::Wildcard | PatKind::Lit(_) => {}
159+ PatKind::Constructor { payload, .. } => self.push_pat_vars(payload),
160+ PatKind::Record(fields) => {
161+ for f in fields {
162+ match &f.pattern {
163+ Some(sub) => self.push_pat_vars(sub),
164+ // Punned field `{name}` binds the field name itself.
165+ None => self.bound_vars.push(f.name.clone()),
166+ }
167+ }
168+ }
169+ PatKind::List(items) => {
170+ for it in items {
171+ self.push_pat_vars(it);
172+ }
173+ }
174+ PatKind::Cons { head, tail } => {
175+ self.push_pat_vars(head);
176+ self.push_pat_vars(tail);
177+ }
178+ }
179+ }
180+
181+ fn is_bound_var(&self, name: &str) -> bool {
182+ self.bound_vars.iter().any(|v| v == name)
183+ }
184+}
185+
126186// ── Token navigation ────────────────────────────────────────────────
127187
128188impl Parser {
@@ -432,6 +492,7 @@ impl Parser {
432492 _ => unreachable!(),
433493 };
434494 path.push_str(&name);
495+ self.consume_import_dashed_suffix(&mut path);
435496 }
436497 ref tok if tok.keyword_str().is_some() => {
437498 let tok = self.advance();
@@ -442,6 +503,7 @@ impl Parser {
442503 return None;
443504 }
444505 }
506+ self.consume_import_dashed_suffix(&mut path);
445507 }
446508 _ => {
447509 self.error("expected path segment after '/'");
@@ -493,6 +555,40 @@ impl Parser {
493555 let span = Span::new(start.start, end.end);
494556 Some(Import { path, items, span })
495557 }
558+
559+ /// Extend the just-consumed import path segment with `-`-joined parts
560+ /// (`./foo-bar` lexes as `foo`, `-`, `bar`). Only joins when the tokens
561+ /// are span-adjacent (no intervening whitespace), so a following binary
562+ /// minus is never absorbed into the path. Mirrors the dashed-literal
563+ /// handling in `parse_route_path`.
564+ fn consume_import_dashed_suffix(&mut self, path: &mut String) {
565+ loop {
566+ if !self.at(&TokenKind::Minus) {
567+ break;
568+ }
569+ let minus_span = self.span();
570+ if minus_span.start != self.prev_span().end {
571+ break;
572+ }
573+ let Some(next) = self.tokens.get(self.pos + 1) else {
574+ break;
575+ };
576+ if next.span.start != minus_span.end {
577+ break;
578+ }
579+ let part: Option<String> = match &next.kind {
580+ TokenKind::Lower(n) | TokenKind::Upper(n) => Some(n.clone()),
581+ k => k.keyword_str().map(|s| s.to_string()),
582+ };
583+ let Some(part) = part else {
584+ break;
585+ };
586+ self.advance(); // consume `-`
587+ self.advance(); // consume the segment part
588+ path.push('-');
589+ path.push_str(&part);
590+ }
591+ }
496592}
497593
498594// ── Layout block helper ─────────────────────────────────────────────
@@ -504,6 +600,18 @@ impl Parser {
504600 return vec![];
505601 }
506602 let indent = self.column_of(&self.span());
603+ // Layout rule: a nested block's items must be indented strictly past
604+ // the enclosing block's indent. Without this, an empty block (e.g.
605+ // `trait Foo a where` followed by a blank line) silently captures the
606+ // next declaration at column 0 as a block item. Only enforced outside
607+ // delimiters — inside parens/brackets the closing delimiter already
608+ // terminates the block, and column positions are free-form there.
609+ if self.delimiter_depth == 0
610+ && self.block_indent != usize::MAX
611+ && indent <= self.block_indent
612+ {
613+ return vec![];
614+ }
507615 let prev_block_indent = self.block_indent;
508616 self.block_indent = indent;
509617 let mut items = vec![];
@@ -2127,7 +2235,13 @@ impl Parser {
21272235 // `yield` is not a keyword but should not start application atoms
21282236 // (like keywords), to prevent `f; yield x` from parsing as `f yield x`
21292237 // in inline do-blocks where `;` is lexed as Newline.
2130− TokenKind::Lower(n) => n != "yield",
2238+ // While parsing a route's response type (including a refined
2239+ // type's `where` predicate), `headers`/`rateLimit` are clause
2240+ // keywords and must not be consumed as application arguments.
2241+ TokenKind::Lower(n) => {
2242+ n != "yield"
2243+ && !(self.stop_type_at_headers && (n == "headers" || n == "rateLimit"))
2244+ }
21312245 TokenKind::Star => {
21322246 // Source ref `*name` only when `*` is immediately adjacent to a Lower token
21332247 // (no whitespace). This avoids ambiguity with the `*` multiplication operator.
@@ -2206,6 +2320,9 @@ impl Parser {
22062320 /// where factor is the millisecond equivalent.
22072321 fn maybe_time_unit(&mut self, lit: Expr) -> Option<Expr> {
22082322 let factor: Option<&str> = match self.peek() {
2323+ // A locally-bound variable named like a time unit is NOT unit
2324+ // sugar: `\ms -> g 2 ms` must apply `g` to `2` and `ms`.
2325+ TokenKind::Lower(u) if self.is_bound_var(u) => None,
22092326 TokenKind::Lower(u) => match u.as_str() {
22102327 "ms" => Some("1"),
22112328 "seconds" => Some("1000"),
@@ -2252,6 +2369,19 @@ impl Parser {
22522369 }
22532370
22542371 fn parse_atom(&mut self) -> Option<Expr> {
2372+ // Guard recursion here: every expression-side delimiter cycle
2373+ // (parens, records, lists → parse_expr → ... → parse_atom) flows
2374+ // through this entry point, so guarding it prevents stack overflow
2375+ // on pathological input like `((((…))))`.
2376+ if !self.enter_recursion_cost(DELIMITER_RECURSION_COST) {
2377+ return None;
2378+ }
2379+ let result = self.parse_atom_inner();
2380+ self.recursion_depth -= DELIMITER_RECURSION_COST;
2381+ result
2382+ }
2383+
2384+ fn parse_atom_inner(&mut self) -> Option<Expr> {
22552385 let start = self.span();
22562386 match self.peek() {
22572387 TokenKind::Int(_) => {
@@ -2644,7 +2774,13 @@ impl Parser {
26442774
26452775 this.expect(&TokenKind::Arrow, "expected '->' in lambda expression")
26462776 .ok()?;
2647− let body = this.parse_expr()?;
2777+ let scope_mark = this.bound_vars.len();
2778+ for p in &params {
2779+ this.push_pat_vars(p);
2780+ }
2781+ let body = this.parse_expr();
2782+ this.bound_vars.truncate(scope_mark);
2783+ let body = body?;
26482784
26492785 let end_sp = body.span;
26502786 Some(Spanned::new(
@@ -2724,7 +2860,11 @@ impl Parser {
27242860 "expected '->' after pattern in case arm",
27252861 )
27262862 .ok()?;
2727− let body = self.parse_expr()?;
2863+ let scope_mark = self.bound_vars.len();
2864+ self.push_pat_vars(&pat);
2865+ let body = self.parse_expr();
2866+ self.bound_vars.truncate(scope_mark);
2867+ let body = body?;
27282868 Some(CaseArm { pat, body })
27292869 }
27302870
@@ -2733,7 +2873,11 @@ impl Parser {
27332873 self.in_context("do expression", |this| {
27342874 this.advance(); // consume `do`
27352875
2876+ // `parse_stmt` pushes bind/let names so later statements see
2877+ // them as bound; the whole do-block scope ends here.
2878+ let scope_mark = this.bound_vars.len();
27362879 let stmts = this.parse_block(|p| p.parse_stmt());
2880+ this.bound_vars.truncate(scope_mark);
27372881
27382882 let end = this.prev_span();
27392883 Some(Spanned::new(
@@ -2830,7 +2974,11 @@ impl Parser {
28302974 let mut value = this.parse_expr()?;
28312975 this.skip_newlines();
28322976 this.expect(&TokenKind::In, "expected 'in' after let binding").ok()?;
2833− let body = this.parse_expr()?;
2977+ let scope_mark = this.bound_vars.len();
2978+ this.push_pat_vars(&pat);
2979+ let body = this.parse_expr();
2980+ this.bound_vars.truncate(scope_mark);
2981+ let body = body?;
28342982
28352983 // If there is a type annotation, wrap the value as `(value : Type)`
28362984 // so that inference sees the constraint.
@@ -2949,6 +3097,9 @@ impl Parser {
29493097 }
29503098
29513099 let end_sp = expr.span;
3100+ // Names bound by this let are in scope for the rest of the
3101+ // do-block (popped by `parse_do_expr`).
3102+ self.push_pat_vars(&pat);
29523103 return Some(Spanned::new(
29533104 StmtKind::Let { pat, expr },
29543105 Span::new(start.start, end_sp.end),
@@ -2970,6 +3121,9 @@ impl Parser {
29703121 None => return None,
29713122 };
29723123 let end_sp = expr.span;
3124+ // Names bound by this bind are in scope for the rest of the
3125+ // do-block (popped by `parse_do_expr`).
3126+ self.push_pat_vars(&pat);
29733127 return Some(Spanned::new(
29743128 StmtKind::Bind { pat, expr },
29753129 Span::new(start.start, end_sp.end),
@@ -3472,6 +3626,19 @@ impl Parser {
34723626 }
34733627
34743628 fn parse_type_atom(&mut self) -> Option<Type> {
3629+ // Guard recursion here: every type-side delimiter cycle (parens,
3630+ // record types, relation types, variant types → parse_type → ... →
3631+ // parse_type_atom) flows through this entry point, preventing stack
3632+ // overflow on pathological input like `[[[[…]]]]`.
3633+ if !self.enter_recursion_cost(DELIMITER_RECURSION_COST) {
3634+ return None;
3635+ }
3636+ let result = self.parse_type_atom_inner();
3637+ self.recursion_depth -= DELIMITER_RECURSION_COST;
3638+ result
3639+ }
3640+
3641+ fn parse_type_atom_inner(&mut self) -> Option<Type> {
34753642 let start = self.span();
34763643 match self.peek() {
34773644 TokenKind::Upper(_) => {
crates/knot/tests/regress_frontend_fixes2.rsnew file+618
@@ -0,0 +1,618 @@
1+//! Regression tests for frontend fixes (round 2):
2+//!
3+//! 1. A refined route response type (`-> Int where \v -> v > 0`) must not
4+//! swallow the following `headers`/`rateLimit` clauses into the predicate
5+//! expression.
6+//! 2. Deeply nested parens/brackets/braces (expressions and types) must
7+//! produce a "nesting depth limit exceeded" diagnostic, not a stack
8+//! overflow.
9+//! 3. An empty `trait ... where` / `impl ... where` block must not capture
10+//! the next top-level declaration: layout blocks require items indented
11+//! strictly past the enclosing block.
12+//! 4. Formatter parenthesizes a `Pow` base that is itself a unit expression
13+//! (`(M^2)^3` must not print as `M^2^3`).
14+//! 5. Time-unit literal sugar (`2 ms`) is suppressed when the identifier is
15+//! a locally-bound variable (lambda param, do-bind, do-let, let-in, case
16+//! binder).
17+//! 6. Formatter keeps required parens around `yield` in application
18+//! argument position (`f (yield)`).
19+//! 7. A bare `Cons` constructor pattern prints as `Cons`, not `Cons {}`
20+//! (which would reparse via the reserved `Cons head tail` path).
21+//! 8. Formatter parenthesizes the right operand of unit `*`/`/` so
22+//! right-nested products don't re-associate on reparse.
23+//! 9. `import ./foo-bar` — dashed import path segments parse.
24+
25+use knot::ast::{BinOp, DeclKind, ExprKind, PatKind, StmtKind, TypeKind};
26+use knot::diagnostic::Severity;
27+
28+fn parse(source: &str) -> Result<knot::ast::Module, String> {
29+ let lexer = knot::lexer::Lexer::new(source);
30+ let (tokens, lex_diags) = lexer.tokenize();
31+ let lex_errs: Vec<String> = lex_diags
32+ .iter()
33+ .filter(|d| d.severity == Severity::Error)
34+ .map(|d| d.render(source, "<input>"))
35+ .collect();
36+ if !lex_errs.is_empty() {
37+ return Err(format!("lex errors:\n{}", lex_errs.join("\n")));
38+ }
39+ let parser = knot::parser::Parser::new(source.to_string(), tokens);
40+ let (module, diags) = parser.parse_module();
41+ let errs: Vec<String> = diags
42+ .iter()
43+ .filter(|d| d.severity == Severity::Error)
44+ .map(|d| d.render(source, "<input>"))
45+ .collect();
46+ if !errs.is_empty() {
47+ return Err(format!("parse errors:\n{}", errs.join("\n")));
48+ }
49+ Ok(module)
50+}
51+
52+/// `Debug`-printed module with `span` payloads stripped, so structural
53+/// equality tolerates any byte-position drift caused by formatting.
54+/// (Same normalization as `format_roundtrip.rs`.)
55+fn normalize(module: &knot::ast::Module) -> String {
56+ let raw = format!("{:#?}", module);
57+ let mut out = String::with_capacity(raw.len());
58+ let mut chars = raw.chars().peekable();
59+ while let Some(c) = chars.next() {
60+ if c == 's' {
61+ let lookahead: String = chars.clone().take(11).collect();
62+ if lookahead.starts_with("pan: Span {") {
63+ let mut depth = 0;
64+ while let Some(c2) = chars.next() {
65+ if c2 == '{' {
66+ depth += 1;
67+ } else if c2 == '}' {
68+ if depth == 0 {
69+ break;
70+ }
71+ depth -= 1;
72+ }
73+ }
74+ while let Some(&c2) = chars.peek() {
75+ if c2 == ',' || c2 == ' ' || c2 == '\n' {
76+ chars.next();
77+ } else {
78+ break;
79+ }
80+ }
81+ continue;
82+ }
83+ }
84+ out.push(c);
85+ }
86+ out
87+}
88+
89+/// Format `source`, verify the output reparses without diagnostics to the
90+/// same AST (modulo spans), and that formatting is idempotent. Returns the
91+/// formatted text for additional assertions.
92+fn check_str(label: &str, source: &str) -> String {
93+ let m1 = parse(source).unwrap_or_else(|e| panic!("parse {}: {}", label, e));
94+ let f1 = knot::format::format_module(source, &m1);
95+ let m2 = parse(&f1).unwrap_or_else(|e| {
96+ panic!(
97+ "{}: formatted output failed to parse:\n--- output ---\n{}\n--- error ---\n{}",
98+ label, f1, e
99+ )
100+ });
101+ let n1 = normalize(&m1);
102+ let n2 = normalize(&m2);
103+ assert_eq!(
104+ n1, n2,
105+ "{}: AST changed after formatting\n--- output ---\n{}",
106+ label, f1
107+ );
108+ let f2 = knot::format::format_module(&f1, &m2);
109+ assert_eq!(f1, f2, "{}: formatter is not idempotent", label);
110+ f1
111+}
112+
113+// ── 1. Refined route response type + headers/rateLimit clauses ──────
114+
115+fn route_entries(m: &knot::ast::Module) -> &[knot::ast::RouteEntry] {
116+ match &m.decls[0].node {
117+ DeclKind::Route { entries, .. } => entries,
118+ other => panic!("expected Route decl, got {:?}", other),
119+ }
120+}
121+
122+#[test]
123+fn refined_response_type_keeps_rate_limit() {
124+ let src = "route Api where\n POST {name: Text} /items -> Int where \\v -> v > 0 rateLimit {key: \\i -> \\c -> Nothing, limit: {requests: 10, window: 1000}} = CreateItem\n";
125+ let m = parse(src).expect("parse");
126+ let entries = route_entries(&m);
127+ assert_eq!(entries.len(), 1);
128+ let e = &entries[0];
129+ assert!(
130+ e.rate_limit.is_some(),
131+ "rateLimit clause was swallowed by the refined predicate: {:?}",
132+ e
133+ );
134+ // The response type must be `Int where \v -> v > 0` with the predicate
135+ // being exactly the comparison lambda — nothing appended.
136+ let rty = e.response_ty.as_ref().expect("response type");
137+ match &rty.node {
138+ TypeKind::Refined { base, predicate } => {
139+ assert!(
140+ matches!(&base.node, TypeKind::Named(n) if n == "Int"),
141+ "base: {:?}",
142+ base
143+ );
144+ match &predicate.node {
145+ ExprKind::Lambda { params, body } => {
146+ assert_eq!(params.len(), 1);
147+ assert!(
148+ matches!(&body.node, ExprKind::BinOp { op: BinOp::Gt, .. }),
149+ "predicate body absorbed trailing tokens: {:?}",
150+ body
151+ );
152+ }
153+ other => panic!("expected lambda predicate, got {:?}", other),
154+ }
155+ }
156+ other => panic!("expected refined response type, got {:?}", other),
157+ }
158+}
159+
160+#[test]
161+fn refined_response_type_keeps_response_headers() {
162+ let src = "route Api where\n GET /items -> Int where \\v -> v > 0 headers {etag: Text} = GetItems\n";
163+ let m = parse(src).expect("parse");
164+ let e = &route_entries(&m)[0];
165+ assert_eq!(
166+ e.response_headers.len(),
167+ 1,
168+ "headers clause was swallowed by the refined predicate: {:?}",
169+ e
170+ );
171+ assert_eq!(e.response_headers[0].name, "etag");
172+ let rty = e.response_ty.as_ref().expect("response type");
173+ match &rty.node {
174+ TypeKind::Refined { predicate, .. } => match &predicate.node {
175+ ExprKind::Lambda { body, .. } => {
176+ assert!(
177+ matches!(&body.node, ExprKind::BinOp { op: BinOp::Gt, .. }),
178+ "predicate body absorbed trailing tokens: {:?}",
179+ body
180+ );
181+ }
182+ other => panic!("expected lambda predicate, got {:?}", other),
183+ },
184+ other => panic!("expected refined response type, got {:?}", other),
185+ }
186+}
187+
188+#[test]
189+fn refined_response_type_with_rate_limit_round_trips() {
190+ check_str(
191+ "refined_rate_limit",
192+ "route Api where\n POST {name: Text} /items -> Int where \\v -> v > 0 rateLimit {key: \\i -> \\c -> Nothing, limit: {requests: 10, window: 1000}} = CreateItem\n",
193+ );
194+}
195+
196+// ── 2. Deep nesting produces a diagnostic instead of a stack overflow ──
197+
198+fn assert_depth_diagnostic(label: &str, src: &str) {
199+ let err = parse(src).err().unwrap_or_else(|| {
200+ panic!("{}: expected nesting-depth diagnostic, parse succeeded", label)
201+ });
202+ assert!(
203+ err.contains("nesting depth limit exceeded"),
204+ "{}: expected depth diagnostic, got:\n{}",
205+ label,
206+ &err[..err.len().min(500)]
207+ );
208+}
209+
210+#[test]
211+fn deep_nested_parens_expr_is_diagnosed() {
212+ let n = 3000;
213+ let src = format!("f = {}1{}\n", "(".repeat(n), ")".repeat(n));
214+ assert_depth_diagnostic("parens_expr", &src);
215+}
216+
217+#[test]
218+fn deep_nested_brackets_expr_is_diagnosed() {
219+ let n = 3000;
220+ let src = format!("f = {}1{}\n", "[".repeat(n), "]".repeat(n));
221+ assert_depth_diagnostic("brackets_expr", &src);
222+}
223+
224+#[test]
225+fn deep_nested_braces_expr_is_diagnosed() {
226+ let n = 3000;
227+ let src = format!("f = {}1{}\n", "{a: ".repeat(n), "}".repeat(n));
228+ assert_depth_diagnostic("braces_expr", &src);
229+}
230+
231+#[test]
232+fn deep_nested_parens_type_is_diagnosed() {
233+ let n = 3000;
234+ let src = format!("f : {}Int{}\n", "(".repeat(n), ")".repeat(n));
235+ assert_depth_diagnostic("parens_type", &src);
236+}
237+
238+#[test]
239+fn deep_nested_brackets_type_is_diagnosed() {
240+ let n = 3000;
241+ let src = format!("f : {}Int{}\n", "[".repeat(n), "]".repeat(n));
242+ assert_depth_diagnostic("brackets_type", &src);
243+}
244+
245+#[test]
246+fn deep_nested_record_type_is_diagnosed() {
247+ let n = 3000;
248+ let src = format!("f : {}Int{}\n", "{a: ".repeat(n), "}".repeat(n));
249+ assert_depth_diagnostic("braces_type", &src);
250+}
251+
252+#[test]
253+fn moderately_nested_parens_still_parse() {
254+ let n = 50;
255+ let src = format!("f = {}1{}\n", "(".repeat(n), ")".repeat(n));
256+ parse(&src).expect("50-deep parens should parse fine");
257+}
258+
259+// ── 3. Empty trait/impl blocks don't capture the next declaration ──
260+
261+#[test]
262+fn empty_trait_block_does_not_capture_next_decl() {
263+ let src = "trait Foo a where\n\nbar = 1\n";
264+ let m = parse(src).expect("parse");
265+ assert_eq!(m.decls.len(), 2, "decls: {:?}", m.decls);
266+ match &m.decls[0].node {
267+ DeclKind::Trait { items, .. } => {
268+ assert!(items.is_empty(), "trait captured items: {:?}", items)
269+ }
270+ other => panic!("expected Trait, got {:?}", other),
271+ }
272+ assert!(
273+ matches!(&m.decls[1].node, DeclKind::Fun { name, .. } if name == "bar"),
274+ "second decl: {:?}",
275+ m.decls[1]
276+ );
277+}
278+
279+#[test]
280+fn empty_impl_block_does_not_capture_next_decl() {
281+ let src = "impl Show Int where\n\nbar = 2\n";
282+ let m = parse(src).expect("parse");
283+ assert_eq!(m.decls.len(), 2, "decls: {:?}", m.decls);
284+ match &m.decls[0].node {
285+ DeclKind::Impl { items, .. } => {
286+ assert!(items.is_empty(), "impl captured items: {:?}", items)
287+ }
288+ other => panic!("expected Impl, got {:?}", other),
289+ }
290+ assert!(
291+ matches!(&m.decls[1].node, DeclKind::Fun { name, .. } if name == "bar"),
292+ "second decl: {:?}",
293+ m.decls[1]
294+ );
295+}
296+
297+#[test]
298+fn indented_trait_block_still_parses() {
299+ let src = "trait Foo a where\n bar : a -> Text\n baz = \\x -> x\n";
300+ let m = parse(src).expect("parse");
301+ assert_eq!(m.decls.len(), 1);
302+ match &m.decls[0].node {
303+ DeclKind::Trait { items, .. } => assert_eq!(items.len(), 2, "items: {:?}", items),
304+ other => panic!("expected Trait, got {:?}", other),
305+ }
306+}
307+
308+#[test]
309+fn indented_impl_and_serve_and_do_blocks_still_parse() {
310+ let src = "impl Show Int where\n show = \\x -> \"i\"\n\nmain = do\n x <- foo\n yield x\n\nsrv = serve Api where\n GetItems = \\req -> handler req\n";
311+ let m = parse(src).expect("parse");
312+ assert_eq!(m.decls.len(), 3, "decls: {:?}", m.decls);
313+ match &m.decls[0].node {
314+ DeclKind::Impl { items, .. } => assert_eq!(items.len(), 1),
315+ other => panic!("expected Impl, got {:?}", other),
316+ }
317+ match &m.decls[1].node {
318+ DeclKind::Fun { body: Some(b), .. } => match &b.node {
319+ ExprKind::Do(stmts) => assert_eq!(stmts.len(), 2),
320+ other => panic!("expected Do, got {:?}", other),
321+ },
322+ other => panic!("expected Fun, got {:?}", other),
323+ }
324+ match &m.decls[2].node {
325+ DeclKind::Fun { body: Some(b), .. } => match &b.node {
326+ ExprKind::Serve { handlers, .. } => assert_eq!(handlers.len(), 1),
327+ other => panic!("expected Serve, got {:?}", other),
328+ },
329+ other => panic!("expected Fun, got {:?}", other),
330+ }
331+}
332+
333+#[test]
334+fn case_arms_on_same_line_still_parse() {
335+ let src = "f = \\x -> case x of A {} -> 1; B {} -> 2\n";
336+ let m = parse(src).expect("parse");
337+ assert_eq!(m.decls.len(), 1);
338+}
339+
340+// ── 4. Unit Pow base parenthesization round-trips ──────────────────
341+
342+#[test]
343+fn unit_pow_of_pow_round_trips() {
344+ let out = check_str("pow_pow", "unit N = (M^2)^3\n");
345+ assert!(out.contains("(M^2)^3"), "output: {}", out);
346+}
347+
348+#[test]
349+fn unit_pow_of_product_round_trips() {
350+ let out = check_str("pow_mul", "unit N = (M * S)^2\n");
351+ assert!(out.contains("(M * S)^2"), "output: {}", out);
352+}
353+
354+#[test]
355+fn unit_simple_pow_stays_minimal() {
356+ let out = check_str("pow_simple", "unit N = M^2 * S\n");
357+ assert!(out.contains("M^2 * S"), "output: {}", out);
358+}
359+
360+// ── 5. Time-unit sugar suppressed for locally-bound identifiers ──────
361+
362+/// Walk `e` and return true if any node is a Var with the given name.
363+fn expr_mentions_var(e: &knot::ast::Expr, name: &str) -> bool {
364+ match &e.node {
365+ ExprKind::Var(n) => n == name,
366+ ExprKind::App { func, arg } => {
367+ expr_mentions_var(func, name) || expr_mentions_var(arg, name)
368+ }
369+ ExprKind::BinOp { lhs, rhs, .. } => {
370+ expr_mentions_var(lhs, name) || expr_mentions_var(rhs, name)
371+ }
372+ ExprKind::Lambda { body, .. } => expr_mentions_var(body, name),
373+ _ => false,
374+ }
375+}
376+
377+#[test]
378+fn lambda_param_named_ms_is_not_unit_sugar() {
379+ let src = "f = \\ms -> g 2 ms\n";
380+ let m = parse(src).expect("parse");
381+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
382+ panic!("expected Fun");
383+ };
384+ let ExprKind::Lambda { body, .. } = &b.node else {
385+ panic!("expected Lambda, got {:?}", b);
386+ };
387+ // Must be `(g 2) ms` — an application — not `g (2 * 1)`.
388+ assert!(
389+ expr_mentions_var(body, "ms"),
390+ "`ms` was desugared to a unit factor: {:?}",
391+ body
392+ );
393+ match &body.node {
394+ ExprKind::App { arg, .. } => {
395+ assert!(matches!(&arg.node, ExprKind::Var(n) if n == "ms"), "arg: {:?}", arg)
396+ }
397+ other => panic!("expected App, got {:?}", other),
398+ }
399+}
400+
401+#[test]
402+fn do_bind_named_seconds_is_not_unit_sugar() {
403+ let src = "main = do\n seconds <- foo\n yield g 2 seconds\n";
404+ let m = parse(src).expect("parse");
405+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
406+ panic!("expected Fun");
407+ };
408+ let ExprKind::Do(stmts) = &b.node else {
409+ panic!("expected Do, got {:?}", b);
410+ };
411+ let StmtKind::Expr(yield_expr) = &stmts[1].node else {
412+ panic!("expected Expr stmt, got {:?}", stmts[1]);
413+ };
414+ assert!(
415+ expr_mentions_var(yield_expr, "seconds"),
416+ "`seconds` was desugared to a unit factor: {:?}",
417+ yield_expr
418+ );
419+}
420+
421+#[test]
422+fn do_let_named_ms_is_not_unit_sugar() {
423+ let src = "main = do\n let ms = 5\n yield g 2 ms\n";
424+ let m = parse(src).expect("parse");
425+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
426+ panic!("expected Fun");
427+ };
428+ let ExprKind::Do(stmts) = &b.node else {
429+ panic!("expected Do, got {:?}", b);
430+ };
431+ let StmtKind::Expr(yield_expr) = &stmts[1].node else {
432+ panic!("expected Expr stmt, got {:?}", stmts[1]);
433+ };
434+ assert!(
435+ expr_mentions_var(yield_expr, "ms"),
436+ "`ms` was desugared to a unit factor: {:?}",
437+ yield_expr
438+ );
439+}
440+
441+#[test]
442+fn let_in_named_ms_is_not_unit_sugar() {
443+ let src = "f = let ms = 5 in g 2 ms\n";
444+ let m = parse(src).expect("parse");
445+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
446+ panic!("expected Fun");
447+ };
448+ // let-in desugars to `(\ms -> g 2 ms) 5`.
449+ let ExprKind::App { func, .. } = &b.node else {
450+ panic!("expected App, got {:?}", b);
451+ };
452+ let ExprKind::Lambda { body, .. } = &func.node else {
453+ panic!("expected Lambda, got {:?}", func);
454+ };
455+ assert!(
456+ expr_mentions_var(body, "ms"),
457+ "`ms` was desugared to a unit factor: {:?}",
458+ body
459+ );
460+}
461+
462+#[test]
463+fn case_binder_named_ms_is_not_unit_sugar() {
464+ let src = "f = \\x -> case x of\n Just {value: ms} -> g 2 ms\n Nothing {} -> 0\n";
465+ let m = parse(src).expect("parse");
466+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
467+ panic!("expected Fun");
468+ };
469+ let ExprKind::Lambda { body, .. } = &b.node else {
470+ panic!("expected Lambda, got {:?}", b);
471+ };
472+ let ExprKind::Case { arms, .. } = &body.node else {
473+ panic!("expected Case, got {:?}", body);
474+ };
475+ assert!(
476+ expr_mentions_var(&arms[0].body, "ms"),
477+ "`ms` was desugared to a unit factor: {:?}",
478+ arms[0].body
479+ );
480+}
481+
482+#[test]
483+fn unbound_time_unit_sugar_still_desugars() {
484+ // No local binding named `ms` — `2 ms` is unit sugar: `2 * 1`.
485+ let src = "main = sleep (2 ms)\n";
486+ let m = parse(src).expect("parse");
487+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
488+ panic!("expected Fun");
489+ };
490+ let ExprKind::App { arg, .. } = &b.node else {
491+ panic!("expected App, got {:?}", b);
492+ };
493+ assert!(
494+ matches!(&arg.node, ExprKind::BinOp { op: BinOp::Mul, .. }),
495+ "expected `2 ms` to desugar to multiplication, got {:?}",
496+ arg
497+ );
498+}
499+
500+#[test]
501+fn time_unit_sugar_unaffected_after_binder_scope_ends() {
502+ // The `\ms -> ...` scope is closed by the time `h` is parsed.
503+ let src = "f = \\ms -> ms\nh = sleep (3 seconds)\n";
504+ let m = parse(src).expect("parse");
505+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[1].node else {
506+ panic!("expected Fun");
507+ };
508+ let ExprKind::App { arg, .. } = &b.node else {
509+ panic!("expected App, got {:?}", b);
510+ };
511+ assert!(
512+ matches!(&arg.node, ExprKind::BinOp { op: BinOp::Mul, .. }),
513+ "expected `3 seconds` to desugar to multiplication, got {:?}",
514+ arg
515+ );
516+}
517+
518+// ── 6. `yield` keeps parens in argument position ────────────────────
519+
520+#[test]
521+fn yield_in_argument_position_round_trips() {
522+ let out = check_str("yield_arg", "g = f (yield)\n");
523+ assert!(out.contains("f (yield)"), "output: {}", out);
524+}
525+
526+#[test]
527+fn yield_statement_stays_bare() {
528+ let out = check_str("yield_stmt", "main = do\n x <- foo\n yield x\n");
529+ assert!(out.contains("yield x"), "output: {}", out);
530+ assert!(!out.contains("(yield)"), "output: {}", out);
531+}
532+
533+// ── 7. Bare `Cons` constructor pattern round-trips ──────────────────
534+
535+#[test]
536+fn bare_cons_pattern_round_trips() {
537+ let src = "f = \\x -> case x of\n Cons -> 1\n _ -> 2\n";
538+ let m = parse(src).expect("parse");
539+ // Verify the parse really is Constructor("Cons", Record([])).
540+ let DeclKind::Fun { body: Some(b), .. } = &m.decls[0].node else {
541+ panic!("expected Fun");
542+ };
543+ let ExprKind::Lambda { body, .. } = &b.node else {
544+ panic!("expected Lambda");
545+ };
546+ let ExprKind::Case { arms, .. } = &body.node else {
547+ panic!("expected Case, got {:?}", body);
548+ };
549+ match &arms[0].pat.node {
550+ PatKind::Constructor { name, payload } => {
551+ assert_eq!(name, "Cons");
552+ assert!(
553+ matches!(&payload.node, PatKind::Record(fs) if fs.is_empty()),
554+ "payload: {:?}",
555+ payload
556+ );
557+ }
558+ other => panic!("expected Constructor pattern, got {:?}", other),
559+ }
560+ let out = check_str("bare_cons", src);
561+ assert!(!out.contains("Cons {}"), "output: {}", out);
562+}
563+
564+#[test]
565+fn cons_head_tail_pattern_still_round_trips() {
566+ let out = check_str(
567+ "cons_head_tail",
568+ "f = \\x -> case x of\n Cons h t -> 1\n _ -> 2\n",
569+ );
570+ assert!(out.contains("Cons h t"), "output: {}", out);
571+}
572+
573+// ── 8. Right-nested unit products keep parens ───────────────────────
574+
575+#[test]
576+fn right_nested_unit_product_round_trips() {
577+ let out = check_str("unit_mul_right", "unit N = M * (S * Kg)\n");
578+ assert!(out.contains("M * (S * Kg)"), "output: {}", out);
579+}
580+
581+#[test]
582+fn right_nested_unit_quotient_round_trips() {
583+ let out = check_str("unit_div_right", "unit N = M / (S / Kg)\n");
584+ assert!(out.contains("M / (S / Kg)"), "output: {}", out);
585+}
586+
587+#[test]
588+fn left_nested_unit_product_stays_minimal() {
589+ let out = check_str("unit_mul_left", "unit N = M * S * Kg\n");
590+ assert!(out.contains("M * S * Kg"), "output: {}", out);
591+ assert!(!out.contains("("), "output: {}", out);
592+}
593+
594+// ── 9. Dashed import path segments ──────────────────────────────────
595+
596+#[test]
597+fn dashed_import_path_parses() {
598+ let src = "import ./foo-bar\n\nmain = 1\n";
599+ let m = parse(src).expect("parse");
600+ assert_eq!(m.imports.len(), 1);
601+ assert_eq!(m.imports[0].path, "./foo-bar");
602+}
603+
604+#[test]
605+fn dashed_import_path_with_items_parses() {
606+ let src = "import ../lib/my-utils-extra (helper, Thing)\n\nmain = 1\n";
607+ let m = parse(src).expect("parse");
608+ assert_eq!(m.imports.len(), 1);
609+ assert_eq!(m.imports[0].path, "../lib/my-utils-extra");
610+ let items = m.imports[0].items.as_ref().expect("items");
611+ assert_eq!(items.len(), 2);
612+}
613+
614+#[test]
615+fn dashed_import_round_trips() {
616+ let out = check_str("dashed_import", "import ./foo-bar\n\nmain = 1\n");
617+ assert!(out.contains("import ./foo-bar"), "output: {}", out);
618+}
unit_skolem.knotnew file+7
@@ -0,0 +1,7 @@
1+unit M
2+unit S
3+
4+f : Float<u> -> Float<u>
5+f = \x -> x + 1.0<M>
6+
7+main = println (show (f 2.0<S>))