Skip to content

Commit 872b754

Browse files
rubysclaude
andcommitted
lower(blank): Array#compact_blank grounds through its element type
ActiveSupport ships `compact_blank` as `reject(&:blank?)` — a core_ext reopen only the CRuby overlay can host, and with no `blank?` for any other target to dispatch. Exactly the problem `blank?`/`present?`/ `presence` are already grounded for in this pass, so it grounds the same way: through the ELEMENT type, using the answer `classify` gives that type, so `[a, b].compact_blank` and `a.blank?` cannot disagree about what blank means. campfire's `User#title` is `[ name, bio ].compact_blank.join(" – ")` — the label on every avatar, so it renders on the message row, the user list and the sidebar. Only `empty?`-able element types ground; everything else keeps the call and files residue, the same policy the predicates use. campfire reports three such sites (a Hash receiver, an untyped receiver, an Array of untyped) — a ledger where there was silence. A NEVER-BLANK element type would make the whole call a no-op, which is a fold worth having but not one any corpus app forces. Verified over the batch: cargo test (all green, fixtures byte-identical), compare ruby 7/7, smoke ruby 6/6, crystal 3/3 + rust 2/2 + spinel 1/1; lobsters emit byte-identical across 555 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2861bcf commit 872b754

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

src/lower/blank.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ fn walk(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec<Diagnostic>) {
380380
}
381381

382382
try_rewrite(expr, defs, diags);
383+
try_rewrite_compact_blank(expr, defs, diags);
383384
}
384385

385386
fn walk_lvalue(target: &mut LValue, defs: &AppDefinitions, diags: &mut Vec<Diagnostic>) {
@@ -528,6 +529,127 @@ fn try_rewrite(expr: &mut Expr, defs: &AppDefinitions, diags: &mut Vec<Diagnosti
528529
expr.leading_blank_line = leading_blank_line;
529530
}
530531

532+
/// `Array#compact_blank` — ActiveSupport's `reject(&:blank?)`, and the
533+
/// same problem the three predicates have: a core_ext reopen only the
534+
/// CRuby overlay could host, with no `blank?` for any other target to
535+
/// dispatch. Grounded through the ELEMENT type, using the answer
536+
/// `classify` gives that type, so `[a, b].compact_blank` and `a.blank?`
537+
/// cannot disagree about what blank means.
538+
///
539+
/// campfire's `User#title` is `[ name, bio ].compact_blank.join(" – ")`
540+
/// — the label on every avatar, so it is on the message row, the user
541+
/// list and the sidebar.
542+
///
543+
/// Only `empty?`-able element types ground; anything else keeps the
544+
/// call and files residue, the same policy the predicates use. A
545+
/// NEVER-BLANK element type would make the whole call a no-op — a fold
546+
/// worth having, but not one any corpus app forces yet.
547+
fn try_rewrite_compact_blank(
548+
expr: &mut Expr,
549+
defs: &AppDefinitions,
550+
diags: &mut Vec<Diagnostic>,
551+
) {
552+
let (grounding, elem_ty, recv_ty) = {
553+
let ExprNode::Send { recv: Some(r), method, args, block: None, .. } = &*expr.node else {
554+
return;
555+
};
556+
if method.as_str() != "compact_blank" || !args.is_empty() {
557+
return;
558+
}
559+
let Some(Ty::Array { elem }) = r.ty.as_ref() else {
560+
// Not an Array (or untyped): the ledger says so rather than
561+
// guessing at a receiver whose surface we do not know.
562+
diags.push(unlowered(
563+
expr,
564+
r.ty.as_ref(),
565+
"compact_blank",
566+
"receiver is not a typed Array",
567+
));
568+
return;
569+
};
570+
let elem = (**elem).clone();
571+
(classify(Some(&elem), defs), elem, r.ty.clone())
572+
};
573+
let nilable = match grounding {
574+
Grounding::Container { nilable } => nilable,
575+
_ => {
576+
diags.push(unlowered(
577+
expr,
578+
recv_ty.as_ref(),
579+
"compact_blank",
580+
"element type has no `empty?` grounding",
581+
));
582+
return;
583+
}
584+
};
585+
586+
let span = expr.span;
587+
let leading_blank_line = expr.leading_blank_line;
588+
let old = std::mem::replace(&mut *expr.node, ExprNode::SelfRef);
589+
let ExprNode::Send { recv: Some(r), .. } = old else { unreachable!() };
590+
591+
// `reject { |__cb| … }`. The block parameter is read twice in the
592+
// nilable form, which is free — it is a local, not the receiver
593+
// expression, so none of the effect-free gating above applies.
594+
let name = Symbol::new("__cb");
595+
let param = |ty: Ty| {
596+
mk(
597+
span,
598+
ExprNode::Var { id: crate::ident::VarId(0), name: name.clone() },
599+
ty,
600+
)
601+
};
602+
let cond = if nilable {
603+
bool_op(
604+
span,
605+
crate::expr::BoolOpKind::Or,
606+
nil_check(span, param(elem_ty.clone())),
607+
plain_empty(span, param(non_nil(&elem_ty))),
608+
)
609+
} else {
610+
plain_empty(span, param(elem_ty.clone()))
611+
};
612+
let block = mk(
613+
span,
614+
ExprNode::Lambda {
615+
params: vec![name],
616+
block_param: None,
617+
body: cond,
618+
block_style: Default::default(),
619+
},
620+
Ty::Untyped,
621+
);
622+
let array_ty = Ty::Array { elem: Box::new(non_nil(&elem_ty)) };
623+
*expr = mk(
624+
span,
625+
ExprNode::Send {
626+
recv: Some(r),
627+
method: Symbol::new("reject"),
628+
args: vec![],
629+
block: Some(block),
630+
parenthesized: false,
631+
},
632+
array_ty,
633+
);
634+
expr.leading_blank_line = leading_blank_line;
635+
}
636+
637+
/// The non-nil half of a nilable type — what survives the reject.
638+
fn non_nil(t: &Ty) -> Ty {
639+
match t {
640+
Ty::Union { variants } => {
641+
let mut kept: Vec<Ty> =
642+
variants.iter().filter(|v| !matches!(v, Ty::Nil)).cloned().collect();
643+
match kept.len() {
644+
0 => Ty::Untyped,
645+
1 => kept.remove(0),
646+
_ => Ty::Union { variants: kept },
647+
}
648+
}
649+
other => other.clone(),
650+
}
651+
}
652+
531653
/// Shared shape for the two `empty?`-style groundings. `empty_form`
532654
/// builds the "is empty" test for a non-nil receiver.
533655
fn rewrite_emptyable(

0 commit comments

Comments
 (0)