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:?}"),
+ }
+ }
}