diff --git a/runtime/ruby/action_controller/authenticity_token.rb b/runtime/ruby/action_controller/authenticity_token.rb index cfd1a0ae3..8da35ca2e 100644 --- a/runtime/ruby/action_controller/authenticity_token.rb +++ b/runtime/ruby/action_controller/authenticity_token.rb @@ -84,4 +84,9 @@ def self.masked_authenticity_token def self.csrf_token_valid?(given, expected) AuthenticityToken.valid?(given, expected) end + + # Real masked tokens are available — turn the shared flag on. Extras + # that omit this file keep the base.rb default (off) so stub-empty + # tokens do not fail-closed every POST. + set_forgery_flag(true) end diff --git a/runtime/ruby/action_controller/base.rb b/runtime/ruby/action_controller/base.rb index 3d89eb060..72d4d4caa 100644 --- a/runtime/ruby/action_controller/base.rb +++ b/runtime/ruby/action_controller/base.rb @@ -5,7 +5,14 @@ module ActionController # One-slot array so class-level CSRF state is a store every target # can index, not a `self` ivar or `class << self` writer. - FORGERY_SLOT = [true] + # + # Default OFF: extras transpile this file without + # `authenticity_token.rb` (empty `masked_authenticity_token` stub), + # so fail-closed CSRF turns every POST into 422. The ruby-family + # reopen in authenticity_token.rb flips the flag on when the real + # masked-token implementation is present. Tests that need the check + # set `allow_forgery_protection = true` explicitly. + FORGERY_SLOT = [false] def self.forgery_flag FORGERY_SLOT[0] == true @@ -78,27 +85,41 @@ def self.sanitize_location(path) s = s.gsub(REDIRECT_LINE_BREAK_PATTERN, REDIRECT_LINE_BREAKS) end s = s.tr("\\", "/") - # No `break`: go/typescript emit cannot lower it (MCP wont_lower - # and the TS real-blog gate both flagged this walk). - keep = true - while keep && s.length > 0 - c = s[0, 1].to_s - if c == " " || header_control?(c) - s = s[1, s.length].to_s - else - keep = false + # Index-walk strip helpers (one counter while each). Not + # `while keep && …` (Elixir BoolOp) and not two whiles in this + # method (while_to_recursion allows one top-level while per method). + s = strip_leading_controls(s) + s = strip_trailing_controls(s) + s + end + + def self.strip_leading_controls(s) + # Index walk — not per-char recursion (stack-safe on long pads). + # Canonical counter `while` so Elixir while_to_recursion applies: + # early return when a kept char is found; trailing `i += 1` step. + n = s.length + i = 0 + while i < n + c = s[i, 1].to_s + if !(c == " " || header_control?(c)) + return s[i, n - i].to_s end + i += 1 end - keep = true - while keep && s.length > 0 - c = s[s.length - 1, 1].to_s - if c == " " || header_control?(c) - s = s[0, s.length - 1].to_s - else - keep = false + "" + end + + def self.strip_trailing_controls(s) + n = s.length + i = n + while i > 0 + c = s[i - 1, 1].to_s + if !(c == " " || header_control?(c)) + return s[0, i].to_s end + i -= 1 end - s + "" end # Host of an absolute URL (`http://h/path`), or "" when the value is @@ -569,11 +590,14 @@ def send_data(data, type: "application/octet-stream", disposition: "attachment") # `authenticity_token` or `X-CSRF-Token`. An empty session token # matches nothing (fail closed). Tests set # `allow_forgery_protection = false`. + # + # No trailing `nil`: Elixir's mutation-threaded emit would assign + # the `unless`/`render` result to `record` and then discard it, + # tripping `--warnings-as-errors` on an unused variable. def verify_authenticity_token unless verified_request? render "

422 Unprocessable Content

", status: :unprocessable_content end - nil end def verified_request? @@ -581,7 +605,11 @@ def verified_request? verb = @request_method.to_s return true if verb == "" || verb == "GET" || verb == "HEAD" expected = session[:_csrf_token].to_s - return true if ActionController.csrf_token_valid?(params["authenticity_token"].to_s, expected) + # `.fetch(k, "")` — not bare `params[k]`. Crystal Hash#[] raises + # KeyError on a missing key; Python's `.get(k)` returns None and + # `.to_s` then AttributeErrors. Cross-target nil-safe read. + token = params.fetch("authenticity_token", "") + return true if ActionController.csrf_token_valid?(token.to_s, expected) ActionController.csrf_token_valid?(csrf_header_token, expected) end diff --git a/runtime/ruby/action_controller/base.rbs b/runtime/ruby/action_controller/base.rbs index f4623569f..fffec5fa4 100644 --- a/runtime/ruby/action_controller/base.rbs +++ b/runtime/ruby/action_controller/base.rbs @@ -3,6 +3,8 @@ module ActionController def self.header_value_ok?: (String? v) -> bool def self.header_control?: (String c) -> bool def self.sanitize_location: (String path) -> String + def self.strip_leading_controls: (String s) -> String + def self.strip_trailing_controls: (String s) -> String def self.location_host: (String url) -> String def self.find_substr: (String hay, String needle) -> Integer def self.find_last: (String hay, String needle) -> Integer diff --git a/src/emit/crystal/expr.rs b/src/emit/crystal/expr.rs index 1b278988e..a55bb4fc1 100644 --- a/src/emit/crystal/expr.rs +++ b/src/emit/crystal/expr.rs @@ -1517,7 +1517,15 @@ pub(super) fn emit_send_base( recv_s }; if args_s.is_empty() { - format!("{recv_s}.{method}") + // Crystal `String#size` / `Array#size` return `Int32`; + // Roundhouse `Ty::Int` is `Int64`. Cast so returns and + // locals typed Int64 (e.g. `find_last`) typecheck — + // `return i` where `i = hay.size - n` was Int32. + if method == "size" { + format!("{recv_s}.{method}.to_i64") + } else { + format!("{recv_s}.{method}") + } } else if parenthesized { format!("{recv_s}.{method}({})", args_s.join(", ")) } else { diff --git a/src/emit/csharp/expr.rs b/src/emit/csharp/expr.rs index 5b511498a..08715d5c2 100644 --- a/src/emit/csharp/expr.rs +++ b/src/emit/csharp/expr.rs @@ -39,8 +39,13 @@ thread_local! { static DECLARED: RefCell> = RefCell::new(HashSet::new()); /// For locals first assigned `nil`, the nullable C# type taken from a /// later non-nil assignment — so `var x = null` (illegal in C#) becomes - /// `T? x = null`. + /// `T? x = null`. Also filled for the first typed assign with a `?` + /// suffix (nil-first path); hoist must not treat that alone as proof + /// of a nil write — see `SAW_NIL`. static NIL_TYPES: RefCell> = RefCell::new(HashMap::new()); + /// Locals that are actually assigned a `nil` literal somewhere in the + /// method. Hoisted primitives stay non-nullable unless listed here. + static SAW_NIL: RefCell> = RefCell::new(HashSet::new()); /// For locals first assigned an empty `{}`/`[]`, the C# container type /// inferred from later `map[k]=v` / `list << x` — so the empty literal /// gets a precise `new List()` instead of `object?`. @@ -327,6 +332,24 @@ fn recv_is_hash(r: &Expr) -> bool { if matches!(instance_prop_ty(name.as_str()), Some(crate::ty::Ty::Hash { .. }))) } +/// Key/value C# types for a Hash-typed receiver. Falls back to +/// `string, object?` when the Hash shape is unknown (Untyped bag). +fn hash_kv_tys(r: &Expr) -> (String, String) { + let hash_ty = match &*r.node { + ExprNode::Ivar { name } => instance_prop_ty(name.as_str()), + _ => None, + }; + let hash_ty = hash_ty.as_ref().or(r.ty.as_ref()); + let Some(t) = hash_ty else { + return ("string".into(), "object?".into()); + }; + // Peel `Hash | Nil` so a nullable opts hash still copies as Dictionary. + match t.peel_nilable() { + crate::ty::Ty::Hash { key, value } => (csharp_ty(key), csharp_ty(value)), + _ => ("string".into(), "object?".into()), + } +} + fn recv_is_array(r: &Expr) -> bool { if ty_is(r.ty.as_ref(), |t| matches!(t, crate::ty::Ty::Array { .. })) { return true; @@ -336,6 +359,51 @@ fn recv_is_array(r: &Expr) -> bool { if matches!(instance_prop_ty(name.as_str()), Some(crate::ty::Ty::Array { .. }))) } +/// Elem type of an Array-typed receiver, peeling a nullable outer `Array[T]?`. +fn array_elem_ty(r: &Expr) -> Option { + // Instance-property types apply only to `@ivar` reads. A local + // `Var` that shadows a same-named property must use `r.ty` so a + // nullable-string array param is not coerced as if it were the + // non-nullable property. + let from_prop = match &*r.node { + ExprNode::Ivar { name } => instance_prop_ty(name.as_str()), + _ => None, + }; + let array_ty = from_prop.as_ref().or(r.ty.as_ref()); + match array_ty { + Some(crate::ty::Ty::Array { elem }) => Some((**elem).clone()), + Some(crate::ty::Ty::Union { variants }) => variants.iter().find_map(|t| match t { + crate::ty::Ty::Array { elem } => Some((**elem).clone()), + _ => None, + }), + _ => None, + } +} + +/// `string?` into `List` (HeaderStore `@vals << value` after the +/// nil-rejecting header_value_ok? guard). With `enable`, +/// CS8604 fails the write; coalesce to `""`. +fn coerce_list_elem_arg(recv: &Expr, arg: &Expr, arg_s: &str) -> String { + let Some(elem) = array_elem_ty(recv) else { + return arg_s.to_string(); + }; + let elem_is_plain_str = matches!(elem, crate::ty::Ty::Str | crate::ty::Ty::Sym); + let arg_nilable_str = matches!( + arg.ty.as_ref(), + Some(crate::ty::Ty::Union { variants }) + if variants.len() == 2 + && variants.iter().any(|v| matches!(v, crate::ty::Ty::Nil)) + && variants + .iter() + .any(|v| matches!(v, crate::ty::Ty::Str | crate::ty::Ty::Sym)) + ); + if elem_is_plain_str && arg_nilable_str { + format!("({arg_s} ?? \"\")") + } else { + arg_s.to_string() + } +} + fn is_instance_method_of(class_name: &str, method: &str) -> bool { let cm = camel(method); let mut cur = Some(class_name.to_string()); @@ -507,10 +575,12 @@ pub(super) fn set_returns_unit(b: bool) { pub(super) fn begin_method(body: &Expr) { let mut counts: HashMap = HashMap::new(); let mut nil_types: HashMap = HashMap::new(); - count_assigns(body, &mut counts, &mut nil_types); + let mut saw_nil: HashSet = HashSet::new(); + count_assigns(body, &mut counts, &mut nil_types, &mut saw_nil); DECLARED.with(|d| d.borrow_mut().clear()); LOOP_ID.with(|c| *c.borrow_mut() = 0); NIL_TYPES.with(|t| *t.borrow_mut() = nil_types); + SAW_NIL.with(|s| *s.borrow_mut() = saw_nil); let mut container_types: HashMap = HashMap::new(); scan_container_types(body, &mut container_types); @@ -530,9 +600,20 @@ pub(super) fn begin_method(body: &Expr) { // Prefer the nullable nil-first type (a `result` assigned // `null` then `Article` should hoist as `Article?`, not // `object?`), so the eventual `return result` type-checks. - let ty = NIL_TYPES - .with(|t| t.borrow().get(n).cloned()) - .unwrap_or_else(|| ty.clone()); + // Do NOT widen primitives merely because count_assigns + // tagged the first typed assign with `?` — that made + // `if (found)` fail CS0266 (`bool?` → `bool`). Keep the + // plain primitive unless the body actually assigns nil. + let ty = match ty.as_str() { + "bool" | "long" | "int" | "double" | "string" + if !SAW_NIL.with(|s| s.borrow().contains(n)) => + { + ty.clone() + } + _ => NIL_TYPES + .with(|t| t.borrow().get(n).cloned()) + .unwrap_or_else(|| ty.clone()), + }; format!("{ty} {n} = {};", cs_default(&ty)) }) .collect(); @@ -682,6 +763,7 @@ fn count_assigns( e: &Expr, counts: &mut HashMap, nil_types: &mut HashMap, + saw_nil: &mut HashSet, ) { if let ExprNode::OpAssign { target: LValue::Var { name, .. }, .. } = &*e.node { *counts.entry(camel(name.as_str())).or_insert(0) += 2; @@ -689,6 +771,11 @@ fn count_assigns( if let ExprNode::Assign { target: LValue::Var { name, .. }, value } = &*e.node { let cn = camel(name.as_str()); *counts.entry(cn.clone()).or_insert(0) += 1; + if matches!(&*value.node, ExprNode::Lit { value: Literal::Nil }) + || matches!(value.ty.as_ref(), Some(crate::ty::Ty::Nil)) + { + saw_nil.insert(cn.clone()); + } if !nil_types.contains_key(&cn) { if let Some(ty) = value.ty.as_ref() { if !matches!(ty, crate::ty::Ty::Nil) { @@ -702,7 +789,7 @@ fn count_assigns( } } for child in children(e) { - count_assigns(child, counts, nil_types); + count_assigns(child, counts, nil_types, saw_nil); } } @@ -1360,7 +1447,7 @@ fn emit_send( if let ExprNode::Range { begin, end, exclusive } = &*args[0].node { return emit_slice_range(&rs, begin.as_ref(), end.as_ref(), *exclusive); } - if matches!(r.ty.as_ref(), Some(crate::ty::Ty::Array { .. })) { + if recv_is_array(r) { return format!("{rs}[(int)({})]", args_s[0]); } // Ruby `Hash#[]` returns nil for a missing key; C#'s Dictionary @@ -1386,7 +1473,8 @@ fn emit_send( return format!("{} {} {}", emit_expr(r), op, args_s[0]); } crate::emit::shared::ops::BinopCase::Append => { - return format!("{}.Add({})", emit_expr(r), args_s[0]); + let arg = coerce_list_elem_arg(r, &args[0], &args_s[0]); + return format!("{}.Add({})", emit_expr(r), arg); } crate::emit::shared::ops::BinopCase::NotBinop => {} } @@ -1406,7 +1494,17 @@ fn emit_send( } if let (Some(r), 2) = (recv, args.len()) { if method == "[]=" { - return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]); + let idx = if list_index_needs_int_cast(r, &args[0]) { + format!("(int)({})", args_s[0]) + } else { + args_s[0].clone() + }; + let val = if recv_is_hash(r) { + args_s[1].clone() + } else { + coerce_list_elem_arg(r, &args[1], &args_s[1]) + }; + return format!("{}[{}] = {}", emit_expr(r), idx, val); } if method == "fetch" { return format!("({}.GetValueOrDefault({}, {}))", emit_expr(r), args_s[0], args_s[1]); @@ -1465,6 +1563,15 @@ fn emit_send( } "keys" if recv_is_hash(r) => return format!("{rs}.Keys.ToList()"), "values" if recv_is_hash(r) => return format!("{rs}.Values.ToList()"), + // Hash#dup must copy: see kotlin form_with (attrs.delete + // must not mutate the caller's opts). Preserve the + // receiver's Dictionary — Dictionary is invariant, so + // `new Dictionary(dict)` does not compile + // when `dict` is `Dictionary`. + "dup" if recv_is_hash(r) => { + let (k, v) = hash_kv_tys(r); + return format!("new Dictionary<{k}, {v}>({rs})"); + } "freeze" | "dup" | "to_a" => return rs, "to_h" if recv_is_hash(r) => return rs, _ => {} @@ -1815,7 +1922,12 @@ fn emit_case_stmt(scrutinee: &Expr, arms: &[Arm]) -> String { } fn emit_assign(target: &LValue, value: &Expr) -> String { - let val = emit_expr(value); + let val = match target { + LValue::Index { recv, .. } if !recv_is_hash(recv) => { + coerce_list_elem_arg(recv, value, &emit_expr(value)) + } + _ => emit_expr(value), + }; match target { LValue::Var { name, .. } => { let n = camel(name.as_str()); @@ -1878,11 +1990,27 @@ fn lvalue_ref(target: &LValue) -> String { LValue::Var { name, .. } => camel(name.as_str()), LValue::Ivar { name } => format!("this.{}", ivar_name(name.as_str())), LValue::Attr { recv, name } => format!("{}.{}", emit_expr(recv), pascal(name.as_str())), - LValue::Index { recv, index } => format!("{}[{}]", emit_expr(recv), emit_expr(index)), + LValue::Index { recv, index } => { + let idx = if list_index_needs_int_cast(recv, index) { + format!("(int)({})", emit_expr(index)) + } else { + emit_expr(index) + }; + format!("{}[{}]", emit_expr(recv), idx) + } LValue::Const { path } => path.iter().map(|s| s.to_string()).collect::>().join("."), } } +/// True when `recv[index]` is a List/Array access (not a Dictionary) +/// that needs a C# `(int)` cast from the IR's `long` index. +fn list_index_needs_int_cast(recv: &Expr, index: &Expr) -> bool { + if recv_is_hash(recv) { + return false; + } + recv_is_array(recv) || matches!(index.ty.as_ref(), Some(crate::ty::Ty::Int)) +} + fn emit_op_assign(target: &LValue, op: OpAssignOp, value: &Expr) -> String { let lhs = lvalue_ref(target); let v = emit_expr(value); diff --git a/src/emit/elixir/expr.rs b/src/emit/elixir/expr.rs index c5dd90b90..34b355ae6 100644 --- a/src/emit/elixir/expr.rs +++ b/src/emit/elixir/expr.rs @@ -852,6 +852,13 @@ fn emit_send(recv: Option<&Expr>, method: &str, args: &[Expr]) -> String { return s; } } + // Mutable SCREAMING_SNAKE constants (`FORGERY_SLOT[0] = …`) — + // module attributes are compile-time only, so index assign/ + // read go through the process dictionary with the attribute + // as the unset default. + if let Some(s) = emit_const_slot_send(r, method, args) { + return s; + } } // Ruby stdlib module calls (`Base64`, `JSON`) → their Elixir @@ -905,9 +912,10 @@ fn emit_send(recv: Option<&Expr>, method: &str, args: &[Expr]) -> String { return "__MODULE__".to_string(); } - // `recv.__index_put__(k, v)` (from local_accumulation's `x[k]=v`) - // rendered by receiver type: a struct routes to its `put` setter, - // a map (or unknown) to `Map.put`. + // `recv.__index_put__(k, v)` (from local_accumulation's `x[k]=v` + // and mutation_to_struct_return's `@x[k]=v`) rendered by receiver + // type: a struct routes to its `put` setter; an Int-indexed write + // is a List slot (`List.replace_at`); otherwise Map.put. if method == "__index_put__" && args.len() == 2 { if let Some(r) = recv { let r_s = emit_expr(r); @@ -916,6 +924,18 @@ fn emit_send(recv: Option<&Expr>, method: &str, args: &[Expr]) -> String { let module = super::library::v2_module_name(id.0.as_str()); return format!("{module}.put({r_s}, {k}, {v})"); } + // Array slot write: HeaderStore `@vals[i] = value` and similar. + // Int index → List; Hash key → Map. + let index_is_int = matches!( + args[0].ty.as_ref(), + Some(crate::ty::Ty::Int) + ) || matches!( + effective_recv_ty(r), + Some(crate::ty::Ty::Array { .. }) + ); + if index_is_int { + return format!("List.replace_at({r_s}, {k}, {v})"); + } return format!("Map.put({r_s}, {k}, {v})"); } } @@ -1565,7 +1585,7 @@ fn emit_send(recv: Option<&Expr>, method: &str, args: &[Expr]) -> String { format!("{fname}(record, {})", arg_strs.join(", ")) }; } - format!("{}({})", method, arg_strs.join(", ")) + format!("{fname}({})", arg_strs.join(", ")) } Some(r) => { let r_s = emit_expr(r); @@ -2066,6 +2086,35 @@ fn emit_ivar_state_send(name: &str, method: &str, args: &[Expr]) -> Option Option { + let ExprNode::Const { path } = &*recv.node else { + return None; + }; + if path.len() != 1 || !is_screaming_snake(path[0].as_str()) { + return None; + } + let name = path[0].as_str(); + if !DECLARED_CONSTANTS.with(|c| c.borrow().contains(name)) { + return None; + } + let attr = format!("@{}", name.to_lowercase()); + let key = format!(":rh_const_{}", name.to_lowercase()); + let get = format!("Process.get({key}, {attr})"); + match (method, args.len()) { + ("[]=", 2) => Some(format!( + "Process.put({key}, List.replace_at({get}, {}, {}))", + emit_expr(&args[0]), + emit_expr(&args[1]) + )), + ("[]", 1) => Some(format!("Enum.at({get}, {})", emit_expr(&args[0]))), + _ => None, + } +} + /// True when `e` is structurally a String — a string literal, /// interpolation, a `Str`-typed node, or a `+` concatenation whose left /// operand is itself string-rooted. Used to recognize string concat diff --git a/src/emit/elixir/library.rs b/src/emit/elixir/library.rs index 29e30d10c..8192580dd 100644 --- a/src/emit/elixir/library.rs +++ b/src/emit/elixir/library.rs @@ -542,20 +542,51 @@ pub(super) fn references_var(e: &Expr, name: &str) -> bool { } /// Map a Ruby method name to a legal Elixir function name. `?`/`!` -/// suffixes are valid in Elixir and pass through. The indexing -/// operators `[]`/`[]=` (illegal as Elixir function names) become -/// `get`/`put`; a writer `foo=` becomes `set_foo`. +/// suffixes are valid in Elixir and pass through when they *terminate* +/// the name. The indexing operators `[]`/`[]=` (illegal as Elixir +/// function names) become `get`/`put`; a writer `foo=` becomes +/// `set_foo`. +/// +/// While→recursion helpers append `__loop` to the Ruby name +/// (`header_key_ok?` → `header_key_ok?__loop`). Elixir identifiers may +/// *end* in `?`/`!` but cannot continue after them — `?_` is a +/// character literal — so mid-name `?`/`!` become `_p`/`_b` before the +/// suffix. pub(super) fn elixir_fn_name(name: &str) -> String { + // Indexing operators and their while→recursion helpers (`[]__loop`, + // `[]=__loop`) are illegal Elixir identifiers — map to get/put. + if let Some(rest) = name.strip_prefix("[]=__") { + return format!("put__{rest}"); + } + if let Some(rest) = name.strip_prefix("[]__") { + return format!("get__{rest}"); + } match name { "[]" => return "get".to_string(), "[]=" => return "put".to_string(), _ => {} } if let Some(base) = name.strip_suffix('=') { - format!("set_{base}") - } else { - name.to_string() + // `foo=` writer — but not `foo?=` / mangled forms. + if !base.ends_with(['?', '!']) { + return format!("set_{base}"); + } + } + // `pred?__loop` / `bang!__loop`: rewrite the mid-name punct so the + // identifier stays legal (`pred_p__loop` / `bang_b__loop`). A + // trailing `?`/`!` (end of name) falls through to the catch-all and + // is preserved. + let mut out = String::with_capacity(name.len() + 2); + let chars: Vec = name.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + let next = chars.get(i + 1).copied(); + match c { + '?' if next.is_some() => out.push_str("_p"), + '!' if next.is_some() => out.push_str("_b"), + _ => out.push(c), + } } + out } #[cfg(test)] @@ -608,4 +639,15 @@ mod tests { }); assert!(references_var(&bare, "notice")); } + + #[test] + fn elixir_fn_name_rewrites_mid_pred_before_loop_suffix() { + // Trailing `?`/`!` stay; mid-name (before `__loop`) cannot. + assert_eq!(elixir_fn_name("header_key_ok?"), "header_key_ok?"); + assert_eq!(elixir_fn_name("header_key_ok?__loop"), "header_key_ok_p__loop"); + assert_eq!(elixir_fn_name("bang!__loop"), "bang_b__loop"); + assert_eq!(elixir_fn_name("[]__loop"), "get__loop"); + assert_eq!(elixir_fn_name("[]=__loop"), "put__loop"); + assert_eq!(elixir_fn_name("title="), "set_title"); + } } diff --git a/src/emit/go/expr.rs b/src/emit/go/expr.rs index d20a1693f..b1f831236 100644 --- a/src/emit/go/expr.rs +++ b/src/emit/go/expr.rs @@ -159,6 +159,20 @@ impl EmitCtx { child.declared = Rc::new(RefCell::new(snapshot)); child } + + /// Value-position IIFE body (`func() T { … }()`). Cleared of + /// `void_method` so string/ternary tails emit `return "…"`, even + /// when the enclosing Ruby method is `() -> void` (e.g. `send_data` + /// assigning `disp = cond ? "inline" : "attachment"`). Also clears + /// `return_ty` so branch returns are not coerced to the outer + /// method's type (a string IIFE inside an `Int`-returning method + /// must not emit `return int64("…")`). + pub fn value_iife(&self) -> Self { + let mut child = self.enter_scope(); + child.void_method = false; + child.return_ty = None; + child + } } /// Render a Go expression in its declaration context, selecting whole-call primitives first. @@ -1126,7 +1140,7 @@ pub(super) fn emit_send( let value = &args[0]; let v = if matches!(&*value.node, ExprNode::If { .. } | ExprNode::Case { .. }) { let ret_ty = super::ty::go_ty_stub(value.ty.as_ref()); - let body = emit_return_body(ctx, value); + let body = emit_return_body(&ctx.value_iife(), value); let indented = body .lines() .map(|l| format!("\t{l}")) @@ -2635,7 +2649,7 @@ fn emit_assign(ctx: &EmitCtx, target: &crate::expr::LValue, value: &Expr) -> Str // above — using the narrow type lets the resulting assignment // typecheck against typed slots without callsite assertion. let ret_ty = super::ty::go_ty_stub(value.ty.as_ref()); - let body = emit_return_body(ctx, value); + let body = emit_return_body(&ctx.value_iife(), value); let indented = body .lines() .map(|l| format!("\t{l}")) @@ -3216,6 +3230,16 @@ fn is_known_class_method(name: &str) -> bool { // The value half of the Dirty read surface, called by the // synthesized `_previously_was` readers. | "attribute_previously_was" + // Rails' implicit `protect_from_forgery` preamble emits a bare + // `verify_authenticity_token` on Self. Controllers inherit it + // via Go embedding of Base, so it is absent from the subclass + // `self_methods` set — without parens, `go vet` flags + // `self.VerifyAuthenticityToken` as an unused method value. + | "verify_authenticity_token" + // NOTE: do NOT force-parens `performed?` — Base emits it as the + // `Performed` struct field (trivial ivar reader), so the call + // site must stay a bare field read (`self.Performed`), not + // `self.PerformedPred()`. ) } @@ -3421,7 +3445,7 @@ pub(super) fn emit_return_body(ctx: &EmitCtx, e: &Expr) -> String { pub(super) fn emit_expr_as_value(ctx: &EmitCtx, e: &Expr) -> String { if matches!(&*e.node, ExprNode::If { .. } | ExprNode::Case { .. }) { let ret_ty = super::ty::go_ty_stub(e.ty.as_ref()); - let body = emit_return_body(ctx, e); + let body = emit_return_body(&ctx.value_iife(), e); let indented = body .lines() .map(|l| format!("\t{l}")) diff --git a/src/emit/go/library.rs b/src/emit/go/library.rs index 06c58dd98..a10ea9a43 100644 --- a/src/emit/go/library.rs +++ b/src/emit/go/library.rs @@ -1537,7 +1537,7 @@ fn emit_module_singleton_method(class_name: &str, m: &MethodDef) -> String { #[cfg(test)] mod predicate_naming_tests { - use super::sanitize_method_name; + use super::{emit_library_class, sanitize_method_name}; /// Regression guard: the bare-fn name path affixes `?` → `_pred` so a /// predicate (`exists?`) never collides with a same-named reader @@ -1549,4 +1549,39 @@ mod predicate_naming_tests { assert_eq!(sanitize_method_name("exists?"), "exists_pred"); assert_eq!(sanitize_method_name("save!"), "save_bang"); } + + /// HeaderStore keys/vals must be `[]string` (RBS ivar seed), and + /// void `send_data`'s disposition ternary IIFE must `return` strings. + #[test] + fn action_controller_go_emit_typechecks_hotspots() { + let ruby = include_str!("../../../runtime/ruby/action_controller/base.rb"); + let rbs = include_str!("../../../runtime/ruby/action_controller/base.rbs"); + let classes = crate::runtime_src::parse_library_with_rbs( + ruby.as_bytes(), + rbs, + "action_controller/base.rb", + ) + .expect("action_controller/base parses and types"); + let mut src = String::new(); + for c in &classes { + src.push_str(&emit_library_class(c).expect("emits")); + src.push('\n'); + } + assert!( + src.contains("Keys []string") && src.contains("Vals []string"), + "HeaderStore must emit []string fields:\n{src}" + ); + assert!( + !src.contains("Keys []interface{}") && !src.contains("Vals []interface{}"), + "HeaderStore must not erase keys/vals to interface{{}}:\n{src}" + ); + let send = src + .find("func (self *ActionControllerBase) SendData") + .and_then(|i| src[i..].find("func (self *ActionControllerBase) Verify").map(|j| &src[i..i + j])) + .expect("SendData method"); + assert!( + send.contains("return \"inline\"") && send.contains("return \"attachment\""), + "void send_data IIFE must return disposition strings:\n{send}" + ); + } } diff --git a/src/emit/kotlin/expr.rs b/src/emit/kotlin/expr.rs index 9482add0d..5a291f706 100644 --- a/src/emit/kotlin/expr.rs +++ b/src/emit/kotlin/expr.rs @@ -318,6 +318,51 @@ fn recv_is_array(r: &Expr) -> bool { if matches!(instance_prop_ty(name.as_str()), Some(crate::ty::Ty::Array { .. }))) } +/// Elem type of an Array-typed receiver (ivar field table or +/// expression ty), peeling a nullable outer `Array[T]?`. +fn array_elem_ty(r: &Expr) -> Option { + // Property types apply only to `@ivar` — a local `Var` that + // shadows must keep `r.ty` (nullable elem vs non-nullable prop). + let from_prop = match &*r.node { + ExprNode::Ivar { name } => instance_prop_ty(name.as_str()), + _ => None, + }; + let array_ty = from_prop.as_ref().or(r.ty.as_ref()); + match array_ty { + Some(crate::ty::Ty::Array { elem }) => Some((**elem).clone()), + Some(crate::ty::Ty::Union { variants }) => variants.iter().find_map(|t| match t { + crate::ty::Ty::Array { elem } => Some((**elem).clone()), + _ => None, + }), + _ => None, + } +} + +/// `String?` into `MutableList` (HeaderStore `@vals << value` +/// after `header_value_ok?` rejects nil). Kotlin rejects the mismatch; +/// coalesce to `""` so the write typechecks. Non-string / already- +/// matching shapes pass through unchanged. +fn coerce_list_elem_arg(recv: &Expr, arg: &Expr, arg_s: &str) -> String { + let Some(elem) = array_elem_ty(recv) else { + return arg_s.to_string(); + }; + let elem_is_plain_str = matches!(elem, crate::ty::Ty::Str | crate::ty::Ty::Sym); + let arg_nilable_str = matches!( + arg.ty.as_ref(), + Some(crate::ty::Ty::Union { variants }) + if variants.len() == 2 + && variants.iter().any(|v| matches!(v, crate::ty::Ty::Nil)) + && variants + .iter() + .any(|v| matches!(v, crate::ty::Ty::Str | crate::ty::Ty::Sym)) + ); + if elem_is_plain_str && arg_nilable_str { + format!("({arg_s}) ?: \"\"") + } else { + arg_s.to_string() + } +} + /// Ruby's `Module#<` family. `RecordNotFound < StandardError` is a /// subclass test over two class objects, not a value comparison — the /// classifier hands it back as [`CmpCase::ClassSubclass`] and every @@ -983,8 +1028,43 @@ fn emit_literal(lit: &Literal) -> String { Literal::Str { value } => format!("\"{}\"", escape_str(value)), // No symbol type in Kotlin → string. Literal::Sym { value } => format!("\"{}\"", escape_str(value.as_str())), - Literal::Regex { pattern, .. } => format!("Regex(\"{}\")", escape_str(pattern)), + Literal::Regex { pattern, .. } => { + // Ruby `/[\r\n\0\t]/` carries a backslash-zero NUL escape. + // Java `Pattern` rejects `\0` (octal needs more digits); use + // `\u0000` which both Kotlin's string literal and Pattern accept. + let normalized = normalize_java_regex_nul(pattern); + format!("Regex(\"{}\")", escape_str(&normalized)) + } + } +} + +/// Rewrite Ruby/PCRE `\0` (NUL) escapes to Java `\u0000` before string +/// escaping. Tracks backslash parity so a literal `\\0` (escaped +/// backslash then `0`) is left alone. +fn normalize_java_regex_nul(pattern: &str) -> String { + let mut out = String::with_capacity(pattern.len()); + let mut chars = pattern.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.peek() { + Some('\\') => { + // Escaped backslash — keep both; do not treat a + // following `0` as a NUL escape. + chars.next(); + out.push('\\'); + out.push('\\'); + } + Some('0') => { + chars.next(); + out.push_str("\\u0000"); + } + _ => out.push('\\'), + } + } else { + out.push(c); + } } + out } fn escape_str(s: &str) -> String { @@ -1218,6 +1298,9 @@ fn emit_assign(target: &LValue, value: &Expr) -> String { (LValue::Var { .. }, ExprNode::Hash { entries, .. }) if !entries.is_empty() => { emit_hash_precise(entries, value) } + (LValue::Index { recv, .. }, _) if !recv_is_hash(recv) => { + coerce_list_elem_arg(recv, value, &emit_expr(value)) + } _ => emit_expr(value), }; match target { @@ -1271,11 +1354,30 @@ fn lvalue_ref(target: &LValue) -> String { LValue::Var { name, .. } => camel(name.as_str()), LValue::Ivar { name } => format!("this.{}", camel(name.as_str())), LValue::Attr { recv, name } => format!("{}.{}", emit_expr(recv), camel(name.as_str())), - LValue::Index { recv, index } => format!("{}[{}]", emit_expr(recv), emit_expr(index)), + LValue::Index { recv, index } => { + // List indices are `Long` in the IR; Kotlin wants `Int`. + // Also cover module constants like `FORGERY_SLOT[0] =` whose + // recv ty may not be stamped as Array yet. + let idx = if list_index_needs_int_cast(recv, index) { + format!("({}).toInt()", emit_expr(index)) + } else { + emit_expr(index) + }; + format!("{}[{}]", emit_expr(recv), idx) + } LValue::Const { path } => path.iter().map(|s| s.to_string()).collect::>().join("."), } } +/// True when `recv[index]` is a List/Array access (not a Hash) that +/// needs a Kotlin `Int` index cast from the IR's `Long`. +fn list_index_needs_int_cast(recv: &Expr, index: &Expr) -> bool { + if recv_is_hash(recv) { + return false; + } + recv_is_array(recv) || matches!(index.ty.as_ref(), Some(crate::ty::Ty::Int)) +} + fn emit_op_assign(target: &LValue, op: OpAssignOp, value: &Expr) -> String { let lhs = lvalue_ref(target); let v = emit_expr(value); @@ -1664,7 +1766,7 @@ fn emit_send( return emit_slice_range(&rs, begin.as_ref(), end.as_ref(), *exclusive); } // List/Array index needs an Int (indices are `Long`). - if matches!(r.ty.as_ref(), Some(crate::ty::Ty::Array { .. })) { + if recv_is_array(r) { return format!("{rs}[({}).toInt()]", args_s[0]); } return format!("{rs}[{}]", args_s[0]); @@ -1693,7 +1795,8 @@ fn emit_send( } // `<<` / `push` → MutableList.add. crate::emit::shared::ops::BinopCase::Append => { - return format!("{}.add({})", emit_expr(r), args_s[0]); + let arg = coerce_list_elem_arg(r, &args[0], &args_s[0]); + return format!("{}.add({})", emit_expr(r), arg); } crate::emit::shared::ops::BinopCase::NotBinop => {} } @@ -1718,7 +1821,17 @@ fn emit_send( } if let (Some(r), 2) = (recv, args.len()) { if method == "[]=" { - return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]); + let idx = if list_index_needs_int_cast(r, &args[0]) { + format!("({}).toInt()", args_s[0]) + } else { + args_s[0].clone() + }; + let val = if recv_is_hash(r) { + args_s[1].clone() + } else { + coerce_list_elem_arg(r, &args[1], &args_s[1]) + }; + return format!("{}[{}] = {}", emit_expr(r), idx, val); } // `Hash#fetch(k, default)` → `(recv[k] ?: default)` (Ruby returns // the value or the default; Kotlin map-get is null for missing). @@ -1784,7 +1897,11 @@ fn emit_send( // Kotlin's are a Set/Collection, so materialize a MutableList. "keys" if recv_is_hash(r) => return format!("{rs}.keys.toMutableList()"), "values" if recv_is_hash(r) => return format!("{rs}.values.toMutableList()"), - // No-ops in Kotlin — drop, keep the receiver. + // `freeze`/`to_a` are no-ops. `dup` on a Hash must shallow- + // copy: `attrs = opts.to_h.dup; attrs.delete(:method)` must + // leave `opts[:method]` readable (form_with). Identity dup + // aliases the MutableMap and the delete erases the fetch. + "dup" if recv_is_hash(r) => return format!("{rs}.toMutableMap()"), "freeze" | "dup" | "to_a" => return rs, // `to_h` is a no-op on a Hash; on a user type (e.g. `Session`, // `Flash`) it's a real `toH()` method — fall through to the call. diff --git a/src/emit/python/expr.rs b/src/emit/python/expr.rs index 85d521890..d180b39be 100644 --- a/src/emit/python/expr.rs +++ b/src/emit/python/expr.rs @@ -1431,6 +1431,17 @@ fn map_builtin_method(recv: &str, method: &str, ty: Option<&Ty>, args_s: &[Strin "downcase" if no_args && is_str => format!("{recv}.lower()"), "start_with?" if one_arg && is_str => format!("{recv}.startswith({})", args_s[0]), "end_with?" if one_arg && is_str => format!("{recv}.endswith({})", args_s[0]), + // Ruby `String#tr(from, to)` — pad a shorter `to` with its last + // char (Ruby semantics) so `str.maketrans` accepts unequal + // lengths; truncate a longer `to`. Range expansion (`a-z`) is + // not implemented — runtime call sites use literal pairs. + "tr" if args_s.len() == 2 && is_str => { + format!( + "(lambda _f, _t: ({recv}).translate(str.maketrans(_f, (_t[:len(_f)] if len(_t) >= len(_f) else _t + ((_t[-1] if _t else \"\") * (len(_f) - len(_t)))))))({}, {})", + args_s[0], + args_s[1] + ) + } // Ruby `coll.include?(x)` → Python membership `x in coll`. Works // for Array (element), Hash (key), and String (substring). Wrapped // in parens since `in` is a comparison-precedence operator and may diff --git a/src/emit/rust.rs b/src/emit/rust.rs index 389348b4a..1096c9533 100644 --- a/src/emit/rust.rs +++ b/src/emit/rust.rs @@ -1050,6 +1050,11 @@ pub fn emit(app: &App) -> Vec { // The field-shape ivars (`flash`, `session`, `params`, // ...) come from `walk_collect_ivars`'s read-only ivar // surface and land on the struct naturally. + // CSRF helpers landed on AC::Base#process_action + // (Masked CSRF). App controllers inherit that body + // but do not embed Base — stub the two Sends so the + // inherited process_action compiles. Full CSRF via + // the http thread-local surface is a follow-on. let ac_shim = format!( "\nimpl {name} {{\n\ \x20 pub fn render(&self, content: String) {{\n\ @@ -1077,6 +1082,8 @@ pub fn emit(app: &App) -> Vec { \x20 let content_type = opts.get(\"content_type\").and_then(|v| v.as_str()).map(|s| s.to_string());\n\ \x20 crate::http::response_set_head(status, content_type);\n\ \x20 }}\n\ + \x20 pub fn verify_authenticity_token(&mut self) {{}}\n\ + \x20 pub fn performed_pred(&self) -> bool {{ false }}\n\ }}\n", name = lc.name.0.as_str() ); diff --git a/src/emit/rust/expr/send/mod.rs b/src/emit/rust/expr/send/mod.rs index 02386afd2..f3dbb3cd6 100644 --- a/src/emit/rust/expr/send/mod.rs +++ b/src/emit/rust/expr/send/mod.rs @@ -221,6 +221,33 @@ pub(super) fn emit_send( ExprNode::Ivar { name } => super::ivar_field_ty(name.as_str()) .map(|t| super::util::is_option_ty(&t)) .unwrap_or(false), + // Array#[] types as `T | Nil` (past-the-end), but rust + // emits a bare `T` (`vec[i].clone()`). Prefer the field- + // table elem type when the recv is an ivar — HeaderStore + // `@keys` is `Array[String]` (no Option) while `@vals` is + // `Array[String?]` (Option). Body-typer `String?` on both + // would Option-map a plain `String` and fail to compile. + // Mirrors `ruby_to_s_emit`'s ivar-array preference. + ExprNode::Send { + method: m, + recv: Some(inner), + .. + } if m.as_str() == "[]" => { + let array_ty = match &*inner.node { + ExprNode::Ivar { name } => super::ivar_field_ty(name.as_str()), + _ => inner.ty.clone(), + }; + match array_ty.as_ref().map(super::util::peel_nil) { + Some(crate::ty::Ty::Array { elem }) => { + super::util::is_option_ty(elem) + } + _ => r + .ty + .as_ref() + .map(super::util::is_option_ty) + .unwrap_or(false), + } + } // A call's `ty` comes from the callee's declared signature // (`article.title()` on a nullable column reads `Option // `), so it is trustworthy here. diff --git a/src/emit/rust/library.rs b/src/emit/rust/library.rs index 8f535704f..51b00c85e 100644 --- a/src/emit/rust/library.rs +++ b/src/emit/rust/library.rs @@ -1157,6 +1157,11 @@ end set_index.contains("header_key_ok_pred(Some("), "header_key_ok? takes String?, wrap &str:\n{set_index}" ); + let key_at = method_body(&src, "key_at"); + assert!( + !key_at.contains(".map("), + "keys[i].to_s on Array[String] must not Option-map a plain String:\n{key_at}" + ); let val_at = method_body(&src, "val_at"); assert!( val_at.contains("unwrap_or_default()"), diff --git a/src/emit/swift/expr.rs b/src/emit/swift/expr.rs index 646a257f0..56cbdb22b 100644 --- a/src/emit/swift/expr.rs +++ b/src/emit/swift/expr.rs @@ -665,6 +665,57 @@ fn coerce_for_prop(prop_camel: &str, value: &Expr, val: String) -> String { } } +/// Declared `Ty` of an instance property by camelCased name (accessors + +/// body ivars installed by `set_instance_prop_types`). +fn instance_prop_ty(name: &str) -> Option { + INSTANCE_PROP_TYPES.with(|m| m.borrow().get(&camel(name)).cloned()) +} + +/// Elem type of an Array-typed receiver (ivar field table or +/// expression ty), peeling a nullable outer `Array[T]?`. +fn array_elem_ty(r: &Expr) -> Option { + // Property types apply only to `@ivar` — a local `Var` that + // shadows must keep `r.ty` (nullable elem vs non-nullable prop). + let from_prop = match &*r.node { + ExprNode::Ivar { name } => instance_prop_ty(name.as_str()), + _ => None, + }; + let array_ty = from_prop.as_ref().or(r.ty.as_ref()); + match array_ty { + Some(crate::ty::Ty::Array { elem }) => Some((**elem).clone()), + Some(crate::ty::Ty::Union { variants }) => variants.iter().find_map(|t| match t { + crate::ty::Ty::Array { elem } => Some((**elem).clone()), + _ => None, + }), + _ => None, + } +} + +/// `String?` into `[String]` (HeaderStore `@vals << value` after +/// `header_value_ok?` rejects nil). Swift rejects the mismatch; coalesce +/// to `""` so the write typechecks. Non-string / already-matching shapes +/// pass through unchanged. +fn coerce_list_elem_arg(recv: &Expr, arg: &Expr, arg_s: &str) -> String { + let Some(elem) = array_elem_ty(recv) else { + return arg_s.to_string(); + }; + let elem_is_plain_str = matches!(elem, crate::ty::Ty::Str | crate::ty::Ty::Sym); + let arg_nilable_str = matches!( + arg.ty.as_ref(), + Some(crate::ty::Ty::Union { variants }) + if variants.len() == 2 + && variants.iter().any(|v| matches!(v, crate::ty::Ty::Nil)) + && variants + .iter() + .any(|v| matches!(v, crate::ty::Ty::Str | crate::ty::Ty::Sym)) + ); + if elem_is_plain_str && arg_nilable_str { + format!("({arg_s} ?? \"\")") + } else { + arg_s.to_string() + } +} + /// Is the receiver statically a Hash (directly or through a nullable /// Union / the declared prop type)? fn recv_is_hash(r: &Expr) -> bool { @@ -2219,7 +2270,12 @@ fn assign_value(value: &Expr) -> String { } fn emit_assign(target: &LValue, value: &Expr) -> String { - let val = emit_expr(value); + let val = match target { + LValue::Index { recv, .. } if !recv_is_hash(recv) => { + coerce_list_elem_arg(recv, value, &emit_expr(value)) + } + _ => emit_expr(value), + }; match target { LValue::Var { name, .. } => { let n = camel(name.as_str()); @@ -2643,7 +2699,8 @@ fn emit_send( } // `<<` / `push` → Array.append. crate::emit::shared::ops::BinopCase::Append => { - return format!("{}.append({})", emit_expr(r), args_s[0]); + let arg = coerce_list_elem_arg(r, &args[0], &args_s[0]); + return format!("{}.append({})", emit_expr(r), arg); } crate::emit::shared::ops::BinopCase::NotBinop => {} } @@ -2748,7 +2805,12 @@ fn emit_send( } if let (Some(r), 2) = (recv, args.len()) { if method == "[]=" { - return format!("{}[{}] = {}", emit_expr(r), args_s[0], args_s[1]); + let val = if recv_is_hash(r) { + args_s[1].clone() + } else { + coerce_list_elem_arg(r, &args[1], &args_s[1]) + }; + return format!("{}[{}] = {}", emit_expr(r), args_s[0], val); } // Ruby `str[start, len]` positional slice. if method == "[]" { diff --git a/src/emit/swift/library.rs b/src/emit/swift/library.rs index 160dca384..d81ed632f 100644 --- a/src/emit/swift/library.rs +++ b/src/emit/swift/library.rs @@ -811,7 +811,13 @@ fn emit_subscript( } if let Some(s) = setter { begin_method(&s.body, false); - let body = emit_body(&s.body, false, None); + // Hoist locals first assigned inside nested scopes (e.g. `found` + // inside `if header_ok?`) — same prologue emit_method uses. + let mut prologue = String::new(); + for (n, st, d) in super::expr::take_hoisted() { + prologue.push_str(&format!("var {n}: {st} = {d}\n")); + } + let body = format!("{prologue}{}", emit_body(&s.body, false, None)); // The Ruby setter's value param reads from Swift's `newValue`. let alias = s .params diff --git a/src/lower/functionalize/mutation_to_struct_return.rs b/src/lower/functionalize/mutation_to_struct_return.rs index d380eed8f..5abe1018f 100644 --- a/src/lower/functionalize/mutation_to_struct_return.rs +++ b/src/lower/functionalize/mutation_to_struct_return.rs @@ -833,9 +833,28 @@ fn rewrite_expr(e: &Expr) -> Expr { // [v]}`. On mutable targets the accessor returns the @errors Array // and `<<` mutates it in place; the functional equivalent threads // a struct-update append (the accessor name is the field name). + // + // Same for `@arr << v` (HeaderStore `@keys << key`): ivar recv, + // not an accessor Send. ExprNode::Send { recv: Some(r), method, args, .. } if method.as_str() == "<<" && args.len() == 1 => { + if let ExprNode::Ivar { name } = &*r.node { + let appended = syn(ExprNode::Send { + recv: Some(field_read(name)), + method: Symbol::from("++"), + args: vec![syn(ExprNode::Array { + elements: vec![rewrite_expr(&args[0])], + style: ArrayStyle::Brackets, + })], + block: None, + parenthesized: false, + }); + return syn(ExprNode::Assign { + target: LValue::Var { id: VarId(0), name: Symbol::from(RECORD) }, + value: struct_put(name, appended), + }); + } if let ExprNode::Send { recv: ar, method: field, args: fargs, .. } = &*r.node { if fargs.is_empty() && ar.as_ref().is_none_or(|x| matches!(&*x.node, ExprNode::SelfRef)) @@ -1079,10 +1098,14 @@ fn mutates_record(e: &Expr) -> bool { found = true } // `errors << v` — `<<` onto a bareword/self list accessor. + // `@arr << v` — same for a direct ivar (HeaderStore `@keys << key`). + // rewrite_expr already rebinds both; classification must match. ExprNode::Send { recv: Some(r), method, args, .. } if method.as_str() == "<<" && args.len() == 1 => { - if let ExprNode::Send { recv: ar, method: _, args: fargs, .. } = &*r.node { + if matches!(&*r.node, ExprNode::Ivar { .. }) { + found = true; + } else if let ExprNode::Send { recv: ar, method: _, args: fargs, .. } = &*r.node { if fargs.is_empty() && ar.as_ref().is_none_or(|x| matches!(&*x.node, ExprNode::SelfRef)) { @@ -1554,6 +1577,50 @@ mod tests { ); } + #[test] + fn ivar_shovel_classifies_as_record_mutation() { + // HeaderStore `@keys << key` — rewrite_expr rebinds, and + // mutates_record must agree so compute_registry / trailing + // `record` return fire for a body that only appends. + let push = send( + Some(syn(ExprNode::Ivar { name: sym("keys") })), + "<<", + vec![vr("key")], + ); + let body = syn(ExprNode::Seq { exprs: vec![push] }); + assert!( + mutates_record(&body), + "@keys << key must count as a record mutation" + ); + let ex = render_via_elixir(vec![tx(instance_method("add_key", &["key"], body))]); + assert!( + ex.contains("%{record | keys: record.keys ++ [key]}"), + "@keys << → struct append:\n{ex}" + ); + } + + #[test] + fn local_shovel_does_not_classify_as_record_mutation() { + // Unsupported `<<` shape (bare local) — classifier leaves it + // alone; emitter keeps the dynamic send rather than a struct + // update. + let push = send(Some(vr("arr")), "<<", vec![vr("key")]); + let body = syn(ExprNode::Seq { exprs: vec![push] }); + assert!( + !mutates_record(&body), + "local arr << key must not count as a record mutation" + ); + let ex = render_via_elixir(vec![tx(instance_method("push_local", &["arr", "key"], body))]); + assert!( + !ex.contains("%{record |"), + "unsupported << must not become a struct update:\n{ex}" + ); + assert!( + ex.contains("<<") || ex.contains("arr"), + "dynamic << path preserved:\n{ex}" + ); + } + #[test] fn return_self_guard_threads_not_stubbed() { // `def destroy; return self unless persisted?; @destroyed = true; diff --git a/src/lower/functionalize/while_to_recursion.rs b/src/lower/functionalize/while_to_recursion.rs index 8b9d3cc65..ffebcc5d6 100644 --- a/src/lower/functionalize/while_to_recursion.rs +++ b/src/lower/functionalize/while_to_recursion.rs @@ -147,13 +147,23 @@ fn try_transform_seq(m: &MethodDef) -> Option> { }; // Carried locals = vars bound in the pre-loop statements, in order, - // that are referenced in the condition or loop body. (An accumulator - // like `params = {}` is found here because `walk` descends into - // index-assign targets, so `params[k] = v` counts as a reference.) + // that are referenced in the condition, loop body, or post-loop + // statements — or assigned in the loop body. (An accumulator like + // `params = {}` is found because `walk` descends into index-assign + // targets, so `params[k] = v` counts as a reference. A flag like + // `found = false` / `found = true` / `unless found` is carried + // because assignment targets and post-loop reads count too — + // `refs_var` alone misses `LValue::Var` writes.) let pre_assigned = assigned_vars(pre); + let post_joined = value_of(post); let carried: Vec = pre_assigned .into_iter() - .filter(|name| refs_var(cond, name) || refs_var(while_body, name)) + .filter(|name| { + refs_var(cond, name) + || refs_var(while_body, name) + || refs_var(&post_joined, name) + || assigns_var(while_body, name) + }) .collect(); // The counter (when there is one) must be a carried (pre-loop-bound) @@ -253,13 +263,22 @@ fn try_transform_seq(m: &MethodDef) -> Option> { body: syn(ExprNode::If { cond: cond.clone(), then_branch: cps(&body_stmts, &recurse_call), - // Post-loop value. A record-threading helper with nothing - // after the loop (e.g. session#initialize's populate loop) - // must yield the threaded `record` so the accumulated - // struct flows back to the caller — not the `nil` that - // falling off a Ruby `while` would produce. - else_branch: if threads_record && post.is_empty() { - var(&Symbol::from("record")) + // Post-loop value. A record-threading helper must yield the + // threaded struct when the loop/post mutates instance state: + // empty post → `record`; mutating post ends in `self` so a + // skipped `unless found` (nil) still returns the struct after + // mutation rewrites `self`→`record`. A reader with a trailing + // `nil` (HeaderStore `[]`) keeps that nil — do not append. + else_branch: if threads_record { + if post.is_empty() { + var(&Symbol::from("record")) + } else if post.iter().any(mutates_ivar_state) { + let mut stmts = post.to_vec(); + stmts.push(syn(ExprNode::SelfRef)); + value_of(&stmts) + } else { + value_of(post) + } } else { value_of(post) }, @@ -523,6 +542,32 @@ fn assigned_vars(stmts: &[Expr]) -> Vec { out } +/// True when `e` writes instance state (`@ivar = …`, `@arr[i] = …`, +/// `@arr << …`). Used to decide whether a record-threading loop's post +/// must end in `self` so a skipped `unless` still returns the struct. +fn mutates_ivar_state(e: &Expr) -> bool { + let mut found = false; + walk(e, &mut |n| { + match &*n.node { + ExprNode::Assign { + target: LValue::Ivar { .. }, + .. + } => found = true, + ExprNode::Send { + recv: Some(r), + method, + .. + } if matches!(method.as_str(), "[]=" | "<<") + && matches!(&*r.node, ExprNode::Ivar { .. }) => + { + found = true; + } + _ => {} + } + }); + found +} + fn refs_var(e: &Expr, name: &Symbol) -> bool { let mut found = false; walk(e, &mut |n| { @@ -535,6 +580,29 @@ fn refs_var(e: &Expr, name: &Symbol) -> bool { found } +/// True when `e` contains an assignment whose target is local `name` +/// (`found = true`). `refs_var` misses these — `walk_lvalue` does not +/// surface `LValue::Var` as an `ExprNode::Var`. +fn assigns_var(e: &Expr, name: &Symbol) -> bool { + let mut found = false; + walk(e, &mut |n| { + match &*n.node { + ExprNode::Assign { + target: LValue::Var { name: vn, .. }, + .. + } + | ExprNode::OpAssign { + target: LValue::Var { name: vn, .. }, + .. + } if vn == name => { + found = true; + } + _ => {} + } + }); + found +} + fn referenced_vars(e: &Expr) -> Vec { let mut out: Vec = Vec::new(); walk(e, &mut |n| { diff --git a/src/runtime_loader.rs b/src/runtime_loader.rs index 64f4635f7..9c05ed2e4 100644 --- a/src/runtime_loader.rs +++ b/src/runtime_loader.rs @@ -443,7 +443,13 @@ const TYPESCRIPT_RUNTIME: &[RuntimeEntry] = &[ // FormBuilder.model is RBS-typed `ActiveRecord::Base`; the // emit surfaces `model: Base` on the field + constructor. // Type-only — runtime never instantiates Base directly. - imports: &[("type Base", "./active_record_base.js")], + // `ActionController.masked_authenticity_token` backs + // `form_authenticity_token` (Masked CSRF); the namespace + // class lives alongside Base in action_controller_base.ts. + imports: &[ + ("type Base", "./active_record_base.js"), + ("ActionController", "./action_controller_base.js"), + ], prelude: NO_PRELUDE, // Roots for the hand-written server.ts that calls into // ViewHelpers directly. Suffix-renames apply (`reset_slots!` @@ -630,6 +636,10 @@ const RUST_RUNTIME: &[RuntimeEntry] = &[ // resolve. Trait lives in `runtime/rust/http.rs` so it // ships alongside the hand-written response shape. ("RubyToS", "http"), + // Masked CSRF: `form_authenticity_token` calls + // `ActionController::masked_authenticity_token`. The + // namespace struct lives in action_controller_base.rs. + ("ActionController", "action_controller_base"), ], prelude: NO_PRELUDE, extra_roots: NO_EXTRA_ROOTS, @@ -1247,7 +1257,13 @@ fn swift_format_import(_name: &str, _source: &str) -> String { /// Top-level `let NAME = VALUE` (Swift allows file-level constants). fn swift_format_constant(name: &str, value: &Expr) -> String { - format!("let {name} = {}", crate::emit::swift::emit_constant_for_runtime(value)) + // Arrays mutated via index assign (`FORGERY_SLOT[0] = …`) need `var`; + // `let` arrays reject subscript assignment. + let kw = match &*value.node { + crate::expr::ExprNode::Array { .. } => "var", + _ => "let", + }; + format!("{kw} {name} = {}", crate::emit::swift::emit_constant_for_runtime(value)) } /// Single flat module; no namespace blocks. @@ -1556,13 +1572,14 @@ fn elixir_format_constant(name: &str, value: &Expr) -> String { /// Each class already emits its own `defmodule V2.` (see /// `emit_library_class`), so this hook's job for Elixir is just to -/// place module-level constants INSIDE their module. `transpile_entry` +/// place module-level constants INSIDE their modules. `transpile_entry` /// emits constants (via `format_constant`) as lines ahead of the class /// bodies, but Elixir has no file-level constants and module attributes -/// don't cross module boundaries — so move any leading constant lines -/// into the first `defmodule`. (Current const-bearing files are single- -/// module — `json_builder`, `action_controller/base`; a multi-module -/// file with constants would need owner-aware routing, revisit then.) +/// don't cross module boundaries — so inject leading constant lines +/// into each `defmodule` that actually references the attribute +/// (`@status_codes` / `@forgery_slot` / …). Blanket injection into +/// every sibling (HeaderStore + Base + ActionController) trips +/// `--warnings-as-errors` on unused module attributes. /// The `namespace` arg is unused: V2-prefixing + naming happen in /// `emit_library_class`. fn elixir_wrap_namespace(_namespace: &str, body: &str) -> String { @@ -1579,16 +1596,55 @@ fn elixir_wrap_namespace(_namespace: &str, body: &str) -> String { if consts.is_empty() { return body.to_string(); } - let mut out = String::new(); - out.push_str(lines[first_mod]); // `defmodule V2.X do` - out.push('\n'); - for c in &consts { - out.push_str(c); - out.push('\n'); + // Pair each `@name …` constant line with the attribute token a + // module body must mention to justify the injection. + let const_attrs: Vec<(&str, &str)> = consts + .iter() + .filter_map(|c| { + let trimmed = c.trim_start(); + if !trimmed.starts_with('@') { + return Some((*c, "")); + } + let name = trimmed[1..] + .split_whitespace() + .next() + .unwrap_or(""); + if name.is_empty() { + return Some((*c, "")); + } + Some((*c, name)) + }) + .collect(); + + // Module spans: start line → end line (exclusive), sibling modules + // only (action_controller emit is flat — no nested defmodule). + let mut mod_starts: Vec = Vec::new(); + for (i, l) in lines.iter().enumerate().skip(first_mod) { + if l.trim_start().starts_with("defmodule ") { + mod_starts.push(i); + } } - for l in &lines[first_mod + 1..] { - out.push_str(l); + let mut out = String::new(); + for (m_idx, &start) in mod_starts.iter().enumerate() { + let end = mod_starts + .get(m_idx + 1) + .copied() + .unwrap_or(lines.len()); + // Emit the defmodule line, then only the constants this body + // references, then the rest of the module. + out.push_str(lines[start]); out.push('\n'); + let body_text = lines[start + 1..end].join("\n"); + for (c, attr) in &const_attrs { + if attr.is_empty() || body_text.contains(&format!("@{attr}")) { + out.push_str(c); + out.push('\n'); + } + } + for l in &lines[start + 1..end] { + out.push_str(l); + out.push('\n'); + } } out } @@ -1880,6 +1936,7 @@ const PYTHON_RUNTIME: &[RuntimeEntry] = &[ // encoding rides the Base64/JSON stdlib mappings. imports: &[ ("Base", "app.active_record_base"), + ("ActionController", "app.action_controller_base"), ("re", ""), ("base64", ""), ("json", ""), diff --git a/src/runtime_src.rs b/src/runtime_src.rs index c1face4fc..644625123 100644 --- a/src/runtime_src.rs +++ b/src/runtime_src.rs @@ -422,6 +422,12 @@ pub fn parse_library_with_rbs( let mut library_classes = ingest_library_classes(ruby_src, file) .map_err(|e| format!("ingest_library_classes: {e:?}"))?; let sigs_by_class = crate::rbs::parse_app_signatures(rbs_src)?; + // `@ivar: T` decls (e.g. HeaderStore `@keys: Array[String]`). Pass B + // seeds these so empty `[]`/`{}` initializers stamp via + // `propagate_expected_to_empty_container` — without this, Go/Kotlin/ + // C#/Swift emit `[]interface{}` / `MutableList` and fail when + // `[]` returns `String?`. + let rbs_ivars_by_class = crate::rbs::parse_app_ivars(rbs_src)?; // The class-grouped sig parser doesn't carry the `%a{abstract}` // annotation; the flat parser does. Use the flat result purely as @@ -626,6 +632,16 @@ pub fn parse_library_with_rbs( } } } + // Explicit RBS `@ivar: T` wins last — including over a + // zero-arg computed method of the same name (e.g. `@items: + // Array[String]` must not be replaced by `def items; …; end` + // returning Integer). Same contract as the seed that made + // HeaderStore `@keys`/`@vals` Array[String]. + if let Some(declared) = rbs_ivars_by_class.get(&lc.name) { + for (name, ty) in declared { + flow_ivars.insert(name.clone(), ty.clone()); + } + } if !flow_ivars.is_empty() { let reseeded: std::collections::HashMap = flow_ivars .into_iter() @@ -1908,4 +1924,48 @@ mod tests { // The If as a whole unions its branches (both StringInterp → Str). assert_eq!(m.body.ty.as_ref(), Some(&Ty::Str)); } + + /// RBS `@keys`/`@vals` must stamp empty `[]` initializers so + /// Go/Kotlin/C#/Swift emit typed slices/lists, not `interface{}`/`Any?`. + #[test] + fn rbs_ivar_decls_stamp_empty_array_initializers() { + let ruby = include_bytes!("../runtime/ruby/action_controller/base.rb"); + let rbs = include_str!("../runtime/ruby/action_controller/base.rbs"); + let classes = parse_library_with_rbs(ruby, rbs, "action_controller/base.rb") + .expect("action_controller/base parses and types"); + let hs = classes + .iter() + .find(|c| c.name.0.as_str() == "ActionController::HeaderStore") + .expect("HeaderStore class"); + let init = hs + .methods + .iter() + .find(|m| m.name.as_str() == "initialize") + .expect("initialize"); + fn ivar_assign_ty<'a>(e: &'a crate::expr::Expr, name: &str) -> Option<&'a Ty> { + use crate::expr::{ExprNode, LValue}; + match &*e.node { + ExprNode::Assign { + target: LValue::Ivar { name: n }, + value, + } if n.as_str() == name => value.ty.as_ref(), + ExprNode::Seq { exprs } => exprs.iter().find_map(|x| ivar_assign_ty(x, name)), + _ => None, + } + } + match ivar_assign_ty(&init.body, "keys") { + Some(Ty::Array { elem }) => assert!( + matches!(elem.as_ref(), Ty::Str), + "expected Array[String], got Array[{elem:?}]" + ), + other => panic!("@keys = [] must carry Array[String], got {other:?}"), + } + match ivar_assign_ty(&init.body, "vals") { + Some(Ty::Array { elem }) => assert!( + matches!(elem.as_ref(), Ty::Str), + "expected Array[String], got Array[{elem:?}]" + ), + other => panic!("@vals = [] must carry Array[String], got {other:?}"), + } + } }