diff --git a/docs/guide/check.md b/docs/guide/check.md index 4e802dc00..fd6471e65 100644 --- a/docs/guide/check.md +++ b/docs/guide/check.md @@ -246,6 +246,10 @@ roundhouse does not model it)* means the receiver comes from a gem the census lists as unknown. Skip these on a first read. They exist so the error count above means "findings", not "shadows of gaps". +Unresolved source constants keep their error severity even when the +gem census identifies a likely owner: their emitted expression is a +refusal stub. Gem context is added without certifying runtime support. + **The survey report** — printed only with `--continue`: every construct ingest skipped, bucketed by kind, most frequent first, with the files it occurred in. This is roundhouse's own to-do list for your app. diff --git a/docs/guide/transpile.md b/docs/guide/transpile.md index 4383ea8ec..135ed039b 100644 --- a/docs/guide/transpile.md +++ b/docs/guide/transpile.md @@ -99,6 +99,11 @@ rather than dropping the callback or evaluating its producer per item. Existing literal blocks, local block variables and bound-method references retain their target-specific support and limitations. +Roundhouse currently refuses generated enum `*_before_type_cast` readers. +Rails returns the original input after an unsaved assignment and stored data +after persistence; the compiler has only the stored enum slot. Negative enum +scopes are supported. Source-defined readers keep their ordinary behavior. + ## Apps that aren't fully covered yet By default, ingest is strict — the first construct roundhouse does not diff --git a/docs/pipeline/analyze.md b/docs/pipeline/analyze.md index bf6c2c3b9..2f35aaff5 100644 --- a/docs/pipeline/analyze.md +++ b/docs/pipeline/analyze.md @@ -77,7 +77,7 @@ registries: Roundhouse keeps compact answers keyed by source position and releases the Rubydex graph before its typing passes. A read that resolves to a class keeps the qualified class path in the IR, so the - read names the same class after ingest copies a concern method into + read names the same class or value after ingest copies a concern method into another class. `Analyzer::build_constant_registry` infers each constant's value type and stores it under the Rubydex declaration ID of its source @@ -85,11 +85,23 @@ registries: constant that ingest moves into a class keeps its top-level ID. The rounds continue until no value changes, so a chain such as `B = A` resolves at any depth. A cycle stays unresolved. + Source snapshots and drains must preserve paths, text and order; + release builds enforce this identity and reject late registrations. + Prepared answers are reused only for the same ordered source contents. + Generated constants borrowing a real span carry an explicit origin bit; + missing Ruby documents or missing source-position answers cannot use + that fallback. Normal ingest persists `source_index_required`, and + clearing its source table triggers `source_index_missing`. Standalone + hand-built IR apps with no sources use exact modeled dispatch instead; + this API mode does not certify source indexing. Views and generated expressions have no Rubydex answer. A bare name there uses the typed local scope. A qualified name uses only the value declared at that full name. - The analyzer reports an error for an unresolved source constant. - It does not select another class by a matching name suffix. + This includes executable library methods, parameter defaults and + constant initializers; an unresolved constant cannot check clean and + emit an unsupported raise. It does not select another class by a matching + name suffix. `parse_module_constants` types literal constants in framework Ruby. Other inference gaps leave `Ty::Var(n)` or RBS `Ty::Untyped`. diff --git a/runtime/ruby/active_support_ext.rb b/runtime/ruby/active_support_ext.rb index 34eb9ba58..16cf004f8 100644 --- a/runtime/ruby/active_support_ext.rb +++ b/runtime/ruby/active_support_ext.rb @@ -817,6 +817,7 @@ def self.underscore(text) end def self.humanize(text) + raise NoMethodError, "undefined method 'humanize' for nil" if text.nil? s = text.to_s.tr("_", " ").lstrip s = s[0, s.length - 3].to_s if s.end_with?(" id") s = s.downcase @@ -825,6 +826,7 @@ def self.humanize(text) end def self.titleize(text) + raise NoMethodError, "undefined method 'titleize' for nil" if text.nil? s = humanize(underscore(text)) out = +"" n = s.length diff --git a/runtime/ruby/active_support_ext.rbs b/runtime/ruby/active_support_ext.rbs index 96ebc19e1..acc72cb82 100644 --- a/runtime/ruby/active_support_ext.rbs +++ b/runtime/ruby/active_support_ext.rbs @@ -103,6 +103,6 @@ module ActiveSupport def self.local_time: (Integer y, Integer mo, Integer d, Integer h, Integer mi, Integer s, Integer nsec) -> Time def self.number_delimited: (Integer | Float number) -> String def self.underscore: (String text) -> String - def self.humanize: (String text) -> String - def self.titleize: (String text) -> String + def self.humanize: (String? text) -> String + def self.titleize: (String? text) -> String end diff --git a/src/analyze/attribution.rs b/src/analyze/attribution.rs index d17d36944..fbb88a643 100644 --- a/src/analyze/attribution.rs +++ b/src/analyze/attribution.rs @@ -347,7 +347,7 @@ pub fn attribute_unknown_gems(diags: &mut [Diagnostic], app: &App) { return Some((gem, Some(format!("matches the declared `{dsl}` surface in {}; runtime method availability is unverified", owner.0)))); } } - match ancestry.as_ref()?.receiver_gem(recv_ty, &census) { + match ancestry.as_ref()?.receiver_gem(recv_ty, &census, &|gem, path| app.gem_boundary.declares_path(gem, path)) { GemClaim::Known { gem, constant } => Some((gem, Some(format!( "receiver ancestry reaches `{constant}`; method ownership is unverified" )))), @@ -363,7 +363,10 @@ pub fn attribute_unknown_gems(diags: &mut [Diagnostic], app: &App) { DiagnosticKind::Unsupported { construct, detail, .. } if construct.as_str() == "constant" => { - crate::gems::gem_owning_constant(&census, detail).map(|gem| (gem, None)) + crate::gems::gem_owning_constant_with(&census, detail, &|gem, path| { + app.gem_boundary.declares_path(gem, path) + }) + .map(|gem| (gem, None)) } _ => None, } @@ -375,7 +378,16 @@ pub fn attribute_unknown_gems(diags: &mut [Diagnostic], app: &App) { &d.kind, DiagnosticKind::Unsupported { construct, .. } if construct.as_str() == "constant" ); - if (!eligible(&d.kind) && !unknown_constant) || d.severity == Severity::Info { + if unknown_constant { + // Attribution cannot certify a constant whose emitted body + // is a refusal stub. Keep severity/kind/span and add context only. + if let Some((gem, _)) = gem_for(d) { + let context = format!(" — unmodeled gem `{gem}`; emitted constant availability is unverified"); + if !d.message.ends_with(&context) { d.message.push_str(&context); } + } + continue; + } + if !eligible(&d.kind) || d.severity == Severity::Info { continue; } if let Some((gem, evidence)) = gem_for(d) { @@ -592,7 +604,7 @@ mod tests { } #[test] - fn unknown_gem_constant_is_a_coverage_note_not_a_user_error() { + fn unknown_gem_constant_keeps_its_error_and_idempotent_context() { let mut app = App::new(); app.gem_lock = Some(crate::gems::Lockfile::parse( "GEM\n remote: https://rubygems.org/\n specs:\n acme-core (1.0.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n acme-core\n", @@ -602,9 +614,12 @@ mod tests { Diagnostic::unsupported(Span::synthetic(), None, "other construct", "AcmeCore::Client"), ]; attribute_unknown_gems(&mut diags, &app); - assert_eq!(diags[0].severity, Severity::Info); + assert_eq!(diags[0].severity, Severity::Error); assert!(diags[0].message.contains("acme-core")); assert_eq!(diags[1].severity, Severity::Error); + let once = diags.clone(); + attribute_unknown_gems(&mut diags, &app); + assert_eq!(diags, once); } #[test] diff --git a/src/analyze/attribution/gem_ancestry.rs b/src/analyze/attribution/gem_ancestry.rs index 8a4603881..f495b938e 100644 --- a/src/analyze/attribution/gem_ancestry.rs +++ b/src/analyze/attribution/gem_ancestry.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use crate::app::App; -use crate::gems::{GemCensus, gems_owning_constant}; +use crate::gems::{GemCensus, gems_owning_constant_with}; use crate::ident::{ClassId, Symbol}; use crate::ty::Ty; @@ -355,16 +355,16 @@ impl GemAncestry { } } - pub(super) fn receiver_gem<'a>(&self, ty: &Ty, census: &'a GemCensus) -> GemClaim<'a> { + pub(super) fn receiver_gem<'a>(&self, ty: &Ty, census: &'a GemCensus, declares: &dyn Fn(&str, &str) -> Option) -> GemClaim<'a> { match ty { - Ty::Class { id, .. } => self.class_gem(id, census), + Ty::Class { id, .. } => self.class_gem(id, census, declares), Ty::Union { variants } => { let mut claim = GemClaim::Absent; let mut missing = false; // Match nullable dispatch: nil is not a concrete receiver arm. // Every other arm must identify the same single gem. for variant in variants.iter().filter(|ty| !matches!(ty, Ty::Nil)) { - match self.receiver_gem(variant, census) { + match self.receiver_gem(variant, census, declares) { GemClaim::Absent => missing = true, GemClaim::Uncertain => return GemClaim::Uncertain, GemClaim::Known { gem, constant } => { @@ -394,7 +394,7 @@ impl GemAncestry { } } - fn class_gem<'a>(&self, id: &ClassId, census: &'a GemCensus) -> GemClaim<'a> { + fn class_gem<'a>(&self, id: &ClassId, census: &'a GemCensus, declares: &dyn Fn(&str, &str) -> Option) -> GemClaim<'a> { let mut pending = vec![id.clone()]; let mut seen = HashSet::new(); let mut candidates = BTreeMap::new(); @@ -410,7 +410,7 @@ impl GemAncestry { pending.push(target); } } else { - for gem in gems_owning_constant(census, id.0.as_str()) { + for gem in gems_owning_constant_with(census, id.0.as_str(), declares) { candidates .entry(gem) .and_modify(|witness: &mut String| { diff --git a/src/analyze/body/const_resolution.rs b/src/analyze/body/const_resolution.rs index a111feee9..f7867e8d8 100644 --- a/src/analyze/body/const_resolution.rs +++ b/src/analyze/body/const_resolution.rs @@ -31,7 +31,7 @@ use crate::ty::Ty; const CORE_RBS: &str = "\ class Numeric < Object\nend\n\ class Integer < Numeric\nend\n\ -class Float < Numeric\nend\n\ +class Float < Numeric\n INFINITY: Float\n NAN: Float\n EPSILON: Float\n MAX: Float\n MIN: Float\nend\n\ class String < Object\nend\n\ class Array < Object\nend\n\ class Hash < Object\nend\n\ @@ -49,7 +49,7 @@ const RUNTIME_URI_PREFIX: &str = "roundhouse-runtime:"; pub(super) enum ResolvedConstant { Namespace { class: Arc, runtime: bool }, - Value { declaration: DeclarationId, runtime: Option> }, + Value { declaration: DeclarationId, name: Arc, runtime: Option> }, } /// Rubydex answers for one source file. @@ -146,7 +146,10 @@ fn constant_graph(local: LocalGraph, text: &str) -> LocalGraph { } for reference in constant_references.into_values() { let singleton_receiver = matches!( - graph.names().get(reference.name_id()).map(|name| name.parent_scope()), + graph + .names() + .get(reference.name_id()) + .map(|name| name.parent_scope()), Some(ParentScope::Attached(_)) ); if !singleton_receiver { @@ -191,7 +194,9 @@ fn index_documents(documents: &[Document<'_>]) -> Graph { .spawn_scoped(scope, move || { loop { let index = next.fetch_add(1, Ordering::Relaxed); - let Some(document) = documents.get(index) else { break }; + let Some(document) = documents.get(index) else { + break; + }; if sender.send((index, document.index())).is_err() { break; } @@ -313,6 +318,12 @@ impl ConstResolver { self.file(file).is_some() } + pub(super) fn has_registered_source(&self, file: FileId) -> bool { + file.0 + .checked_sub(1) + .is_some_and(|index| (index as usize) < self.files.len()) + } + /// IR spans cover the entire `A::B::C` path. Rubydex records the /// final name at the span end minus the name's byte length. The typer /// can qualify a relative path, but the final name stays the same. @@ -323,7 +334,10 @@ impl ConstResolver { ) -> Option> { let length = u32::try_from(path.last()?.as_str().len()).ok()?; let start = span.end.checked_sub(length)?; - self.file(span.file)?.references.get(&start).map(Option::as_ref) + self.file(span.file)? + .references + .get(&start) + .map(Option::as_ref) } /// Exact source-resolved namespace, also used by ingest admission gates @@ -346,7 +360,9 @@ impl ConstResolver { return None; } let constants = &self.file(value.file)?.constants; - let index = constants.partition_point(|(end, ..)| *end <= value.start).checked_sub(1)?; + let index = constants + .partition_point(|(end, ..)| *end <= value.start) + .checked_sub(1)?; let (_, defined, id) = &constants[index]; (defined.as_ref() == name).then_some(*id) } @@ -454,18 +470,25 @@ fn index_sources(sources: &[SourceFile]) -> Graph { text: CORE_RBS, language: LanguageId::Rbs, }]; - documents.extend(crate::runtime_files::ruby_sources().map(|(path, text)| Document { - uri_prefix: RUNTIME_URI_PREFIX, - path, - text, - language: LanguageId::Ruby, - })); - documents.extend(sources.iter().filter(|source| is_indexed(source)).map(|source| Document { - uri_prefix: "", - path: &source.path, - text: &source.text, - language: LanguageId::Ruby, - })); + documents.extend( + crate::runtime_files::ruby_sources().map(|(path, text)| Document { + uri_prefix: RUNTIME_URI_PREFIX, + path, + text, + language: LanguageId::Ruby, + }), + ); + documents.extend( + sources + .iter() + .filter(|source| is_indexed(source)) + .map(|source| Document { + uri_prefix: "", + path: &source.path, + text: &source.text, + language: LanguageId::Ruby, + }), + ); index_documents(&documents) } @@ -487,7 +510,9 @@ fn collect_answers(graph: &Graph, sources: &[SourceFile]) -> Vec (it.name_id(), it.offset()), _ => continue, }; - let name = graph.names().get(name_id).and_then(|name| graph.strings().get(name.str())); + let name = graph + .names() + .get(name_id) + .and_then(|name| graph.strings().get(name.str())); let id = definition.and_then(|definition| graph.definition_to_declaration_id(definition)); if let (Some(name), Some(id)) = (name, id) { answers.constants.push((offset.end(), name.as_str().into(), *id)); @@ -733,4 +782,80 @@ mod tests { sources[0].path = "first.md".into(); assert!(!prepared.for_sources(&sources).has_source_file(FileId(1))); } + + fn source(path: &str, name: &str) -> SourceFile { + SourceFile { + path: path.into(), + text: format!("class {name}; end; {name}"), + } + } + fn resolved_name( + resolver: &ConstResolver, + sources: &[SourceFile], + index: usize, + name: &str, + ) -> String { + let text = &sources[index].text; + let start = text.rfind(name).unwrap() as u32; + let span = Span { + file: FileId(index as u32 + 1), + start, + end: start + name.len() as u32, + }; + match resolver.reference(span, &[Symbol::from(name)]) { + Some(Some(ResolvedConstant::Namespace { class, .. })) => class.0.to_string(), + _ => panic!("source reference must be indexed at its original FileId"), + } + } + + #[test] + fn missing_documents_and_missing_source_answers_are_not_generated_ir() { + use super::super::{BodyTyper, ClassInfo, Ctx}; + use crate::{Expr, ExprNode}; + let sources = vec![source("app/services/first.rb", "First")]; + let resolver = Arc::new(ConstResolver::from_app_sources(&sources)); + let classes = HashMap::from([(ClassId(Symbol::from("Object")), ClassInfo::default())]); + let typer = BodyTyper::new(&classes).with_const_resolver(resolver); + let make = |file| { + Expr::new( + Span { + file: FileId(file), + start: 0, + end: 6, + }, + ExprNode::Const { + path: vec![Symbol::from("Object")], + }, + ) + }; + for file in [1, 99] { + let mut expr = make(file); + typer.analyze_expr(&mut expr, &Ctx::default()); + assert!( + expr.diagnostic.is_some(), + "source FileId({file}) cannot use a generated fallback" + ); + } + let mut generated = make(99); + generated.decisions |= crate::expr::GENERATED_CONST_REF; + typer.analyze_expr(&mut generated, &Ctx::default()); + assert!(generated.diagnostic.is_none()); + assert!(matches!(generated.ty, Some(crate::ty::Ty::Class { .. }))); + } + + #[test] + fn prepared_answers_reject_same_count_source_changes_and_reordering() { + let mut sources = vec![ + source("app/services/first.rb", "First"), + source("components/payments/lib/second.rb", "Second"), + ]; + let prepared = ConstResolverTask::start(Arc::new(sources.clone())).finish(); + sources[0] = source("app/services/first.rb", "Third"); + let changed = prepared.for_sources(&sources); + assert_eq!(resolved_name(&changed, &sources, 0, "Third"), "Third"); + sources.swap(0, 1); + let reordered = prepared.for_sources(&sources); + assert_eq!(resolved_name(&reordered, &sources, 0, "Second"), "Second"); + assert_eq!(resolved_name(&reordered, &sources, 1, "Third"), "Third"); + } } diff --git a/src/analyze/body/diagnostic.rs b/src/analyze/body/diagnostic.rs index 8dba959ce..488189b42 100644 --- a/src/analyze/body/diagnostic.rs +++ b/src/analyze/body/diagnostic.rs @@ -20,6 +20,12 @@ use crate::expr::{Expr, ExprNode}; use crate::ty::Ty; pub(super) fn detect_diagnostic(expr: &mut Expr) { + // Operator ownership/operand types can become known in a later fixpoint + // round. Recompute this classifier's annotation instead of retaining a + // refusal from an earlier incomplete registry. + if matches!(&expr.diagnostic, Some(crate::diagnostic::DiagnosticKind::IncompatibleBinop { .. })) { + expr.diagnostic = None; + } if let ExprNode::Send { recv: Some(r), method, args, .. } = &*expr.node { if args.len() != 1 { return; diff --git a/src/analyze/body/mod.rs b/src/analyze/body/mod.rs index 3957915ca..5e5109f6b 100644 --- a/src/analyze/body/mod.rs +++ b/src/analyze/body/mod.rs @@ -70,6 +70,16 @@ impl ConstScope { pub fn get(&self, name: &Symbol) -> Option<&Ty> { self.own.get(name).or_else(|| self.global.get(name)) } + + /// Only the constants this scope's class declares itself. + pub fn get_own(&self, name: &Symbol) -> Option<&Ty> { + self.own.get(name) + } + + /// Only the app-wide, by-bare-name registry. + pub fn get_global(&self, name: &Symbol) -> Option<&Ty> { + self.global.get(name) + } } /// Recursion context — what `self` is, what locals/ivars are in scope. @@ -86,6 +96,9 @@ pub struct Ctx { /// assignments accumulated through a `Seq`, and block parameters /// seeded from a receiver-aware dispatch. pub local_bindings: HashMap, + /// Locals whose assigned value is proven to be a class/module object. + /// Nominal instance types alone do not establish this identity. + pub class_objects: std::collections::HashSet, /// Module/class-level typed constants such as /// `STATUS_CODES = { ok: 200, ... }.freeze`. Rubydex IDs resolve /// source-backed reads; this scope types generated expressions. @@ -115,6 +128,11 @@ pub struct Ctx { /// it `String` instead of the generic `Untyped`. Off elsewhere, where a /// block's return type isn't tracked through the method signature. pub in_view: bool, + /// Set while typing the body of a class-side method (`def self.x`, + /// `class << self; def x`). `self` there is the class object, so an + /// implicit-self `new` answers "an instance of whichever class + /// received the call", not of the class the `def` sits in. + pub class_side: bool, } /// User-class dispatch data: table name (if any), instance shape, @@ -122,12 +140,18 @@ pub struct Ctx { /// Rails schema + conventions; the body-typer reads it. #[derive(Default, Clone)] pub struct ClassInfo { + /// Kind of the indexed source declaration; never inferred from its name. + pub is_module: bool, + /// Constant values declared by external gem RBI/RBS files. + pub constants: HashMap, /// If this class maps to a database table, which one. pub table: Option, /// Instance-state shape (columns + attr_accessor). pub attributes: Row, /// Methods callable on the class itself: `Post.all`, `Post.find(id)`. pub class_methods: HashMap, + /// Source declares its own class-side `new`, whose result need not be self. + pub declares_constructor: bool, /// Which of `class_methods` had their return type inferred FROM a /// query chain over this model — every `scope`, plus any class /// method whose body tail is such a chain. @@ -205,8 +229,64 @@ pub struct ClassInfo { /// class's own methods and before walking the parent. Empty for /// most classes. pub includes: Vec, + /// Declared by a gem's RBI rather than by the app or the catalog + /// (`crate::gem_boundary`). Its own table is the gem's whole + /// declared surface, so a miss on the class itself is a real miss -- + /// but an APP class that inherits from it also inherits whatever the + /// gem's macros generate, which no declaration lists. + pub gem_boundary: bool, + /// The surface cannot be enumerated (`method_missing`, an unresolved + /// mixin, an ancestor the RBI could not name): a lookup that misses + /// is unknown, not wrong. + pub open: bool, + /// Declared in the app's own source (a model, controller, or + /// library class), as opposed to the framework catalog. An app class + /// is reachable from a bare name only through Ruby's constant + /// lookup (the lexical scope, then the ancestors), never by its last + /// segment alone. + pub app_declared: bool, } +/// Resolve a single-segment Const ref (like `Const { path: +/// ["HashWithIndifferentAccess"] }` from app source) to a fully- +/// qualified ClassId by walking the class registry. Returns the +/// fully-qualified `Symbol` when exactly one registry key matches — +/// "matches" meaning the key has `::` separator(s) AND the last +/// segment equals `name`. App-class single-segment keys (`Article`, +/// `Comment`) don't match (no `::`), so they stay bare. Multiple +/// matches (rare — would be e.g. `ActiveRecord::Base` and +/// `ActionController::Base`) leave the ref bare too — body-typer +/// can't disambiguate without lexical scope, and the bare form +/// still types via the registry's last-segment alias entry. +/// The class a written owner path names, resolved the way Ruby +/// resolves a constant: the enclosing scopes from the inside out, then +/// the top level. +/// +/// `DEFAULT_MODE = Mode::Fill` inside `UI::Selector` means +/// `UI::Selector::Mode`, and nothing else does — which matters in an +/// app where a dozen components each declare their own `Mode`. Walking +/// the nesting is what tells them apart; a suffix match over the class +/// registry cannot, and gives up on the ambiguity instead. +/// A class name written inside `scope` (a qualified class name), +/// resolved lexically: `scope::name`, then each enclosing namespace, +/// and finally the name as written. `None` when no registered class +/// answers, so an unknown name (a gem's class) is left as written. +pub(crate) fn lexical_class( + written: &ClassId, + scope: &str, + classes: &HashMap, +) -> Option { + let name = written.0.as_str(); + let mut parts: Vec<&str> = scope.split("::").filter(|s| !s.is_empty()).collect(); + while !parts.is_empty() { + let candidate = ClassId(Symbol::from(format!("{}::{name}", parts.join("::")).as_str())); + if classes.contains_key(&candidate) { + return Some(candidate); + } + parts.pop(); + } + classes.contains_key(written).then(|| written.clone()) +} /// Reusable body-type walker. Holds a borrow of the dispatch table so /// repeated `analyze_expr` calls reuse the same lookup structures @@ -273,6 +353,8 @@ impl<'a> BodyTyper<'a> { pub fn analyze_expr(&self, expr: &mut Expr, ctx: &Ctx) -> Ty { let ty = self.compute(expr, ctx); expr.ty = Some(ty.clone()); + expr.decisions &= !crate::expr::CLASS_OBJECT_VALUE; + if self.is_class_object(expr, ctx) { expr.decisions |= crate::expr::CLASS_OBJECT_VALUE; } diagnostic::detect_diagnostic(expr); ty } @@ -391,6 +473,7 @@ impl<'a> BodyTyper<'a> { } else { union_of(ctx.local_bindings.get(&name).cloned().unwrap_or(Ty::Nil), ty) }; + ctx.class_objects.remove(&name); ctx.local_bindings.insert(name, ty); } }; @@ -406,6 +489,88 @@ impl<'a> BodyTyper<'a> { expr.node.for_each_child(&mut |child| self.propagate_match_bindings(child, ctx, false)); } + /// The type of `name` when it is an attribute of the class `self` is an + /// instance of: a column or an `attr_accessor`/`attr_reader`, its own or an + /// ancestor's. Such a read is a plain state read, which is what lets a + /// guard on it (`expires_at.present? && expires_at > now`) speak for + /// the reads that follow it the way a guard on a local does. + fn self_attribute_ty(&self, name: &Symbol, ctx: &Ctx) -> Option { + let Some(Ty::Class { id, .. }) = &ctx.self_ty else { + return None; + }; + let mut cursor = Some(id.clone()); + // Bounded: a parent link that cycles must not hang the typer. + for _ in 0..16 { + let Some(cur) = cursor else { break }; + let Some(info) = self.classes.get(&cur) else { break }; + if let Some(ty) = info.attributes.fields.get(name) { + return Some(ty.clone()); + } + cursor = info.parent.clone(); + } + None + } + + /// Identity evidence for Module protocol, independent of nominal instance type. + fn is_class_object(&self, expr: &Expr, ctx: &Ctx) -> bool { + match &*expr.node { + ExprNode::SelfRef => ctx.class_side, + ExprNode::Var { name, .. } => ctx.class_objects.contains(name), + ExprNode::Const { path } => { + if expr.decisions & crate::expr::RESOLVED_CLASS_REF != 0 { return true; } + if let Some(answer) = self.const_resolver.as_ref().and_then(|r| r.reference(expr.span, path)) { + if let Some(resolved) = answer { + return matches!(resolved, ResolvedConstant::Namespace { .. }); + } + } + // An exact modeled namespace is valid without a source document. + // A typed VALUE constant, even with the same nominal type, is not. + let id = written_class_id(path); + let value = if let [name] = path.as_slice() { + ctx.constants.get_own(name).or_else(|| ctx.constants.get_global(name)) + } else { None }; + if value.is_some() { + return false; + } + // Lowerers retype with a partial registry (no stdlib seed). + // A Const whose computed type is the written class itself is + // still the class object — `RecordNotFound < StandardError` + // must stay Module#<, not Incompatible value comparison. + self.classes.contains_key(&id) + || matches!( + expr.ty.as_ref(), + Some(Ty::Class { id: ty_id, .. }) if *ty_id == id + ) + } + _ => false, + } + } + + fn is_module_callback(&self, recv_ty: Option<&Ty>, method: &Symbol) -> bool { + matches!(method.as_str(), "included" | "prepended" | "append_features" | "prepend_features") + && matches!(recv_ty, Some(Ty::Class { id, .. }) if self.classes.get(id).is_some_and(|c| c.is_module && c.class_methods.contains_key(method))) + } + + fn owns_operator(&self, ty: Option<&Ty>, method: &Symbol, class_object: bool) -> bool { + match ty { + Some(Ty::Union { variants }) => !variants.is_empty() + && variants.iter().all(|ty| self.owns_operator(Some(ty), method, class_object)), + Some(Ty::Class { id, .. }) => { + let mut current = Some(id); + for _ in 0..32 { + let Some(id) = current else { break }; + let Some(class) = self.classes.get(id) else { break }; + let own = if class_object { &class.class_methods } else { &class.instance_methods }; + if own.contains_key(method) { return true; } + if !class_object && class.includes.iter().any(|m| self.lookup_in_module(m, method).is_some()) { return true; } + current = class.parent.as_ref(); + } + false + } + _ => false, + } + } + fn compute(&self, expr: &mut Expr, ctx: &Ctx) -> Ty { let expr_span = expr.span; match &mut *expr.node { @@ -415,10 +580,34 @@ impl<'a> BodyTyper<'a> { // A later typing pass may resolve a value constant whose // owner was unknown in an earlier constant fixpoint round. expr.diagnostic = None; + let generated = expr_span.file.0 == 0 + || expr.decisions & crate::expr::GENERATED_CONST_REF != 0; + // A real FileId outside the snapshot is an indexing + // defect, not a generated expression or an ERB fallback. + if !generated && self.const_resolver.as_ref() + .is_some_and(|resolver| !resolver.has_registered_source(expr_span.file)) + { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: Symbol::from("constant"), + detail: format!("{}: missing source document for FileId({})", + written_class_id(path).0, expr_span.file.0), + }); + return unknown(); + } // Never search by suffix or borrow another scope's // same-named declaration: only the exact written class. let exact_modeled_class = |path: &[Symbol]| { let id = written_class_id(path); + if let Some((name, owner)) = path.split_last() { + if !owner.is_empty() { + if let Some(info) = self.classes().get(&written_class_id(owner)) { + if info.gem_boundary { + if let Some(ty) = info.constants.get(name) { return ty.clone(); } + } + } + } + } if self.classes().contains_key(&id) { Ty::Class { id, args: vec![] } } else { @@ -433,6 +622,7 @@ impl<'a> BodyTyper<'a> { let source_reference = self .const_resolver .as_ref() + .filter(|_| !generated) .and_then(|resolver| resolver.reference(expr_span, path)); let ty = match source_reference { Some(Some(ResolvedConstant::Namespace { class, runtime })) => { @@ -445,25 +635,60 @@ impl<'a> BodyTyper<'a> { unknown() } } - Some(Some(ResolvedConstant::Value { declaration, runtime })) => self.typed_constants - .and_then(|values| values.get(declaration)) - .cloned() - .or_else(|| runtime.as_ref().map(|ty| (**ty).clone())) - .unwrap_or_else(unknown), + Some(Some(ResolvedConstant::Value { declaration, name, runtime })) => { + // Concern splices retain the resolved value owner. + // Test-helper splice lifts a foreign module's + // constant onto the test class as a bare name + // (`DnsTestHelper::WEB_PUSH_PUBLIC_TEST_IP` → + // `WEB_PUSH_PUBLIC_TEST_IP`) and never emits the + // helper module — re-qualifying that bare path is a + // NameError at load. Leave bare paths bare when the + // class owns the leaf and the declaration's owner + // is foreign. Do not strip an already-qualified + // path (`FactoryModel::Result`) just because the + // leaf name also exists locally — that collision + // is a different constant (`data_factory_constants`). + let leaf = path.last(); + let locally_owned = leaf + .is_some_and(|n| ctx.constants.get_own(n).is_some()); + let enclosing = match &ctx.self_ty { + Some(Ty::Class { id, .. }) => Some(id.0.as_str()), + _ => None, + }; + let decl_owner = name.0.as_str().rsplit_once("::").map(|(o, _)| o); + let keep_bare_splice = path.len() == 1 + && locally_owned + && enclosing + .zip(decl_owner) + .is_some_and(|(enc, owner)| enc != owner); + if !keep_bare_splice { + qualify_resolved_path(path, name); + } + self.typed_constants + .and_then(|values| values.get(declaration)) + .cloned() + .or_else(|| runtime.as_ref().map(|ty| (**ty).clone())) + .unwrap_or_else(unknown) + } // An unresolved source reference may still name an // exact modeled external class (for example Time). Some(None) => exact_modeled_class(path), - // No reference in an indexed file: a lowering pass - // generated this node with a borrowed source span - // (Alba's serializers). - None if indexed_source => exact_modeled_class(path), + // A borrowed span is not evidence of a written constant + // only when the producer marked its generated origin. + None if indexed_source && generated => exact_modeled_class(path), + None if indexed_source => unknown(), // Views and generated IR have no Rubydex answer. A // bare name can use the app's bare-name values. A // qualified name `A::B` names its owner, so only the // value declared at that full name answers it. None => { let value = if let [name] = path.as_slice() { - ctx.constants.get(name).cloned() + ctx.constants.get_own(name).cloned() + .or_else(|| { + let ty = exact_modeled_class(path); + (!matches!(ty, Ty::Var { .. })).then_some(ty) + }) + .or_else(|| ctx.constants.get_global(name).cloned()) } else { let name = written_class_id(path); self.typed_constants @@ -536,15 +761,28 @@ impl<'a> BodyTyper<'a> { self.analyze_expr(c, ctx); } if let Some(name) = &rc.binding { - // An unregistered class (a gem's error) stays StandardError: typed as itself, even `e.message` would fail. - let rescued = match rc.classes.as_slice() { - [c] => match &c.ty { - Some(ty @ Ty::Class { id, .. }) if self.classes().contains_key(id) => Some(ty.clone()), - _ => None, - }, - _ => None, - }; + // `rescue A, B => e` binds the union: `e` is one of them. A class + // that failed constant resolution has already produced a + // blocking diagnostic. Its binding is unknown, rather than + // StandardError: inventing that class causes unrelated method + // errors and hides the original resolution failure in cascades. + let mut rescued: Option = None; + for c in rc.classes.iter() { + match &c.ty { + Some(ty @ Ty::Class { .. }) => { + rescued = Some(match rescued.take() { + Some(prev) => union_of(prev, ty.clone()), + None => ty.clone(), + }); + } + _ => { + rescued = Some(Ty::Untyped); + break; + } + } + } let mut inner = ctx.clone(); + inner.class_objects.remove(name); inner.local_bindings.insert( name.clone(), rescued.unwrap_or_else(|| Ty::Class { @@ -694,7 +932,7 @@ impl<'a> BodyTyper<'a> { let mut seeded = ctx.clone(); collect_var_assignments_into(left, &mut seeded.local_bindings); self.propagate_match_bindings(left, &mut seeded, true); - let pred = narrowing::extract_narrowing(left); + let pred = narrowing::extract_narrowing_with(left, &|n| self.self_attribute_ty(n, ctx)); let right_ctx = match (&pred, &*op) { (Some(p), crate::expr::BoolOpKind::And) => { narrowing::apply_narrowing(&seeded, p, true) @@ -764,11 +1002,17 @@ impl<'a> BodyTyper<'a> { let v_ty = self.analyze_expr(value, ctx); let mut inner = ctx.clone(); inner.local_bindings.insert(name.clone(), v_ty); + inner.class_objects.remove(name); + if self.is_class_object(value, ctx) { inner.class_objects.insert(name.clone()); } self.analyze_expr(body, &inner) } - ExprNode::Lambda { body, .. } => { - let body_ty = self.analyze_expr(body, ctx); + ExprNode::Lambda { params, rest_param, block_param, body, .. } => { + let mut inner = ctx.clone(); + for name in params.iter().chain(rest_param.iter()).chain(block_param.iter()) { + inner.class_objects.remove(name); + } + let body_ty = self.analyze_expr(body, &inner); // Synthesize a `Fn` type from the body's type. Param // types aren't tracked here (they were seeded into the // outer Ctx by block_ctx_for from the receiver @@ -830,6 +1074,9 @@ impl<'a> BodyTyper<'a> { ExprNode::Defined { .. } => union_of(Ty::Str, Ty::Nil), ExprNode::Send { recv, method, args, block, parenthesized } => { + if matches!(&expr.diagnostic, Some(crate::diagnostic::DiagnosticKind::Unsupported { construct, .. }) + if matches!(construct.as_str(), "Module protocol" | "ActiveModel::Errors" | "ActiveModel::Error" | "Rails" | "ActiveRecord::Base" | "String extension" | "Hash extension" | "not_nil!" | "Object extension" | "Module callback argument")) + { expr.diagnostic = None; } expr.decisions &= !crate::expr::RESOLVED_DATA_FACTORY; if let Some(ty) = self.data_factories.and_then(|factories| factories.get(&expr_span)) { // Only admitted declarations establish a Data class identity. @@ -913,10 +1160,54 @@ impl<'a> BodyTyper<'a> { }); } - let mut recv_ty = match recv.as_mut() { + let recv_ty = match recv.as_mut() { Some(r) => Some(self.analyze_expr(r, ctx)), None => ctx.self_ty.clone(), }; + // Inside the class-side method, the instance a class-side + // `new` built (`Ty::SelfInstance`, see below) answers the + // methods of the class the `def` sits in: the receiving + // subclass is only known at the call site, and what the + // body can call on it is the base's surface. The value + // itself stays `SelfInstance`, so the method still answers + // the receiving class. + let recv_ty = match (recv_ty, &ctx.self_ty) { + (Some(t), Some(self_ty @ Ty::Class { .. })) if ctx.class_side => { + Some(t.subst_self(self_ty)) + } + (other, _) => other, + }; + // `Parameters` is a Hash-shaped bag: what its own class does + // not answer (`fetch`, `each`, `map`, `count`, ...) is the + // Hash reading, over Symbol -> param value. + let mut recv_ty = match recv_ty { + Some(Ty::Class { id, .. }) + if id.0.as_str() == "ActionController::Parameters" + && method.as_str() != "new" + && !self.classes().get(&id).is_some_and(|c| + c.instance_methods.contains_key(method) + || c.class_methods.contains_key(method)) => + { + Some(send::params_as_hash()) + } + other => other, + }; + // `new` on the implicit or explicit `self` of a class-side + // method builds an instance of the class that RECEIVED the + // call: `CurrencyDb.load` runs `YamlDb.load` with `self == + // CurrencyDb`. Typing it as the class the `def` sits in made + // every subclass factory answer the base class. + if ctx.class_side + && method.as_str() == "new" + && recv.as_ref().map_or(true, |r| matches!(&*r.node, ExprNode::SelfRef)) + && matches!(recv_ty, Some(Ty::Class { .. })) + && !self.has_declared_constructor(recv_ty.as_ref()) + { + for a in args.iter_mut() { + self.analyze_expr(a, ctx); + } + return Ty::SelfInstance; + } for a in args.iter_mut() { self.analyze_expr(a, ctx); } @@ -926,8 +1217,26 @@ impl<'a> BodyTyper<'a> { recv_ty = r.ty.clone(); } } + // Ruby's Module callback ABI supplies a class/module object. Direct + // source calls must satisfy the same identity contract. + if self.is_module_callback(recv_ty.as_ref(), method) { + if let Some(arg) = args.first() { + if !self.is_class_object(arg, ctx) { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, construct: Symbol::from("Module callback argument"), + detail: format!("{} requires a proven class/module object argument", method), + }); + } + } + } let block_ret = if let Some(b) = block { - let block_ctx = self.block_ctx_for(ctx, recv_ty.as_ref(), method, args, b); + let mut block_ctx = self.block_ctx_for(ctx, recv_ty.as_ref(), method, args, b); + if matches!(method.as_str(), "instance_eval" | "instance_exec" | "class_eval" | "class_exec" | "module_eval" | "module_exec") { + if let Some(receiver) = recv.as_ref() { + block_ctx.self_ty = recv_ty.clone(); + block_ctx.class_side = self.is_class_object(receiver, ctx); + } + } let method_ref_ty = self.analyze_expr(b, &block_ctx); // The Lambda walker stores the analyzed body's type // on the body expr itself. `map`/`collect`/similar @@ -1093,7 +1402,21 @@ impl<'a> BodyTyper<'a> { { return Ty::Str; } + if let Some(t) = recv_ty + .as_ref() + .and_then(|t| literal_extremum_ty(recv.as_ref(), t, method, args)) + { + return t; + } let dispatched = self.dispatch(recv_ty.as_ref(), method, block_ret.as_ref(), args); + if let Some(receiver) = recv.as_mut() { + receiver.decisions &= !crate::expr::RESOLVED_OPERATOR_RECEIVER; + if matches!(method.as_str(), "+" | "-" | "*" | "/" | "**" | "%" | "<" | "<=" | ">" | ">=") + && self.owns_operator(recv_ty.as_ref(), method, self.is_class_object(receiver, ctx)) + { + receiver.decisions |= crate::expr::RESOLVED_OPERATOR_RECEIVER; + } + } // Kernel.Array is a container even for scalar params. // App methods (including inherited/included overrides) // have already dispatched above and must win. @@ -1102,12 +1425,69 @@ impl<'a> BodyTyper<'a> { { return Ty::Array { elem: Box::new(unknown()) }; } + // What every object and every module answers, when the + // receiver's own table did not. App analyzer only. + let class_object_receiver = recv.as_ref().map_or(ctx.class_side, |r| self.is_class_object(r, ctx)); + if matches!(dispatched, Ty::Var { .. } | Ty::Untyped) && self.inquirers.is_some() + && (recv.is_some() || (ctx.self_ty.is_some() && send::is_module_protocol(method))) + && !self.owns_operator(recv_ty.as_ref(), method, class_object_receiver) { + let class_object = class_object_receiver; + // `include?` is Module's ancestor check AND String/Enumerable + // membership. Refuse Module protocol only for true Module-only + // names, or for `include?` on a nominal class *instance* (where + // Module#include? would be the wrong answer). Untyped / + // String / Array receivers fall through to membership → Bool. + let module_only = send::is_module_protocol(method) + && (method.as_str() != "include?" + || matches!(recv_ty, Some(Ty::Class { .. }))); + if module_only && !class_object + && !self.owns_operator(recv_ty.as_ref(), method, false) { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: Symbol::from("Module protocol"), + detail: format!("{} requires a proven class/module object receiver", method), + }); + return dispatched; + } + if let Some(t) = send::object_protocol_method(recv_ty.as_ref(), method, block_ret.as_ref(), class_object) { + return t; + } + } + if matches!(dispatched, Ty::Var { .. } | Ty::Untyped) && self.inquirers.is_some() + && !self.owns_operator(recv_ty.as_ref(), method, class_object_receiver) { + let gap = match recv_ty.as_ref() { + Some(Ty::Class { id, .. }) if matches!(id.0.as_str(), "ActiveModel::Errors" | "ActiveModel::Error") => Some(id.0.as_str()), + Some(Ty::Class { id, .. }) if matches!(id.0.as_str(), "Rails" | "ActiveRecord::Base") => Some(id.0.as_str()), + Some(Ty::Str) if matches!(method.as_str(), "to_date" | "to_time" | "to_datetime" | "in_time_zone" | "to_d" | "as_json") => Some("String extension"), + Some(Ty::Hash { .. }) if method.as_str() == "to_sentence" => Some("Hash extension"), + _ if method.as_str() == "not_nil!" => Some("not_nil!"), + // `to_param` on Untyped goes through ActiveSupport.to_param + // (residue lowering + runtime). Refuse only on a nominal + // class with no modeled/synthesized reader (e.g. a plain + // PORO). Untyped helper params (campfire AvatarsHelper) + // must not hard-fail. + _ if method.as_str() == "to_param" + && matches!(recv_ty, Some(Ty::Class { .. })) => + { + Some("Object extension") + } + _ if matches!(method.as_str(), "to_query" | "instance_values" | "acts_like?" | "presence_in" | "as_json" | "with_options" | "pretty_inspect") => Some("Object extension"), + _ => None, + }; + if let Some(owner) = gap.filter(|_| expr.diagnostic.is_none()) { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: Symbol::from(owner), + detail: format!("{} lacks a shared runtime/lowering implementation", method), + }); + } + } dispatched } ExprNode::If { cond, then_branch, else_branch } => { self.analyze_expr(cond, ctx); - let pred = narrowing::extract_narrowing(cond); + let pred = narrowing::extract_narrowing_with(cond, &|n| self.self_attribute_ty(n, ctx)); // Var assignments inside `cond` (e.g. // `if (user = User.find_by(...)) && user.is_active?`) // must flow into both branches: the assignment @@ -1248,7 +1628,16 @@ impl<'a> BodyTyper<'a> { _ => false, }; let e = &mut exprs[i]; + if !matches!(&*e.node, ExprNode::Assign { target: LValue::Var { .. }, .. }) { + forget_class_object_writes(e, &mut local_ctx); + } last = self.analyze_expr(e, &local_ctx); + if let ExprNode::Assign { target: LValue::Var { name, .. }, value } = &*e.node { + let proven = self.is_class_object(value, &local_ctx); + forget_class_object_writes(value, &mut local_ctx); + local_ctx.class_objects.remove(name); + if proven { local_ctx.class_objects.insert(name.clone()); } + } if let ExprNode::Assign { target, value } = &*e.node { let slot = match target { LValue::Ivar { name } => Some((true, name.clone())), @@ -1617,10 +2006,25 @@ impl<'a> BodyTyper<'a> { _ => false, }; if then_diverges && else_empty { - if let Some(pred) = narrowing::extract_narrowing(cond) { + if let Some(pred) = narrowing::extract_narrowing_with(cond, &|n| self.self_attribute_ty(n, ctx)) { local_ctx = narrowing::apply_narrowing(&local_ctx, &pred, false); } } + // The mirror image is `unless`, which the ingest + // spells `if cond; ; else ; end`: + // `return false unless current` continues only when + // `current` was truthy. + let then_empty = match &*then_branch.node { + ExprNode::Lit { value: crate::expr::Literal::Nil } => true, + ExprNode::Seq { exprs } => exprs.is_empty(), + _ => false, + }; + let else_diverges = matches!(else_branch.ty.as_ref(), Some(Ty::Bottom)); + if else_diverges && then_empty { + if let Some(pred) = narrowing::extract_narrowing_with(cond, &|n| self.self_attribute_ty(n, ctx)) { + local_ctx = narrowing::apply_narrowing(&local_ctx, &pred, true); + } + } } } last @@ -1767,13 +2171,76 @@ impl<'a> BodyTyper<'a> { // `target_ty` regardless of what the value's flow // type computed to. let _ = self.analyze_expr(value, ctx); - target_ty.clone() + // A class named inside a namespace means the lexically + // nearest one (`Capabilities::Charge` in + // `ShopifyPayments::Capability` is + // `ShopifyPayments::Capabilities::Charge`). + let resolved = match &ctx.self_ty { + Some(Ty::Class { id: scope, .. }) => target_ty.map_class_ids(&|id| { + lexical_class(id, scope.0.as_str(), self.classes).unwrap_or_else(|| id.clone()) + }), + _ => target_ty.clone(), + }; + let missing = std::cell::RefCell::new(Vec::new()); + // Lowerers also synthesize casts whose identities were proven + // against the full app before a reduced view/runtime registry + // retypes them. Resolve source declarations at their boundary. + if expr.decisions & crate::expr::SOURCE_TYPE_ASCRIPTION != 0 { + resolved.map_class_ids(&|id| { + if !self.classes.contains_key(id) && !RUBY_TOP_LEVEL.contains(&id.0.as_str()) + && !self.const_resolver.as_ref().is_some_and(|r| r.has_source_namespace(id.0.as_str())) { + missing.borrow_mut().push(id.0.as_str().to_owned()); + } + id.clone() + }); + } + let missing = missing.into_inner(); + if !missing.is_empty() { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: Symbol::from("declared type"), + detail: format!("{} has no indexed declaration or modeled dependency", missing.join(", ")), + }); + } else if matches!(&expr.diagnostic, Some(crate::diagnostic::DiagnosticKind::Unsupported { construct, .. }) if construct.as_str() == "declared type") { + expr.diagnostic = None; + } + resolved } } } } +/// Conditional writes, block writes and parameter rebinding invalidate identity. +fn forget_class_object_writes(expr: &Expr, ctx: &mut Ctx) { + let mut names = Vec::new(); + match &*expr.node { + ExprNode::Assign { target: LValue::Var { name, .. }, .. } + | ExprNode::OpAssign { target: LValue::Var { name, .. }, .. } => names.push(name.clone()), + ExprNode::MultiAssign { targets, .. } => { + for target in targets { + if let LValue::Var { name, .. } = target { names.push(name.clone()); } + } + } + ExprNode::Lambda { params, rest_param, block_param, .. } => { + names.extend(params.iter().cloned()); + names.extend(rest_param.iter().cloned()); + names.extend(block_param.iter().cloned()); + } + ExprNode::Let { name, .. } => names.push(name.clone()), + ExprNode::CaseMatch { arms, .. } => { + for arm in arms { arm.pattern.bound_names(&mut names); } + } + ExprNode::MatchRequired { pattern, .. } | ExprNode::MatchPredicate { pattern, .. } => pattern.bound_names(&mut names), + ExprNode::BeginRescue { rescues, .. } => { + for rescue in rescues { names.extend(rescue.binding.iter().cloned()); } + } + _ => {} + } + for name in names { ctx.class_objects.remove(&name); } + expr.node.for_each_child(&mut |child| forget_class_object_writes(child, ctx)); +} + // Literal / primitive types --------------------------------------------- pub(super) fn lit_ty(lit: &Literal) -> Ty { @@ -2227,6 +2694,32 @@ mod tests { ctx } + #[test] + fn const_class_ref_keeps_class_object_without_registry_entry() { + // Lower retypes with a partial registry that omits stdlib. A Const + // whose type is the written class must still stamp CLASS_OBJECT_VALUE + // so `RecordNotFound < StandardError` classifies as Module#<. + let classes = empty_classes(); + let typer = BodyTyper::new(&classes); + let ctx = Ctx::default(); + let mut expr = synth(ExprNode::Const { + path: vec![Symbol::from("StandardError")], + }); + let ty = typer.analyze_expr(&mut expr, &ctx); + assert_eq!( + ty, + Ty::Class { + id: ClassId(Symbol::from("StandardError")), + args: vec![], + } + ); + assert_ne!( + expr.decisions & crate::expr::CLASS_OBJECT_VALUE, + 0, + "Const class ref must remain a class object without a registry entry" + ); + } + fn optional_str() -> Ty { Ty::Union { variants: vec![Ty::Str, Ty::Nil], @@ -3535,6 +4028,26 @@ fn time_parse_ty(recv: &Expr, method: &Symbol, args: &[Expr]) -> Option { } } +impl BodyTyper<'_> { + // A declared constructor can return another class. Only the default + // Class#new operation promises an instance of the receiving class. + fn has_declared_constructor(&self, ty: Option<&Ty>) -> bool { + let Some(Ty::Class { id, .. }) = ty else { return false }; + let mut next = Some(id); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = next { + if !seen.insert(id) { break; } + let Some(class) = self.classes().get(id) else { break }; + if class.declares_constructor + || matches!(class.class_methods.get(&Symbol::from("new")), Some(Ty::Fn { .. })) { + return true; + } + next = class.parent.as_ref(); + } + false + } +} + fn is_time_const(e: &Expr) -> bool { matches!(&*e.node, ExprNode::Const { path } if path.len() == 1 && path[0].as_str() == "Time") } @@ -3545,7 +4058,13 @@ fn expect_hash_arg_ty(recv_ty: Option<&Ty>, method: &str, args: &[crate::expr::E if method != "expect" { return None; } - let Some(Ty::Hash { key, value }) = recv_ty else { return None }; + let (key, value) = match recv_ty { + Some(Ty::Hash { key, value }) => (key.clone(), value.clone()), + Some(Ty::Class { id, .. }) if id.0.as_str() == "ActionController::Parameters" => { + (Box::new(Ty::Str), Box::new(Ty::Untyped)) + } + _ => return None, + }; let [arg] = args else { return None }; let ExprNode::Hash { entries, .. } = &*arg.node else { return None }; let [(_, permitted)] = entries.as_slice() else { return None }; @@ -3565,6 +4084,16 @@ fn promotes_to_param_value( locals: &HashMap, ) -> bool { use crate::expr::{ExprNode, Literal}; + // Keep the canonical strong-params chain intact for typed factory + // discovery. Nested indexed values still use the request-value runtime. + if method.as_str() == "permit" + && matches!(&*recv.node, ExprNode::Send { recv: Some(root), method, .. } + if method.as_str() == "require" && matches!(&*root.node, + ExprNode::Send { recv: None, method, args, .. } + if method.as_str() == "params" && args.is_empty())) + { + return false; + } let stringish = match recv_ty { Some(Ty::Str) => true, Some(Ty::Union { variants }) => { @@ -3578,8 +4107,15 @@ fn promotes_to_param_value( Some(Ty::Union { variants }) => variants.iter().all(|v| matches!(v, Ty::Untyped | Ty::Nil)), _ => false, }; + // The fork models the full parameter-value union before this + // contextual runtime lowering, including nested Parameters/uploads. + let parameter_value = recv_ty.is_some_and(|ty| match ty { + Ty::Union { variants } => variants.iter().any(|v| + matches!(v, Ty::Class { id, .. } if id.0.as_str() == "ActionController::Parameters")), + _ => false, + }); let lowered = untyped && is_ivar_params_rooted(recv, locals); - if !(stringish && is_params_rooted(recv, locals)) && !lowered { + if !((stringish || parameter_value) && is_params_rooted(recv, locals)) && !lowered { return false; } let keyed = matches!( @@ -3646,3 +4182,58 @@ fn is_ivar_params_rooted(e: &crate::expr::Expr, locals: &HashMap) -> _ => is_param_local(e, locals), } } + +/// `[a, b].min` / `.max` (no count, no block) on an array LITERAL: the +/// literal has at least one element, so the extremum is an element, never +/// the `nil` an empty collection would give. `[x, LIMIT].min` is the +/// clamp idiom; typing it `T?` (as `Enumerable#min` does for an arbitrary +/// array) makes every `MAX - [x, LIMIT].min` look like arithmetic on nil. +/// +/// Nil is stripped from the element type only when some element excludes +/// Nil: comparing nil with a non-nil value raises, so a nil result cannot +/// occur. A single nilable element (`[maybe].min`) or an all-nilable +/// literal can still return nil without a comparison error. +fn literal_extremum_ty(recv: Option<&Expr>, recv_ty: &Ty, method: &Symbol, args: &[Expr]) -> Option { + if !matches!(method.as_str(), "min" | "max") || !args.is_empty() { + return None; + } + let recv = recv?; + let ExprNode::Array { elements, .. } = &*recv.node else { return None }; + if elements.is_empty() { + return None; + } + let Ty::Array { elem } = recv_ty else { return None }; + // A nil result needs every element to be nilable. One non-nilable + // element makes a nil result raise instead of returning nil. + let some_non_nil = elements.iter().any(|e| match e.ty.as_ref() { + Some(Ty::Nil) | None => false, + Some(Ty::Union { variants }) => !variants.iter().any(|v| matches!(v, Ty::Nil)), + Some(_) => true, + }); + if !some_non_nil { + return None; + } + Some(match &**elem { + Ty::Union { variants } => { + let kept: Vec = variants.iter().filter(|v| !matches!(v, Ty::Nil)).cloned().collect(); + match kept.len() { + 0 => return None, + 1 => kept.into_iter().next().unwrap(), + _ => Ty::Union { variants: kept }, + } + } + other => other.clone(), + }) +} + +// Exact top-level stdlib names used by receiver dispatch. +pub(super) const RUBY_TOP_LEVEL: &[&str] = &[ + "Base64", "Benchmark", "BigDecimal", "CGI", "CSV", "Comparable", "Complex", "Coverage", + "Date", "DateTime", "Digest", "Dir", "ERB", "Encoding", "Enumerable", "Errno", "Etc", + "Fiber", "File", "FileUtils", "Find", "Forwardable", "GC", "IO", "IPAddr", "JSON", + "Kernel", "Logger", "Marshal", "Math", "Monitor", "Mutex", "Net", "ObjectSpace", "Open3", + "OpenSSL", "OpenStruct", "PP", "Pathname", "Prism", "Process", "Psych", "Random", + "Rational", "Ripper", "SecureRandom", "Set", "Shellwords", "Signal", "Singleton", + "Socket", "StringIO", "Struct", "Tempfile", "Thread", "Time", "Timeout", "URI", "YAML", + "Zlib", +]; diff --git a/src/analyze/body/narrowing.rs b/src/analyze/body/narrowing.rs index 2d06b458a..f6044975b 100644 --- a/src/analyze/body/narrowing.rs +++ b/src/analyze/body/narrowing.rs @@ -22,15 +22,29 @@ use super::Ctx; /// A variable reference that narrowing can target: either a local /// binding (`x`) or an instance variable (`@x`). +#[derive(Clone)] pub(super) enum VarKey { Local(Symbol), Ivar(Symbol), + /// A receiver-less, zero-argument read of an attribute of `self` + /// (`expires_at.present? && expires_at > now` in a model: a column or + /// `attr_accessor` reader). It is not a binding, so the type the + /// condition saw rides along, and a narrowing of it binds the name as + /// a local for the guarded region (bare `x` resolves local-first). + Reader(Symbol, Ty), } /// A condition that narrows a variable's type in the branches of an /// `if`. Only nil-shaped and class-shaped predicates are recognized — /// more complex conditions fall through with no narrowing applied. +#[derive(Clone)] pub(super) enum NarrowPred { + /// A compound condition. `if_true` holds when the whole condition is + /// true, `if_false` when it is false; every entry is a leaf predicate + /// applied as if TRUE. `a && b`: both hold when true, nothing certain + /// when false (either may have failed). `a || b`: both are false when + /// false, nothing certain when true. Negation swaps the two lists. + Compound { if_true: Vec, if_false: Vec }, /// `x.nil?` or `x == nil` — true in then, false in else. IsNil(VarKey), /// `!x.nil?` or `x != nil` — false in then, true in else. @@ -48,9 +62,26 @@ pub(super) enum NarrowPred { IsTruthy(VarKey), /// Negated truthiness (`if !x` / `unless x`). IsFalsy(VarKey), + /// `x.present?` — Rails' `!blank?`. A present value is never nil, so the + /// then side drops the nil arm; the else side is only "nil, false or + /// empty", which says nothing certain about the type. + IsPresent(VarKey), + /// `x.blank?` / `!x.present?` — the inverse: the else side is non-nil. + IsBlank(VarKey), } pub(super) fn extract_narrowing(cond: &Expr) -> Option { + extract_narrowing_with(cond, &|_| None) +} + +/// What a condition guarantees on each side. `is_reader` says whether a bare +/// name is an attribute reader of `self` (see [`VarKey::Reader`]). +pub(super) fn extract_narrowing_with( + cond: &Expr, + is_reader: &dyn Fn(&Symbol) -> Option, +) -> Option { + let extract_narrowing = |e: &Expr| extract_narrowing_with(e, is_reader); + let var_key = |e: &Expr| var_key(e, is_reader); match &*cond.node { // Ruby's `!` is a method call: `!x` parses as `x.!`. So // `!x.nil?` is Send(method="!", recv=Some(Send(method="nil?", recv=Var(x)))). @@ -69,9 +100,20 @@ pub(super) fn extract_narrowing(cond: &Expr) -> Option { { extract_narrowing(&args[0]).map(negate_pred) } + // `max_qty?` -- ActiveRecord's attribute query, true only when the + // attribute is present (set, non-blank, non-zero), so it says what + // `max_qty.present?` says. + ExprNode::Send { recv: None, method, args, block: None, .. } + if args.is_empty() && method.as_str().len() > 1 && method.as_str().ends_with('?') => + { + let attr = Symbol::from(&method.as_str()[..method.as_str().len() - 1]); + is_reader(&attr).map(|ty| NarrowPred::IsPresent(VarKey::Reader(attr, ty))) + } ExprNode::Send { recv: Some(target), method, args, .. } => { match (method.as_str(), args.as_slice()) { ("nil?", []) => var_key(target).map(NarrowPred::IsNil), + ("present?", []) => var_key(target).map(NarrowPred::IsPresent), + ("blank?", []) => var_key(target).map(NarrowPred::IsBlank), ("==", [arg]) if is_nil_lit(arg) => { var_key(target).map(NarrowPred::IsNil) } @@ -86,16 +128,30 @@ pub(super) fn extract_narrowing(cond: &Expr) -> Option { _ => None, } } - // `A && B` is true iff both A and B are true, so a narrowing - // predicate in either conjunct applies in the surrounding If's - // then-branch. Single-pred return is the common-case - // approximation — the dominant pattern is the nil-guarded - // method call (`!x.nil? && x.foo != "bar"`) where the - // narrowing rides on the left conjunct. Composing narrowings - // on different vars across both conjuncts would need a Vec - // return; defer until a real shape demands it. - ExprNode::BoolOp { op: BoolOpKind::And, left, right, .. } => { - extract_narrowing(left).or_else(|| extract_narrowing(right)) + // `A && B` is true iff both are true, so a predicate in either + // conjunct holds in the then-branch; when it is false either may + // have failed, so the else-branch learns nothing. `A || B` is the + // mirror image: false only if both are false. Predicates on + // different variables compose (`a.present? && b.present? && a < b`). + ExprNode::BoolOp { op, left, right, .. } + if matches!(op, BoolOpKind::And | BoolOpKind::Or) => + { + let sides = [extract_narrowing(left), extract_narrowing(right)]; + let mut on_true: Vec = Vec::new(); + let mut on_false: Vec = Vec::new(); + for p in sides.into_iter().flatten() { + let (t, f) = p.sides(); + if matches!(op, BoolOpKind::And) { + on_true.extend(t); + } else { + on_false.extend(f); + } + } + if on_true.is_empty() && on_false.is_empty() { + None + } else { + Some(NarrowPred::Compound { if_true: on_true, if_false: on_false }) + } } // Assignment as the condition: `if user = User.authenticate_by(…)` // (the Rails authentication generator's idiom) tests the @@ -119,18 +175,33 @@ pub(super) fn extract_narrowing(cond: &Expr) -> Option { } } +impl NarrowPred { + /// The leaf predicates that hold when this one is true, and when false. + fn sides(self) -> (Vec, Vec) { + match self { + NarrowPred::Compound { if_true, if_false } => (if_true, if_false), + leaf => (vec![leaf.clone()], vec![negate_pred(leaf)]), + } + } +} + fn negate_pred(p: NarrowPred) -> NarrowPred { match p { + NarrowPred::Compound { if_true, if_false } => { + NarrowPred::Compound { if_true: if_false, if_false: if_true } + } NarrowPred::IsNil(k) => NarrowPred::IsNotNil(k), NarrowPred::IsNotNil(k) => NarrowPred::IsNil(k), NarrowPred::IsA(k, t) => NarrowPred::IsNotA(k, t), NarrowPred::IsNotA(k, t) => NarrowPred::IsA(k, t), NarrowPred::IsTruthy(k) => NarrowPred::IsFalsy(k), NarrowPred::IsFalsy(k) => NarrowPred::IsTruthy(k), + NarrowPred::IsPresent(k) => NarrowPred::IsBlank(k), + NarrowPred::IsBlank(k) => NarrowPred::IsPresent(k), } } -fn var_key(e: &Expr) -> Option { +fn var_key(e: &Expr, is_reader: &dyn Fn(&Symbol) -> Option) -> Option { match &*e.node { ExprNode::Var { name, .. } => Some(VarKey::Local(name.clone())), ExprNode::Ivar { name } => Some(VarKey::Ivar(name.clone())), @@ -148,7 +219,10 @@ fn var_key(e: &Expr) -> Option { ExprNode::Send { recv: None, method, args, block: None, .. } if args.is_empty() => { - Some(VarKey::Local(method.clone())) + match is_reader(method) { + Some(ty) => Some(VarKey::Reader(method.clone(), ty)), + None => Some(VarKey::Local(method.clone())), + } } _ => None, } @@ -199,6 +273,11 @@ fn const_to_ty(e: &Expr) -> Option { pub(super) fn apply_narrowing(ctx: &Ctx, pred: &NarrowPred, then_branch: bool) -> Ctx { let mut new_ctx = ctx.clone(); match pred { + NarrowPred::Compound { if_true, if_false } => { + for p in if then_branch { if_true } else { if_false } { + new_ctx = apply_narrowing(&new_ctx, p, true); + } + } NarrowPred::IsNil(k) | NarrowPred::IsNotNil(k) => { let is_is_nil = matches!(pred, NarrowPred::IsNil(_)); let narrow_to_nil = is_is_nil == then_branch; @@ -221,6 +300,12 @@ pub(super) fn apply_narrowing(ctx: &Ctx, pred: &NarrowPred, then_branch: bool) - } }); } + NarrowPred::IsPresent(k) | NarrowPred::IsBlank(k) => { + let is_present = matches!(pred, NarrowPred::IsPresent(_)); + if is_present == then_branch { + narrow_binding(&mut new_ctx, k, |current| remove_nil(current)); + } + } NarrowPred::IsTruthy(k) | NarrowPred::IsFalsy(k) => { // Only narrow the truthy side. The falsy side would // normally narrow to `Ty::Nil`, but that turns subsequent @@ -243,6 +328,20 @@ fn narrow_binding Ty>(ctx: &mut Ctx, key: &VarKey, f: F) { let (name, bindings) = match key { VarKey::Local(n) => (n, &mut ctx.local_bindings), VarKey::Ivar(n) => (n, &mut ctx.ivar_bindings), + VarKey::Reader(n, ty) => { + // A real local of that name shadows the reader and narrows as + // usual; otherwise the reader's own type is what narrows. Only + // the "it is there" direction is recorded: a reader proved nil + // is not worth rebinding, and re-reading it may say otherwise. + if !ctx.local_bindings.contains_key(n) { + let narrowed = f(ty); + if narrowed != Ty::Nil && &narrowed != ty { + ctx.local_bindings.insert(n.clone(), narrowed); + } + return; + } + (n, &mut ctx.local_bindings) + } }; if let Some(current) = bindings.get(name).cloned() { let narrowed = f(¤t); diff --git a/src/analyze/body/send.rs b/src/analyze/body/send.rs index 4d0117e7f..ea6804a40 100644 --- a/src/analyze/body/send.rs +++ b/src/analyze/body/send.rs @@ -185,6 +185,8 @@ impl<'a> BodyTyper<'a> { let ExprNode::Lambda { params, .. } = &*block.node else { return new_ctx; }; + for name in params { new_ctx.class_objects.remove(name); } + super::forget_class_object_writes(block, &mut new_ctx); // `form_with model: product do |form|` / `form_for @product do // |f|`: the builder is parameterized by the record the form is // for, so `form.object` (and `form.object.errors`) answer it. @@ -417,9 +419,16 @@ impl<'a> BodyTyper<'a> { // username and a password. Spread them, // or the second parameter binds nothing // and everything read from it is - // untyped. + // untyped. A block of ONE parameter + // yields that parameter's type and a + // block of none yields nothing: the + // block's `Ty::Fn` is its signature, + // not the value it yields. (A stub + // that registers the yielded type + // itself has a non-`Fn` block and + // takes the last arm.) Ty::Fn { block: Some(block_ty), .. } => match &**block_ty { - Ty::Fn { params, .. } if params.len() > 1 => Some( + Ty::Fn { params, .. } => Some( params .iter() .map(|p| p.ty.subst_self(&self_ty)) @@ -617,6 +626,40 @@ impl<'a> BodyTyper<'a> { block_ret: Option<&Ty>, args: &[crate::expr::Expr], ) -> Ty { + // `Parameters` is a Hash-shaped bag: what its own class does not + // answer (`fetch`, `each`, `map`, `count`, ...) is the Hash + // reading over Symbol -> param value. Done here, + // not only where a `params` receiver is typed, so the Parameters + // arm of an element read's union answers the same surface. + if let Some(Ty::Class { id, .. }) = recv_ty { + if id.0.as_str() == "ActionController::Parameters" + && method.as_str() != "new" + && !self.classes().get(id).is_some_and(|c| { + c.instance_methods.contains_key(method) || c.class_methods.contains_key(method) + }) + { + let as_hash = params_as_hash(); + return self.dispatch(Some(&as_hash), method, block_ret, args); + } + } + // `self[:generated_at]` / `read_attribute(:generated_at)` read + // the attribute TYPECAST, so `Time?` for a datetime column and + // `BigDecimal?` for a decimal one -- the same value the + // column's own reader answers. The catalog entry for `[]` + // and `read_attribute` says `String`, the shape of the + // storage text the lowered model deals in, which is not what + // the source program reads: `self[:rate] || BigDecimal("1")` + // was `String`, and `1 / rate` an "Integer / String" error. + // Only a literal name that IS an attribute is answered; any + // other key keeps the catalog's answer. + if let (true, Some(Ty::Class { id, .. })) = ( + matches!(method.as_str(), "[]" | "read_attribute" | "_read_attribute"), + recv_ty, + ) { + if let Some(ty) = self.attribute_by_name(id, args) { + return ty; + } + } // A tuple (a method returning `[a, b]` of mixed types — see // `tuple_return_ty`) is still an Array at runtime: anything but // destructuring reads it as one, over the union of its slots. @@ -1001,6 +1044,16 @@ impl<'a> BodyTyper<'a> { } let mut steps = 0usize; let mut unknown_named_ancestor = false; + // The first `method_missing` on the ancestor walk (the + // class's own, then its mixins', then its parents'). + // Ruby consults it only once the WHOLE chain has failed + // to find the method, so it is held here and applied + // after the walk, never in place of a real method. + let mut method_missing_ty: Option = None; + // Whether the walk has been through a class the RBI did not + // declare: an app class inheriting a gem class gets what the + // gem's macros generate on top of the declared surface. + let mut via_undeclared = false; while let Some(cid) = current_id { depth += 1; if depth > 32 { @@ -1013,6 +1066,12 @@ impl<'a> BodyTyper<'a> { break; }; steps += 1; + if cls.open || (cls.gem_boundary && via_undeclared) { + unknown_named_ancestor = true; + } + if !cls.gem_boundary { + via_undeclared = true; + } // A signature found ANYWHERE on this walk may name // the receiving class as `instance` / // `T.attached_class` — a fact the declaring class @@ -1030,6 +1089,26 @@ impl<'a> BodyTyper<'a> { let receiver_is_model = self.classes().get(id).is_some_and(|c| c.table.is_some()); if cid != id && cls.table.is_some() && receiver_is_model { ty.rebind_class(cid, id) } else { ty } }; + // The class object and its instances share this one type, so a + // name both sides define is ambiguous. The catalog gives every model + // the relation builders (`order`, `group`, `limit`, ...) class-side; + // `belongs_to :order` gives an instance the reader `order`. A relation + // builder called with no arguments is not a query (`Refund.order` is an + // error), so the zero-argument call is the instance reader. + if call_args.is_empty() + && matches!( + method.as_str(), + "order" | "group" | "limit" | "offset" | "having" | "joins" | "includes" | "select" | "distinct" + ) + { + if let (Some(cm), Some(im)) = + (cls.class_methods.get(method), cls.instance_methods.get(method)) + { + if matches!(unwrap_fn_ret(cm), Ty::Relation { .. }) { + return unwrap_fn_ret(&subst(im)); + } + } + } if let Some(ty) = cls.class_methods.get(method) { return unwrap_fn_ret(&subst(ty)); } @@ -1046,6 +1125,16 @@ impl<'a> BodyTyper<'a> { return subst(&ty); } } + // Class-object sends only consult class-side + // `method_missing`. Instance / included handlers answer + // instance sends; applying them here would type an + // undefined class-side call from an RBS instance sig. + if method_missing_ty.is_none() { + method_missing_ty = cls + .class_methods + .get(&Symbol::from("method_missing")) + .cloned(); + } current_id = cls.parent.as_ref(); } let _ = args; // already shadowed below for new-call shortcut @@ -1167,6 +1256,19 @@ impl<'a> BodyTyper<'a> { if id.0.as_str() == "Process" && method.as_str() == "pid" { return Ty::Int; } + // `Process.clock_gettime(clock, unit = :float_second)`: + // the `:float_*` units answer Float; every other unit + // (`:millisecond`, `:microsecond`, `:nanosecond`, …) + // answers Integer. No unit means Float. + if id.0.as_str() == "Process" && method.as_str() == "clock_gettime" { + return match call_args.get(1).map(|a| &*a.node) { + None => Ty::Float, + Some(ExprNode::Lit { value: crate::expr::Literal::Sym { value } }) => { + if value.as_str().starts_with("float_") { Ty::Float } else { Ty::Int } + } + Some(_) => Ty::Untyped, + }; + } // JSON stdlib — `JSON.generate` and `JSON.dump` return // String; `JSON.parse` / `JSON.load` return parsed // structure (untyped — the body is genuinely @@ -1202,6 +1304,19 @@ impl<'a> BodyTyper<'a> { if unknown_named_ancestor { return Ty::Untyped; } + // The chain never defined the method but a class on it + // defines `method_missing`: the message is answered at + // runtime, by code the analyzer cannot follow name by + // name (core's `CustomerAccountUrlHelpers` turns + // `customer_account_*_url` into a `UrlHelpers` send). The + // answer is what `method_missing` is declared to return, + // gradual when it says nothing more. + if let Some(mm) = method_missing_ty { + return match unwrap_fn_ret(&mm) { + Ty::Var { .. } => Ty::Untyped, + t => t, + }; + } unknown() } Some(Ty::Array { elem }) => { @@ -1529,6 +1644,29 @@ impl<'a> BodyTyper<'a> { } } + /// The type of the attribute a literal `:name` / `"name"` argument + /// names on `class`, its own or an ancestor's (`self[:name]`). + fn attribute_by_name(&self, class: &ClassId, args: &[Expr]) -> Option { + let [arg] = args else { return None }; + let ExprNode::Lit { value } = &*arg.node else { return None }; + let key = match value { + crate::expr::Literal::Sym { value } => value.clone(), + crate::expr::Literal::Str { value } => Symbol::from(value.as_str()), + _ => return None, + }; + let mut cursor = Some(class.clone()); + // Bounded: a parent link that cycles must not hang the typer. + for _ in 0..16 { + let cur = cursor?; + let info = self.classes().get(&cur)?; + if let Some(ty) = info.attributes.fields.get(&key) { + return Some(ty.clone()); + } + cursor = info.parent.clone(); + } + None + } + /// Resolve `method` against a mixed-in module's registered methods, /// chasing the module's own `include`s transitively. Returns the /// call-site result type (return type unwrapped). `Module`s carry @@ -1591,7 +1729,7 @@ impl<'a> BodyTyper<'a> { None } - fn lookup_in_module(&self, module_id: &ClassId, method: &Symbol) -> Option { + pub(super) fn lookup_in_module(&self, module_id: &ClassId, method: &Symbol) -> Option { let mut stack = vec![module_id.clone()]; let mut seen = std::collections::BTreeSet::new(); while let Some(id) = stack.pop() { @@ -2087,12 +2225,15 @@ pub(super) fn array_method(method: &Symbol, elem: &Ty, block_ret: Option<&Ty>) - Ty::Float => Ty::Float, _ => Ty::Untyped, }, - "exclude?" => Ty::Bool, + "exclude?" | "intersect?" => Ty::Bool, // `Set` isn't parameterized, so the element type can't be carried. "to_set" => Ty::Class { id: ClassId(Symbol::from("Set")), args: vec![] }, // JSON serialization of a collection is a String whatever the // elements are. "to_json" => Ty::Str, + // ActiveSupport `Array#as_json`: the JSON-primitive structure, + // an Array of whatever each element serializes to. + "as_json" => Ty::Array { elem: Box::new(Ty::Untyped) }, "find" | "detect" => Ty::Union { variants: vec![elem.clone(), Ty::Nil], }, @@ -2208,13 +2349,6 @@ pub(super) fn hash_method( ) -> Ty { match method.as_str() { "[]" => Ty::Union { variants: vec![value.clone(), Ty::Nil] }, - // Not `deep_*` on nested values: grounding is identity or one-level conversion, and nested hashes stay as they are. - "symbolize_keys" | "symbolize_keys!" | "deep_symbolize_keys" => { - Ty::Hash { key: Box::new(Ty::Sym), value: Box::new(value.clone()) } - } - "stringify_keys" | "deep_stringify_keys" => { - Ty::Hash { key: Box::new(Ty::Str), value: Box::new(value.clone()) } - } // `h[k] = v` returns the assigned value in Ruby, but here we // can't tell the argument's type from just the receiver's // generic Value — and the result is rarely chained. Return @@ -2244,7 +2378,7 @@ pub(super) fn hash_method( }, "length" | "size" | "count" => Ty::Int, "values" => Ty::Array { elem: Box::new(value.clone()) }, - "empty?" | "any?" | "none?" | "key?" | "has_key?" | "include?" => Ty::Bool, + "empty?" | "any?" | "none?" | "all?" | "one?" | "key?" | "has_key?" | "include?" => Ty::Bool, "keys" => Ty::Array { elem: Box::new(key.clone()) }, "key" => Ty::Union { variants: vec![key.clone(), Ty::Nil] }, // `Hash#fetch(k, default)` answers `default` when the key is @@ -2295,6 +2429,18 @@ pub(super) fn hash_method( key: Box::new(key.clone()), value: Box::new(block_ret.cloned().unwrap_or_else(|| value.clone())), }, + // ActiveSupport key conversions: the same values under Symbol / + // String keys (`params.permit(:x).to_h.symbolize_keys`). + "symbolize_keys" | "deep_symbolize_keys" | "symbolize_keys!" + | "deep_symbolize_keys!" => Ty::Hash { + key: Box::new(Ty::Sym), + value: Box::new(value.clone()), + }, + "stringify_keys" | "deep_stringify_keys" | "stringify_keys!" + | "deep_stringify_keys!" | "with_indifferent_access" => Ty::Hash { + key: Box::new(Ty::Str), + value: Box::new(value.clone()), + }, // `transform_keys { |k| ... }` → Hash[U, V]. "transform_keys" | "transform_keys!" => Ty::Hash { key: Box::new(block_ret.cloned().unwrap_or_else(|| key.clone())), @@ -2370,10 +2516,34 @@ pub(super) fn hash_method( key: Box::new(key.clone()), value: Box::new(value.clone()), }, + // ActiveSupport's Hash core_ext. The key-normalizing copies + // (`symbolize_keys`, `with_indifferent_access`, ...) and + // `to_query`/`to_param` are typed above; `to_xml` renders a + // document. + "to_xml" => Ty::Str, + // `first` without a count is the first `[key, value]` pair (nil + // when empty); with a count, an Array of pairs. + "first" => { + let pair = Ty::Tuple { elems: vec![key.clone(), value.clone()] }; + if args.is_empty() { + Ty::Union { variants: vec![pair, Ty::Nil] } + } else { + Ty::Array { elem: Box::new(pair) } + } + } + // The first key that maps to a value, or nil. + "filter_map" => Ty::Array { elem: Box::new(block_ret.cloned().unwrap_or_else(unknown)) }, + // Strong-parameters `permit!` marks everything permitted. + "to_hash" => Ty::Hash { + key: Box::new(key.clone()), + value: Box::new(value.clone()), + }, // JSON/string renderings of a Hash are Strings whatever the // value type — campfire's `Webhook#payload(message).to_json` // nests hashes three deep. "to_json" | "to_s" | "inspect" => Ty::Str, + // ActiveSupport `Hash#as_json`: string keys, JSON-primitive values. + "as_json" => Ty::Hash { key: Box::new(Ty::Str), value: Box::new(Ty::Untyped) }, _ => unknown(), } } @@ -2432,7 +2602,7 @@ pub(super) fn str_method(method: &Symbol) -> Ty { "casecmp" => Ty::Int, // Bang forms answer nil when nothing changed, so the value is `String?`. "gsub!" | "sub!" | "strip!" | "lstrip!" | "rstrip!" | "chomp!" | "chop!" | "squeeze!" - | "downcase!" | "upcase!" | "capitalize!" | "tr!" | "delete!" => Ty::Union { variants: vec![Ty::Str, Ty::Nil] }, + | "downcase!" | "upcase!" | "capitalize!" | "slice!" | "tr!" | "delete!" | "squish!" => Ty::Union { variants: vec![Ty::Str, Ty::Nil] }, "casecmp?" => Ty::Bool, // `ord` → the codepoint of the first character. "ord" => Ty::Int, @@ -2446,7 +2616,23 @@ pub(super) fn str_method(method: &Symbol) -> Ty { // `index`/`rindex` → the substring position or nil. "index" | "rindex" => Ty::Union { variants: vec![Ty::Int, Ty::Nil] }, "bytes" => Ty::Array { elem: Box::new(Ty::Int) }, - "chars" | "lines" | "split" | "scan" => Ty::Array { elem: Box::new(Ty::Str) }, + "chars" | "lines" | "split" | "scan" | "each_char" => Ty::Array { elem: Box::new(Ty::Str) }, + // `String#count(chars)` — how many of the given characters occur. + "count" => Ty::Int, + // `String#ascii_only?` and ActiveSupport's `exclude?` (the + // negation of `include?`). + "ascii_only?" | "exclude?" => Ty::Bool, + // `String#insert(index, str)` mutates and answers the receiver; + // `to_str` is the implicit-conversion spelling of itself; + // `encode` re-encodes; ActiveSupport's `first(n)` / `last(n)` + // are the leading / trailing slice. + "insert" | "to_str" | "encode" | "first" | "last" => Ty::Str, + // The in-place forms answer the receiver, or nil when nothing + // changed. + // `String#unpack` decodes into an Array of whatever the + // template names; `unpack1` its first element. + "unpack" => Ty::Array { elem: Box::new(Ty::Untyped) }, + "unpack1" => Ty::Untyped, "empty?" | "blank?" | "present?" | "include?" | "start_with?" | "end_with?" | "match?" => Ty::Bool, // ActiveSupport `Object#presence_in(collection)` — the receiver @@ -2487,6 +2673,9 @@ pub(super) fn str_method(method: &Symbol) -> Ty { | "squish" | "remove" | "indent" | "strip_heredoc" | "html_safe" | "to_query" | "to_param" => Ty::Str, "constantize" | "safe_constantize" => unknown(), + // JSON encoding is provided by the Ruby runtime; the other + // ActiveSupport conversions need their own shared implementations. + "to_json" => Ty::Str, // ActiveSupport boolean predicates (Object#blank? is universal // and lives there; String#starts_with? / ends_with? are // ActiveSupport's underscore-style aliases of start_with? / @@ -2550,7 +2739,22 @@ pub(super) fn int_method(method: &Symbol) -> Ty { // ActiveSupport byte-size helpers — like the duration helpers, // they yield a Numeric-ish value we don't model structurally. "bytes" | "kilobytes" | "megabytes" | "gigabytes" | "terabytes" - | "petabytes" | "exabytes" => Ty::Untyped, + | "petabytes" | "exabytes" | "byte" | "kilobyte" | "megabyte" + | "gigabyte" | "terabyte" | "petabyte" | "exabyte" => Ty::Untyped, + // Integer's own protocol, which `Comparable`, `Numeric` and + // `Integer` give it and which the table above left out. + // `clamp` answers one of its bounds or the receiver: Integer for + // Integer bounds, the common shape (`page.clamp(1, 100)`). + "to_int" | "size" | "remainder" | "ceildiv" | "ord" | "magnitude" => Ty::Int, + "between?" | "integer?" | "finite?" | "infinite?" | "nan?" | "allbits?" + | "anybits?" | "nobits?" => Ty::Bool, + "digits" => Ty::Array { elem: Box::new(Ty::Int) }, + "nonzero?" => Ty::Union { variants: vec![Ty::Int, Ty::Nil] }, + // `upto` / `downto` / `step` return the receiver with a block and + // an Enumerator without one; the two are not told apart here. + // `to_d` / `to_r` / `to_c` build BigDecimal / Rational / Complex, + // which the registry does not model. + "to_r" | "to_c" | "rationalize" | "coerce" => Ty::Untyped, // ActiveSupport Numeric duration helpers — `1.day`, `2.hours`, // `30.minutes`, etc. Each returns an ActiveSupport::Duration // instance; we don't model that structurally so propagate @@ -2695,6 +2899,86 @@ fn flatten_elem(t: &Ty) -> Ty { } } +/// Ruby's own object protocol, answered for any receiver whose type +/// table has no entry for `method`. +/// +/// Every object is a `Kernel`/`Object` and every class is a `Module`, +/// so the reflective surface (`instance_variable_get`, +/// `define_singleton_method`, `singleton_class`, `extend`) and +/// ActiveSupport's `Object` extensions (`in?`, `as_json`, `to_param`, +/// `instance_values`) exist on an `Integer`, a `Symbol`, a rescued +/// `StandardError` and an app model alike. The per-type tables model +/// what a type adds; none of them repeats what all of them inherit, so +/// a send that reaches the end of a table without a hit tries this one +/// before it is called unknown. Consulted AFTER the receiver's own +/// dispatch, so an app class that defines `send` or `in?` itself, or a +/// table that types `Symbol#in?` more precisely, wins. +pub(super) fn is_module_protocol(method: &Symbol) -> bool { + matches!(method.as_str(), + "define_method" | "alias_method" | "class_eval" | "class_exec" | "module_eval" | "module_exec" + | "instance_method" | "public_instance_method" | "const_get" | "constants" + | "class_variable_get" | "class_variable_set" | "class_variable_defined?" + | "method_defined?" | "public_method_defined?" | "private_method_defined?" + | "const_defined?" | "include?" | "instance_methods" | "public_instance_methods" + | "private_instance_methods" | "remove_method" | "undef_method") +} + +pub(super) fn object_protocol_method( + recv_ty: Option<&Ty>, + method: &Symbol, + block_ret: Option<&Ty>, + class_object: bool, +) -> Option { + // `include?` doubles as Enumerable/String membership — answer Bool even + // without a proven Module receiver. Other Module-protocol names still + // require a class/module object. + if is_module_protocol(method) && !class_object && method.as_str() != "include?" { + return None; + } + let sym_list = || Ty::Array { elem: Box::new(Ty::Sym) }; + let recv = || recv_ty.cloned().unwrap_or(Ty::Untyped); + let block = || block_ret.filter(|t| !matches!(t, Ty::Var { .. })).cloned().unwrap_or(Ty::Untyped); + Some(match method.as_str() { + // Kernel / Object reflection. + "instance_variable_get" | "instance_variable_set" | "method" | "instance_method" + | "public_instance_method" | "const_get" | "class_variable_get" | "class_variable_set" + | "enum_for" | "to_enum" => Ty::Untyped, + "instance_variable_defined?" | "in?" | "method_defined?" + | "public_method_defined?" | "private_method_defined?" | "const_defined?" + | "class_variable_defined?" | "include?" => Ty::Bool, + "instance_variables" | "methods" | "public_methods" | "private_methods" + | "protected_methods" | "singleton_methods" | "instance_methods" + | "public_instance_methods" | "private_instance_methods" | "constants" => sym_list(), + "define_singleton_method" | "define_method" | "alias_method" => Ty::Sym, + "singleton_class" => Ty::Class { id: ClassId(Symbol::from("Class")), args: vec![] }, + "extend" | "remove_method" | "undef_method" | "dup" | "clone" => recv(), + "instance_eval" | "instance_exec" | "class_eval" | "class_exec" | "module_eval" + | "module_exec" => block(), + "display" => Ty::Nil, + // ActiveSupport's Object extensions. `to_param` on Untyped (and + // other non-nominal receivers) lowers to `ActiveSupport.to_param` + // (runtime exists). A nominal class without a synthesized/ + // registered reader must hit the Object-extension refusal instead + // of being answered here (see critic_admission Value.new.to_param). + "to_json" | "to_yaml" => Ty::Str, + "to_param" if !matches!(recv_ty, Some(Ty::Class { .. })) => Ty::Str, + "===" | "!~" => Ty::Bool, + "<=>" => Ty::Union { variants: vec![Ty::Int, Ty::Nil] }, + _ => return None, + }) +} + +/// `ActionController::Parameters` read as the Hash it stands in for: +/// Symbol keys over param values (`String | Array | Parameters`), so +/// `params.fetch(:ids, [])`, `params.each { |k, v| }` and +/// `params.values` hand out the same element union `params[:k]` does. +pub(super) fn params_as_hash() -> Ty { + Ty::Hash { + key: Box::new(Ty::Sym), + value: Box::new(crate::analyze::registry::controllers::param_value_ty(false)), + } +} + /// The request-params value a nested read answers: a scalar, a hash or an array, whichever the request carried. pub(crate) const PARAM_VALUE: &str = "Roundhouse::ParamValue"; @@ -2714,7 +2998,8 @@ fn param_value_method(method: &Symbol, block_ret: Option<&Ty>) -> Option { | "!=" | "===" | "equal?" | "eql?" => Ty::Bool, "each" | "each_pair" | "each_value" | "each_key" | "each_with_index" | "reverse_each" | "select" | "filter" | "reject" | "compact" | "uniq" | "sort" | "sort_by" | "reverse" - | "merge" | "except" | "slice" | "permit" | "permit!" | "to_unsafe_h" | "to_h" | "require" => pv(), + | "merge" | "except" | "slice" | "permit" | "permit!" | "to_unsafe_h" | "to_h" | "require" + | "with_defaults" | "with_defaults!" | "reverse_merge" | "reverse_merge!" => pv(), "map" | "collect" | "flat_map" | "filter_map" => { Ty::Array { elem: Box::new(block_ret.cloned().unwrap_or(Ty::Untyped)) } } diff --git a/src/analyze/class_configuration.rs b/src/analyze/class_configuration.rs index 7ed3acbc7..2ddb31b6c 100644 --- a/src/analyze/class_configuration.rs +++ b/src/analyze/class_configuration.rs @@ -47,8 +47,8 @@ impl Analyzer { for item in &controller.body { if let ControllerBodyItem::ClassMethod { method, - configuration_slot: key, - configuration_role: ClassConfigurationRole::Writer, + configuration_slot: Some(key), + configuration_role: Some(ClassConfigurationRole::Writer), .. } = item { @@ -72,8 +72,8 @@ impl Analyzer { for item in &mut controller.body { let ControllerBodyItem::ClassMethod { method, - configuration_slot, - configuration_role, + configuration_slot: Some(configuration_slot), + configuration_role: Some(configuration_role), .. } = item else { diff --git a/src/analyze/diagnostics.rs b/src/analyze/diagnostics.rs index fcbb396e6..bd7bc6c23 100644 --- a/src/analyze/diagnostics.rs +++ b/src/analyze/diagnostics.rs @@ -45,6 +45,65 @@ pub fn diagnose_with_coverage(app: &App) -> (Vec, PreloadCoverage) { // graphql-ruby object types: their bodies, and each `null: false` // field's resolved value. out.extend(super::graphql::diagnose(app, diagnose_expr)); + out.extend(super::enum_raw_input::diagnose(app)); + // Rubydex's unresolved constants emit refusal stubs. Collect those + // annotations from support methods/defaults/constants as well, so + // an emitted raise cannot be hidden by the library diagnostic policy. + // Restore the nominal-class operator refusal widened by the fork. + // Primitive/nullable arithmetic keeps the existing library policy. + fn nominal_class_operand(ty: &Ty) -> bool { + match ty { + Ty::Class { .. } => true, + Ty::Union { variants } => !variants.is_empty() && variants.iter().all(nominal_class_operand), + _ => false, + } + } + fn collect_constants(expr: &Expr, out: &mut Vec) { + if let Some(kind @ DiagnosticKind::IncompatibleBinop { op, lhs_ty, .. }) = &expr.diagnostic { + if nominal_class_operand(lhs_ty) { + out.push(Diagnostic { + span: expr.span, + severity: Diagnostic::default_severity(kind), + kind: kind.clone(), + message: format!("`{op}` with incompatible operand types"), + }); + } + } + if matches!(&expr.diagnostic, + Some(DiagnosticKind::Unsupported { .. })) + { + if let Some(DiagnosticKind::Unsupported { target, construct, detail }) = &expr.diagnostic { + out.push(Diagnostic::unsupported(expr.span, target.clone(), construct.as_str(), detail.clone())); + } + } + expr.node.for_each_child(&mut |child| collect_constants(child, out)); + } + // Declaration DSL arguments are handled by the class-body ledger; + // this pass covers executable support methods and initializers. + for class in app.library_classes.iter().chain(app.rails_application.iter()) { + for method in &class.methods { + collect_constants(&method.body, &mut out); + for param in &method.params { + if let Some(default) = ¶m.default { collect_constants(default, &mut out); } + } + } + for (_, value) in &class.constants { collect_constants(value, &mut out); } + for call in &class.unknown_calls { collect_constants(call, &mut out); } + } + // The analyzer resolves model include identities against the registry. + // Collect that edge's refusal; declaration-marker arguments are metadata + // handled by the shared model lowerer, rather than executable reads. + for model in &app.models { + for item in &model.body { + if let crate::dialect::ModelBodyItem::Unknown { expr, .. } = item { + if let Some(DiagnosticKind::Unsupported { construct, detail, .. }) = &expr.diagnostic { + if construct.as_str() == "include" { + out.push(Diagnostic::unsupported(expr.span, None, "include", detail.clone())); + } + } + } + } + } // A filter's return value is Rails' to discard (`around_action // :switch_locale` → `I18n.with_locale(locale, &action)`): nothing // escapes from its tail, so an `untyped` there is not a gradual @@ -315,7 +374,10 @@ fn diagnose_expr_in(expr: &Expr, out: &mut Vec, value_used: bool) { // is itself unresolved is reported on the receiver node when we // recurse, so the outer send is skipped here to avoid double-counting // the same root cause. - if is_unknown_ty(expr.ty.as_ref()) + // + // A node the compiler synthesized (an `attr_writer`'s `value`) has no + // source position: there is nothing for the author to look at or fix. + if is_unknown_ty(expr.ty.as_ref()) && !expr.span.is_synthetic() && !matches!(expr.diagnostic, Some(DiagnosticKind::Unsupported { .. })) { let report = matches!( diff --git a/src/analyze/enum_raw_input.rs b/src/analyze/enum_raw_input.rs new file mode 100644 index 000000000..14052cca7 --- /dev/null +++ b/src/analyze/enum_raw_input.rs @@ -0,0 +1,58 @@ +//! Enum storage alone cannot implement Rails' original-input reader. +//! Refuse that generated API until assignment provenance is represented. +//! Source-defined implementations retain ordinary dispatch and diagnostics. + +use crate::{App, ClassId, Expr, ExprNode, Symbol}; +use crate::diagnostic::Diagnostic; +use crate::dialect::MethodReceiver; +use crate::ty::Ty; +use std::collections::HashSet; + +pub(super) fn diagnose(app: &App) -> Vec { + if app.models.iter().all(|model| model.enums.is_empty()) { + return Vec::new(); + } + fn source_method(app: &App, owner: &ClassId, name: &Symbol, seen: &mut HashSet) -> bool { + if !seen.insert(owner.clone()) { return false; } + if let Some(model) = app.models.iter().find(|model| &model.name == owner) { + if model.methods().any(|m| m.receiver == MethodReceiver::Instance && &m.name == name) { + return true; + } + return model.parent.as_ref().is_some_and(|p| source_method(app, p, name, seen)) + || crate::analyze::model_includes(model).iter().any(|p| source_method(app, p, name, seen)); + } + app.library_classes.iter().filter(|class| &class.name == owner).any(|class| { + class.methods.iter().any(|m| m.receiver == MethodReceiver::Instance && &m.name == name) + || class.parent.as_ref().is_some_and(|p| source_method(app, p, name, seen)) + || class.includes.iter().any(|p| source_method(app, p, name, seen)) + }) + } + fn walk(expr: &Expr, owner: Option<&ClassId>, app: &App, out: &mut Vec) { + if let ExprNode::Send { recv, method, .. } = &*expr.node { + if let Some(column) = method.as_str().strip_suffix("_before_type_cast") { + let receiver = match recv { + Some(recv) => match &recv.ty { Some(Ty::Class { id, .. }) => Some(id), _ => None }, + None => owner, + }; + if let Some(id) = receiver { + if app.models.iter().any(|m| &m.name == id && m.enums.contains_key(&Symbol::from(column))) + && !source_method(app, id, method, &mut HashSet::new()) + { + out.push(Diagnostic::unsupported(expr.span, None, "enum_before_type_cast", + format!("{}#{} requires original enum assignment inputs, which are not modeled; a stored-value reader is not equivalent", id.0, method))); + } + } + } + } + expr.node.for_each_child(&mut |child| walk(child, owner, app, out)); + } + let mut out = Vec::new(); + crate::lower::for_each_hook_body_ref(app, &mut |body| walk(body, None, app, &mut out)); + for model in &app.models { + for method in model.methods().filter(|m| m.receiver == MethodReceiver::Instance) { + walk(&method.body, Some(&model.name), app, &mut out); + } + } + for view in &app.views { walk(&view.body, None, app, &mut out); } + out +} diff --git a/src/analyze/forwarding.rs b/src/analyze/forwarding.rs index 90d73b0c4..2cc30b7c1 100644 --- a/src/analyze/forwarding.rs +++ b/src/analyze/forwarding.rs @@ -301,7 +301,7 @@ fn association_method(app: &App, owner: &ClassId, method: &MethodDef) -> bool { }) } -fn declaration_error((method, model): (&MethodDef, bool)) -> Option<&'static str> { +fn declaration_error((method, _model): (&MethodDef, bool)) -> Option<&'static str> { if method.unsupported_formals.is_some() { Some("forwarding destination has an unrepresented parameter declaration") } else if method.has_anonymous_block { @@ -312,10 +312,6 @@ fn declaration_error((method, model): (&MethodDef, bool)) -> Option<&'static str .any(|p| p.from_keyword || p.from_kwrest) { Some("forwarding destination has flattened keyword parameters") - } else if model && !method.params.iter().any(|p| p.forwarding) { - Some( - "forwarding into a model callee with incomplete rest/block declaration retention is not supported yet", - ) } else { None } diff --git a/src/analyze/mod.rs b/src/analyze/mod.rs index e510b996f..5c128cc34 100644 --- a/src/analyze/mod.rs +++ b/src/analyze/mod.rs @@ -24,6 +24,7 @@ //! Each of those comes when a fixture forces it. mod alba; +mod enum_raw_input; mod body; mod class_configuration; mod data; @@ -85,6 +86,11 @@ pub struct Analyzer { /// The Symbol key is the method name; the Vec aligns positionally /// with `MethodDef.params`. inferred_params: HashMap<(ClassId, Symbol), Vec>, + /// (class, method) pairs an author wrote a signature for, as opposed + /// to a `Fn` the registry stamped from the def itself. Only in the + /// first is an `untyped` slot a statement rather than an inference + /// gap. + declared_signatures: std::collections::HashSet<(ClassId, Symbol)>, /// Backend-specific effect classification. The analyzer consults /// this when deciding whether a Send on an AR model carries /// `DbRead` or `DbWrite`. Defaults to `SqliteAdapter` via @@ -123,6 +129,8 @@ pub struct Analyzer { refined_action_bindings: HashMap<(ClassId, Symbol), HashMap>, /// Resolved once from the source snapshot supplied to `Analyzer::new`. const_resolver: std::sync::Arc, + /// Explicit source-free API mode never certifies source indexing. + source_indexed: bool, /// Inferred values keyed by Rubydex declaration IDs, not by names. typed_constants: IdentityHashMap, /// Literal Data constants on library classes, keyed by source span. @@ -298,6 +306,31 @@ impl Analyzer { cls.class_methods.insert(Symbol::from("attribute_names"), Ty::Array { elem: Box::new(Ty::Str) }); cls.class_methods.insert(Symbol::from("column_names"), Ty::Array { elem: Box::new(Ty::Str) }); cls.class_methods.insert(Symbol::from("columns_hash"), Ty::Untyped); + // The rest of the schema-reflection surface every model has: + // `columns` is the list of `ActiveRecord::ConnectionAdapters::Column` + // objects (not modelled, so their elements stay untyped), the + // `sanitize_sql*` family builds a SQL fragment string, and + // `base_class` is the STI root, a class. + cls.class_methods.insert(Symbol::from("columns"), Ty::Array { elem: Box::new(Ty::Untyped) }); + for sanitizer in ["sanitize_sql", "sanitize_sql_array", "sanitize_sql_for_conditions", "sanitize_sql_like"] { + cls.class_methods.insert(Symbol::from(sanitizer), Ty::Str); + } + cls.class_methods.insert(Symbol::from("base_class"), Ty::Class { id: ClassId(Symbol::from("Class")), args: vec![] }); + let class_ty = Ty::Class { id: ClassId(Symbol::from("Class")), args: vec![] }; + for family in ["descendants", "subclasses"] { + cls.class_methods.insert(Symbol::from(family), Ty::Array { elem: Box::new(class_ty.clone()) }); + } + for text in ["quoted_table_name", "inheritance_column", "sti_name"] { + cls.class_methods.insert(Symbol::from(text), Ty::Str); + } + for flag in ["abstract_class?", "table_exists?"] { + cls.class_methods.insert(Symbol::from(flag), Ty::Bool); + } + // `ActiveModel::Name`, the `Type::Value`s and the enum table are + // objects the registry does not model. + for opaque in ["model_name", "attribute_types", "type_for_attribute", "defined_enums", "reset_column_information"] { + cls.class_methods.insert(Symbol::from(opaque), Ty::Untyped); + } // The rest of the class-side query surface — everything // from here to the `ids` seed below reads or writes the // database, so it is gated on `is_ar_model` for the same @@ -446,6 +479,9 @@ impl Analyzer { // `has_json` schema keys — same situation, JSON instead of // YAML, plus the one type the declaration erases. register_has_json(&model.body, &mut cls.instance_methods); + // `serialize :col, coder: …` — the column reads back as the + // coder's object, not the storage text. + register_serialized_columns(&model.body, &mut cls.instance_methods); // `attribute :name, :type` virtual attributes (ActiveModel) — // backed by something other than a schema column, so absent // from `model.attributes` above. @@ -586,6 +622,15 @@ impl Analyzer { cls.instance_methods .entry(Symbol::from("attachable_sgid")) .or_insert(Ty::Str); + // `to_param` — every concrete model gets a synthesized + // `id.to_s` (or its own override) at the emit seam + // (`lower::model_to_library::markers::push_to_param_method`). + // Register here so analyzer dispatch matches that surface + // (campfire's `user.to_param` in AvatarsHelper) instead of + // the Object-extension refusal for an unmodeled call. + cls.instance_methods + .entry(Symbol::from("to_param")) + .or_insert(Ty::Str); // Core AR instance methods every model gets. Sourced // from the shared catalog — same mechanism as class // methods above. Covers mutation (save/update/destroy), @@ -781,6 +826,17 @@ impl Analyzer { // see `registry::library`. registry::library::register(&mut classes, app, &route_helper_names); + // A helper object built from a YAML table + // (`WebUrlHelpers = Factory.create(YAML.load_file(…)["paths"])`): + // one path/URL String per generated name. See + // `ingest::generated_helpers`. + for (constant, names) in &app.generated_helper_methods { + let cls = classes.entry(constant.clone()).or_default(); + for name in names { + cls.class_methods.entry(name.clone()).or_insert(Ty::Str); + } + } + // Controllers: register each as a known class so self-method // dispatch (a bare `find_story` inside an action) resolves against // the controller's own methods and walks the parent chain to the @@ -805,11 +861,78 @@ impl Analyzer { } } + // Classes a locked gem's RBI declares. After everything the app + // and the catalog register, so it only ever adds. + registry::gem_boundary::register(&mut classes, app); + + // Which registered classes the app itself declares: a bare name + // reaches those through Ruby's constant lookup only (see + // Rubydex). + for id in app + .models + .iter() + .map(|m| &m.name) + .chain(app.controllers.iter().map(|c| &c.name)) + .chain(app.library_classes.iter().map(|lc| &lc.name)) + { + if let Some(info) = classes.get_mut(id) { + info.app_declared = true; + } + } + + for (id, method) in app.models.iter() + .flat_map(|model| model.methods().map(move |method| (&model.name, method))) + .chain(app.library_classes.iter() + .flat_map(|class| class.methods.iter().map(move |method| (&class.name, method)))) + { + if method.receiver == crate::dialect::MethodReceiver::Class + && method.name.as_str() == "new" + { + classes.entry(id.clone()).or_default().declares_constructor = true; + } + } + + // A class named in a signature means the lexically nearest one + // (`Capabilities::Charge` inside `ShopifyPayments::Capability`), + // which is only knowable once every class is registered. + let resolved: Vec<(ClassId, Symbol, Ty, Ty)> = app + .rbs_signatures + .iter() + .flat_map(|(class_id, methods)| { + methods.iter().map(|(name, ty)| { + let scoped = ty.map_class_ids(&|id| { + body::lexical_class(id, class_id.0.as_str(), &classes) + .unwrap_or_else(|| id.clone()) + }); + (class_id.clone(), name.clone(), ty.clone(), scoped) + }) + }) + .filter(|(_, _, before, after)| before != after) + .collect(); + for (class_id, name, before, after) in resolved { + if let Some(slot) = classes + .get_mut(&class_id) + .and_then(|c| c.instance_methods.get_mut(&name)) + { + if *slot == before { + *slot = after; + } + } + } // Test helpers are source methods, not a surface invented by test // emission. Register their real identities and declarations before // source typing; inferred returns converge in the same registry as // ordinary application methods. test_module::register(&mut classes, app); + for declaration in &app.library_classes { + if let Some(info) = classes.get_mut(&declaration.name) { info.is_module = declaration.is_module; } + } + + + assert!( + !app.source_index_required || !app.sources.is_empty(), + "source_index_missing: ingested source app cannot use IR-only analysis" + ); let const_resolver = app.const_resolver.for_sources(&app.sources); let data_factories = data::register(app, &const_resolver, &mut classes); @@ -817,12 +940,18 @@ impl Analyzer { Self { classes, inferred_params: HashMap::new(), + declared_signatures: app + .rbs_signatures + .iter() + .flat_map(|(c, ms)| ms.keys().map(move |m| (c.clone(), m.clone()))) + .collect(), adapter, concern_folded: HashMap::new(), host_folded: HashMap::new(), refined_action_bindings: HashMap::new(), inquirers: inquiry::inquirer_methods(app), const_resolver, + source_indexed: !app.sources.is_empty(), typed_constants: IdentityHashMap::default(), data_factories, } @@ -831,11 +960,11 @@ impl Analyzer { /// Build a body-typer borrowing this analyzer's dispatch tables. /// Cheap — just a struct with a reference. fn body_typer(&self) -> BodyTyper<'_> { - BodyTyper::new(&self.classes) + let typer = BodyTyper::new(&self.classes) .with_inquirers(&self.inquirers) - .with_const_resolver(self.const_resolver.clone()) .with_typed_constants(&self.typed_constants) - .with_data_factories(&self.data_factories) + .with_data_factories(&self.data_factories); + if self.source_indexed { typer.with_const_resolver(self.const_resolver.clone()) } else { typer } } /// The per-class member registry — schema columns, catalog-sourced @@ -868,6 +997,51 @@ impl Analyzer { /// the refined registry. Iterates to a fixed point (capped; see /// `FIXPOINT_CAP`) using a structural registry snapshot to detect convergence. pub fn analyze(&mut self, app: &mut App) { + // An unresolvable include is a load-time error, not an open method + // surface. Keep it in the class-body ledger even when no method is called. + for class in &mut app.library_classes { + for included in &class.includes { + let known = self.classes.contains_key(included) + || body::lexical_class(included, class.name.0.as_str(), &self.classes).is_some() + || body::RUBY_TOP_LEVEL.contains(&included.0.as_str()); + if known { continue; } + let detail = format!("{} includes unresolved {}", class.name.0, included.0); + if class.unknown_calls.iter().any(|call| matches!(&call.diagnostic, + Some(crate::diagnostic::DiagnosticKind::Unsupported { construct, detail: old, .. }) + if construct.as_str() == "include" && old == &detail)) { continue; } + let mut refusal = crate::expr::Expr::new(crate::span::Span::synthetic(), + crate::expr::ExprNode::Lit { value: crate::expr::Literal::Nil }); + refusal.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, construct: Symbol::from("include"), detail, + }); + class.unknown_calls.push(refusal); + } + } + for model in &mut app.models { + for item in &mut model.body { + let ModelBodyItem::Unknown { expr, .. } = item else { continue }; + let ExprNode::Send { recv: None, method, args, .. } = &*expr.node else { continue }; + if method.as_str() != "include" { continue; } + if matches!(&expr.diagnostic, Some(crate::diagnostic::DiagnosticKind::Unsupported { construct, .. }) if construct.as_str() == "include") { + expr.diagnostic = None; + } + let missing: Vec<_> = args.iter().filter_map(|arg| { + let ExprNode::Const { path } = &*arg.node else { return None }; + let name = path.iter().map(|s| s.as_str()).collect::>().join("::"); + let id = ClassId(Symbol::from(name.as_str())); + let known = self.classes.contains_key(&id) + || body::lexical_class(&id, model.name.0.as_str(), &self.classes).is_some() + || body::RUBY_TOP_LEVEL.contains(&name.as_str()); + (!known).then_some(name) + }).collect(); + if !missing.is_empty() { + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, construct: Symbol::from("include"), + detail: format!("{} includes unresolved {}", model.name.0, missing.join(", ")), + }); + } + } + } const FIXPOINT_CAP: usize = 12; // View-name and dynamic-render ivar sets are invariant across // fixpoint rounds — they read source views, not the registry. @@ -1295,9 +1469,10 @@ impl Analyzer { self_ty: None, ivar_bindings: HashMap::new(), local_bindings, + class_objects: Default::default(), constants: Default::default(), annotate_self_dispatch: false, - in_view: false, + in_view: false, class_side: false, }; self.body_typer().analyze_expr(&mut helper.body, &ctx); } @@ -1371,6 +1546,14 @@ impl Analyzer { continue; } + // Class-side `new` answers `Ty::SelfInstance` so inherited + // factories stay receiver-dependent in the registry. The + // MethodDef signature is what RBS emit reads, and emit + // refuses a bare SelfInstance — pin it to the owner the + // method is stamped on (same concrete shape + // `concern_class_methods` requires). + let owner_ty = Ty::Class { id: owner.clone(), args: Vec::new() }; + let params: Vec = method .params .iter() @@ -1390,6 +1573,7 @@ impl Analyzer { } else { param_ty_with_default(inferred.and_then(|v| v.get(i)).cloned(), p) .unwrap_or(Ty::Untyped) + .subst_self(&owner_ty) }; // Kind must survive verbatim: the untyped // fallback this replaces is kind-aware, and a @@ -1402,7 +1586,7 @@ impl Analyzer { method.signature = Some(Ty::Fn { params, block: None, - ret: Box::new(ret.unwrap_or(Ty::Untyped)), + ret: Box::new(ret.unwrap_or(Ty::Untyped).subst_self(&owner_ty)), effects: method.effects.clone(), }); } @@ -1495,17 +1679,18 @@ impl Analyzer { let shared = body::ConstScope::global(map.clone()); let typer = BodyTyper::new(&self.classes) .with_inquirers(&self.inquirers) - .with_const_resolver(self.const_resolver.clone()) .with_typed_constants(&resolved) .with_data_factories(&self.data_factories); + let typer = if self.source_indexed { typer.with_const_resolver(self.const_resolver.clone()) } else { typer }; for (self_ty, name, id, value, production) in entries.iter_mut() { let ctx = Ctx { self_ty: Some(self_ty.clone()), ivar_bindings: HashMap::new(), local_bindings: HashMap::new(), + class_objects: Default::default(), constants: shared.clone(), annotate_self_dispatch: false, - in_view: false, + in_view: false, class_side: false, }; let ty = typer.analyze_expr(value, &ctx); if matches!(ty, Ty::Var { .. }) { @@ -1535,6 +1720,14 @@ impl Analyzer { map = next; resolved = next_resolved; } + // These source-declared constants have modeled factory-generated + // object methods even though their initializer is not a retained class. + for constant in app.generated_helper_methods.keys() { + resolved.insert( + rubydex::model::ids::declaration_id_from_lookup_name(constant.0.as_str()), + Ty::Class { id: constant.clone(), args: vec![] }, + ); + } (map, resolved) } @@ -1562,6 +1755,20 @@ impl Analyzer { }); self.typed_constants = resolved_values; let global_constants = body::ConstScope::global(fallback); + // Type the actual initializer trees, not only registry clones. + // Their lexical class references become load-time dependencies + // when a source file's nested declarations emit into separate files. + for class in &mut app.library_classes { + let ctx = Ctx { + self_ty: Some(Ty::Class { id: class.name.clone(), args: vec![] }), + constants: global_constants.clone(), + class_side: true, + ..Ctx::default() + }; + for (_, value) in &mut class.constants { + self.body_typer().analyze_expr(value, &ctx); + } + } // Controller→view ivar channel: as each action is analyzed, we harvest // the ivars it sets and key them by the view that action renders. // When we reach the view pass below, the view's Ctx is seeded from @@ -1694,8 +1901,9 @@ impl Analyzer { self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), ivar_bindings: class_ivars.clone(), local_bindings: HashMap::new(), + class_objects: Default::default(), constants: global_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; for item in model.body.iter_mut() { if let ModelBodyItem::Unknown { expr, .. } = item { @@ -1709,8 +1917,9 @@ impl Analyzer { self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), ivar_bindings: class_ivars.clone(), local_bindings: HashMap::new(), + class_objects: Default::default(), constants: class_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; // Pass A: type every method body with only `@attributes` @@ -1753,16 +1962,24 @@ impl Analyzer { // Memoizing ivars become `Union` to reflect that // the read can be nil before the first assignment. let mut reseeded = class_ivars; + let initialized = ivars_initialized_by(model.methods()); for (name, ty) in flow_ivars { - let union_ty = crate::analyze::body::union_of(ty, Ty::Nil); + // `initialize` sets its own ivars before any other method + // runs (see the library-class pass). + let union_ty = if initialized.contains(&name) && !ty.is_open() { + ty + } else { + crate::analyze::body::union_of(ty, Ty::Nil) + }; reseeded.insert(name, union_ty); } let reseeded_ctx = Ctx { self_ty: Some(Ty::Class { id: model.name.clone(), args: vec![] }), ivar_bindings: reseeded, local_bindings: HashMap::new(), + class_objects: Default::default(), constants: class_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; for scope in model.scopes_mut() { @@ -1800,8 +2017,9 @@ impl Analyzer { self_ty: Some(self_ty.clone()), ivar_bindings: HashMap::new(), local_bindings: HashMap::new(), + class_objects: Default::default(), constants: global_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; for item in controller.body.iter_mut() { if let ControllerBodyItem::Unknown { expr, .. } = item { @@ -1817,8 +2035,9 @@ impl Analyzer { self_ty: Some(self_ty.clone()), ivar_bindings: HashMap::new(), local_bindings: HashMap::new(), + class_objects: Default::default(), constants: class_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; // Snapshot this controller's own segment of the filter chain @@ -1861,6 +2080,23 @@ impl Analyzer { self.body_typer().analyze_expr(&mut action.body, &mctx); } + // The class-side methods (`def self.x`, `class << self`), + // typed with `self` the class itself. Nothing reads their + // ivars into a view; typing them is what resolves their own + // bodies and their harvested return types. + for method in controller.body.iter_mut().filter_map(|item| match item { + ControllerBodyItem::ClassMethod { method, configuration_slot: None, .. } => Some(method), + _ => None, + }) { + for p in &mut method.params { + if let Some(default) = &mut p.default { + self.body_typer().analyze_expr(default, &ctx); + } + } + let mctx = self.seed_method_params(&ctx, &ctrl_id, method); + self.body_typer().analyze_expr(&mut method.body, &mctx); + } + // Snapshot each action's ivar bindings (this controller's // own actions only — parent's actions get layered in by // Phase B's `chained_bindings` builder). @@ -2271,8 +2507,9 @@ impl Analyzer { self_ty: Some(meta.self_ty.clone()), ivar_bindings: seed, local_bindings: HashMap::new(), + class_objects: Default::default(), constants: meta.class_constants.clone(), - annotate_self_dispatch: false, in_view: false, + annotate_self_dispatch: false, in_view: false, class_side: false, }; // Seed helper-method params from the inferred-params // table too, so `period(query)`'s body resolves on @@ -2757,9 +2994,10 @@ impl Analyzer { self_ty: Some(self_ty.clone()), ivar_bindings: seed, local_bindings: HashMap::new(), + class_objects: Default::default(), constants: class_constants.clone(), annotate_self_dispatch: false, - in_view: false, + in_view: false, class_side: false, }; let origin = app .concern_spliced_actions @@ -2898,7 +3136,8 @@ impl Analyzer { self_ty: Some(Ty::Class { id: self_id, args: vec![] }), ivar_bindings: HashMap::new(), local_bindings: HashMap::new(), - constants: Default::default(), annotate_self_dispatch: false, in_view: false, + class_objects: Default::default(), + constants: Default::default(), annotate_self_dispatch: false, in_view: false, class_side: false, }; for initializer in &mut lc.class_ivar_initializers { @@ -3007,6 +3246,7 @@ impl Analyzer { } } + let initialized = ivars_initialized_by(lc.methods.iter()); if !flow_ivars.is_empty() { let mut reseeded: HashMap = HashMap::new(); for (name, ty) in flow_ivars { @@ -3019,7 +3259,12 @@ impl Analyzer { // Union { User, Nil }` on the very next hop — the // same reasoning the controller-wide seed's // `strip_nil` already carries. - let seeded = if is_current_attributes { + // Nor for an ivar `initialize` assigns as one of its own + // statements: it is set before any other method can run, + // so `@total = T.let(attrs[:total], Money)` reads as a + // `Money` everywhere, not `Money?`. The union with the + // other writes already keeps a `nil` some method writes. + let seeded = if is_current_attributes || (initialized.contains(&name) && !ty.is_open()) { ty } else { crate::analyze::body::union_of(ty, Ty::Nil) @@ -3030,7 +3275,8 @@ impl Analyzer { self_ty: class_ctx.self_ty.clone(), ivar_bindings: reseeded, local_bindings: HashMap::new(), - constants: Default::default(), annotate_self_dispatch: false, in_view: false, + class_objects: Default::default(), + constants: Default::default(), annotate_self_dispatch: false, in_view: false, class_side: false, }; for method in &mut lc.methods { let mctx = self.seed_method_params(&reseeded_ctx, &lc_name, method); @@ -3347,6 +3593,15 @@ impl Analyzer { /// its parameters and so does the `MethodDef`, and for keyword /// arguments the two orders can differ. /// + /// `positional` is the parameter's index among the method's + /// POSITIONAL parameters, or `None` for a keyword or rest one. The + /// position fallback counts only the declared positional parameters: + /// the table is keyed by name alone, so a signature may belong to a + /// different arity (a concern's instance method and the class method + /// it contributes share a name), and reading a declared KEYWORD + /// parameter as the def's first positional one typed `variants` as + /// `bool`. + /// /// Used only where inference has nothing better — see the call /// sites. A declaration is worth reading where inference runs out, /// which for a parameter is the common case: its type is a fact @@ -3356,7 +3611,7 @@ impl Analyzer { &self, class_id: &ClassId, method: &Symbol, - index: usize, + positional: Option, name: &Symbol, ) -> Option { let cls = self.classes.get(class_id)?; @@ -3365,10 +3620,51 @@ impl Analyzer { .get(method) .or_else(|| cls.class_methods.get(method))?; let Ty::Fn { params, .. } = ty else { return None }; - let found = params.iter().find(|p| p.name == *name).or_else(|| params.get(index))?; + let found = params.iter().find(|p| p.name == *name).or_else(|| { + let n = positional?; + params + .iter() + .filter(|p| matches!(p.kind, crate::ty::ParamKind::Required | crate::ty::ParamKind::Optional)) + .nth(n) + })?; (!matches!(found.ty, Ty::Var { .. } | Ty::Untyped)).then(|| found.ty.clone()) } + /// Whether a signature says this parameter is `untyped`, in as many + /// words. That is a statement, unlike an unannotated parameter (whose + /// slot is an inference gap): the author opted the parameter out of + /// typing, and what the call sites happen to pass says nothing about + /// what the body must accept. `#: (untyped, untyped) -> bool` on a + /// comparator that is handed Money, Integers and Strings is the shape. + fn declared_untyped_param( + &self, + class_id: &ClassId, + method: &Symbol, + positional: Option, + name: &Symbol, + ) -> bool { + if !self.declared_signatures.contains(&(class_id.clone(), method.clone())) { + return false; + } + let Some(cls) = self.classes.get(class_id) else { return false }; + let Some(Ty::Fn { params, .. }) = + cls.instance_methods.get(method).or_else(|| cls.class_methods.get(method)) + else { + return false; + }; + params + .iter() + .find(|p| p.name == *name) + .or_else(|| { + let n = positional?; + params + .iter() + .filter(|p| matches!(p.kind, crate::ty::ParamKind::Required | crate::ty::ParamKind::Optional)) + .nth(n) + }) + .is_some_and(|p| matches!(p.ty, Ty::Untyped)) + } + fn seed_method_params( &self, base: &Ctx, @@ -3378,23 +3674,35 @@ impl Analyzer { let key = (class_id.clone(), method.name.clone()); let observed = self.inferred_params.get(&key); let mut ctx = base.clone(); + ctx.class_side = matches!(method.receiver, crate::dialect::MethodReceiver::Class); + let mut positional_seen = 0usize; for (i, param) in method.params.iter().enumerate() { + let is_positional = !param.keyword && !param.rest && !param.from_keyword; + let positional = is_positional.then_some(positional_seen); + positional_seen += usize::from(is_positional); + if self.declared_untyped_param(class_id, &method.name, positional, ¶m.name) { + ctx.local_bindings.insert(param.name.clone(), Ty::Untyped); + continue; + } let from_sites = observed.and_then(|v| v.get(i)).cloned(); let seeded = param_ty_with_default(from_sites, param); - // A declared type fills in where the call sites said - // nothing. Strictly additive: an observed type that IS - // something keeps winning, so nothing that resolves today - // resolves differently. - let ty = match &seeded { - Some(t) if !matches!(t, Ty::Var { .. }) => seeded.clone(), - _ => self - .declared_param_ty(class_id, &method.name, i, ¶m.name) - .or_else(|| seeded.clone()), - }; + let ty = prefer_declared( + self.declared_param_ty(class_id, &method.name, positional, ¶m.name), + seeded, + ); if let Some(ty) = ty { ctx.local_bindings.insert(param.name.clone(), ty); } } + // An indexed module's Ruby callback gets its host as a Module object, + // even when the hook is dormant and no explicit call site supplies types. + if ctx.class_side && self.classes.get(class_id).is_some_and(|c| c.is_module) + && matches!(method.name.as_str(), "included" | "prepended" | "append_features" | "prepend_features") + { + if let Some(param) = method.params.first().filter(|p| !p.keyword && !p.rest) { + ctx.class_objects.insert(param.name.clone()); + } + } if let Some(bp) = &method.block_param { ctx.local_bindings.insert(bp.name.clone(), captured_block_ty()); } @@ -3424,14 +3732,20 @@ impl Analyzer { origin.and_then(|m| self.inferred_params.get(&(m.clone(), action_name.clone()))); let mut ctx = base.clone(); for (i, name) in params.fields.keys().enumerate() { + if self.declared_untyped_param(class_id, action_name, Some(i), name) { + ctx.local_bindings.insert(name.clone(), Ty::Untyped); + continue; + } let observed = [own, from_origin] .into_iter() .flatten() .filter_map(|v| v.get(i).cloned()) .filter(|t| !matches!(t, Ty::Var { .. })) .reduce(unify_param_ty); - let ty = observed - .or_else(|| self.declared_param_ty(class_id, action_name, i, name)); + let ty = prefer_declared( + self.declared_param_ty(class_id, action_name, Some(i), name), + observed, + ); if let Some(ty) = ty { ctx.local_bindings.insert(name.clone(), ty); } @@ -3441,12 +3755,12 @@ impl Analyzer { // index to read an observation from. The declaration is the // only source, which is the case the signature readers exist // for. - for (i, (name, _)) in kw_params.iter().enumerate() { + for (name, _) in kw_params.iter() { if ctx.local_bindings.contains_key(name) { continue; } if let Some(ty) = - self.declared_param_ty(class_id, action_name, params.fields.len() + i, name) + self.declared_param_ty(class_id, action_name, None, name) { ctx.local_bindings.insert(name.clone(), ty); } @@ -3632,6 +3946,10 @@ impl Analyzer { // no class-receiver variant). for controller in &app.controllers { let class_id = &controller.name; + // Class-side methods register like a library class's: the + // method exists whether or not its body can be typed, so a + // call to it resolves to the inferred return or to Untyped + // rather than "no known method". for method in controller.class_methods() { let ret = self.method_return_ty(class_id, method); let target = &mut self.classes.entry(class_id.clone()).or_default().class_methods; @@ -3655,6 +3973,7 @@ impl Analyzer { self.harvest_block_value_methods(app); self.fold_concern_surfaces(app); self.fold_host_surfaces(app); + self.fold_extended_modules(app); self.fold_current_attribute_forwarders(app); } @@ -3755,6 +4074,97 @@ impl Analyzer { /// (rather than chasing includes at dispatch time) means every /// consumer — dispatch, `ide::members_of`, completion — sees the /// mixed-in surface identically. + + /// `extend Mod` in a class body: Mod's INSTANCE methods become the + /// class's singleton methods (`extend TrackCurrent` gives `User.current`, + /// `User.with_current`). Folded the way an `include`d module's surface + /// is, onto the class-side table, with the class's own methods winning. + /// `extend self` is the module-function idiom, handled at ingest. + fn fold_extended_modules(&mut self, app: &App) { + let mut wanted: Vec<(ClassId, Vec)> = Vec::new(); + let extends_of = |exprs: &mut dyn Iterator, owner: &ClassId| { + let mut out: Vec = Vec::new(); + for expr in exprs { + let ExprNode::Send { recv: None, method, args, .. } = &*expr.node else { continue }; + if method.as_str() != "extend" { + continue; + } + for arg in args { + let ExprNode::Const { path } = &*arg.node else { continue }; + let written = path.iter().map(|s| s.as_str()).collect::>().join("::"); + // Lexical resolution: the enclosing scopes outward. + let mut scope: Vec<&str> = owner.0.as_str().split("::").collect(); + let mut resolved = None; + loop { + let candidate = if scope.is_empty() { + written.clone() + } else { + format!("{}::{written}", scope.join("::")) + }; + if self.classes.contains_key(&ClassId(Symbol::from(candidate.as_str()))) { + resolved = Some(ClassId(Symbol::from(candidate.as_str()))); + break; + } + if scope.is_empty() { + break; + } + scope.pop(); + } + if let Some(id) = resolved { + out.push(id); + } + } + } + out + }; + for model in &app.models { + let mut items = model.body.iter().filter_map(|item| match item { + ModelBodyItem::Unknown { expr, .. } => Some(expr), + _ => None, + }); + let ext = extends_of(&mut items, &model.name); + if !ext.is_empty() { + wanted.push((model.name.clone(), ext)); + } + } + for lc in &app.library_classes { + let ext = extends_of(&mut lc.unknown_calls.iter(), &lc.name); + if !ext.is_empty() { + wanted.push((lc.name.clone(), ext)); + } + } + let module_ids: std::collections::HashSet<&ClassId> = + app.library_classes.iter().filter(|lc| lc.is_module).map(|lc| &lc.name).collect(); + for (id, modules) in wanted { + let mut queue = modules; + let mut seen: BTreeSet = queue.iter().cloned().collect(); + let mut qi = 0; + while qi < queue.len() { + let m = queue[qi].clone(); + qi += 1; + if !module_ids.contains(&m) { + continue; + } + let Some(module) = self.classes.get(&m) else { continue }; + let inst = module.instance_methods.clone(); + for n in module.includes.clone() { + if seen.insert(n.clone()) { + queue.push(n); + } + } + let folded = self.concern_folded.entry(id.clone()).or_default(); + let cls = self.classes.entry(id.clone()).or_default(); + for (name, ty) in inst { + if cls.class_methods.contains_key(&name) && !folded.1.contains(&name) { + continue; + } + cls.class_methods.insert(name.clone(), ty); + folded.1.insert(name); + } + } + } + } + fn fold_concern_surfaces(&mut self, app: &App) { type Surface = (HashMap, HashMap, Vec); let module_surfaces: HashMap = app @@ -6041,6 +6451,30 @@ fn captured_block_ty() -> Ty { } } +/// What a parameter is typed as inside its method, given what its +/// signature declares and what the call sites were seen to pass. +/// +/// A declaration that is fully spelled out IS the parameter's type: the +/// method body is written against it, and a call site that disagrees +/// (a `nil` the analysis inferred because the only value it saw assigned +/// was `nil`, an empty `[]` literal) is a fact about the caller, not +/// about the parameter. Before this, the observation won whenever there +/// was one, so `#: (FatalError error) -> void` above a helper that one +/// caller passed a nil-typed attribute to typed `error` as `nil`, and +/// `error.message` failed to dispatch on it. +/// +/// A declaration that leaves something `untyped` (`Hash[Symbol, +/// untyped]`, a bare `Array`) is a hint the observation may sharpen, so +/// there the observation still wins when it is something, and the +/// declaration fills in where the call sites said nothing. +fn prefer_declared(declared: Option, observed: Option) -> Option { + match (declared, observed) { + (Some(d), _) if !d.mentions_unknown() => Some(d), + (_, Some(o)) if !matches!(o, Ty::Var { .. }) => Some(o), + (d, o) => d.or(o), + } +} + fn param_ty_with_default(observed: Option, param: &crate::dialect::Param) -> Option { let observed = observed.filter(|t| !matches!(t, Ty::Var { .. })); let default = param @@ -6134,6 +6568,26 @@ pub(crate) fn is_setter_name(name: &Symbol) -> bool { && n.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c == '_') } +/// A body that is nothing but `raise NotImplementedError` — the abstract +/// hook a concern or base class declares for its includer or subclass +/// to implement (`def set_class; raise NotImplementedError, "..."; end`). +fn is_abstract_body(body: &Expr) -> bool { + match &*body.node { + ExprNode::Seq { exprs } => exprs.len() == 1 && is_abstract_body(&exprs[0]), + ExprNode::Send { recv: None, method, args, .. } if matches!(method.as_str(), "raise" | "fail") => { + let names_it = |e: &Expr| match &*e.node { + ExprNode::Const { path } => path.last().is_some_and(|s| s.as_str() == "NotImplementedError"), + ExprNode::Send { recv: Some(r), method, .. } if method.as_str() == "new" => { + matches!(&*r.node, ExprNode::Const { path } if path.last().is_some_and(|s| s.as_str() == "NotImplementedError")) + } + _ => false, + }; + args.first().is_some_and(names_it) + } + _ => false, + } +} + fn effective_return_ty(body: &Expr) -> Option { let mut tys: Vec = Vec::new(); let mut saw_return = false; @@ -6158,6 +6612,12 @@ fn effective_return_ty(body: &Expr) -> Option { if saw_return && matches!(body.ty, Some(Ty::Bottom)) { return Some(crate::analyze::body::unknown()); } + // An abstract hook returns whatever its implementation does, not + // `Bottom`: harvesting that made every call on the result (`set_class.new`) + // a dispatch failure on an unreachable type. + if !saw_return && is_abstract_body(body) { + return Some(Ty::Untyped); + } // Nothing usable collected — preserve prior behavior so the // `Var`/`Bottom`/`None` fallbacks downstream are unchanged. return body.ty.clone(); @@ -6332,6 +6792,44 @@ fn is_clean_binding(ty: &Ty) -> bool { } } +/// The ivars `initialize` assigns as statements of its own, across `methods`. +fn ivars_initialized_by<'a>( + methods: impl Iterator, +) -> std::collections::HashSet { + methods + .filter(|m| { + m.name.as_str() == "initialize" + && m.receiver == crate::dialect::MethodReceiver::Instance + }) + .flat_map(|m| ivars_assigned_by_statement(&m.body)) + .collect() +} + +/// The ivars a method body assigns as statements of its own -- not inside +/// a branch, loop or block, where the write may never run. After the method +/// returns, each of these is set. +fn ivars_assigned_by_statement(body: &Expr) -> Vec { + let statements: Vec<&Expr> = match &*body.node { + ExprNode::Seq { exprs } => exprs.iter().collect(), + _ => vec![body], + }; + let mut out = Vec::new(); + for stmt in statements { + match &*stmt.node { + ExprNode::Assign { target: LValue::Ivar { name }, .. } => out.push(name.clone()), + ExprNode::MultiAssign { targets, .. } => { + for t in targets { + if let LValue::Ivar { name } = t { + out.push(name.clone()); + } + } + } + _ => {} + } + } + out +} + /// Harvest `@ivar = expr` / OpAssign / MultiAssign writes from a typed /// body, union-merging repeated assignments. Used by the analyzer's /// two-pass library typing and by the Spinel AR RBS probe. @@ -6900,6 +7398,29 @@ fn register_has_json(body: &[ModelBodyItem], methods: &mut HashMap) } } +/// `serialize :settings, coder: JSON` (Rails' attribute serialization) +/// keeps the column's storage type in the schema (`text`/`string`) but +/// the attribute reads back as whatever the coder loads: a Hash, an +/// Array, a value object. Typing the reader and writer as the storage +/// `String` makes every use of the deserialized value (`line_items.map`, +/// `settings.fetch`) a dispatch failure on `String?`. The coder is an +/// arbitrary object (`JSON`, `VersionedSerializer.column(…)`, a custom +/// class), so the honest type is the gradual one. `insert`, not +/// `or_insert`: this deliberately overrides the schema-derived type. +fn register_serialized_columns(body: &[ModelBodyItem], methods: &mut HashMap) { + for item in body { + let ModelBodyItem::Unknown { expr, .. } = item else { continue }; + let ExprNode::Send { recv: None, method, args, .. } = &*expr.node else { continue }; + if method.as_str() != "serialize" { + continue; + } + for name in args.iter().map_while(symbol_arg) { + methods.insert(name.clone(), Ty::Untyped); + methods.insert(Symbol::from(format!("{}=", name.as_str())), Ty::Untyped); + } + } +} + fn register_typed_store(body: &[ModelBodyItem], methods: &mut HashMap) { for item in body { let ModelBodyItem::Unknown { expr, .. } = item else { continue }; diff --git a/src/analyze/registry/activemodel.rs b/src/analyze/registry/activemodel.rs index e16dd4411..8c499f4b4 100644 --- a/src/analyze/registry/activemodel.rs +++ b/src/analyze/registry/activemodel.rs @@ -89,6 +89,7 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { ] { errors_cls.instance_methods.insert(Symbol::from(m), ty); } + // Additional collection APIs require shared runtime/lowering support. classes.insert( ClassId(Symbol::from("ActiveModel::Errors")), errors_cls, diff --git a/src/analyze/registry/ar.rs b/src/analyze/registry/ar.rs index 1d3f8aab1..1f80a024d 100644 --- a/src/analyze/registry/ar.rs +++ b/src/analyze/registry/ar.rs @@ -33,6 +33,50 @@ fn register_connection_surface(classes: &mut HashMap) { cls.instance_methods.entry(m.clone()).or_insert_with(|| ty.clone()); } } + // Beyond what the runtime implements, the connection a program gets is + // Rails' `AbstractAdapter`, and code that reaches for the adapter + // (`quote_column_name` in a hand-built query, `transaction_open?` in a + // guard) calls its documented surface. Those names were reported as + // unknown methods of `ActiveRecord::Connection`. The runtime's own + // signatures above win where both speak; what is added has plain Ruby + // result types, and the adapter objects it hands out (the transaction + // manager, the pool, the current transaction) stay untyped. + let str = Ty::Str; + let untyped_rows = Ty::Array { elem: Box::new(Ty::Array { elem: Box::new(Ty::Untyped) }) }; + let untyped_list = Ty::Array { elem: Box::new(Ty::Untyped) }; + let cls = classes.entry(ClassId(Symbol::from("ActiveRecord::Connection"))).or_default(); + for (name, ty) in [ + ("quote_column_name", str.clone()), + ("quote_table_name", str.clone()), + ("quoted_date", str.clone()), + ("quoted_true", str.clone()), + ("quoted_false", str.clone()), + ("transaction_open?", Ty::Bool), + ("table_exists?", Ty::Bool), + ("column_exists?", Ty::Bool), + ("index_exists?", Ty::Bool), + ("active?", Ty::Bool), + ("supports_json?", Ty::Bool), + ("open_transactions", Ty::Int), + ("select_rows", untyped_rows), + ("select_values", untyped_list.clone()), + ("select_one", Ty::Untyped), + ("select_value", Ty::Untyped), + ("tables", Ty::Array { elem: Box::new(Ty::Str) }), + ("columns", untyped_list.clone()), + ("indexes", untyped_list), + ("insert", Ty::Untyped), + ("update", Ty::Int), + ("delete", Ty::Int), + ("transaction", Ty::Untyped), + ("current_transaction", Ty::Untyped), + ("transaction_manager", Ty::Untyped), + ("pool", Ty::Untyped), + ("raw_connection", Ty::Untyped), + ("database_version", Ty::Untyped), + ] { + cls.instance_methods.entry(Symbol::from(name)).or_insert(ty); + } } pub(in crate::analyze) fn register(classes: &mut HashMap) { @@ -345,6 +389,11 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { uploaded.instance_methods.insert(Symbol::from("content_type"), Ty::Str); uploaded.instance_methods.insert(Symbol::from("read"), Ty::Str); uploaded.instance_methods.insert(Symbol::from("size"), Ty::Int); + // The file behind the part, as Rack hands it over: the Tempfile + // (`params[:file].tempfile`) and its path on disk. The Tempfile + // is not modelled, so it is a gradual boundary. + uploaded.instance_methods.insert(Symbol::from("tempfile"), Ty::Untyped); + uploaded.instance_methods.insert(Symbol::from("path"), Ty::Str); uploaded.instance_methods.insert(Symbol::from("to_s"), Ty::Str); let uploaded_id = ClassId(Symbol::from("ActionDispatch::Http::UploadedFile")); uploaded.class_methods.insert( diff --git a/src/analyze/registry/controllers.rs b/src/analyze/registry/controllers.rs index d6ec51f79..6152c838b 100644 --- a/src/analyze/registry/controllers.rs +++ b/src/analyze/registry/controllers.rs @@ -43,13 +43,138 @@ pub(in crate::analyze) fn register( // Hardcoded ApplicationController-ish surface. Real inheritance chains // and per-controller overrides land when a fixture forces them. let mut app_ctrl = ClassInfo::default(); - let params_ty = Ty::Hash { - key: Box::new(Ty::Sym), - value: Box::new(Ty::Str), - }; - app_ctrl.class_methods.insert(Symbol::from("params"), params_ty); - app_ctrl.class_methods.insert(Symbol::from("session"), - Ty::Hash { key: Box::new(Ty::Str), value: Box::new(Ty::Str) }); + // `params` is an `ActionController::Parameters` — NOT a Hash. It + // answers the Hash-like reads (`[]`, `fetch`, `key?`, `to_h`), the + // strong-parameters chain (`require`/`permit`/`expect`, each + // answering a Parameters again or the value under the key), and the + // typed_parameters gem's `fetch_type`/`require_type`/`require_hash`/ + // `permit_types`. What a key holds is whatever the request carried + // (String, Array, nested Parameters, nil), so element reads are + // gradual; the chain methods keep the receiver's class. + { + let params_id = ClassId(Symbol::from("ActionController::Parameters")); + let params_ty = Ty::Class { id: params_id.clone(), args: vec![] }; + let mut p = ClassInfo::default(); + let hash_str_untyped = Ty::Hash { key: Box::new(Ty::Str), value: Box::new(Ty::Untyped) }; + for m in [ + // strong parameters + "permit", "permit!", "except", "slice", "merge", "merge!", "reverse_merge", + "reverse_merge!", "with_defaults", "with_defaults!", "deep_dup", "dup", "clone", + "compact", "compact_blank", "select", "reject", "filter", "keep_if", "delete_if", + "transform_values", "transform_keys", "deep_transform_keys", "extract!", "update", + "slice!", "except!", + // typed_parameters: answers the Parameters restricted to the schema + "permit_types", "require_hash", + ] { + p.instance_methods.insert(Symbol::from(m), params_ty.clone()); + } + for m in [ + // typed_parameters answers the value under a key, typed by the schema + // argument: gradual until that schema is modeled. + "delete", "required", "fetch_type", "require_type", "required_type", + "instance_variable_get", + ] { + p.instance_methods.insert(Symbol::from(m), Ty::Untyped); + } + for m in [ + "key?", "has_key?", "include?", "member?", "empty?", "blank?", "present?", + "permitted?", "nil?", "any?", "none?", "all?", "eql?", "==", "!", "!=", + "value?", "has_value?", "frozen?", "respond_to?", "is_a?", "kind_of?", + "instance_of?", "equal?", + ] { + p.instance_methods.insert(Symbol::from(m), Ty::Bool); + } + for m in ["to_h", "to_unsafe_h", "to_hash", "to_unsafe_hash", "to_hwia", "as_json"] { + p.instance_methods.insert(Symbol::from(m), hash_str_untyped.clone()); + } + p.instance_methods.insert( + Symbol::from("keys"), + Ty::Array { elem: Box::new(Ty::Str) }, + ); + for m in ["to_query", "to_param", "to_s", "inspect", "to_json"] { + p.instance_methods.insert(Symbol::from(m), Ty::Str); + } + for m in ["hash", "object_id"] { + p.instance_methods.insert(Symbol::from(m), Ty::Int); + } + // What a key holds is whatever the request carried: a scalar + // String, an Array (`ids[]=1&ids[]=2`, a JSON array), a nested + // Parameters (`order[a]=1`), an uploaded file, or nothing. The element reads answer + // that whole union (`param_value_ty`); union dispatch resolves a + // send on whichever arms answer it, so `params[:id].to_i` still + // reads as the String arm's Integer while `params[:ids].map` and + // `params[:order].permit(...)` read as the Array and Parameters + // arms. `expect(:id)` is the scalar form. + p.instance_methods.insert(Symbol::from("[]"), param_value_ty(true)); + p.instance_methods.insert(Symbol::from("dig"), param_value_ty(true)); + // `require(:key)` answers the value under the key (raising when it + // is blank), so it is the same union minus nil. + p.instance_methods.insert(Symbol::from("require"), param_value_ty(false)); + for m in ["[]=", "expect"] { + p.instance_methods.insert(Symbol::from(m), Ty::Str); + } + // The class-level configuration (`always_permitted_parameters`, + // `permit_all_parameters`, `action_on_unpermitted_parameters`). + p.class_methods.insert(Symbol::from("new"), params_ty.clone()); + p.class_methods.insert( + Symbol::from("always_permitted_parameters"), + Ty::Array { elem: Box::new(Ty::Str) }, + ); + p.class_methods.insert( + Symbol::from("always_permitted_parameters="), + Ty::Array { elem: Box::new(Ty::Str) }, + ); + p.class_methods.insert(Symbol::from("permit_all_parameters"), Ty::Bool); + p.class_methods.insert(Symbol::from("permit_all_parameters="), Ty::Bool); + classes.insert(params_id, p); + app_ctrl.class_methods.insert(Symbol::from("params"), params_ty); + } + // `session` is an `ActionDispatch::Request::Session`, hash-like but not a + // Hash: it answers `id`, `options`, `loaded?`, `destroy`, and whatever an app + // mixes onto it (core adds `essential`). Typing it `Hash[String, String]` + // made every one of those a send_dispatch_failed. + { + let session_id = ClassId(Symbol::from("ActionDispatch::Request::Session")); + let mut session = ClassInfo::default(); + // An unregistered parent: the rack session hash underneath is not + // modeled, so a method this table does not list stays gradual instead + // of failing dispatch. + session.parent = Some(ClassId(Symbol::from("Rack::Session::Abstract::SessionHash"))); + // Reads keep the String-or-nil answer the Hash typing gave: the + // blank-predicate grounding of `(rd = session[:k]).present?` needs a + // nilable String receiver to lower. + // Deleting a key returns the same value as reading it, or nil + // when absent (e.g. `session.delete(:return_to) || root_url`). + for m in ["[]", "delete"] { + session.instance_methods.insert( + Symbol::from(m), + Ty::Union { variants: vec![Ty::Str, Ty::Nil] }, + ); + } + for m in ["fetch", "dig"] { + session.instance_methods.insert(Symbol::from(m), Ty::Untyped); + } + for m in ["[]=", "store", "clear", "destroy", "update", "merge!", "each", "reload!"] { + session.instance_methods.insert(Symbol::from(m), Ty::Untyped); + } + for m in ["key?", "has_key?", "include?", "empty?", "loaded?", "exists?"] { + session.instance_methods.insert(Symbol::from(m), Ty::Bool); + } + session.instance_methods.insert(Symbol::from("id"), Ty::Union { variants: vec![Ty::Str, Ty::Nil] }); + session.instance_methods.insert(Symbol::from("keys"), Ty::Array { elem: Box::new(Ty::Str) }); + session.instance_methods.insert( + Symbol::from("options"), + Ty::Hash { key: Box::new(Ty::Sym), value: Box::new(Ty::Untyped) }, + ); + for m in ["to_hash", "to_h"] { + session.instance_methods.insert( + Symbol::from(m), + Ty::Hash { key: Box::new(Ty::Str), value: Box::new(Ty::Untyped) }, + ); + } + classes.insert(session_id.clone(), session); + app_ctrl.class_methods.insert(Symbol::from("session"), Ty::Class { id: session_id, args: vec![] }); + } app_ctrl.class_methods.insert(Symbol::from("render"), Ty::Nil); app_ctrl.class_methods.insert(Symbol::from("redirect_to"), Ty::Nil); app_ctrl.class_methods.insert(Symbol::from("redirect_back_or_to"), Ty::Nil); @@ -152,6 +277,10 @@ pub(in crate::analyze) fn register( "host", "host_with_port", "domain", "protocol", "scheme", "port_string", "request_method", "method", "raw_post", "uuid", "request_id", "base_url", "script_name", "path_info", "query_string", "media_type", + // The cookie-jar salts (`Request::Cookies`), read where an app + // builds its own MessageVerifier off the request. + "signed_cookie_salt", "encrypted_cookie_salt", + "encrypted_signed_cookie_salt", "authenticated_encrypted_cookie_salt", ] { request.instance_methods.insert(Symbol::from(m), Ty::Str); } @@ -174,10 +303,28 @@ pub(in crate::analyze) fn register( request.instance_methods.insert(Symbol::from(m), Ty::Bool); } request.instance_methods.insert(Symbol::from("port"), Ty::Int); + // Rack env accessors and the parameter/header views Rails exposes on every + // request. `POST`/`GET` are the raw form/query hashes; the header + // readers are Rack env lookups. + for m in ["POST", "GET", "session_options"] { + request.instance_methods.insert( + Symbol::from(m), + Ty::Hash { key: Box::new(Ty::Str), value: Box::new(Ty::Untyped) }, + ); + } + for m in ["get_header", "set_header", "delete_header", "cookies"] { + request.instance_methods.insert(Symbol::from(m), Ty::Untyped); + } + request.instance_methods.insert(Symbol::from("original_fullpath"), Ty::Str); + request.instance_methods.insert(Symbol::from("request_method_symbol"), Ty::Sym); + request.instance_methods.insert(Symbol::from("route_uri_pattern"), str_or_nil()); request.instance_methods.insert(Symbol::from("content_length"), Ty::Int); for m in ["headers", "env", "cookie_jar", "session", "params", "query_parameters", "request_parameters", "path_parameters", "format", "body", "variant", - "flash", "subdomains", "accepts", "mime_type", "authorization"] { + "flash", "subdomains", "accepts", "mime_type", "authorization", + // The app's key generator, and the controller CLASS the + // request routed to (`Request#controller_class`). + "key_generator", "controller_class"] { request.instance_methods.insert(Symbol::from(m), Ty::Untyped); } classes.insert(request_id.clone(), request); @@ -351,3 +498,21 @@ pub(in crate::analyze) fn register( app_ctrl_entry.parent = Some(acb_id); classes.insert(ClassId(Symbol::from("ApplicationController")), app_ctrl_entry); } + +/// The type of a value read out of an `ActionController::Parameters`: +/// `String | Array[untyped] | ActionController::Parameters | +/// ActionDispatch::Http::UploadedFile` (a multipart file part), plus nil +/// when the key may be absent. Request data is untyped at the element +/// level (`Array[untyped]`): nothing in the request constrains it. +pub(crate) fn param_value_ty(nilable: bool) -> Ty { + let mut variants = vec![ + Ty::Str, + Ty::Array { elem: Box::new(Ty::Untyped) }, + Ty::Class { id: ClassId(Symbol::from("ActionController::Parameters")), args: vec![] }, + Ty::Class { id: ClassId(Symbol::from("ActionDispatch::Http::UploadedFile")), args: vec![] }, + ]; + if nilable { + variants.push(Ty::Nil); + } + Ty::Union { variants } +} diff --git a/src/analyze/registry/gem_boundary.rs b/src/analyze/registry/gem_boundary.rs new file mode 100644 index 000000000..c6d206af9 --- /dev/null +++ b/src/analyze/registry/gem_boundary.rs @@ -0,0 +1,261 @@ +//! The gem boundary: classes a locked gem's RBI declares, registered +//! so dispatch can answer for them ([`crate::gem_boundary`]). +//! +//! Purely additive. A class the registry already has -- the app's own, +//! the framework catalog's, the stdlib's -- is left exactly as it is: +//! an RBI describes what a gem defines, and a name the app or the +//! catalog also defines is a different (or a better-known) thing that +//! this table has no business overriding. Core classes an RBI merely +//! reopens (`class String`, `module Kernel`) are never registered for +//! the same reason: registering `Object` would turn every "ancestor we +//! don't model" gradual escape into a hard miss. +//! +//! Soundness is by pruning toward `untyped`: +//! +//! * a class name in a signature that resolves to no registered class +//! becomes `untyped`, never a class nobody defined; +//! * a mixin that resolves to nothing marks the class `open`, so a +//! method the declared surface lacks is unknown rather than an error; +//! * an app class that inherits a gem class keeps the gradual escape it +//! always had (see `ClassInfo::gem_boundary`). + +use std::collections::{HashMap, HashSet}; + +use crate::app::App; +use crate::ident::{ClassId, Symbol}; +use crate::ty::Ty; + +use super::super::body::ClassInfo; + +/// Ruby's own top-level names. An RBI that mentions them is patching or +/// extending the language, not declaring a gem class. +const RUBY_CORE: &[&str] = &[ + "BasicObject", "Object", "Module", "Class", "Kernel", "Comparable", "Enumerable", "Enumerator", + "Numeric", "Integer", "Float", "Rational", "Complex", "String", "Symbol", "Array", "Hash", + "Range", "Regexp", "MatchData", "Struct", "Proc", "Method", "UnboundMethod", "Time", "Date", + "DateTime", "IO", "File", "Dir", "Exception", "StandardError", "RuntimeError", "NilClass", + "TrueClass", "FalseClass", "Set", "Random", "Process", "Signal", "Math", "Marshal", "GC", + "ObjectSpace", "Thread", "Mutex", "Fiber", "Ractor", "Data", "Encoding", "Binding", + "BigDecimal", "Pathname", "StringIO", "URI", "JSON", "Logger", "Singleton", "Forwardable", +]; + +/// Mixins that add no callable surface a caller could miss: sorbet's +/// own `extend T::Sig`, and the ActiveSupport::Concern marker. +fn is_marker(name: &str) -> bool { + name.starts_with("T::") || name == "T" || name == "ActiveSupport::Concern" +} + +pub(in crate::analyze) fn register(classes: &mut HashMap, app: &App) { + if app.gem_boundary.is_empty() { + return; + } + let declared = &app.gem_boundary.classes; + + // Which declarations become classes: not core, not already known. + let fresh: HashSet<&ClassId> = declared + .keys() + .filter(|id| { + let name = id.0.as_str(); + let top = name.split("::").next().unwrap_or(name); + !RUBY_CORE.contains(&top) && !classes.contains_key(*id) + }) + .collect(); + + // Names resolve against the union of what was already known and + // what is being added, so gem classes may refer to each other. + let known = |id: &ClassId| classes.contains_key(id) || fresh.contains(id); + let resolve = |written: &ClassId, scope: &str| -> Option { + // Tapioca writes absolute names (`::Money::Currency`), so the + // name as written is tried first; a relative name (hand-written + // shims) then resolves lexically from the class it appears in. + if known(written) { + return Some(written.clone()); + } + let mut parts: Vec<&str> = scope.split("::").filter(|s| !s.is_empty()).collect(); + while !parts.is_empty() { + let candidate = ClassId(Symbol::new(&format!("{}::{}", parts.join("::"), written.0.as_str()))); + if known(&candidate) { + return Some(candidate); + } + parts.pop(); + } + None + }; + let prune = |ty: &Ty, scope: &str| -> Ty { prune_ty(ty, &|id| resolve(id, scope)) }; + + // First pass: build every ClassInfo with resolved types and the + // instance-method surface of each declared mixin. + let mut built: HashMap = HashMap::new(); + let mut open: HashSet = HashSet::new(); + for id in &fresh { + let gem = &declared[*id]; + let scope = id.0.as_str(); + let mut info = ClassInfo { gem_boundary: true, ..ClassInfo::default() }; + for (name, ty) in &gem.instance_methods { + info.instance_methods.insert(name.clone(), prune(ty, scope)); + } + for (name, ty) in &gem.class_methods { + info.class_methods.insert(name.clone(), prune(ty, scope)); + } + for name in &gem.constants { + info.constants.insert(name.clone(), Ty::Untyped); + } + let mut is_open = gem.dynamic; + if let Some(parent) = &gem.parent { + // Unresolved: left as written. Dispatch already reads a + // named ancestor the registry lacks as "inherited surface we + // don't model", which is exactly what it is. + info.parent = Some(resolve(parent, scope).unwrap_or_else(|| parent.clone())); + } + for include in &gem.includes { + match resolve(include, scope) { + Some(found) => info.includes.push(found), + None if is_marker(include.0.as_str()) => {} + None => is_open = true, + } + } + if is_open { + open.insert((*id).clone()); + } + built.insert((*id).clone(), info); + } + + // `extend M` makes M's instance methods this class's class-side + // surface. Copied from the declared table (a gem module) or the + // registry (a catalog module); an `M` neither knows leaves the + // class-side surface unenumerable. + for id in &fresh { + let gem = &declared[*id]; + let scope = id.0.as_str(); + for extend in &gem.extends { + let Some(target) = resolve(extend, scope) else { + if !is_marker(extend.0.as_str()) { + open.insert((*id).clone()); + } + continue; + }; + let methods: Vec<(Symbol, Ty)> = match (built.get(&target), classes.get(&target)) { + (Some(info), _) => info.instance_methods.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + (None, Some(info)) => info.instance_methods.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + (None, None) => Vec::new(), + }; + if let Some(info) = built.get_mut(*id) { + for (name, ty) in methods { + info.class_methods.entry(name).or_insert(ty); + } + } + } + } + + // A class that includes an open module is open, transitively. + loop { + let mut grew = false; + for (id, info) in &built { + if open.contains(id) { + continue; + } + if info.includes.iter().any(|m| open.contains(m)) { + open.insert(id.clone()); + grew = true; + } + } + if !grew { + break; + } + } + + for (id, mut info) in built { + info.open = open.contains(&id); + classes.insert(id, info); + } + + // A class the catalog or stdlib registers and an RBI also describes + // (`Rack::Utils`, `Rack::Mime`) gains the RBI's methods it lacks. The + // registered entry wins wherever both speak, and a class the app + // itself declares is never touched: its source is the whole truth. + let app_owned: HashSet<&ClassId> = app + .models + .iter() + .map(|m| &m.name) + .chain(app.controllers.iter().map(|c| &c.name)) + .chain(app.library_classes.iter().map(|lc| &lc.name)) + .collect(); + let reopened: Vec<&ClassId> = declared + .keys() + .filter(|id| !fresh.contains(*id) && !app_owned.contains(*id) && classes.contains_key(*id)) + .filter(|id| { + let name = id.0.as_str(); + !RUBY_CORE.contains(&name.split("::").next().unwrap_or(name)) + }) + .collect(); + for id in reopened { + let gem = &declared[id]; + let scope = id.0.as_str(); + let known = |c: &ClassId| classes.contains_key(c); + let mut add_instance = Vec::new(); + let mut add_class = Vec::new(); + for (name, ty) in &gem.instance_methods { + add_instance.push((name.clone(), prune_ty(ty, &|c| resolve_in(&known, c, scope)))); + } + for (name, ty) in &gem.class_methods { + add_class.push((name.clone(), prune_ty(ty, &|c| resolve_in(&known, c, scope)))); + } + if let Some(info) = classes.get_mut(id) { + for (name, ty) in add_instance { + info.instance_methods.entry(name).or_insert(ty); + } + for (name, ty) in add_class { + info.class_methods.entry(name).or_insert(ty); + } + } + } +} + +/// `written` as a registered class, from `scope` outward. +fn resolve_in(known: &dyn Fn(&ClassId) -> bool, written: &ClassId, scope: &str) -> Option { + if known(written) { + return Some(written.clone()); + } + let mut parts: Vec<&str> = scope.split("::").filter(|s| !s.is_empty()).collect(); + while !parts.is_empty() { + let candidate = ClassId(Symbol::new(&format!("{}::{}", parts.join("::"), written.0.as_str()))); + if known(&candidate) { + return Some(candidate); + } + parts.pop(); + } + None +} + +/// `ty` with every class name resolved through `resolve`; a name that +/// resolves to nothing becomes `untyped`. +fn prune_ty(ty: &Ty, resolve: &dyn Fn(&ClassId) -> Option) -> Ty { + match ty { + Ty::Class { id, args } => match resolve(id) { + Some(id) => Ty::Class { + id, + args: args.iter().map(|a| prune_ty(a, resolve)).collect(), + }, + None => Ty::Untyped, + }, + Ty::Array { elem } => Ty::Array { elem: Box::new(prune_ty(elem, resolve)) }, + Ty::Hash { key, value } => Ty::Hash { + key: Box::new(prune_ty(key, resolve)), + value: Box::new(prune_ty(value, resolve)), + }, + Ty::Tuple { elems } => Ty::Tuple { elems: elems.iter().map(|t| prune_ty(t, resolve)).collect() }, + Ty::Union { variants } => { + Ty::Union { variants: variants.iter().map(|t| prune_ty(t, resolve)).collect() } + } + Ty::Fn { params, block, ret, effects } => Ty::Fn { + params: params + .iter() + .map(|p| crate::ty::Param { name: p.name.clone(), ty: prune_ty(&p.ty, resolve), kind: p.kind.clone() }) + .collect(), + block: block.as_ref().map(|b| Box::new(prune_ty(b, resolve))), + ret: Box::new(prune_ty(ret, resolve)), + effects: effects.clone(), + }, + other => other.clone(), + } +} diff --git a/src/analyze/registry/library.rs b/src/analyze/registry/library.rs index 315444d67..a7cad56d3 100644 --- a/src/analyze/registry/library.rs +++ b/src/analyze/registry/library.rs @@ -69,7 +69,16 @@ pub(in crate::analyze) fn register( // A helper module's own `include`s carry transitively to // any class that includes it; record them so dispatch can // chase nested mixins. - cls.includes = lc.includes.clone(); + // MERGED, not assigned: a framework class is reopened once per + // patch file (core reopens `ActionDispatch::Request` to include + // `RequestHelperMethods`, `RequestBuyerSession`, ...), and each + // reopen is a library class of the same name. Assigning kept only + // the last file's mixins and lost the methods of every other. + for inc in &lc.includes { + if !cls.includes.contains(inc) { + cls.includes.push(inc.clone()); + } + } // `include Singleton` provides `.instance` returning the // singleton — the one stdlib mixin worth special-casing: // service objects use it pervasively diff --git a/src/analyze/registry/mod.rs b/src/analyze/registry/mod.rs index 5bdad631d..af51e3949 100644 --- a/src/analyze/registry/mod.rs +++ b/src/analyze/registry/mod.rs @@ -7,6 +7,7 @@ pub(super) mod activemodel; pub(super) mod ar; pub(super) mod controllers; +pub(super) mod gem_boundary; pub(super) mod library; pub(super) mod routes; pub(super) mod stdlib; diff --git a/src/analyze/registry/stdlib.rs b/src/analyze/registry/stdlib.rs index 294fd38c5..560cae314 100644 --- a/src/analyze/registry/stdlib.rs +++ b/src/analyze/registry/stdlib.rs @@ -183,6 +183,37 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { ("exist?", Ty::Bool), ("exists?", Ty::Bool), ("mkdir", Ty::Int), ("pwd", Ty::Str), ("home", Ty::Str), ], &[]); + // Psych/YAML — campfire's Purchaser loads `config/purchased_by.yml` + // via `YAML.load_file`. Register the class-side surface the corpus + // writes so the constant is modeled (not a silent raise stub) and + // ruby-family emit keeps the call. Return types stay gradual: the + // document shape is whatever the file held. + register_stdlib_class(classes, "YAML", &[ + ("load", Ty::Untyped), ("load_file", Ty::Untyped), + ("safe_load", Ty::Untyped), ("safe_load_file", Ty::Untyped), + ("dump", Ty::Str), + ], &[]); + // Framework modules an app `include`s that emit already handles + // (Attachable sgid, Turbo stream names, ActiveModel callbacks, + // SanitizeHelper). Empty module markers so the unresolved-include + // gate does not refuse known seams; methods come from lowering / + // other registry entries. + for name in [ + "ActionText::Attachable", + "Turbo::Streams::StreamName", + "Turbo::Streams::StreamName::ClassMethods", + "ActiveModel::Validations::Callbacks", + "ActionView::Helpers::SanitizeHelper", + ] { + let info = classes.entry(ClassId(Symbol::from(name))).or_default(); + info.is_module = true; + } + // SanitizeHelper readers Opengraph::Metadata calls after include. + if let Some(sanitize) = classes.get_mut(&ClassId(Symbol::from("ActionView::Helpers::SanitizeHelper"))) { + for m in ["sanitize", "strip_tags", "sanitize_css"] { + sanitize.instance_methods.entry(Symbol::from(m)).or_insert(Ty::Str); + } + } register_stdlib_class(classes, "Math", &[ ("sqrt", Ty::Float), ("cbrt", Ty::Float), ("log", Ty::Float), ("log2", Ty::Float), ("log10", Ty::Float), ("exp", Ty::Float), @@ -281,9 +312,18 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { // would hand a strict target a non-null it has to trust. let match_data = Ty::Class { id: ClassId(Symbol::from("MatchData")), args: vec![] }; let str_or_nil_m = Ty::Union { variants: vec![Ty::Str, Ty::Nil] }; - register_stdlib_class(classes, "Regexp", &[], &[ + register_stdlib_class(classes, "Regexp", &[ + // Class side: escaping is a pure String -> String function, `last_match` reads the + // `$~` of the previous match, `union` builds a Regexp. + ("escape", Ty::Str), + ("quote", Ty::Str), + ("last_match", Ty::Union { variants: vec![match_data.clone(), Ty::Nil] }), + ("union", Ty::Class { id: ClassId(Symbol::from("Regexp")), args: vec![] }), + ], &[ ("match", Ty::Union { variants: vec![match_data.clone(), Ty::Nil] }), ("match?", Ty::Bool), + ("=~", Ty::Union { variants: vec![Ty::Int, Ty::Nil] }), + ("===", Ty::Bool), ("source", Ty::Str), ]); register_stdlib_class(classes, "MatchData", &[], &[ @@ -401,6 +441,29 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { // loop below repeats itself. The list is Ruby's own hierarchy under // `StandardError` plus `Exception` itself, which `rescue Exception` // names and campfire's `MessagesHelper` actually writes. + // `cause` is the exception being handled when this one was raised, or + // nil; `exception` answers the exception itself (`raise e` and + // `e.exception("msg")` both go through it). + let exception_surface = [ + ("message", Ty::Str), + ("to_s", Ty::Str), + ("full_message", Ty::Str), + ("detailed_message", Ty::Str), + ("inspect", Ty::Str), + ("backtrace", Ty::Array { elem: Box::new(Ty::Str) }), + ("backtrace_locations", Ty::Array { elem: Box::new(Ty::Untyped) }), + ("set_backtrace", Ty::Untyped), + ("exception", Ty::Untyped), + ( + "cause", + Ty::Union { + variants: vec![ + Ty::Class { id: ClassId(Symbol::from("Exception")), args: vec![] }, + Ty::Nil, + ], + }, + ), + ]; for exc in [ "Exception", "StandardError", "RuntimeError", "ArgumentError", "TypeError", "NameError", "NoMethodError", "IndexError", @@ -411,21 +474,8 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { "URI::InvalidURIError", "Net::OpenTimeout", "Net::ReadTimeout", "OpenSSL::OpenSSLError", "JSON::ParserError", ] { - register_stdlib_class(classes, exc, &[], &[ - ("message", Ty::Str), - ("to_s", Ty::Str), - ("full_message", Ty::Str), - ("inspect", Ty::Str), - ("backtrace", Ty::Array { elem: Box::new(Ty::Str) }), - ]); + register_stdlib_class(classes, exc, &[], &exception_surface); } - let exception_surface = [ - ("message", Ty::Str), - ("to_s", Ty::Str), - ("full_message", Ty::Str), - ("inspect", Ty::Str), - ("backtrace", Ty::Array { elem: Box::new(Ty::Str) }), - ]; for (exc, extra) in [ ("ActiveRecord::RecordNotFound", None), ("ActiveRecord::RecordNotUnique", None), @@ -540,7 +590,8 @@ pub(in crate::analyze) fn register(classes: &mut HashMap) { // implement the collection operations the app uses; don't invent // one concrete representation for the two runtimes. register_stdlib_class(classes, "Rails::HTML5::SafeListSanitizer", &[ - ("allowed_tags", Ty::Untyped), ("allowed_attributes", Ty::Untyped), + ("allowed_tags", Ty::Union { variants: vec![Ty::Array { elem: Box::new(Ty::Str) }, Ty::Class { id: ClassId(Symbol::from("Set")), args: vec![] }] }), + ("allowed_attributes", Ty::Union { variants: vec![Ty::Array { elem: Box::new(Ty::Str) }, Ty::Class { id: ClassId(Symbol::from("Set")), args: vec![] }] }), ], &[]); // `Set` is a value type: `Set.new` yields `Class { Set }` (via the // universal `.new`), then these instance methods dispatch on it. diff --git a/src/app.rs b/src/app.rs index 624b9a3e6..38f3e7cde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -89,6 +89,12 @@ pub struct App { /// replaces Action Text's `rich_text_area`. #[serde(default, skip_serializing_if = "Option::is_none")] pub gem_lock: Option, + /// The classes and method headers the locked gems' Tapioca RBIs + /// declare ([`crate::gem_boundary`]). Declarations only, read at + /// ingest when the tree (or the real location of its lockfile) has + /// `sorbet/rbi/gems/`; empty otherwise. + #[serde(default, skip_serializing_if = "crate::gem_boundary::GemBoundary::is_empty")] + pub gem_boundary: crate::gem_boundary::GemBoundary, /// Attributes the app (and its gems) add to Action Text's sanitizer /// allow-list at boot — `ActionText::ContentHelper.allowed_attributes` /// as the initializers leave it, minus the framework defaults the @@ -143,6 +149,15 @@ pub struct App { /// that live on the CONTROLLER. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub view_visible_controller_methods: BTreeSet, + /// Methods of a helper object the app builds at load time from a + /// YAML table (`WebUrlHelpers = WebUrlHelpersFactory.create( + /// YAML.load_file(…)["paths"])`), keyed by the constant. The names + /// come from the table's keys and the factory's `define_method` + /// suffixes — see [`crate::ingest::generated_helpers`]. A registry + /// fact for the analyzer only: the constant's initializer still + /// builds the object at runtime, so nothing is emitted for it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub generated_helper_methods: BTreeMap>, /// Model class names a `GlobalID::Locator.locate(gid, only: K)` /// call site names, collected by [`crate::lower::global_id_locate`] /// as it rewrites each site to a per-model `locate_`. @@ -387,6 +402,10 @@ pub struct App { /// The analyzer resolves the sources itself when this is absent. #[serde(skip)] pub const_resolver: crate::analyze::PreparedConstResolver, + /// Ingested source apps cannot enter the standalone IR-only analyzer mode + /// if a caller drops their source table. Persisted across serialization. + #[serde(default)] + pub source_index_required: bool, /// Per-controller resolved request machinery, computed once by /// analyze's parent-chain walk and persisted (the self-describing-IR /// move: `run_typing_passes` already built these to seed ivars, and @@ -680,10 +699,12 @@ impl App { rbs_signatures: HashMap::new(), rbs_includes: HashMap::new(), gem_lock: None, + gem_boundary: Default::default(), content_helper_allowed_attributes: Vec::new(), inferred_method_params: HashMap::new(), helper_method_index: HashMap::new(), view_visible_controller_methods: BTreeSet::new(), + generated_helper_methods: BTreeMap::new(), global_id_locate_models: BTreeSet::new(), attachable_unsigned_models: Vec::new(), load_hook_class_macros: Vec::new(), @@ -707,6 +728,7 @@ impl App { controller_resolutions: HashMap::new(), sources: Vec::new(), const_resolver: Default::default(), + source_index_required: false, root: String::new(), app_roots: Vec::new(), } diff --git a/src/dialect.rs b/src/dialect.rs index 265b51a4a..cbd5f030a 100644 --- a/src/dialect.rs +++ b/src/dialect.rs @@ -1145,12 +1145,20 @@ impl Controller { }) } + /// The class-side methods (`def self.x`, `class << self` defs). pub fn class_methods(&self) -> impl Iterator { self.body.iter().filter_map(|item| match item { ControllerBodyItem::ClassMethod { method, .. } => Some(method), _ => None, }) } + + pub fn class_methods_mut(&mut self) -> impl Iterator { + self.body.iter_mut().filter_map(|item| match item { + ControllerBodyItem::ClassMethod { method, .. } => Some(method), + _ => None, + }) + } } /// The two method forms admitted by finite class configuration. @@ -1190,8 +1198,10 @@ pub enum ControllerBodyItem { method: MethodDef, /// Finite macro carrier and storage slot. /// Used to infer a shared method contract without sharing values. - configuration_slot: (ClassId, Symbol), - configuration_role: ClassConfigurationRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + configuration_slot: Option<(ClassId, Symbol)>, + #[serde(default, skip_serializing_if = "Option::is_none")] + configuration_role: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] leading_comments: Vec, #[serde(default, skip_serializing_if = "std::ops::Not::not")] diff --git a/src/emit/elixir/expr.rs b/src/emit/elixir/expr.rs index 34b355ae6..ae3b56e45 100644 --- a/src/emit/elixir/expr.rs +++ b/src/emit/elixir/expr.rs @@ -925,14 +925,12 @@ fn emit_send(recv: Option<&Expr>, method: &str, args: &[Expr]) -> String { 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 { .. }) - ); + // Int index → List; Hash key → Map. A Hash with integer keys + // (`counts[record.id] = n`) must stay Map.put — same guard as + // C#/Kotlin `list_index_needs_int_cast`. + let index_is_int = !recv_is_hash(r) + && (matches!(args[0].ty.as_ref(), Some(crate::ty::Ty::Int)) + || recv_is_array(r)); if index_is_int { return format!("List.replace_at({r_s}, {k}, {v})"); } @@ -2087,9 +2085,10 @@ fn emit_ivar_state_send(name: &str, method: &str, args: &[Expr]) -> Option Option { let ExprNode::Const { path } = &*recv.node else { return None; @@ -2111,6 +2110,15 @@ fn emit_const_slot_send(recv: &Expr, method: &str, args: &[Expr]) -> Option Some(format!("Enum.at({get}, {})", emit_expr(&args[0]))), + // `STUB_ADDRS << addrs` / `STUB_HOSTS.clear` must update the same + // Process entry as `[]=`; otherwise teardown leaves a stale list + // that a later indexed read would still see. + ("<<", 1) => Some(format!( + "Process.put({key}, {get} ++ [{}])", + emit_expr(&args[0]) + )), + ("clear", 0) => Some(format!("Process.put({key}, [])")), + ("length" | "size", 0) => Some(format!("Kernel.length({get})")), _ => None, } } @@ -3051,6 +3059,47 @@ mod tests { clear_declared_constants(); } + #[test] + fn hash_int_key_index_put_uses_map_put() { + // `counts[record.id] = n` is Hash[Integer, Integer] — not a List. + let recv = var_t( + "counts", + Ty::Hash { + key: Box::new(Ty::Int), + value: Box::new(Ty::Int), + }, + ); + let key = var_t("id", Ty::Int); + let val = var_t("n", Ty::Int); + let e = call(recv, "__index_put__", vec![key, val]); + assert_eq!(emit_expr(&e), "Map.put(counts, id, n)"); + } + + #[test] + fn declared_const_array_mutators_share_process_dict() { + fn const_ref(name: &str) -> Expr { + Expr::new(crate::span::Span::synthetic(), ExprNode::Const { + path: vec![Symbol::from(name)], + }) + } + clear_declared_constants(); + register_declared_constant("STUB_ADDRS"); + let recv = || const_ref("STUB_ADDRS"); + assert_eq!( + emit_expr(&call(recv(), "<<", vec![var_t("addrs", arr())])), + "Process.put(:rh_const_stub_addrs, Process.get(:rh_const_stub_addrs, @stub_addrs) ++ [addrs])" + ); + assert_eq!( + emit_expr(&call(recv(), "clear", vec![])), + "Process.put(:rh_const_stub_addrs, [])" + ); + assert_eq!( + emit_expr(&call(recv(), "length", vec![])), + "Kernel.length(Process.get(:rh_const_stub_addrs, @stub_addrs))" + ); + clear_declared_constants(); + } + fn str_lit_e(s: &str) -> Expr { Expr::new(crate::span::Span::synthetic(), ExprNode::Lit { value: Literal::Str { value: s.to_string() }, diff --git a/src/emit/ruby/library.rs b/src/emit/ruby/library.rs index e52d56706..dd79550df 100644 --- a/src/emit/ruby/library.rs +++ b/src/emit/ruby/library.rs @@ -6504,6 +6504,30 @@ fn partition_deferred_constants(lc: &LibraryClass) -> (Vec, Vec) { let ExprNode::Const { path } = &*r.node else { return false }; path.iter().map(|s| s.as_str()).collect::>().join("::") == class_name } + /// Does this Const name a constant already marked deferred — bare + /// `BUILTIN`, or `OwnClass::BUILTIN` (qualified by a later rewrite)? + fn const_names_deferred( + path: &[crate::ident::Symbol], + deferred_names: &std::collections::HashSet, + class_name: &str, + ) -> bool { + if path.is_empty() { + return false; + } + if path.len() == 1 { + return deferred_names.contains(path[0].as_str()); + } + let leaf = path[path.len() - 1].as_str(); + if !deferred_names.contains(leaf) { + return false; + } + let prefix = path[..path.len() - 1] + .iter() + .map(|s| s.as_str()) + .collect::>() + .join("::"); + prefix == class_name + } fn calls_self(expr: &Expr, own: &std::collections::HashSet<&str>, deferred_names: &std::collections::HashSet, class_name: &str) -> bool { match &*expr.node { // A closure that is STORED rather than run — `proc { … }`, @@ -6545,9 +6569,12 @@ fn partition_deferred_constants(lc: &LibraryClass) -> (Vec, Vec) { _ => calls_self(b, own, deferred_names, class_name), })) } - ExprNode::Const { path } - if path.len() == 1 && deferred_names.contains(path[0].as_str()) => - { + // Bare `BUILTIN` *and* `Sound::BUILTIN` — index_by grounding + // (and similar) rewrites the receiver to the qualified form, + // and a rule that only matched path.len() == 1 left INDEX + // eager while BUILTIN deferred: `uninitialized constant + // Sound::BUILTIN` at load (campfire models.rb → sound.rb). + ExprNode::Const { path } if const_names_deferred(path, deferred_names, class_name) => { true } _ => { diff --git a/src/emit/shared/add.rs b/src/emit/shared/add.rs index b2c27d599..282f79719 100644 --- a/src/emit/shared/add.rs +++ b/src/emit/shared/add.rs @@ -55,6 +55,10 @@ pub enum AddCase { } /// Classify a pair of operands for `+` emission. +fn is_time(ty: &Ty) -> bool { + matches!(ty, Ty::Time) || matches!(ty, Ty::Class { id, .. } if id.0.as_str() == "Time") +} + pub fn classify_add(lhs: &Expr, rhs: &Expr) -> AddCase { let lhs_ty = lhs.ty.as_ref(); let rhs_ty = rhs.ty.as_ref(); @@ -68,12 +72,24 @@ pub fn classify_add(lhs: &Expr, rhs: &Expr) -> AddCase { return AddCase::Unknown; } + // `Time + seconds` / `Time + Duration` is a Time; only `Time + Time` + // (and `Time + String`) is the TypeError. + if is_time(lhs_ty.unwrap()) && !is_time(rhs_ty.unwrap()) && !matches!(rhs_ty, Some(Ty::Str)) { + return AddCase::Unknown; + } + if super::operand::is_user_operator_receiver(lhs) { + return AddCase::Unknown; + } + let lhs_ty = lhs_ty.unwrap(); let rhs_ty = rhs_ty.unwrap(); match (lhs_ty, rhs_ty) { (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => AddCase::Numeric, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => AddCase::NumericPromote, + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => { + AddCase::NumericPromote + } (Ty::Str, Ty::Str) => AddCase::StringConcat, _ => { // Collection `+` collection is *always* valid Ruby — it diff --git a/src/emit/shared/cmp.rs b/src/emit/shared/cmp.rs index 26b4be959..9b3ba36a1 100644 --- a/src/emit/shared/cmp.rs +++ b/src/emit/shared/cmp.rs @@ -62,17 +62,39 @@ pub fn classify_cmp(lhs: &Expr, rhs: &Expr) -> CmpCase { match (lhs_ty, rhs_ty) { // Not SameType: Go's `time.Time` has no native `<`, so leave the rendering to each target. - (l, r) if is_time(l) && is_time(r) => CmpCase::Unknown, + (l, r) if temporal_kind(l).is_some() && temporal_kind(l) == temporal_kind(r) => CmpCase::Unknown, (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => CmpCase::SameType, (Ty::Str, Ty::Str) | (Ty::Sym, Ty::Sym) => CmpCase::SameType, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => CmpCase::NumericPromote, - (Ty::Class { .. }, Ty::Class { .. }) => CmpCase::ClassSubclass, + (Ty::Class { .. }, Ty::Class { .. }) if lhs.decisions & crate::expr::CLASS_OBJECT_VALUE != 0 + && rhs.decisions & crate::expr::CLASS_OBJECT_VALUE != 0 => CmpCase::ClassSubclass, + // `Gem::Version < Integer?`, `Money <= Money?`: `<` is `Comparable` + // on the lhs class; whether it accepts the rhs is that class's + // business, not decidable here. + _ if super::operand::is_user_operator_receiver(lhs) => CmpCase::Unknown, + // `(score || BEST) <= LIMIT` where the arms are Integer and + // `Numeric`: every arm is an ordered number. + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => CmpCase::NumericPromote, _ => CmpCase::Incompatible, } } -fn is_time(ty: &Ty) -> bool { - matches!(ty, Ty::Time) || matches!(ty, Ty::Class { id, .. } if id.0.as_str() == "Time") +/// A point in time, of whatever class: the first-class `Time`, the legacy +/// `Class { Time }`, and the other classes Rails orders against it +/// (`DateTime`, `Date`, `ActiveSupport::TimeWithZone`), or a union of +/// only those. Equal temporal representations can compare directly. Cross-kind coercion +/// requires runtime support; a nil arm never proves temporal ordering. +fn temporal_kind(ty: &Ty) -> Option<&str> { + match ty { + Ty::Time => Some("Time"), + Ty::Date => Some("Date"), + Ty::Class { id, .. } if matches!(id.0.as_str(), "Time" | "DateTime" | "Date") => Some(id.0.as_str()), + Ty::Union { variants } => { + let first = temporal_kind(variants.first()?); + first.filter(|kind| variants.iter().all(|v| temporal_kind(v) == Some(*kind))) + } + _ => None, + } } #[cfg(test)] @@ -197,6 +219,22 @@ mod tests { } } + #[test] + fn date_vs_date_is_not_incompatible() { + use crate::ident::ClassId; + let legacy = Ty::Class { id: ClassId(Symbol::from("Date")), args: vec![] }; + for (l, r) in [ + (Ty::Date, Ty::Date), + (Ty::Date, legacy.clone()), + (legacy.clone(), Ty::Date), + (legacy.clone(), legacy), + ] { + let l = var_typed("a", l); + let r = var_typed("b", r); + assert!(matches!(classify_cmp(&l, &r), CmpCase::Unknown)); + } + } + #[test] fn nullable_time_vs_time_is_incompatible() { let l = var_typed("a", Ty::Union { variants: vec![Ty::Time, Ty::Nil] }); @@ -211,14 +249,17 @@ mod tests { // must surface this so emitters render a target-appropriate // relation check instead of raising "incompatible operands". use crate::ident::ClassId; - let lhs = var_typed( + let mut lhs = var_typed( "L", Ty::Class { id: ClassId(Symbol::from("RecordNotFound")), args: vec![] }, ); - let rhs = var_typed( + let mut rhs = var_typed( "R", Ty::Class { id: ClassId(Symbol::from("StandardError")), args: vec![] }, ); + assert!(matches!(classify_cmp(&lhs, &rhs), CmpCase::Incompatible)); + lhs.decisions |= crate::expr::CLASS_OBJECT_VALUE; + rhs.decisions |= crate::expr::CLASS_OBJECT_VALUE; assert!(matches!(classify_cmp(&lhs, &rhs), CmpCase::ClassSubclass)); } } diff --git a/src/emit/shared/div_pow.rs b/src/emit/shared/div_pow.rs index c82138a47..462817d6f 100644 --- a/src/emit/shared/div_pow.rs +++ b/src/emit/shared/div_pow.rs @@ -39,9 +39,16 @@ pub fn classify_div_pow(lhs: &Expr, rhs: &Expr) -> DivPowCase { let lhs_ty = lhs_ty.unwrap(); let rhs_ty = rhs_ty.unwrap(); + if super::operand::is_user_operator_receiver(lhs) { + return DivPowCase::Unknown; + } + match (lhs_ty, rhs_ty) { (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => DivPowCase::Numeric, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => DivPowCase::NumericPromote, + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => { + DivPowCase::NumericPromote + } _ => DivPowCase::Incompatible, } } diff --git a/src/emit/shared/modulo.rs b/src/emit/shared/modulo.rs index 86c3b0e62..59ebd6b25 100644 --- a/src/emit/shared/modulo.rs +++ b/src/emit/shared/modulo.rs @@ -52,6 +52,9 @@ pub fn classify_modulo(lhs: &Expr, rhs: &Expr) -> ModuloCase { match (lhs_ty, rhs_ty) { (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => ModuloCase::Numeric, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => ModuloCase::NumericPromote, + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => { + ModuloCase::NumericPromote + } // Str % anything typed (Array of args, Hash, single value) is // string formatting in Ruby. Reject Str % Str as that's not // meaningful (but Ruby allows it — `"%" % "foo"` is a no-op). diff --git a/src/emit/shared/mul.rs b/src/emit/shared/mul.rs index a561a38c8..5479f8e84 100644 --- a/src/emit/shared/mul.rs +++ b/src/emit/shared/mul.rs @@ -60,9 +60,16 @@ pub fn classify_mul<'a>(lhs: &'a Expr, rhs: &'a Expr) -> MulCase<'a> { let lhs_ty = lhs_ty.unwrap(); let rhs_ty = rhs_ty.unwrap(); + if super::operand::is_user_operator_receiver(lhs) { + return MulCase::Unknown; + } + match (lhs_ty, rhs_ty) { (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => MulCase::Numeric, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => MulCase::NumericPromote, + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => { + MulCase::NumericPromote + } (Ty::Str, Ty::Int) => MulCase::StringRepeat, (Ty::Array { elem }, Ty::Int) => MulCase::ArrayRepeat { elem: elem.as_ref() }, (Ty::Array { elem }, Ty::Str) => MulCase::ArrayJoin { elem: elem.as_ref() }, diff --git a/src/emit/shared/operand.rs b/src/emit/shared/operand.rs index 23684930e..ae3953ffd 100644 --- a/src/emit/shared/operand.rs +++ b/src/emit/shared/operand.rs @@ -39,6 +39,24 @@ pub fn is_gradual_operand(t: Option<&Ty>) -> bool { } } +/// Admission comes from the operator's defining method, never the class label. +pub fn is_user_operator_receiver(receiver: &crate::expr::Expr) -> bool { + receiver.decisions & crate::expr::RESOLVED_OPERATOR_RECEIVER != 0 +} + +/// An ordered number: `Int`, `Float`, `Numeric`/`BigDecimal`/`Rational`, or +/// a union whose every arm is one (`Float | Integer`, `Numeric | Integer`). +/// Ruby's arithmetic and comparison are defined between any two of them, so +/// for these the classifiers treat the pair like `Int` and `Float`. +pub fn is_number(ty: &Ty) -> bool { + match ty { + Ty::Int | Ty::Float => true, + Ty::Class { id, .. } => matches!(id.0.as_str(), "Numeric" | "BigDecimal" | "Rational"), + Ty::Union { variants } => !variants.is_empty() && variants.iter().all(is_number), + _ => false, + } +} + /// Only the lhs is checked: `Array - Set` raises `TypeError` in Ruby. pub fn is_set_receiver(t: Option<&Ty>) -> bool { matches!(t, Some(Ty::Class { id, .. }) if id.0.as_str() == "Set") diff --git a/src/emit/shared/sub.rs b/src/emit/shared/sub.rs index 6b30cf530..af3d87253 100644 --- a/src/emit/shared/sub.rs +++ b/src/emit/shared/sub.rs @@ -66,7 +66,10 @@ pub fn classify_sub<'a>(lhs: &'a Expr, rhs: &'a Expr) -> SubCase<'a> { match (lhs_ty, rhs_ty) { (Ty::Int, Ty::Int) | (Ty::Float, Ty::Float) => SubCase::Numeric, (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => SubCase::NumericPromote, - (Ty::Array { elem: l }, Ty::Array { elem: r }) if l == r => { + (l, r) if super::operand::is_number(l) && super::operand::is_number(r) => { + SubCase::NumericPromote + } + (Ty::Array { elem: l }, Ty::Array { .. }) => { SubCase::ArrayDifference { elem: l.as_ref() } } // Time `-` is receiver-overloaded and not disambiguable from @@ -78,6 +81,7 @@ pub fn classify_sub<'a>(lhs: &'a Expr, rhs: &'a Expr) -> SubCase<'a> { // first-class `Ty::Time` variant (datetime columns hydrate to it, // possibly as a `Time | Nil` union) and a legacy concrete // `Class { Time }` (`Time.now`/`Time.current`/`Time.at`). + _ if super::operand::is_user_operator_receiver(lhs) => SubCase::Unknown, _ if is_time_operand(lhs_ty) || is_time_operand(rhs_ty) => { SubCase::Unknown } @@ -150,10 +154,11 @@ mod tests { } #[test] - fn array_minus_array_different_elem_is_incompatible() { + fn array_minus_array_different_elem_is_a_difference() { + // `[1] - ["a"]` is valid Ruby (nothing matches); elements are compared by `eql?`. let l = var_typed("a", Ty::Array { elem: Box::new(Ty::Int) }); let r = var_typed("b", Ty::Array { elem: Box::new(Ty::Str) }); - assert!(matches!(classify_sub(&l, &r), SubCase::Incompatible)); + assert!(matches!(classify_sub(&l, &r), SubCase::ArrayDifference { .. })); } #[test] diff --git a/src/expr.rs b/src/expr.rs index 5f901cc77..ad1f10e61 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -21,6 +21,15 @@ use crate::ty::Ty; /// The source reference names a modeled class or module. The Ruby emitter /// uses its resolved `Ty::Class` when it changes lexical nesting. pub const RESOLVED_CLASS_REF: u64 = 1 << 2; +/// The value is a proven class/module object, rather than a nominal instance. +pub const CLASS_OBJECT_VALUE: u64 = 1 << 5; +/// This receiver's operator call is backed by a registered method definition. +pub const RESOLVED_OPERATOR_RECEIVER: u64 = 1 << 6; +/// The cast's type was declared by source, rather than synthesized by lowering. +pub const SOURCE_TYPE_ASCRIPTION: u64 = 1 << 7; +/// A generated constant may borrow a source span for diagnostics/layout; +/// that position is not a written Ruby constant reference to index. +pub const GENERATED_CONST_REF: u64 = 1 << 4; /// An admitted library-class Data factory with its exact declaration identity. pub const RESOLVED_DATA_FACTORY: u64 = 1 << 3; @@ -168,6 +177,9 @@ impl Expr { /// threading a span argument through every small IR constructor. pub fn inherit_span(&mut self, enclosing: Span) { if self.span.is_synthetic() { + if matches!(&*self.node, ExprNode::Const { .. }) { + self.decisions |= GENERATED_CONST_REF; + } self.span = enclosing; } let here = self.span; diff --git a/src/gem_boundary.rs b/src/gem_boundary.rs new file mode 100644 index 000000000..ecf4d9577 --- /dev/null +++ b/src/gem_boundary.rs @@ -0,0 +1,82 @@ +//! The typed boundary to gems the analyzer does not otherwise model. +//! +//! A gem's public surface is not in the app's tree, but it is often on +//! disk anyway: Tapioca writes one RBI per locked gem under +//! `sorbet/rbi/gems/`. Those files are declarations only -- classes, +//! their ancestry, and `sig`-typed method headers -- which is exactly +//! what a boundary needs and nothing more. This is the table +//! [`crate::ingest::rbi`] reads them into. +//! +//! It is kept apart from `App::rbs_signatures` on purpose. That table +//! is the app's OWN sidecar (`sig/**/*.rbs`) and every consumer of it, +//! the RBS emitters included, treats its entries as things the app +//! declares. A gem's methods are not the app's to declare, so they are +//! carried here and only the analyzer's dispatch registry reads them. + +use std::collections::{BTreeSet, HashMap}; + +use serde::{Deserialize, Serialize}; + +use crate::ident::{ClassId, Symbol}; +use crate::ty::Ty; + +/// Every gem class or module an RBI declares, by qualified name. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GemBoundary { + pub classes: HashMap, + /// For each gem whose RBI was read: the qualified names of the + /// classes and modules it actually defines something in (methods, + /// constants, ancestry). A gem in here has told us exactly what it + /// owns; a bare namespace container (`module Shopify` opened only + /// to nest a class) owns nothing beside what it nests. + #[serde(default)] + pub namespaces: HashMap>, +} + +impl GemBoundary { + pub fn is_empty(&self) -> bool { + self.classes.is_empty() + } + + /// Whether `gem`'s RBI accounts for the constant `path`: it defines + /// it, defines something inside it, or `path` sits inside something + /// it defines. `None` when its RBI was not read: nothing is known + /// either way. + pub fn declares_path(&self, gem: &str, path: &str) -> Option { + let owned = self.namespaces.get(gem)?; + let inside = format!("{path}::"); + if owned.contains(path) || owned.range(inside.clone()..).next().is_some_and(|n| n.starts_with(&inside)) { + return Some(true); + } + Some(path.match_indices("::").any(|(i, _)| owned.contains(&path[..i]))) + } +} + +/// One class or module as an RBI declares it. +/// +/// Class names inside `Ty`s are as WRITTEN: resolving them needs the +/// registry of every class the analysis knows, which only exists once +/// the analyzer is built. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GemClass { + /// The gem the declaration came from (a lockfile spec name). + pub gem: String, + pub is_module: bool, + /// The superclass as written, `None` when the RBI names none. + pub parent: Option, + /// `include` / `prepend` targets, as written. + pub includes: Vec, + /// `extend` targets, as written: their instance methods are this + /// class's class-side surface. + pub extends: Vec, + pub instance_methods: HashMap, + pub class_methods: HashMap, + /// Constants the body assigns (`DEFAULT = ...`), which are values, + /// not classes. + pub constants: Vec, + /// The surface cannot be enumerated from the declaration: the class + /// answers `method_missing`, or its superclass is an expression + /// rather than a name. A lookup that misses is then unknown, not + /// wrong. + pub dynamic: bool, +} diff --git a/src/gems.rs b/src/gems.rs index 81444542c..94e40d410 100644 --- a/src/gems.rs +++ b/src/gems.rs @@ -49,6 +49,11 @@ pub struct Lockfile { pub specs: Vec<(String, String)>, /// The `DEPENDENCIES` section — what the Gemfile names, lock order. pub dependencies: Vec, + /// The specs resolved from a `PATH` source: gems that live in this + /// repository. Their source is part of the tree, so they are + /// analyzed like the rest of the app rather than being a boundary. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub in_repo: Vec, } impl Lockfile { @@ -83,6 +88,9 @@ impl Lockfile { let (name, rest) = body.split_once(' ').unwrap_or((body, "")); let version = rest.trim().trim_start_matches('(').trim_end_matches(')'); lock.specs.push((name.to_string(), version.to_string())); + if section == "PATH" { + lock.in_repo.push(name.to_string()); + } } } "DEPENDENCIES" => { @@ -106,6 +114,11 @@ impl Lockfile { self.specs.iter().any(|(n, _)| n == name) } + /// Does `name` live in this repository (a `PATH` source)? + pub fn is_in_repo(&self, name: &str) -> bool { + self.in_repo.iter().any(|n| n == name) + } + pub fn version_of(&self, name: &str) -> Option<&str> { self.specs.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) } @@ -130,6 +143,11 @@ pub enum GemFate { /// Not in the table. Anything it adds to the app's classes is /// invisible to the analysis — a dispatch on its surface fails. Unknown, + /// A component of this repository, locked from a `PATH` source. Its + /// code is in the tree under analysis, so it is not a gem the + /// analyzer has to model: a dispatch that fails on its constants is + /// a fact about the analysis of that code, and is reported as such. + InRepo, } impl GemFate { @@ -140,6 +158,7 @@ impl GemFate { GemFate::Modeled => "modeled", GemFate::Infrastructure => "infrastructure", GemFate::Unknown => "unknown", + GemFate::InRepo => "in-repo", } } } @@ -159,6 +178,10 @@ pub struct GemCensus { pub gems: Vec, /// Resolved specs beyond the direct ones. pub transitive: usize, + /// Every resolved spec name, direct or not: which gem owns a + /// constant is a question about all of them. + #[serde(default)] + pub resolved: Vec, } impl GemCensus { @@ -169,11 +192,11 @@ impl GemCensus { .map(|name| GemEntry { name: name.clone(), version: lock.version_of(name).map(|v| v.to_string()), - fate: fate_of(name), + fate: if lock.is_in_repo(name) { GemFate::InRepo } else { fate_of(name) }, }) .collect(); let transitive = lock.specs.len().saturating_sub(gems.len()); - GemCensus { gems, transitive } + GemCensus { gems, transitive, resolved: lock.specs.iter().map(|(n, _)| n.clone()).collect() } } pub fn count(&self, fate: GemFate) -> usize { @@ -194,6 +217,7 @@ impl GemCensus { GemFate::Stdlib, GemFate::Modeled, GemFate::Infrastructure, + GemFate::InRepo, ] { let n = self.count(fate); if n > 0 { @@ -570,7 +594,16 @@ pub fn gem_owning_constant<'a>(census: &'a GemCensus, constant_path: &str) -> Op /// All candidates in the best namespace-matching tier. Full/irregular /// names beat dashed-prefix guesses (`Alba`: alba, not alba-inertia). pub fn gems_owning_constant<'a>(census: &'a GemCensus, constant_path: &str) -> Vec<&'a str> { - let head = constant_path.split("::").next().unwrap_or(constant_path); + gems_owning_constant_with(census, constant_path, &|_, _| None) +} + +pub fn gem_owning_constant_with<'a>(census: &'a GemCensus, constant_path: &str, declares: &dyn Fn(&str, &str) -> Option) -> Option<&'a str> { + let owners = gems_owning_constant_with(census, constant_path, declares); + (owners.len() == 1).then(|| owners[0]) +} + +pub(crate) fn gems_owning_constant_with<'a>(census: &'a GemCensus, constant_path: &str, declares: &dyn Fn(&str, &str) -> Option) -> Vec<&'a str> { + let head = constant_path.trim_start_matches("::").split("::").next().unwrap_or(constant_path); let exact: Vec<_> = census .unknown() .filter(|g| g.version.is_some() && namespace_of(&g.name) == head) @@ -579,7 +612,11 @@ pub fn gems_owning_constant<'a>(census: &'a GemCensus, constant_path: &str) -> V if !exact.is_empty() { return exact; } + if census.resolved.iter().any(|s| namespace_of(s) == head) { + return Vec::new(); + } census.unknown() + .filter(|g| declares(&g.name, constant_path) != Some(false)) .filter(|g| g.version.is_some() && namespace_candidates(&g.name).iter().any(|c| c == head)) .map(|g| g.name.as_str()) .collect() diff --git a/src/ingest/alba.rs b/src/ingest/alba.rs index 94006d023..f1a2ebf26 100644 --- a/src/ingest/alba.rs +++ b/src/ingest/alba.rs @@ -542,6 +542,9 @@ fn synthesize_methods(class: &mut LibraryClass, fields: &[Field], span: Span) -> }); } fn mark(expr: &mut Expr, span: Span) { + if matches!(&*expr.node, crate::ExprNode::Const { .. }) { + expr.decisions |= crate::expr::GENERATED_CONST_REF; + } expr.span = span; expr.node.for_each_child_mut(&mut |child| mark(child, span)); } diff --git a/src/ingest/app.rs b/src/ingest/app.rs index d15329e18..693c6d799 100644 --- a/src/ingest/app.rs +++ b/src/ingest/app.rs @@ -29,7 +29,7 @@ use super::library_class::{ ingest_helper_method_names, ingest_library_classes, ingest_rails_application_singleton_methods, }; use super::model::ingest_model_with_enum_constants; -use super::routes::ingest_routes_with_draws; +use super::routes::ingest_routes_with_dsl; use super::schema::{ingest_migration, ingest_schema}; use super::structure_sql::ingest_structure_sql; use super::test::ingest_test_files; @@ -256,6 +256,9 @@ pub fn ingest_app_with_vfs(vfs: &V, dir: &Path) -> IngestResult if vfs.exists(&lock_path) { if let Ok(text) = vfs.read_to_string(&lock_path) { app.gem_lock = Some(crate::gems::Lockfile::parse(&text)); + if let Some(lock) = &app.gem_lock { + app.gem_boundary = super::rbi::load_gem_boundary(vfs, dir, lock); + } } } @@ -1200,6 +1203,16 @@ end } } + let mut model_files = Vec::new(); + for root in &roots { + let models_dir = dir.join(root).join("models"); + if vfs.is_dir(&models_dir) { + model_files.extend(read_rb_files(vfs, &models_dir)?); + } + } + app.generated_helper_methods = + super::generated_helpers::ingest_generated_helpers(&app, vfs, dir, &model_files); + let routes_path = dir.join("config/routes.rb"); if vfs.exists(&routes_path) { if let Some(source) = read_or_ledger(vfs, &routes_path)? { @@ -1227,10 +1240,12 @@ end draw_files.insert(key, (split_source, entry.display().to_string())); } } - if let Some(routes) = unwrap_or_record(ingest_routes_with_draws( + let block_wrappers = mapper_extension_block_methods(&app, vfs, dir); + if let Some(routes) = unwrap_or_record(ingest_routes_with_dsl( &source, &routes_path.display().to_string(), &draw_files, + &block_wrappers, ))? { // `to: redirect("/x")` routes point at actions nobody // wrote, so write them: one controller, one action per @@ -1618,7 +1633,9 @@ end let Ok(entries) = read_rb_files(vfs, &tree) else { continue }; for entry in entries { let Ok(source) = vfs.read(&entry) else { continue }; - for (class_id, methods) in super::sorbet_sig::ingest_sorbet_signatures(&source) { + let (signatures, abstracts) = super::sorbet_sig::ingest_sorbet_declarations(&source); + drop_abstract_stubs(&mut app, &abstracts); + for (class_id, methods) in signatures { let declared = app.rbs_signatures.entry(class_id).or_default(); for (name, ty) in methods { declared.entry(name).or_insert(ty); @@ -1674,7 +1691,7 @@ end // Before the splice: it (and every later consumer) looks concerns up // by ClassId, so the lexical-scope resolution has to have happened. - qualify_relative_model_includes(&mut app); + qualify_relative_includes(&mut app); // Before the concern splices: they read `library_classes`, and this // turns `Current`'s metaprogrammed surface into real methods first. super::current_attributes::lower_current_attributes(&mut app); @@ -1718,11 +1735,8 @@ end // this is the same real-file list that Rubydex indexes, and its // answers use these `FileId`s. app.sources = super::sources::drain(); - debug_assert_eq!( - app.sources.len(), - sources.len(), - "a pass registered a source after Rubydex took its snapshot" - ); + super::sources::assert_snapshot_matches(&sources, &app.sources); + app.source_index_required = !app.sources.is_empty(); drop(sources); splice_concerns_into_controllers(&mut app); // After the splice: an action a concern provides is not implicit. @@ -1767,11 +1781,10 @@ end super::concern_accessors::validate(&mut app, &concern_class_method_spans, &framework_shadow_scopes)?; collect_binary_assets(vfs, dir, &mut app); - - debug_assert!( - super::sources::drain().is_empty(), - "a pass registered a source after ingest drained the registry" - ); + // Generated re-ingest labels never register real sources. No later + // pass may append source-backed FileIds beyond the indexed snapshot. + let late_sources = super::sources::drain(); + super::sources::assert_snapshot_matches(&[], &late_sources); Ok(app) } @@ -2243,6 +2256,34 @@ fn synthesize_template_only_actions(app: &mut App) { /// emits) and the reference becomes `IntervalHelper::TIME_INTERVALS`, /// which is what Ruby's lexical lookup means and what every strict /// target can resolve. +/// A `sig { abstract… }` method is a declaration: its `def` is an empty stub +/// and the includer supplies the real one. Spliced into the includer, the +/// stub answered `nil` ahead of the implementation the includer inherits +/// (a concern declaring `abstract.returns(ActionController::Parameters) +/// def params; end` made every includer's `params` nil). The sig stays in +/// `rbs_signatures` as the module's declaration; only the empty body goes. +fn drop_abstract_stubs( + app: &mut App, + abstracts: &HashMap>, +) { + for lc in &mut app.library_classes { + let Some(names) = abstracts.get(&lc.name) else { continue }; + lc.methods.retain(|m| { + !(matches!(m.receiver, crate::dialect::MethodReceiver::Instance) + && names.contains(&m.name) + && is_empty_body(&m.body)) + }); + } +} + +fn is_empty_body(body: &crate::expr::Expr) -> bool { + match &*body.node { + crate::expr::ExprNode::Seq { exprs } => exprs.is_empty(), + crate::expr::ExprNode::Lit { value: crate::expr::Literal::Nil } => true, + _ => false, + } +} + fn splice_concerns_into_controllers(app: &mut App) { use crate::dialect::{Action, ControllerBodyItem, MethodReceiver, RenderTarget}; use crate::ty::{Row, Ty}; @@ -2447,6 +2488,23 @@ fn splice_concerns_into_controllers(app: &mut App) { filters.extend(methods); controller.body = filters; } + // The `sig` above a module's `def` describes the COPY as well: the + // includer's method is that `def`, and inference over the copy would + // otherwise replace the declared return with whatever the body + // happens to end in. + for (controller, origins) in &spliced_origin { + for (name, module) in origins { + let Some(sig) = app.rbs_signatures.get(module).and_then(|m| m.get(name)).cloned() + else { + continue; + }; + app.rbs_signatures + .entry(controller.clone()) + .or_default() + .entry(name.clone()) + .or_insert(sig); + } + } app.concern_spliced_actions = spliced_origin; } @@ -3533,25 +3591,36 @@ fn qualify_relative_controller_superclasses( } } -/// Resolve a model's `include ` against Ruby's lexical scope: -/// inside `class User`, `include Avatar` names `User::Avatar` when such -/// a module exists, and only falls back to a top-level `Avatar`. +/// Resolve an `include ` against Ruby's lexical scope. +/// +/// Inside `class User`, `include Avatar` names `User::Avatar` when such +/// a module exists, and only falls back to a top-level `Avatar`. The +/// same walk covers a namespaced owner: in `module Auth; class +/// SessionsController`, `include Concerns::RequireClient` names +/// `Auth::SessionsController::Concerns::RequireClient`, then +/// `Auth::Concerns::RequireClient`, then `Concerns::RequireClient`, +/// innermost first, the way `Module.nesting` is consulted. /// /// Campfire keeps every model concern that way — /// `app/models/user/{avatar,bannable,bot,mentionable,role,transferable}.rb` /// each declare `module User::Avatar` and friends — so the unqualified /// ClassId matched no ingested module and the whole mixed-in surface /// (`ban`, `create_bot!`, `active_bots`, `from_avatar_token`) dispatched -/// into nothing. +/// into nothing. Shopify's customer-authentication controllers keep +/// theirs one namespace up (`Auth::Concerns::RequireClient`, included +/// as `Concerns::RequireClient`), so every ivar those concerns write +/// (`@client_id`, `@redirect_uri`) read as `has no known type` in the +/// includer. /// /// Rewrites the IR node rather than resolving at each consumer: -/// `model_includes` (analyze), `splice_concerns_into_models` above, and -/// every emitter that re-emits the line then read one qualified path. -/// Narrow trigger — only when `::` actually names an -/// ingested module, so apps whose concerns live at the top level +/// `model_includes` / `controller_includes` (analyze), +/// `splice_concerns_into_models` above, and every emitter that +/// re-emits the line then read one qualified path. Narrow trigger — +/// only when some `::` actually names an ingested module, +/// so apps whose concerns live at the top level /// (`app/models/concerns/…`) are untouched. -fn qualify_relative_model_includes(app: &mut App) { - use crate::dialect::ModelBodyItem; +fn qualify_relative_includes(app: &mut App) { + use crate::dialect::{ControllerBodyItem, ModelBodyItem}; use crate::expr::ExprNode; let known: std::collections::HashSet = app @@ -3561,27 +3630,58 @@ fn qualify_relative_model_includes(app: &mut App) { .chain(app.concern_model_items.keys().cloned()) .collect(); + /// `path` as written inside `owner`, when a nesting scope above it + /// (innermost first) defines it. `None` leaves the node alone. + fn resolve( + owner: &str, + path: &[crate::ident::Symbol], + known: &std::collections::HashSet, + ) -> Option> { + let scope: Vec<&str> = owner.split("::").collect(); + let tail = path.iter().map(|s| s.as_str()).collect::>().join("::"); + for depth in (1..=scope.len()).rev() { + let candidate = format!("{}::{tail}", scope[..depth].join("::")); + if known.contains(&crate::ident::ClassId(crate::ident::Symbol::from( + candidate.as_str(), + ))) { + return Some(candidate.split("::").map(crate::ident::Symbol::from).collect()); + } + } + None + } + + fn rewrite_include( + expr: &mut crate::expr::Expr, + owner: &str, + known: &std::collections::HashSet, + ) { + let ExprNode::Send { recv: None, method, args, .. } = &mut *expr.node else { + return; + }; + if method.as_str() != "include" { + return; + } + for arg in args.iter_mut() { + let ExprNode::Const { path } = &mut *arg.node else { continue }; + if let Some(qualified) = resolve(owner, path, known) { + *path = qualified; + } + } + } + for model in &mut app.models { let model_name = model.name.0.as_str().to_string(); for item in &mut model.body { let ModelBodyItem::Unknown { expr, .. } = item else { continue }; - let ExprNode::Send { recv: None, method, args, .. } = &mut *expr.node else { - continue; - }; - if method.as_str() != "include" { - continue; - } - for arg in args.iter_mut() { - let ExprNode::Const { path } = &mut *arg.node else { continue }; - let [segment] = &path[..] else { continue }; - let qualified = crate::ident::ClassId(crate::ident::Symbol::from(format!( - "{model_name}::{}", - segment.as_str() - ))); - if known.contains(&qualified) { - *path = vec![crate::ident::Symbol::from(model_name.as_str()), segment.clone()]; - } - } + rewrite_include(expr, &model_name, &known); + } + } + + for controller in &mut app.controllers { + let name = controller.name.0.as_str().to_string(); + for item in &mut controller.body { + let ControllerBodyItem::Unknown { expr, .. } = item else { continue }; + rewrite_include(expr, &name, &known); } } @@ -3595,15 +3695,11 @@ fn qualify_relative_model_includes(app: &mut App) { for lc in &mut app.library_classes { let owner = lc.name.0.as_str().to_string(); for inc in &mut lc.includes { - if inc.0.as_str().contains("::") { - continue; - } - let qualified = crate::ident::ClassId(crate::ident::Symbol::from(format!( - "{owner}::{}", - inc.0.as_str() - ))); - if known.contains(&qualified) { - *inc = qualified; + let path: Vec = + inc.0.as_str().split("::").map(crate::ident::Symbol::from).collect(); + if let Some(qualified) = resolve(&owner, &path, &known) { + let joined = qualified.iter().map(|s| s.as_str()).collect::>().join("::"); + *inc = crate::ident::ClassId(crate::ident::Symbol::from(joined)); } } } @@ -6294,6 +6390,93 @@ fn unqualify_helper_constants( .for_each_child_mut(&mut |c| unqualify_helper_constants(c, qualified)); } +/// The block-taking methods an app adds to the routes DSL by prepending +/// (or including) a module into `ActionDispatch::Routing::Mapper`: +/// +/// ```ruby +/// ActionDispatch::Routing::Mapper.prepend(Podding::RoutingAnnotations) +/// # ... then in routes.rb: +/// routing_method :main_pod do ... end +/// ``` +/// +/// Read off the initializers and `lib/` the way Rails loads them: a +/// top-level `Mapper.prepend(Const)`, with `Const` looked up among the +/// ingested modules. The methods that take a block are the DSL scopes; +/// the module's other methods are not. +fn mapper_extension_block_methods( + app: &App, + vfs: &V, + dir: &Path, +) -> std::collections::HashSet { + const TARGET: &str = "Routing::Mapper"; + let mut modules: Vec = Vec::new(); + for sub in ["config/initializers", "lib"] { + let d = dir.join(sub); + if !vfs.is_dir(&d) { + continue; + } + for entry in read_rb_files(vfs, &d).unwrap_or_default() { + let Ok(bytes) = vfs.read(&entry) else { continue }; + // Cheap reject before parsing every initializer and lib file. + if !bytes.windows(TARGET.len()).any(|w| w == TARGET.as_bytes()) { + continue; + } + let file = entry.display().to_string(); + let result = super::prism::parse(&bytes, &file); + let src = String::from_utf8_lossy(&bytes).into_owned(); + let root = result.node(); + let Some(program) = root.as_program_node() else { continue }; + for stmt in initializer_statements(&program) { + let Some(call) = stmt.as_call_node() else { continue }; + if !matches!(super::util::constant_id_str(&call.name()), "prepend" | "include") { + continue; + } + let Some(recv) = call.receiver() else { continue }; + if constant_text(&recv, &src) + .map(|t| t.trim_start_matches("::") == format!("ActionDispatch::{TARGET}")) + != Some(true) + { + continue; + } + let Some(args) = call.arguments() else { continue }; + for arg in args.arguments().iter() { + if let Some(name) = constant_text(&arg, &src) { + modules.push(name.trim_start_matches("::").to_string()); + } + } + } + } + } + // The DSL's own scoping macros, which the routes ingest handles + // natively; a same-named override in the extension wraps them and + // still means the native thing. + const NATIVE: [&str; 9] = [ + "resources", "resource", "collection", "member", "namespace", "scope", "constraints", + "concern", "concerns", + ]; + let mut out = std::collections::HashSet::new(); + for name in modules { + // Top-level `Mapper.prepend(Const)` resolves `Const` by its + // written name; a suffix match would also pull in unrelated + // `Other::Const` modules that share only the final segment. + for lc in &app.library_classes { + let full = lc.name.0.as_str(); + if full != name { + continue; + } + for m in &lc.methods { + if m.receiver == MethodReceiver::Instance + && m.block_param.is_some() + && !NATIVE.contains(&m.name.as_str()) + { + out.insert(m.name.as_str().to_string()); + } + } + } + } + out +} + // Not left on the declaring base: an abstract base's `enum :state` reads through the subclass's own column reader, which has to know the mapping. fn inherit_enums(models: &mut [crate::dialect::Model]) { let declared: std::collections::HashMap, indexmap::IndexMap>)> = diff --git a/src/ingest/class_configuration.rs b/src/ingest/class_configuration.rs index 401082bd6..4e03b6dc8 100644 --- a/src/ingest/class_configuration.rs +++ b/src/ingest/class_configuration.rs @@ -239,7 +239,7 @@ fn expand_controller( ] { // The analyzer's parameter table has no receiver-kind key. // Refuse rather than mixing instance and class call-site types. - if surface.instance_methods.contains(&method.name) { + if surface.instance_methods.contains(&method.name) || controller.class_methods().any(|existing| existing.name == method.name) { return Err(refuse( "instance/class configuration method name collision is not supported", )); @@ -261,8 +261,8 @@ fn expand_controller( } methods.push(ControllerBodyItem::ClassMethod { method, - configuration_slot: (config.carrier.clone(), config.slot.clone()), - configuration_role: role, + configuration_slot: Some((config.carrier.clone(), config.slot.clone())), + configuration_role: Some(role), leading_comments: vec![], leading_blank_line: false, }); diff --git a/src/ingest/controller.rs b/src/ingest/controller.rs index 1b6c23a53..aeaa8cd1d 100644 --- a/src/ingest/controller.rs +++ b/src/ingest/controller.rs @@ -101,6 +101,17 @@ pub(super) fn ingest_controller_with_nesting( let Some(class) = class else { return Ok(None); }; + // No `*Controller` class in the file: the first class is only a + // controller when it descends from one. `app/controllers/` also + // holds plain objects (a redirection strategy, a request-options + // struct nested in a concern, a `FormBuilder` subclass), and + // ingesting those as controllers types their `initialize` as an + // action: no default-value typing, no ivar environment, so every + // ivar they assign reads `has no known type`. `Ok(None)` hands the + // file to the library-class path, where a class is a class. + if chosen_idx.is_none() && !descends_from_controller(&class) { + return Ok(None); + } let mut name_path = scope; name_path.extend(class_name_path(&class).ok_or_else(|| IngestError::Unsupported { @@ -122,6 +133,7 @@ pub(super) fn ingest_controller_with_nesting( let mut comments = collect_comments(&result); drain_comments_before(&mut comments, class.location().start_offset()); let mut body_items: Vec = Vec::new(); + let owner = ClassId(Symbol::from(name_path.join("::"))); let mut layout = LayoutDecl::Inherit; if let Some(class_body) = class.body() { let mut prev_end: Option = None; @@ -165,6 +177,57 @@ pub(super) fn ingest_controller_with_nesting( prev_end = Some(stmt.location().end_offset()); continue; } + // Class-side methods: `def self.x`, and every `def` in a + // `class << self`. They are methods of the controller CLASS, + // not actions — read as actions (as a `def self.x` used to + // be) they became instance methods, and a `class << self` + // block reached the expression ingester, which refused it. + let class_side = if let Some(def) = stmt.as_def_node() { + def.receiver() + .is_some_and(|r| r.as_self_node().is_some()) + .then(|| super::library_class::ingest_library_method(&def, &owner, file)) + .map(|m| m.map(|m| (vec![m], Vec::new()))) + } else if let Some(sc) = stmt.as_singleton_class_node() { + Some( + super::singleton_class::ingest_singleton_body(&sc, &owner, file, &|def| { + super::library_class::ingest_library_method(def, &owner, file) + }) + .map(|b| (b.methods, b.class_body)), + ) + } else { + None + }; + if let Some(result) = class_side { + match result { + Ok((methods, class_body)) => { + let mut leading = leading; + let mut blank = leading_blank; + let items = methods + .into_iter() + .map(|method| ControllerBodyItem::ClassMethod { + configuration_slot: None, + configuration_role: None, + method, + leading_comments: Vec::new(), + leading_blank_line: false, + }) + .chain(class_body.into_iter().map(|expr| ControllerBodyItem::Unknown { + expr, + leading_comments: Vec::new(), + leading_blank_line: false, + })); + for mut item in items { + *item.leading_comments_mut() = std::mem::take(&mut leading); + item.set_leading_blank_line(std::mem::take(&mut blank)); + body_items.push(item); + } + } + Err(err) if super::survey::is_active() => super::survey::record(&err), + Err(err) => return Err(err), + } + prev_end = Some(stmt.location().end_offset()); + continue; + } // Survey mode: an unsupported item costs itself, not the whole // controller — record the gap and keep walking (same gate as // the model walk; see ingest/model.rs). Strict mode aborts. @@ -912,3 +975,15 @@ pub fn render_template_name(args: &[Expr]) -> Option { _ => None, } } + +/// Whether `class`'s superclass names a controller: anything under +/// `ActionController::` (`Base`, `API`, `Metal`), or a constant whose +/// last segment ends in `Controller` (`ApplicationController`, +/// `Admin::SectionController`). +fn descends_from_controller(class: &ruby_prism::ClassNode<'_>) -> bool { + let Some(parent) = class.superclass().and_then(|n| constant_path_of(&n)) else { + return false; + }; + parent.first().is_some_and(|s| s == "ActionController") + || parent.last().is_some_and(|s| s.ends_with("Controller")) +} diff --git a/src/ingest/expr.rs b/src/ingest/expr.rs index 0cdd5e217..1d0d0e797 100644 --- a/src/ingest/expr.rs +++ b/src/ingest/expr.rs @@ -60,6 +60,22 @@ fn multi_write_target(node: &Node<'_>, file: &str) -> IngestResult = cp + .parent() + .and_then(|p| crate::ingest::util::constant_path_segments_strs(&p)) + .unwrap_or_default() + .into_iter() + .map(Symbol::from) + .collect(); + if let Some(id) = cp.name() { + path.push(Symbol::from(constant_id_str(&id))); + } + Ok(crate::expr::LValue::Const { path }) } else { Err(IngestError::Unsupported { file: file.into(), @@ -225,6 +241,13 @@ pub(super) fn sorbet_assertion_argument<'pr>(node: &Node<'pr>) -> Option) -> bool { + node.as_call_node() + .is_some_and(|c| matches!(constant_id_str(&c.name()), "must" | "must_because")) +} + /// The argument of a `T.absurd(x)`, which raises rather than /// evaluating to it. See the call site in `ingest_expr_strict`. fn sorbet_absurd_argument<'pr>(node: &Node<'pr>) -> Option> { @@ -327,7 +350,64 @@ fn ingest_defined_operand(node: &Node<'_>, file: &str) -> IngestResult { }) } +/// Whether a trailing `#:` comment after `node` can be a type +/// ascription of the value it evaluates to. +/// +/// Writes carry theirs on the assigned value (see the write arms), and +/// definitions have no value to ascribe. A call whose name ends in `=` +/// is an attribute or index write, and `attr_reader :x #: Integer` +/// documents the attribute rather than the call's result. +fn takes_trailing_ascription(node: &Node<'_>) -> bool { + if let Some(call) = node.as_call_node() { + let name = call.name(); + let name = constant_id_str(&name); + let is_write = name.ends_with('=') + && !matches!(name, "==" | "!=" | "<=" | ">=" | "==="); + let is_attr = call.receiver().is_none() + && matches!(name, "attr_reader" | "attr_writer" | "attr_accessor"); + return !is_write && !is_attr; + } + node.as_parentheses_node().is_some() + || node.as_local_variable_read_node().is_some() + || node.as_instance_variable_read_node().is_some() + || node.as_and_node().is_some() + || node.as_or_node().is_some() + || node.as_super_node().is_some() + || node.as_forwarding_super_node().is_some() + || node.as_yield_node().is_some() +} + fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { + if !takes_trailing_ascription(node) { + return ingest_expr_node(node, file); + } + // `x || y #: T`: both `y` and the OrNode share the same end offset. + // Only the outermost eligible expression may consume the comment. + let end = node.location().end_offset(); + let outermost = super::type_ascription::push_trailing_ascription_claim(end); + // Drop-guard: ingest paths can panic (`expect` in constant_id_str); + // a skipped pop would leave a stale end on the thread-local stack + // and silently drop later trailing `#:` ascriptions at that offset. + struct PopClaim; + impl Drop for PopClaim { + fn drop(&mut self) { + super::type_ascription::pop_trailing_ascription_claim(); + } + } + let expr = { + let _pop = PopClaim; + ingest_expr_node(node, file) + }?; + if !outermost { + return Ok(expr); + } + Ok(super::type_ascription::ascribe_trailing( + expr, + super::type_ascription::trailing_ascription(file, end), + )) +} + +fn ingest_expr_node(node: &Node<'_>, file: &str) -> IngestResult { // Byte offsets into the text registered for `file` (the exact text // prism is parsing). FileId(0) when the entry point didn't // register — spans then render message-only downstream. @@ -337,21 +417,18 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { start: loc.start_offset() as u32, end: loc.end_offset() as u32, }; - // sorbet-runtime's value-level assertions evaluate to their first - // argument: `T.let(x, String)` IS `x` at run time, and so is - // `T.must(x)` / `T.cast(x, T)` / `T.bind(self, T)`. Unwrapping here - // keeps the wrapped expression's own type flowing downstream; - // without it the site dispatches a method on `T` — a module no gem - // in the catalog defines — and every use of the value below it is - // untyped from there on. - // - // Deliberately lossy: the declared type is discarded rather than - // read. Turning an annotation into a SEED is a separate question - // with its own policy (whether it wins over inference, what a - // contradiction means, what `T.untyped` does), and the unwrap has - // to be able to land without answering any of it. + // Type ascriptions retain the value and its declared type. Nil assertions + // are behavior: until a shared nil-check lowerer exists, retain their + // explicit Unsupported annotation instead of erasing a possible raise. if let Some(inner) = sorbet_assertion_argument(node) { - return ingest_expr_strict(&inner, file); + let declared = super::type_ascription::sorbet_declared_type(node); + let value = ingest_expr_strict(&inner, file)?; + let value = if sorbet_assertion_rules_out_nil(node) { + super::type_ascription::not_nil(value) + } else { + value + }; + return Ok(super::type_ascription::ascribe(value, declared)); } // `T.absurd(x)` is NOT an assertion that evaluates to its argument: @@ -393,7 +470,22 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { vec![] }; let recv = match c.receiver() { - Some(r) => Some(ingest_expr(&r, file)?), + Some(r) => { + let value = ingest_expr(&r, file)?; + // `self #: as untyped` above a leading-dot line: + // the assertion sits after the receiver and before + // the `.method` it is the receiver of. + // Receivers that read their own trailing comment + // (see `takes_trailing_ascription`) already did. + if takes_trailing_ascription(&r) { + Some(value) + } else { + Some(super::type_ascription::ascribe_trailing( + value, + super::type_ascription::receiver_rbs_assertion(file, r.location().end_offset()), + )) + } + } None => None, }; let block = match c.block() { @@ -909,6 +1001,7 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { } n if n.as_lambda_node().is_some() => { let l = n.as_lambda_node().unwrap(); + refuse_block_capture(l.parameters(), file)?; let params = block_param_names(l.parameters()); let mut rest_param = block_rest_param(l.parameters()); let body = match l.body() { @@ -983,6 +1076,7 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { let raw = constant_id_str(&w.name()); let name = raw.strip_prefix('@').unwrap_or(raw); let value = ingest_expr(&w.value(), file)?; + let value = super::type_ascription::ascribe_trailing(value, super::type_ascription::trailing_ascription(file, loc.end_offset())); ExprNode::Assign { target: crate::expr::LValue::Ivar { name: Symbol::from(name) }, value, @@ -992,6 +1086,7 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { let w = n.as_local_variable_write_node().unwrap(); let name = Symbol::from(constant_id_str(&w.name())); let value = ingest_expr(&w.value(), file)?; + let value = super::type_ascription::ascribe_trailing(value, super::type_ascription::trailing_ascription(file, loc.end_offset())); ExprNode::Assign { target: crate::expr::LValue::Var { id: crate::ident::VarId(0), name }, value, @@ -1132,6 +1227,7 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { let raw = constant_id_str(&w.name()); let name = Symbol::from(raw.strip_prefix('@').unwrap_or(raw)); let value = ingest_expr(&w.value(), file)?; + let value = super::type_ascription::ascribe_trailing(value, super::type_ascription::trailing_ascription(file, loc.end_offset())); ExprNode::OpAssign { target: crate::expr::LValue::Ivar { name }, op: crate::expr::OpAssignOp::OrOr, @@ -2045,6 +2141,20 @@ fn ingest_expr_strict(node: &Node<'_>, file: &str) -> IngestResult { .into(), }); } + // `class << self … end` at expression position (inside an + // `included do` / `Class.new do` block, where no class-body walk + // classifies it). Ruby evaluates the body with `self` the + // singleton class and the block's value is its last statement + // (nil when empty). The `def`s in it lift to nil exactly as a + // bare `def` does above; the statements around them keep their + // own expressions, so a constant or call in the block is not lost. + n if n.as_singleton_class_node().is_some() => { + let sc = n.as_singleton_class_node().unwrap(); + match sc.body() { + Some(body) => return ingest_expr(&body, file), + None => ExprNode::Lit { value: Literal::Nil }, + } + } other => { return Err(IngestError::Unsupported { file: file.into(), @@ -2660,6 +2770,7 @@ fn ingest_call_block( // standalone-lambda-vs-attached-block distinction is lost, // which is immaterial once it sits in block-argument position. if let Some(lam) = expr.as_lambda_node() { + refuse_block_capture(lam.parameters(), file)?; let params = block_param_names(lam.parameters()); let mut rest_param = block_rest_param(lam.parameters()); let body = match lam.body() { @@ -2726,6 +2837,7 @@ fn ingest_call_block( /// hands this the same `BlockNode` shape one level deeper (inside the /// `proc`/`lambda` call's own block). fn ingest_block_node_as_lambda(b: &ruby_prism::BlockNode<'_>, file: &str) -> IngestResult { + refuse_block_capture(b.parameters(), file)?; let params = block_param_names(b.parameters()); let mut rest_param = block_rest_param(b.parameters()); let body = match b.body() { @@ -2777,6 +2889,20 @@ fn block_style_from_opening(bytes: &[u8]) -> crate::expr::BlockStyle { } } +/// Authored block captures are not retained by the Lambda ingest below. +/// Refuse before dropping a binding that could shadow an outer local. +fn refuse_block_capture(params_node: Option>, file: &str) -> IngestResult<()> { + if params_node.and_then(|node| node.as_block_parameters_node()) + .and_then(|node| node.parameters()).is_some_and(|params| params.block().is_some()) + { + return Err(IngestError::Unsupported { + file: file.into(), + message: "block parameter capture requires preserved Lambda signature semantics".into(), + }); + } + Ok(()) +} + fn block_param_names(params_node: Option>) -> Vec { let Some(params_node) = params_node else { return vec![] }; if params_node.as_it_parameters_node().is_some() { diff --git a/src/ingest/generated_helpers.rs b/src/ingest/generated_helpers.rs new file mode 100644 index 000000000..d2ae6e1a7 --- /dev/null +++ b/src/ingest/generated_helpers.rs @@ -0,0 +1,175 @@ +//! Helper objects an app builds at load time from a table it keeps in +//! YAML — core's `WebUrlHelpers`: +//! +//! ```ruby +//! WebUrlHelpers = WebUrlHelpersFactory.create( +//! YAML.load_file(File.join(Rails.root, "config/web_paths.yml"))["paths"], +//! ) +//! +//! # the factory: +//! Class.new do +//! paths.each do |config_name, path| +//! define_method "#{config_name}_path" do |params: {}, **args| … end +//! define_method("#{config_name}_url") do |host: nil, **args| … end +//! end +//! end.new +//! ``` +//! +//! Nothing here is a `def`, so a reader of the source sees no method at +//! all, and every `WebUrlHelpers.admin_settings_path` is a call to +//! nothing. The methods exist because two facts meet: the FACTORY says +//! what a table entry defines (one method per `define_method` whose name +//! interpolates the entry's key — the suffixes), and the constant's +//! initializer says WHICH table (a YAML file and a key in it). Reading +//! both gives the exact method set, with no guess about the names. +//! +//! The result is a registry fact for the analyzer +//! ([`crate::App::generated_helper_methods`]); it emits nothing, because +//! the constant's own initializer still runs at load and builds the +//! object. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use ruby_prism::Node; + +use crate::App; +use crate::dialect::MethodReceiver; +use crate::expr::{Expr, ExprNode, InterpPart}; +use crate::ident::{ClassId, Symbol}; +use crate::vfs::Vfs; + +use super::util::{constant_id_str, string_value}; + +/// Every `NAME = Factory.method(YAML.load_file()["key"])` at the +/// top level of a file under `roots`, resolved to the methods the +/// factory defines per table entry. `app.library_classes` must already +/// hold the factory (it is read as IR). +pub(super) fn ingest_generated_helpers( + app: &App, + vfs: &V, + dir: &Path, + files: &[std::path::PathBuf], +) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for file in files { + let Ok(bytes) = vfs.read(file) else { continue }; + if !bytes.windows(b"YAML.load_file".len()).any(|w| w == b"YAML.load_file") { + continue; + } + let result = super::prism::parse(&bytes, &file.display().to_string()); + let src = String::from_utf8_lossy(&bytes).into_owned(); + let root = result.node(); + let Some(program) = root.as_program_node() else { continue }; + for stmt in program.statements().body().iter() { + let Some(cw) = stmt.as_constant_write_node() else { continue }; + let Some(call) = cw.value().as_call_node() else { continue }; + let Some(factory_recv) = call.receiver() else { continue }; + let Some(factory) = const_text(&factory_recv, &src) else { continue }; + let factory_method = constant_id_str(&call.name()); + let Some(arg) = call.arguments().and_then(|a| a.arguments().iter().next()) else { + continue; + }; + let Some((yaml_path, key)) = yaml_table_ref(&arg, &src) else { continue }; + let Some(suffixes) = factory_suffixes(app, &factory, factory_method) else { + continue; + }; + let Ok(text) = vfs.read_to_string(&dir.join(&yaml_path)) else { continue }; + let Ok(doc) = serde_yaml_ng::from_str::(&text) else { + continue; + }; + let Some(table) = doc.get(key.as_str()).and_then(|t| t.as_mapping()) else { + continue; + }; + let names = out + .entry(ClassId(Symbol::from(cw.name().as_slice().iter().map(|b| *b as char).collect::()))) + .or_default(); + for entry in table.keys().filter_map(|k| k.as_str()) { + for suffix in &suffixes { + names.insert(Symbol::from(format!("{entry}{suffix}"))); + } + } + } + } + out.retain(|_, names| !names.is_empty()); + out +} + +fn const_text(node: &Node<'_>, src: &str) -> Option { + if node.as_constant_read_node().is_none() && node.as_constant_path_node().is_none() { + return None; + } + let loc = node.location(); + Some(src[loc.start_offset()..loc.end_offset()].trim_start_matches("::").to_string()) +} + +/// `YAML.load_file(File.join(Rails.root, "rel/path.yml"))["key"]` (or +/// `Rails.root.join("rel/path.yml")`) → (`"rel/path.yml"`, `"key"`). +fn yaml_table_ref(node: &Node<'_>, src: &str) -> Option<(String, String)> { + let index = node.as_call_node()?; + if constant_id_str(&index.name()) != "[]" { + return None; + } + let key = string_value(&index.arguments()?.arguments().iter().next()?)?; + let load = index.receiver()?.as_call_node()?; + if constant_id_str(&load.name()) != "load_file" { + return None; + } + if const_text(&load.receiver()?, src).as_deref() != Some("YAML") { + return None; + } + let path_expr = load.arguments()?.arguments().iter().next()?; + if let Some(s) = string_value(&path_expr) { + return Some((s, key)); + } + let join = path_expr.as_call_node()?; + if constant_id_str(&join.name()) != "join" { + return None; + } + // The path is the last string of the join: `File.join(Rails.root, "x")` + // and `Rails.root.join("x")` both end in it. + let rel = join + .arguments()? + .arguments() + .iter() + .filter_map(|a| string_value(&a)) + .last()?; + Some((rel, key)) +} + +/// The name suffixes the factory's `define_method`s add to a table +/// key: `["_path", "_url"]` for `define_method "#{k}_path"` and +/// `define_method("#{k}_url")`. None when the factory is not an ingested +/// class method or defines nothing by interpolated name. +fn factory_suffixes(app: &App, factory: &str, method: &str) -> Option> { + let suffix = format!("::{factory}"); + let class = app.library_classes.iter().find(|c| { + let n = c.name.0.as_str(); + n == factory || n.ends_with(&suffix) + })?; + let def = class + .methods + .iter() + .find(|m| m.receiver == MethodReceiver::Class && m.name.as_str() == method)?; + let mut out = Vec::new(); + collect_define_method_suffixes(&def.body, &mut out); + (!out.is_empty()).then_some(out) +} + +fn collect_define_method_suffixes(expr: &Expr, out: &mut Vec) { + if let ExprNode::Send { recv: None, method, args, .. } = &*expr.node { + if method.as_str() == "define_method" { + if let Some(Expr { node, .. }) = args.first() { + if let ExprNode::StringInterp { parts } = &**node { + // `"#{key}_path"`: one embedded value, then literal text. + if let [InterpPart::Expr { .. }, InterpPart::Text { value }] = parts.as_slice() { + if !out.contains(value) { + out.push(value.clone()); + } + } + } + } + } + } + expr.node.for_each_child(&mut |c| collect_define_method_suffixes(c, out)); +} diff --git a/src/ingest/graphql_ruby.rs b/src/ingest/graphql_ruby.rs index 1eb65c7b0..9d8eec059 100644 --- a/src/ingest/graphql_ruby.rs +++ b/src/ingest/graphql_ruby.rs @@ -912,6 +912,12 @@ fn synthesize( } }; fn mark(expr: &mut Expr, span: Span) { + // The method was parsed from , not this source location. + // Its constants name exact classes selected by the field declaration; + // the borrowed span only locates diagnostics, never Rubydex answers. + if matches!(&*expr.node, ExprNode::Const { .. }) { + expr.decisions |= crate::expr::GENERATED_CONST_REF; + } expr.span = span; expr.node.for_each_child_mut(&mut |child| mark(child, span)); } diff --git a/src/ingest/library_class.rs b/src/ingest/library_class.rs index 07a9abae2..e38f6539c 100644 --- a/src/ingest/library_class.rs +++ b/src/ingest/library_class.rs @@ -1045,7 +1045,7 @@ fn struct_base_class(owner: &ClassId, members: &[Symbol]) -> LibraryClass { /// `is_module: true` and `parent: None`. The `is_module` flag is /// load-bearing: callers using `include` on the result need it to be /// emitted as `module`, not `class`, or Ruby will raise TypeError. -fn library_class_from_module_node_with_scope( +pub(super) fn library_class_from_module_node_with_scope( module: &ruby_prism::ModuleNode<'_>, scope: &[String], file: &str, @@ -1186,7 +1186,7 @@ impl DeclBody { /// `include T::Struct::ActsAsComparable` is deliberately NOT here: it /// gives a struct its `==`, which is behavior, and it goes when the /// struct itself is lowered. -fn is_sorbet_annotation_mixin(call: &ruby_prism::CallNode<'_>) -> bool { +pub(super) fn is_sorbet_annotation_mixin(call: &ruby_prism::CallNode<'_>) -> bool { let name = call.name(); if !matches!(constant_id_str(&name), "extend" | "include") { return false; @@ -1232,7 +1232,7 @@ fn constant_path_written(path: &ruby_prism::ConstantPathNode<'_>) -> String { /// constructor and a reader, not an annotation, and deleting it would /// leave a class that cannot be built. Those need lowering, not /// dropping. -const SORBET_ANNOTATIONS: &[&str] = &[ +pub(super) const SORBET_ANNOTATIONS: &[&str] = &[ "sig", "abstract!", "interface!", @@ -1665,7 +1665,12 @@ fn walk_decl_body_with_visibility<'pr>( }); } if let Some(call) = stmt.as_call_node() { - if call.receiver().is_none() { + // `include X` and `self.include(X)` are the same call: the + // explicit-self spelling is how a file that must satisfy a + // type checker writes it (`self #: as untyped` on the line + // before `.include(Rails.application.routes.url_helpers)`), + // and it means the body's own self, exactly as the bare form. + if call.receiver().is_none_or(|r| r.as_self_node().is_some()) { let kw = constant_id_str(&call.name()); // `class_methods do … end` — ActiveSupport::Concern's // class-side block: its defs become class methods of @@ -1689,7 +1694,7 @@ fn walk_decl_body_with_visibility<'pr>( // replays it, the rest see a class body they // cannot model. if args.arguments().iter().any(|arg| { - constant_path_of(&arg).is_none() && !is_rails_url_helpers_chain(&arg) + constant_path_of(&arg).is_none() && !crate::ingest::util::is_rails_url_helpers_chain(&arg) }) { if let Ok(e) = ingest_expr(&stmt, file) { out.unknown_calls.push(e); @@ -1711,7 +1716,7 @@ fn walk_decl_body_with_visibility<'pr>( continue; } out.includes.push(ClassId(Symbol::from(path.join("::")))); - } else if is_rails_url_helpers_chain(&arg) { + } else if crate::ingest::util::is_rails_url_helpers_chain(&arg) { // `include Rails.application.routes. // url_helpers` (lobsters' Routes class, // inside `class << self`) — the whole @@ -1946,35 +1951,6 @@ fn walk_decl_body_with_visibility<'pr>( Ok(out) } -/// Match the `Rails.application.routes.url_helpers` receiver chain (a -/// nested CallNode ladder rooted at the `Rails` constant). -fn is_rails_url_helpers_chain(node: &ruby_prism::Node<'_>) -> bool { - let mut expected = ["url_helpers", "routes", "application"].iter(); - let mut cur = match node.as_call_node() { - Some(c) => c, - None => return false, - }; - loop { - let Some(want) = expected.next() else { return false }; - if cur.name().as_slice() != want.as_bytes() { - return false; - } - match cur.receiver() { - Some(r) => { - if let Some(cr) = r.as_constant_read_node() { - return expected.next().is_none() - && cr.name().as_slice() == b"Rails"; - } - match r.as_call_node() { - Some(next) => cur = next, - None => return false, - } - } - None => return false, - } - } -} - /// Only declared cattr/mattr reads use the existing class-ivar approximation. /// Ordinary class-variable reads retain native shared inheritance storage. fn normalize_classvars_to_ivars(e: &mut Expr, class_attributes: &HashSet) { @@ -2003,7 +1979,7 @@ pub(super) fn alias_keyword_name(node: &ruby_prism::Node<'_>) -> Option .map(|call| constant_id_str(&call.name()).to_string()) } -fn alias_source( +pub(super) fn alias_source( call: &ruby_prism::CallNode<'_>, methods: &[MethodDef], class_side: bool, @@ -2285,15 +2261,18 @@ pub(super) fn ingest_library_method( // `&block` rides in `MethodDef.block_param`, not the flat list — // it occupies the call-site `block:` slot, never `args:`. Mirrors // the runtime_src split (see runtime_src::method_params). - let block_param = def.parameters().and_then(|pn| pn.block()).map(|block| { - let name = block - .name() - .and_then(|loc| std::str::from_utf8(loc.as_slice()).ok()) - // Ruby 3.4 anonymous block param (`def f(&)`) — synthesize a - // name so body-side bare-`&` forwarding (`__blk`) binds. - .unwrap_or("__blk"); - Param::positional(Symbol::from(name)) - }); + let block_param = def + .parameters() + .and_then(|pn| pn.block()) + .map(|block| { + let name = block + .name() + .and_then(|loc| std::str::from_utf8(loc.as_slice()).ok()) + // Ruby 3.4 anonymous block param (`def f(&)`) — synthesize a + // name so body-side bare-`&` forwarding (`__blk`) binds. + .unwrap_or("__blk"); + Param::positional(Symbol::from(name)) + }); let body = match def.body() { Some(b) => ingest_expr(&b, file)?, @@ -3202,6 +3181,7 @@ pub(in crate::ingest) fn ingest_concern_model_items_with_constants( &call, file, &[], + &super::model::ClassConsts::default(), &resolve_constant, ) { Ok(Some(super::model::ClassBodyExpansion::DelegatedType(expanded))) => { @@ -3209,11 +3189,13 @@ pub(in crate::ingest) fn ingest_concern_model_items_with_constants( continue; } Ok(Some(super::model::ClassBodyExpansion::Enum(expanded))) => { - enums.push(ConcernEnumDecl { - column: expanded.column, - mapping: expanded.mapping, - default: expanded.default, - }); + if let Some(mapping) = expanded.mapping { + enums.push(ConcernEnumDecl { + column: expanded.column, + mapping, + default: expanded.default, + }); + } items.extend(expanded.items); continue; } diff --git a/src/ingest/mod.rs b/src/ingest/mod.rs index 3337a8746..c78b39b33 100644 --- a/src/ingest/mod.rs +++ b/src/ingest/mod.rs @@ -25,6 +25,7 @@ pub mod controller; pub mod expr; pub mod fixture; pub(crate) mod forwarding; +pub mod generated_helpers; pub mod jbuilder; pub mod library_class; pub mod channel_callbacks; @@ -36,14 +37,17 @@ mod delegated_type; mod model_macros; pub mod on_load_reopen; pub mod prism; +pub mod rbi; pub mod rate_limit; pub mod roda_app; pub mod routes; +pub mod singleton_class; pub mod schema; pub mod sequel_migration; pub mod sequel_model; pub mod sorbet_sig; pub mod sources; +pub mod type_ascription; pub mod sql_functions; pub mod structure_sql; pub mod survey; diff --git a/src/ingest/model.rs b/src/ingest/model.rs index def875cef..5a5f13f43 100644 --- a/src/ingest/model.rs +++ b/src/ingest/model.rs @@ -72,6 +72,30 @@ pub fn ingest_table_name_prefixes(source: &[u8], file: &str) -> TablePrefixes { out } +/// `self.table_name_prefix = "three_d_secure_"` in a model body: the +/// class's own prefix, which wins over any namespace module's. +fn explicit_table_prefix(body: ruby_prism::Node<'_>) -> Option { + explicit_class_setting(body, "table_name_prefix=") +} + +fn explicit_class_setting(body: ruby_prism::Node<'_>, setter: &str) -> Option { + for stmt in flatten_statements(body) { + let Some(call) = stmt.as_call_node() else { continue }; + if constant_id_str(&call.name()) != setter { + continue; + } + if call.receiver().and_then(|r| r.as_self_node()).is_none() { + continue; + } + let args = call.arguments()?; + let first = args.arguments().iter().next()?; + if let Some(name) = string_value(&first).or_else(|| symbol_value(&first)) { + return Some(name); + } + } + None +} + /// Parse a single model file. The first class definition is treated as the /// model; any schema-derived attributes are filled in from `schema`. pub fn ingest_model( @@ -132,8 +156,8 @@ pub(super) fn ingest_model_with_enum_constants( } else { let mut segments: Vec<&str> = class_name.as_str().split("::").collect(); segments.pop(); - let mut prefix = String::new(); - while !segments.is_empty() { + let mut prefix = class.body().and_then(explicit_table_prefix).unwrap_or_default(); + while prefix.is_empty() && !segments.is_empty() { if let Some(p) = prefixes.get(&segments.join("::")) { prefix = p.clone(); break; @@ -164,28 +188,11 @@ pub(super) fn ingest_model_with_enum_constants( let visibility = Visibility::resolve(class.body().as_ref(), file, None)?; if let Some(class_body) = class.body() { let mut prev_end: Option = None; - // Constants the class body assigns, for `enum :x, CONST`. + // Constants the class body assigns, for `enum :x, CONST` and for + // labels spelled with a constant (`NAME => NAME`). let stmts = flatten_statements(class_body); - let mut class_consts: std::collections::HashMap> = - std::collections::HashMap::new(); - for stmt in &stmts { - if let Some(cw) = stmt.as_constant_write_node() { - // Keep the existing class-local folding boundary: qualified - // cross-file constants are enum inputs, not arbitrary aliases. - if let Some(labels) = enum_label_values(&cw.value(), &|node| { - class_consts.get(constant_id_str(&node.as_constant_read_node()?.name())).cloned() - }) { - class_consts.insert(constant_id_str(&cw.name()).to_string(), labels); - } - } - } - let resolve_constant = |node: &Node<'_>| { - if let Some(read) = node.as_constant_read_node() { - class_consts.get(constant_id_str(&read.name())).cloned() - } else { - enum_constants.resolve(node, &enum_owners) - } - }; + let class_consts = ClassConsts::collect(&stmts, file); + let resolve_constant = |node: &Node<'_>| enum_constants.resolve(node, &enum_owners); for statement in stmts { let definition = visibility::definition(&statement).map(|d| d.as_node()); let stmt = definition.as_ref().unwrap_or(&statement); @@ -223,7 +230,7 @@ pub(super) fn ingest_model_with_enum_constants( // scope + predicate + bang writer per label, so it expands // in the walk loop for the same reason `class << self` does. if let Some(call) = stmt.as_call_node() { - match expand_class_body_dsl(&call, file, &leading, &resolve_constant) { + match expand_class_body_dsl(&call, file, &leading, &class_consts, &resolve_constant) { Ok(Some(ClassBodyExpansion::DelegatedType(expanded))) => { let mut blank = leading_blank; for mut item in expanded { @@ -237,7 +244,9 @@ pub(super) fn ingest_model_with_enum_constants( if let Some(d) = expanded.default { enum_defaults.insert(expanded.column.clone(), d); } - enums.insert(expanded.column, expanded.mapping); + if let Some(mapping) = expanded.mapping { + enums.insert(expanded.column, mapping); + } let mut blank = leading_blank; for mut item in expanded.items { item.set_leading_blank_line(std::mem::take(&mut blank)); @@ -292,16 +301,27 @@ pub(super) fn ingest_model_with_enum_constants( }); } if let Some(sc) = stmt.as_singleton_class_node() { - match ingest_singleton_class_methods(&sc, file, &visibility) { - Ok(methods) => { + match ingest_singleton_class_body(&sc, &owner, file, &visibility) { + Ok(singleton) => { let mut leading = leading; let mut blank = leading_blank; - for method in methods { - let mut item = ModelBodyItem::Method { + let items = singleton + .methods + .into_iter() + .map(|method| ModelBodyItem::Method { method, - leading_comments: std::mem::take(&mut leading), + leading_comments: Vec::new(), leading_blank_line: false, - }; + }) + .chain(singleton.class_body.into_iter().map(|expr| { + ModelBodyItem::Unknown { + expr, + leading_comments: Vec::new(), + leading_blank_line: false, + } + })); + for mut item in items { + *item.leading_comments_mut() = std::mem::take(&mut leading); item.set_leading_blank_line(std::mem::take(&mut blank)); body.push(item); } @@ -947,9 +967,13 @@ pub(super) fn ingest_model_body_item( /// :deactivated)` sites) is a separate, type-aware pass. pub(super) struct EnumExpansion { pub column: Symbol, - /// Label → stored value, in declaration order. - pub mapping: Vec<(String, Literal)>, - /// The stored value `default:` names. + /// Label → stored value, in declaration order. `None` when some + /// stored value is only known at runtime (`Status::ARCHIVED` from + /// another file): the scopes and predicates still carry it as an + /// expression, but the label → literal table other passes rewrite + /// hand-written sites with (`where(status: :archived)`) must be + /// complete or absent, so a partial one is never recorded. + pub mapping: Option>, pub default: Option, pub items: Vec, } @@ -976,6 +1000,7 @@ pub(super) fn expand_class_body_dsl( call: &ruby_prism::CallNode<'_>, file: &str, leading_comments: &[Comment], + class_consts: &ClassConsts, resolve_constant: &impl Fn(&Node<'_>) -> Option>, ) -> IngestResult> { match super::delegated_type::expand_delegated_type_decl( @@ -987,7 +1012,7 @@ pub(super) fn expand_class_body_dsl( Some(items) => return Ok(Some(ClassBodyExpansion::DelegatedType(items))), None => {} } - match expand_enum_decl(call, file, leading_comments, resolve_constant)? { + match expand_enum_decl(call, file, leading_comments, class_consts, resolve_constant)? { Some(exp) => Ok(Some(ClassBodyExpansion::Enum(exp))), None => Ok(None), } @@ -1115,6 +1140,7 @@ pub(super) fn expand_enum_decl( call: &ruby_prism::CallNode<'_>, file: &str, leading_comments: &[crate::dialect::Comment], + class_consts: &ClassConsts, resolve_constant: &impl Fn(&Node<'_>) -> Option>, ) -> IngestResult> { use crate::dialect::{MethodDef, MethodReceiver, Scope}; @@ -1133,22 +1159,33 @@ pub(super) fn expand_enum_decl( return Ok(None) }; // `enum :status, STATUSES` — the mapping named by a constant the class - // body assigned above (`STATUSES = %i[…].freeze`) — and everything - // else `enum_label_values` resolves, now including a computed - // mapping over that same constant (`enum :x, STATUSES.map { |s| - // [s, s.to_s] }.to_h`). - // A Sorbet serialize-pair returns a HASH, not an array operand for the - // legacy recursive transformations. Admit it only as a complete mapping. - let labels = serialized_enum_receiver(&mapping_node) - .and_then(|receiver| resolve_constant(&receiver)) - .or_else(|| enum_label_values(&mapping_node, resolve_constant)) - .ok_or_else(|| enum_mapping_error(file, &column))?; + // body assigned above (`STATUSES = %i[…].freeze`). + let labels = if let Some(receiver) = serialized_enum_receiver(&mapping_node) { + resolve_constant(&receiver).map(|mapping| mapping.into_iter().map(|(label, value)| (label, EnumStored::Lit(value))).collect()) + } else { match mapping_node.as_constant_read_node() { + Some(cr) => class_consts.mappings.get(constant_id_str(&cr.name())).cloned(), + None => enum_label_values(&mapping_node, file, class_consts, resolve_constant)?, + }} + .ok_or_else(|| IngestError::Unsupported { + file: file.into(), + message: format!( + "enum :{} mapping must be an array or hash literal (or `%w[…].index_by(&:itself)`)", + column + ), + })?; let all_labels = labels.clone(); - let default = default_label.and_then(|d| labels.iter().find(|(l, _)| *l == d).map(|(_, v)| v.clone())); + let default = default_label.and_then(|d| labels.iter().find(|(l, _)| *l == d).and_then(|(_, v)| { + match v { EnumStored::Lit(value) => Some(value.clone()), EnumStored::Expr(_) => None } + })); + let literal_mapping = all_labels.iter().map(|(label, stored)| match stored { + EnumStored::Lit(value) => Some((label.clone(), value.clone())), + EnumStored::Expr(_) => None, + }).collect::>>(); + let reads_label = literal_mapping.as_ref().is_some_and(|m| crate::dialect::enum_mapping_reads_label(m)); // A label that is not a Ruby identifier (`32bits`, `64bits`) has no // predicate, scope or bang writer Ruby could name: Rails reaches them // through `send`, which the emit has no equivalent of. Skipped. - let labels: Vec<(String, Literal)> = labels + let labels: Vec<(String, EnumStored)> = labels .into_iter() .filter(|(l, _)| { l.chars().next().map_or(false, |c| c.is_ascii_alphabetic() || c == '_') @@ -1171,14 +1208,16 @@ pub(super) fn expand_enum_decl( ) }; let mut items = Vec::new(); - // Not the stored value: the reader answers the label, as Rails' does. - let reads_label = crate::dialect::enum_mapping_reads_label(&labels); - for (label, value) in labels.iter().cloned() { + for (label, stored) in labels.iter().cloned() { let base = format!("{prefix}{label}{suffix}"); + let stored_expr = || match &stored { + EnumStored::Lit(value) => Expr::new(span, ExprNode::Lit { value: value.clone() }), + EnumStored::Expr(e) => e.clone(), + }; let pair = Expr::new( span, ExprNode::Hash { - entries: vec![(sym(&column), Expr::new(span, ExprNode::Lit { value: value.clone() }))], + entries: vec![(sym(&column), stored_expr())], kwargs: true, }, ); @@ -1255,6 +1294,13 @@ pub(super) fn expand_enum_decl( }); } if instance_methods { + // Aliases share their stored value and therefore their + // canonical reader label. Every alias predicate is true + // for that value, even though the reader names only the first. + let predicate_label = literal_mapping.as_ref().and_then(|mapping| { + let EnumStored::Lit(value) = &stored else { return None }; + mapping.iter().find(|(_, v)| v == value).map(|(label, _)| label.clone()) + }).unwrap_or_else(|| label.clone()); items.push(method_def( format!("{base}?"), Expr::new( @@ -1262,22 +1308,9 @@ pub(super) fn expand_enum_decl( ExprNode::Send { recv: Some(column_read()), method: Symbol::from("=="), - args: vec![Expr::new( - span, - ExprNode::Lit { - value: if reads_label { - Literal::Str { - value: all_labels - .iter() - .find(|(_, stored)| stored == &value) - .map(|(canonical, _)| canonical.clone()) - .unwrap_or_else(|| label.clone()), - } - } else { - value - }, - }, - )], + args: vec![if reads_label { + Expr::new(span, ExprNode::Lit { value: Literal::Str { value: predicate_label } }) + } else { stored_expr() }], block: None, parenthesized: false, }, @@ -1304,7 +1337,7 @@ pub(super) fn expand_enum_decl( .map(|(label, value)| { ( Expr::new(span, ExprNode::Lit { value: Literal::Str { value: label.clone() } }), - Expr::new(span, ExprNode::Lit { value: value.clone() }), + match value { EnumStored::Lit(value) => Expr::new(span, ExprNode::Lit { value: value.clone() }), EnumStored::Expr(expr) => expr.clone() }, ) }) .collect(), @@ -1337,143 +1370,269 @@ pub(super) fn expand_enum_decl( leading_comments: mapping_comments, leading_blank_line: false, }); - Ok(Some(EnumExpansion { column: Symbol::from(column.as_str()), mapping: labels, default, items })) + let mapping = all_labels + .iter() + .map(|(l, v)| match v { + EnumStored::Lit(lit) => Some((l.clone(), lit.clone())), + EnumStored::Expr(_) => None, + }) + .collect::>>(); + Ok(Some(EnumExpansion { column: Symbol::from(column.as_str()), mapping, default, items })) +} + +/// The stored value of one enum label. A literal is what Rails' own +/// table holds and what other passes rewrite hand-written sites with; an +/// expression (`Status::ARCHIVED`, `Eligibility::Paused.serialize`) +/// is a value only the running program knows. The generated scope and +/// predicate use it either way — they compare against the column, not +/// against a constant folded at ingest. +#[derive(Clone)] +enum EnumStored { + Lit(Literal), + Expr(Expr), +} + +/// Constants a class body assigns that an `enum` declaration below them +/// may name: the mapping itself (`enum :status, STATUSES`), a label +/// (`NAME => NAME`, `"#{PENDING}": 1`) or a stored value +/// (`active: ACTIVE`). Collected in source order so an alias +/// (`ASSOCIATE = POS_USER`) resolves through the constant it names. +#[derive(Default)] +pub(super) struct ClassConsts { + /// `NAME = "x"`, `NAME = :x`, `NAME = 3`, `NAME = OTHER`. + scalars: std::collections::HashMap, + /// `NAME = %i[…]` / `{ … }`, as the mapping an enum would build. + mappings: std::collections::HashMap>, + /// `NAME = %i[…]` / `%w[…]` — the bare labels, for the idioms that + /// derive a mapping from them (`NAME.index_by(&:to_s)`). + labels: std::collections::HashMap>, +} + +impl ClassConsts { + pub(super) fn collect(stmts: &[Node<'_>], file: &str) -> Self { + let mut out = ClassConsts::default(); + for stmt in stmts { + let Some(cw) = stmt.as_constant_write_node() else { continue }; + let name = constant_id_str(&cw.name()).to_string(); + let value = cw.value(); + if let Some(lit) = out.scalar(&value) { + out.scalars.insert(name, lit); + continue; + } + if let Some(labels) = label_list(&value) { + out.labels.insert(name.clone(), labels); + } + // A failure to ingest a value is not this pass's to report: + // the constant is simply not usable as a mapping, and the + // enum that names it says so. + if let Ok(Some(mapping)) = enum_label_values(&value, file, &out, &|_| None) { + out.mappings.insert(name, mapping); + } + } + out + } + + /// A scalar the class body can fold: a string, a symbol (stored as + /// its string, which is what a string column holds), an integer, + /// or a constant already folded. `.freeze` is transparent. + fn scalar(&self, node: &Node<'_>) -> Option { + if let Some(call) = node.as_call_node() { + if constant_id_str(&call.name()) == "freeze" && call.arguments().is_none() { + return self.scalar(&call.receiver()?); + } + return None; + } + if let Some(s) = string_value(node).or_else(|| symbol_value(node)) { + return Some(Literal::Str { value: s }); + } + if let Some(i) = node.as_integer_node() { + return Some(Literal::Int { value: super::util::integer_i64(&i.value())? }); + } + if let Some(cr) = node.as_constant_read_node() { + return self.scalars.get(constant_id_str(&cr.name())).cloned(); + } + None + } + + /// A label spelled as a symbol, a string, a folded constant, or an + /// interpolation of folded constants (`"#{UNSET_STATUS}": 0`). + fn label(&self, node: &Node<'_>) -> Option { + if let Some(Literal::Str { value }) = self.scalar(node) { + return Some(value); + } + let parts: Vec> = if let Some(d) = node.as_interpolated_symbol_node() { + d.parts().iter().collect() + } else if let Some(d) = node.as_interpolated_string_node() { + d.parts().iter().collect() + } else { + return None; + }; + let mut out = String::new(); + for part in parts { + if let Some(s) = string_value(&part) { + out.push_str(&s); + } else if let Some(e) = part.as_embedded_statements_node() { + let body: Vec> = e.statements()?.body().iter().collect(); + let [only] = body.as_slice() else { return None }; + match self.scalar(only)? { + Literal::Str { value } => out.push_str(&value), + Literal::Int { value } => out.push_str(&value.to_string()), + _ => return None, + } + } else { + return None; + } + } + Some(out) + } +} + +/// The bare labels of a `%i[…]` / `%w[…]` array (optionally `.freeze`d). +fn label_list(node: &Node<'_>) -> Option> { + if let Some(call) = node.as_call_node() { + if constant_id_str(&call.name()) == "freeze" && call.arguments().is_none() { + return label_list(&call.receiver()?); + } + } + node.as_array_node()? + .elements() + .iter() + .map(|el| symbol_value(&el).or_else(|| string_value(&el))) + .collect() } /// Label → stored value for an `enum` mapping. An array literal maps by /// index the way Rails does (`%i[active deactivated]` → 0, 1); a hash /// literal carries its own values; `%w[…].index_by(&:itself)` — the -/// idiom for a string-backed column — maps each label to itself; a bare -/// `CONST` resolves through `class_consts` (the class body's own -/// `CONST = %i[…]` assignments, collected before this ever runs); a -/// qualified constant resolves only a cross-file literal string array; and -/// `.map { |v| [v, v.to_s] }.to_h` / `.index_by(&:to_s)` / -/// `.index_with(&:to_s)` recurse into whichever of the above -/// `` already is — Procore's `bid_package.rb` and -/// `potential_change_order.rb` compute their (string-backed) mapping -/// this way from a `CONST` instead of writing the hash out by hand, to -/// keep the column and the constant's allowed values in one place. -/// `None` for anything else (a computed hash, an unresolvable -/// constant), which the caller reports as a gap rather than guessing at -/// storage. +/// idiom for a string-backed column — maps each label to itself. +/// +/// A hash label may be a symbol, a string, or a class-body constant +/// folded through `consts`; a stored value that is not a foldable scalar +/// is kept as an expression. `Ok(None)` for anything whose LABELS are not +/// known at ingest (a computed hash, a constant from another file), +/// which the caller reports as a gap rather than guessing at names. fn enum_label_values( node: &Node<'_>, + file: &str, + consts: &ClassConsts, resolve_constant: &impl Fn(&Node<'_>) -> Option>, -) -> Option> { - // `CONST` — folded in here (rather than only at the `enum_label_values` - // call sites) so a computed mapping's `` can ALSO be a - // constant, not just the top-level `enum :x, CONST` spelling. - if node.as_constant_read_node().is_some() || node.as_constant_path_node().is_some() { - return resolve_constant(node); +) -> IngestResult>> { + if node.as_constant_path_node().is_some() { + return Ok(resolve_constant(node).map(|pairs| pairs.into_iter().map(|(label, value)| (label, EnumStored::Lit(value))).collect())); } + // `%i[…].freeze` / `{ … }.freeze` — the literal is the receiver. if let Some(call) = node.as_call_node() { if constant_id_str(&call.name()) == "freeze" && call.arguments().is_none() && call.block().is_none() { if let Some(recv) = call.receiver() { - return enum_label_values(&recv, resolve_constant); + return enum_label_values(&recv, file, consts, resolve_constant); } } } if let Some(arr) = node.as_array_node() { - return arr - .elements() - .iter() - .enumerate() - .map(|(i, el)| { - symbol_value(&el) - .or_else(|| string_value(&el)) - .map(|label| (label, Literal::Int { value: i as i64 })) - }) - .collect(); + let Some(labels) = label_list(node) else { return Ok(None) }; + let _ = arr; + return Ok(Some( + labels + .into_iter() + .enumerate() + .map(|(i, label)| (label, EnumStored::Lit(Literal::Int { value: i as i64 }))) + .collect(), + )); } if let Some(hash) = node.as_hash_node() { - return enum_label_pairs(hash.elements().iter()); + return enum_label_pairs(hash.elements().iter(), file, consts); } // `enum :status, processing: 'processing', ready: 'ready'` — Rails 7's // dominant spelling. A trailing bare-hash argument (no braces) parses // as a `KeywordHashNode`, not a `HashNode`; its elements are the same // `AssocNode`s either way, so the extraction logic is shared. if let Some(kwhash) = node.as_keyword_hash_node() { - return enum_label_pairs(kwhash.elements().iter()); - } - let call = node.as_call_node()?; - let call_name = constant_id_str(&call.name()); - - // `.index_by(&:itself)` / `.index_by(&:to_s)` / - // `.index_with(&:itself)` / `.index_with(&:to_s)` — identity string - // mappings over the literal/constant input resolved recursively. - if call_name == "index_by" || call_name == "index_with" { - // These are identity mappings only for the two explicit symbol - // procs. Arbitrary blocks (e.g. &:length) must stay ledgered. - let block = call.block()?.as_block_argument_node()?; - let proc = symbol_value(&block.expression()?)?; - if call.arguments().is_some() || !matches!(proc.as_str(), "itself" | "to_s") { - return None; - } - let recv = call.receiver()?; - let labels = enum_label_values(&recv, resolve_constant)?; - return Some( - labels - .into_iter() - .map(|(label, _)| (label.clone(), Literal::Str { value: label })) - .collect(), - ); - } - - // `.map { |v| [v, v.to_s] }.to_h` — the other spelling - // of the same identity string mapping. Recognized narrowly: the - // `map` block takes exactly one parameter, and its body is a - // single statement — a 2-element array literal `[v, v.to_s]` built - // from that same parameter. Anything looser (a different second - // element, extra elements, multiple statements, a differently - // shaped block) falls through to `None` — the caller's refusal — - // rather than guessing at storage. - if call_name == "to_h" && call.arguments().is_none() { - let map_call = call.receiver()?; - let map_call = map_call.as_call_node()?; - if constant_id_str(&map_call.name()) != "map" { - return None; - } - let recv = map_call.receiver()?; - let labels = enum_label_values(&recv, resolve_constant)?; - - let block = map_call.block()?.as_block_node()?; - let block_params = block.parameters()?.as_block_parameters_node()?.parameters()?; - let requireds: Vec<_> = block_params.requireds().iter().collect(); - let [only_param] = &requireds[..] else { return None }; - let only_param = only_param.as_required_parameter_node()?; - let param_name = constant_id_str(&only_param.name()); - - let body_stmts = flatten_statements(block.body()?); - let [body_stmt] = &body_stmts[..] else { return None }; - let pair = body_stmt.as_array_node()?; - let elements: Vec<_> = pair.elements().iter().collect(); - let [first, second] = &elements[..] else { return None }; - - // First element: a bare read of the block param. - let lv = first.as_local_variable_read_node()?; - if constant_id_str(&lv.name()) != param_name { - return None; - } - // Second element: `.to_s`. - let to_s_call = second.as_call_node()?; - if constant_id_str(&to_s_call.name()) != "to_s" || to_s_call.arguments().is_some() { - return None; + return enum_label_pairs(kwhash.elements().iter(), file, consts); + } + let Some(call) = node.as_call_node() else { return Ok(None) }; + let Some(recv) = call.receiver() else { return Ok(None) }; + // The labels a derived mapping starts from: a literal array, or a + // class-body constant naming one. + let labels = || match recv.as_constant_read_node() { + Some(cr) => consts.labels.get(constant_id_str(&cr.name())).cloned(), + None => label_list(&recv).or_else(|| resolve_constant(&recv).map(|pairs| pairs.into_iter().map(|(label, _)| label).collect())), + }; + let identity = |labels: Vec| { + labels + .into_iter() + .map(|l| (l.clone(), EnumStored::Lit(Literal::Str { value: l }))) + .collect::>() + }; + match constant_id_str(&call.name()) { + // `%w[ invisible nothing ].index_by(&:itself)`, + // `STATUSES.index_with(&:itself)` — the labels ARE the stored + // strings. (`&:to_s`/`&:to_sym` on symbols or strings name the + // same label.) + "index_by" | "index_with" if is_identity_proc(&call) => { + Ok(labels().map(identity)) } - let to_s_recv = to_s_call.receiver()?.as_local_variable_read_node()?; - if constant_id_str(&to_s_recv.name()) != param_name { - return None; + // `STATUSES.to_h { |status| [status, status.to_s] }` + "to_h" if is_identity_pair_block(&call) => Ok(labels().map(identity)), + // The same mapping written as `STATUSES.map { |s| [s, s.to_s] }.to_h`. + "to_h" if call.arguments().is_none() && call.block().is_none() => { + let Some(map) = recv.as_call_node() else { return Ok(None) }; + if constant_id_str(&map.name()) != "map" || !is_identity_pair_block(&map) { + return Ok(None); + } + let Some(source) = map.receiver() else { return Ok(None) }; + let labels = match source.as_constant_read_node() { + Some(cr) => consts.labels.get(constant_id_str(&cr.name())).cloned(), + None => label_list(&source).or_else(|| resolve_constant(&source).map(|pairs| pairs.into_iter().map(|(label, _)| label).collect())), + }; + Ok(labels.map(identity)) } - - return Some( - labels - .into_iter() - .map(|(label, _)| (label.clone(), Literal::Str { value: label })) - .collect(), - ); + _ => Ok(None), } +} - None +/// `&:itself` / `&:to_s` / `&:to_sym` as the only argument: the block maps +/// a label to itself. (`["draft", "active"].index_by(&:to_sym)` keys by +/// symbol and stores the string; the label text and the stored value are +/// the same either way.) +fn is_identity_proc(call: &ruby_prism::CallNode<'_>) -> bool { + let Some(block) = call.block().and_then(|b| b.as_block_argument_node()) else { + return false; + }; + block + .expression() + .and_then(|e| symbol_value(&e)) + .is_some_and(|s| s == "itself" || s == "to_s" || s == "to_sym") +} + +/// `{ |x| [x, x.to_s] }` / `{ |x| [x, x] }`: a `to_h` block pairing each +/// label with its own string. +fn is_identity_pair_block(call: &ruby_prism::CallNode<'_>) -> bool { + let Some(block) = call.block().and_then(|b| b.as_block_node()) else { return false }; + let Some(params) = block.parameters().and_then(|p| p.as_block_parameters_node()) else { + return false; + }; + let Some(pn) = params.parameters() else { return false }; + let reqs: Vec<_> = pn.requireds().iter().collect(); + let [only] = reqs.as_slice() else { return false }; + let Some(rp) = only.as_required_parameter_node() else { return false }; + let param = constant_id_str(&rp.name()).to_string(); + let Some(body) = block.body() else { return false }; + let stmts: Vec> = flatten_statements(body); + let [stmt] = stmts.as_slice() else { return false }; + let Some(arr) = stmt.as_array_node() else { return false }; + let els: Vec> = arr.elements().iter().collect(); + let [k, v] = els.as_slice() else { return false }; + let is_param = |n: &Node<'_>| { + n.as_local_variable_read_node().is_some_and(|l| constant_id_str(&l.name()) == param) + }; + is_param(k) + && (is_param(v) + || v.as_call_node().is_some_and(|c| { + constant_id_str(&c.name()) == "to_s" + && c.arguments().is_none() + && c.receiver().is_some_and(|r| is_param(&r)) + })) } /// The one admitted consumer of enum-valued reads. Shared with the source @@ -1522,21 +1681,21 @@ fn serialized_enum_receiver<'pr>(node: &Node<'pr>) -> Option> { /// `{ … }` or left the braces off. fn enum_label_pairs<'a>( elements: impl Iterator>, -) -> Option> { - elements - .map(|el| { - let assoc = el.as_assoc_node()?; - let label = symbol_value(&assoc.key()).or_else(|| string_value(&assoc.key()))?; - let value = assoc.value(); - let lit = if let Some(s) = string_value(&value) { - Literal::Str { value: s } - } else { - let raw = value.as_integer_node()?; - Literal::Int { value: super::util::integer_i64(&raw.value())? } - }; - Some((label, lit)) - }) - .collect() + file: &str, + consts: &ClassConsts, +) -> IngestResult>> { + let mut out = Vec::new(); + for el in elements { + let Some(assoc) = el.as_assoc_node() else { return Ok(None) }; + let Some(label) = consts.label(&assoc.key()) else { return Ok(None) }; + let value = assoc.value(); + let stored = match consts.scalar(&value) { + Some(lit) => EnumStored::Lit(lit), + None => EnumStored::Expr(ingest_expr(&value, file)?), + }; + out.push((label, stored)); + } + Ok(Some(out)) } /// Rails `scopes:` / `instance_methods:` — only the unprefixed @@ -1545,7 +1704,8 @@ fn enum_bool_option(elements: &ruby_prism::NodeList<'_>, name: &str, default: bo for el in elements.iter() { let Some(assoc) = el.as_assoc_node() else { continue }; let Some(key) = symbol_value(&assoc.key()) else { continue }; - if key != name { + // `_scopes:` / `_instance_methods:` are the pre-Rails-7 spellings. + if key.trim_start_matches('_') != name { continue; } let value = assoc.value(); @@ -1603,87 +1763,18 @@ fn enum_affixes(elements: &ruby_prism::NodeList<'_>, column: &str) -> (String, S (prefix, suffix) } -/// Expand a model's `class << self … end` into the class methods it -/// declares. Visibility has already been resolved in lexical order; -/// unsupported singleton statements still fail rather than disappearing. -fn ingest_singleton_class_methods( +/// Expand the singleton body while retaining the enclosing visibility decisions. +fn ingest_singleton_class_body( sc: &ruby_prism::SingletonClassNode<'_>, + owner: &ClassId, file: &str, visibility: &Visibility, -) -> IngestResult> { - use crate::dialect::MethodReceiver; - - let Some(body) = sc.body() else { return Ok(Vec::new()) }; - let mut methods: Vec = Vec::new(); - for statement in super::util::flatten_statements(body) { - let definition = visibility::definition(&statement).map(|d| d.as_node()); - let stmt = definition.as_ref().unwrap_or(&statement); - if let Some(call) = stmt.as_call_node() { - if visibility::marker(&call) { - continue; - } - // `deprecate(name: { message: …, deprecator: … })` — pure - // call-site metadata (ActiveSupport::Deprecation wraps the - // method to warn; callers still dispatch through it), no - // singleton-scope state to carry. `ChangeOrderRequest`, - // `ChangeOrderPackage`, and `PotentialChangeOrder` all - // deprecate a class method exactly this way. Dropped like - // an unknown-call annotation elsewhere, rather than - // refused — refusing killed the whole model's ingest for - // one annotation on an otherwise-modeled class method. - if call.receiver().is_none() - && call.block().is_none() - && constant_id_str(&call.name()) == "deprecate" - { - continue; - } - // `alias_method :new_name, :old_name` — the other call - // shape the corpus uses here (`PaymentApplicationMarkup - // LineItem` aliases `vattr` to `virtual_attribute`). - // Unlike the marker/annotation cases above, this DOES need - // modeling: `vattr` is called from sibling class methods. - // Clone the already-ingested target — `alias_method` - // always follows its target in this corpus — under the - // new name. A target ingested outside this singleton - // block, or not found, falls through to the refusal below - // rather than silently doing nothing. - if call.receiver().is_none() - && call.block().is_none() - && constant_id_str(&call.name()) == "alias_method" - { - if let Some(args) = call.arguments() { - let args: Vec<_> = args.arguments().iter().collect(); - if let [new_name, old_name] = &args[..] { - if let (Some(new_name), Some(old_name)) = - (symbol_value(new_name), symbol_value(old_name)) - { - if let Some(target) = - methods.iter().find(|m| m.name.as_str() == old_name) - { - let mut alias = target.clone(); - alias.name = Symbol::from(new_name); - alias.name_span = Span::synthetic(); - visibility.apply(&statement, &mut alias); - methods.push(alias); - continue; - } - } - } - } - } - } - let Some(def) = stmt.as_def_node() else { - return Err(IngestError::Unsupported { - file: file.into(), - message: format!("unsupported statement inside `class << self`: {stmt:?}"), - }); - }; - let mut method = ingest_method(&def, file)?; - method.receiver = MethodReceiver::Class; - visibility.apply(&statement, &mut method); - methods.push(method); - } - Ok(methods) +) -> IngestResult { + super::singleton_class::ingest_singleton_body(sc, owner, file, &|def| { + let mut method = ingest_method(def, file)?; + visibility.apply(&def.as_node(), &mut method); + Ok(method) + }) } pub(super) fn ingest_method( @@ -2786,7 +2877,7 @@ mod singleton_visibility_tests { fn a_real_statement_in_a_singleton_block_still_refuses() { let err = ingest( "class Thing < ApplicationRecord\n \ - class << self\n attr_accessor :cache\n end\nend\n", + class << self\n memoize_everything :cache\n end\nend\n", ); assert!(err.is_err(), "an unmodeled singleton statement is still an error: {err:?}"); } diff --git a/src/ingest/on_load_reopen.rs b/src/ingest/on_load_reopen.rs index 1bd7daa72..dbe238411 100644 --- a/src/ingest/on_load_reopen.rs +++ b/src/ingest/on_load_reopen.rs @@ -38,10 +38,9 @@ //! took for `to: redirect(…)`: a hole nobody can see is how a gap //! stays open. //! -//! Direct receiverless `include` calls are also reported: Writebook's -//! `:active_record` hook installs `ActionText::HasMarkdown` this way, -//! without reopening a class. Reporting does not install the mixin or -//! execute the hook. This is not an exhaustive ledger of hook bodies; +//! Direct receiverless `include` calls with literal module names are carried +//! for known framework hooks. Other include shapes are reported without +//! executing the hook. This is not an exhaustive ledger of hook bodies; //! nested/conditional calls and other executable statements remain gaps. use ruby_prism::Node; @@ -49,9 +48,10 @@ use ruby_prism::Node; use crate::app::App; use crate::ident::{ClassId, Symbol}; +use super::library_class::library_class_from_module_node_with_scope; use super::util::{ class_name_path, constant_id_str, constant_path_of, find_all_classes_with_scope, - find_all_modules_with_scope, module_name_path, + find_all_modules_with_scope, flatten_statements, }; use super::{survey, IngestError}; @@ -94,19 +94,8 @@ pub(super) fn ingest_on_load_reopens(source: &[u8], file: &str, app: &mut App) { if include.receiver().is_some() || constant_id_str(&include.name()) != "include" { continue; } - let loc = include.location(); - let declaration = String::from_utf8_lossy(loc.as_slice()); - survey::record(&IngestError::Unsupported { - file: file.into(), - message: format!( - "`ActiveSupport.on_load(:{hook})` contains `{declaration}`, which is not carried: \ - load-hook mixin installation is unsupported" - ), - }); - // Class-method macros may still expand at ingest - // (`App.load_hook_class_macros`). Mixin instance - // methods stay uninstalled — that is the gap this - // survey line names. + // Class-method macros are indexed independently of mixin carrying + // (`App.load_hook_class_macros`), including carried literal hooks. if hook == "active_record" { for arg in include.arguments().iter().flat_map(|a| a.arguments().iter()) { if let Some(path) = constant_path_of(&arg) { @@ -117,6 +106,24 @@ pub(super) fn ingest_on_load_reopens(source: &[u8], file: &str, app: &mut App) { } } } + let carried = hook_class(&hook).is_some() + && include.arguments().is_some_and(|args| { + args.arguments().iter().next().is_some() + && args.arguments().iter().all(|arg| constant_path_of(&arg).is_some()) + }); + if carried { + continue; + } + let loc = include.location(); + let declaration = String::from_utf8_lossy(loc.as_slice()); + survey::record(&IngestError::Unsupported { + file: file.into(), + message: format!( + "`ActiveSupport.on_load(:{hook})` contains `{declaration}`, which is not carried: \ + load-hook mixin installation is unsupported" + ), + }); + } } @@ -133,10 +140,59 @@ pub(super) fn ingest_on_load_reopens(source: &[u8], file: &str, app: &mut App) { not_carried(file, &hook, &name); } for (scope, module) in find_all_modules_with_scope(&body) { - let mut path = scope.clone(); - path.extend(module_name_path(&module).unwrap_or_default()); - not_carried(file, &hook, &path.join("::")); + // A module DEFINED in the block is an ordinary top-level + // module (`module` does not close over the block's self): + // carry it as the library class it would be outside the + // block, so what the hook includes has methods to resolve. + match library_class_from_module_node_with_scope(&module, &scope, file) { + Ok(lc) => app.library_classes.push(lc), + Err(err) => survey::record(&err), + } } + carry_hook_includes(body, &hook, file, app); + } +} + +/// The framework class a load hook hands its block, for the hooks whose +/// receiver is one class. Deliberately a short list: a hook this does not +/// name is reported rather than guessed at. +fn hook_class(hook: &str) -> Option<&'static str> { + Some(match hook { + "action_dispatch_request" => "ActionDispatch::Request", + "action_dispatch_integration_test" => "ActionDispatch::IntegrationTest", + "action_controller" | "action_controller_base" => "ActionController::Base", + "action_controller_test_case" => "ActionController::TestCase", + "action_view" => "ActionView::Base", + "action_mailer" => "ActionMailer::Base", + "active_job" => "ActiveJob::Base", + "active_record" => "ActiveRecord::Base", + _ => return None, + }) +} + +/// `include(X)` / `include X` written straight in a load hook's block +/// runs against the hooked class: the same mixin as +/// `class ActionDispatch::Request; include(X); end`, and recorded the +/// same way, as a reopen of that class. +fn carry_hook_includes(body: Node<'_>, hook: &str, file: &str, app: &mut App) { + let mut mixins: Vec = Vec::new(); + for stmt in flatten_statements(body) { + let Some(call) = stmt.as_call_node() else { continue }; + if call.receiver().is_some() || constant_id_str(&call.name()) != "include" { + continue; + } + let Some(args) = call.arguments() else { continue }; + mixins.extend(args.arguments().iter().filter_map(|a| constant_path_of(&a)).map(|p| p.join("::"))); + } + if mixins.is_empty() { + return; + } + // Unsupported direct includes were reported at their original byte spans. + let Some(class) = hook_class(hook) else { return }; + let source = format!("class {class}\n include {}\nend\n", mixins.join(", ")); + match super::library_class::ingest_library_classes(source.as_bytes(), file) { + Ok(classes) => app.library_classes.extend(classes), + Err(err) => survey::record(&err), } } diff --git a/src/ingest/rbi.rs b/src/ingest/rbi.rs new file mode 100644 index 000000000..59db27587 --- /dev/null +++ b/src/ingest/rbi.rs @@ -0,0 +1,464 @@ +//! Tapioca RBIs as a typed gem boundary. +//! +//! `sorbet/rbi/gems/@.rbi` is what Tapioca generated +//! from the installed gem: its classes and modules, their ancestry, and +//! a `def` per public method, most with a `sig`. That is a declaration +//! of the gem's surface, and reading it is the difference between "the +//! receiver is a gem class, so nothing can be said" and "the receiver +//! is a `Money::Bank::Base`, and `#exchange_with` answers a `Money`". +//! +//! Only declarations are read; there are no bodies to read. The reader +//! is lenient where [`super::sorbet_sig`] is strict, and the leniency +//! is always toward `untyped`: +//! +//! * a `sig` the type grammar cannot fully read does not drop the +//! method (the method EXISTS, that is what the RBI says), it types +//! the pieces it cannot read as `untyped`; +//! * a method with no `sig` is `(untyped...) -> untyped`; +//! * `void` is `untyped`, not `nil`: sorbet's `void` says "do not use +//! the result", which is not a claim that the result is nil. +//! +//! Nothing here reaches the analysis until the analyzer resolves the +//! written class names against the classes it actually knows (see +//! `analyze::registry::gem_boundary`). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use ruby_prism::Node; + +use crate::effect::EffectSet; +use crate::gem_boundary::{GemBoundary, GemClass}; +use crate::gems::{fate_of, GemFate, Lockfile}; +use crate::ident::{ClassId, Symbol}; +use crate::ty::{Param, ParamKind, Ty}; +use crate::vfs::Vfs; + +use super::sorbet_sig::{ + collect_type_aliases, constant_path_name, def_parameters, is_sig_call, qualify, sorbet_ty, + symbol_name, +}; +use super::util::constant_id_str; + +/// Where a tree's RBIs live: `sorbet/rbi/gems` under the tree, or under +/// the directory its `Gemfile.lock` really lives in. The second matters +/// for a tree that is assembled from a checkout by linking (the lockfile +/// is a symlink to the real one, and `sorbet/` is not copied): the RBIs +/// sit beside the real lockfile. +fn rbi_dir(vfs: &V, dir: &Path) -> Option { + let direct = dir.join("sorbet/rbi/gems"); + if vfs.is_dir(&direct) { + return Some(direct); + } + let lock = vfs.canonical(&dir.join("Gemfile.lock"))?; + let beside = lock.parent()?.join("sorbet/rbi/gems"); + vfs.is_dir(&beside).then_some(beside) +} + +/// Read the RBIs of the locked gems the analyzer has no model for. +/// +/// Rails' own components, the stdlib gems and the tooling gems are +/// skipped: the first two are already the analyzer's catalog (a second, +/// partial, description of them would only disagree), and the last never +/// enters the analysis at all. +pub fn load_gem_boundary(vfs: &V, dir: &Path, lock: &Lockfile) -> GemBoundary { + let mut boundary = GemBoundary::default(); + let Some(root) = rbi_dir(vfs, dir) else { return boundary }; + let Ok(entries) = vfs.read_dir(&root) else { return boundary }; + + // gem name -> the RBI file to read. A gem locked at one version can + // have RBIs for several (a stale one left behind); the locked + // version's wins, otherwise the highest-sorting one. + let mut chosen: HashMap = HashMap::new(); + for path in entries { + let Some(file) = path.file_name().and_then(|f| f.to_str()) else { continue }; + let Some(stem) = file.strip_suffix(".rbi") else { continue }; + let Some((name, version)) = stem.split_once('@') else { continue }; + if lock.is_in_repo(name) || !matches!(fate_of(name), GemFate::Unknown | GemFate::Modeled) { + continue; + } + let Some(locked) = lock.version_of(name) else { continue }; + let exact = version == locked || version.starts_with(&format!("{locked}-")); + match chosen.get(name) { + Some((was_exact, was)) if (*was_exact, was) >= (exact, &path) => {} + _ => { + chosen.insert(name.to_string(), (exact, path)); + } + } + } + + let mut files: Vec<(String, PathBuf)> = chosen.into_iter().map(|(n, (_, p))| (n, p)).collect(); + files.sort(); + for (gem, path) in files { + if let Ok(source) = vfs.read(&path) { + read_rbi(&source, &gem, &mut boundary); + boundary.namespaces.entry(gem).or_default(); + } + } + let mut namespaces = std::mem::take(&mut boundary.namespaces); + for (id, class) in &boundary.classes { + let content = !class.instance_methods.is_empty() + || !class.class_methods.is_empty() + || !class.constants.is_empty() + || class.parent.is_some() + || !class.includes.is_empty() + || !class.extends.is_empty() + || class.dynamic; + if content { + if let Some(names) = namespaces.get_mut(&class.gem) { + names.insert(id.0.as_str().to_string()); + } + } + } + boundary.namespaces = namespaces; + boundary +} + +/// Declarations of one RBI file, merged into `out`. A class reopened +/// across files (or twice in one) accumulates: the first parent wins, +/// methods and ancestry union. +pub fn read_rbi(source: &[u8], gem: &str, out: &mut GemBoundary) { + let result = ruby_prism::parse(source); + let node = result.node(); + let Some(program) = node.as_program_node() else { return }; + let statements = program.statements().body().iter().collect::>(); + let aliases = collect_type_aliases(&statements, &HashMap::new()); + let mut reader = Reader { gem, out }; + reader.walk(&statements, None, false, &aliases); +} + +struct Reader<'a> { + gem: &'a str, + out: &'a mut GemBoundary, +} + +/// The name a `class`/`module` header declares, given its enclosing +/// scope. `class ::Foo` is `Foo` wherever it sits. +fn declared_name(scope: Option<&str>, path: &Node<'_>) -> String { + let cbase = path.as_constant_path_node().is_some_and(|p| { + let mut root = p.parent(); + loop { + match root { + None => return true, + Some(node) => match node.as_constant_path_node() { + Some(inner) => root = inner.parent(), + None => return false, + }, + } + } + }); + let written = constant_path_name(path); + if cbase { written } else { qualify(scope, &written) } +} + +impl Reader<'_> { + fn class_entry(&mut self, name: &str, is_module: bool) -> &mut GemClass { + let gem = self.gem; + let entry = self.out.classes.entry(ClassId(Symbol::new(name))).or_default(); + if entry.gem.is_empty() { + entry.gem = gem.to_string(); + entry.is_module = is_module; + } + entry + } + + fn walk( + &mut self, + statements: &[Node<'_>], + scope: Option<&str>, + singleton: bool, + aliases: &HashMap, + ) { + // The `sig` directly above a member applies to it; remembered + // by index because prism nodes are not `Clone`. + let mut pending: Option = None; + for (index, statement) in statements.iter().enumerate() { + let sig = pending.take().map(|i| &statements[i]); + if let Some(class) = statement.as_class_node() { + let name = declared_name(scope, &class.constant_path()); + let parent = class.superclass(); + let parent_name = parent.as_ref().map(constant_path_name).filter(|n| !n.is_empty()); + let entry = self.class_entry(&name, false); + match (parent_name, parent.is_some()) { + (Some(p), _) => { + if entry.parent.is_none() { + entry.parent = Some(ClassId(Symbol::new(&p))); + } + } + // `class X < Struct.new(:a)` and the like: an + // ancestor exists and cannot be named. + (None, true) => entry.dynamic = true, + (None, false) => {} + } + if let Some(body) = class.body().and_then(|b| b.as_statements_node()) { + let inner = body.body().iter().collect::>(); + let aliases = collect_type_aliases(&inner, aliases); + self.walk(&inner, Some(&name), false, &aliases); + } + continue; + } + if let Some(module) = statement.as_module_node() { + let name = declared_name(scope, &module.constant_path()); + self.class_entry(&name, true); + if let Some(body) = module.body().and_then(|b| b.as_statements_node()) { + let inner = body.body().iter().collect::>(); + let aliases = collect_type_aliases(&inner, aliases); + self.walk(&inner, Some(&name), false, &aliases); + } + continue; + } + if let Some(single) = statement.as_singleton_class_node() { + if let Some(body) = single.body().and_then(|b| b.as_statements_node()) { + self.walk(&body.body().iter().collect::>(), scope, true, aliases); + } + continue; + } + let Some(scope) = scope else { + // Top-level: only a `sig` carries over to the next + // statement; there is no class to declare into. + pending = statement + .as_call_node() + .is_some_and(|c| is_sig_call(&c)) + .then_some(index); + continue; + }; + if let Some(def) = statement.as_def_node() { + self.declare_def(scope, &def, sig, singleton, aliases); + continue; + } + if let Some(alias) = statement.as_alias_method_node() { + if let (Some(new), Some(old)) = + (symbol_name(&alias.new_name()), symbol_name(&alias.old_name())) + { + self.declare_alias(scope, &new, &old, singleton); + } + continue; + } + if let Some(write) = statement.as_constant_write_node() { + if !aliases.contains_key(constant_id_str(&write.name())) { + let name = Symbol::new(constant_id_str(&write.name())); + self.class_entry(scope, false).constants.push(name); + } + continue; + } + let Some(call) = statement.as_call_node() else { continue }; + if is_sig_call(&call) { + pending = Some(index); + continue; + } + if call.receiver().is_some() { + continue; + } + let name = constant_id_str(&call.name()); + let args: Vec> = call + .arguments() + .map(|a| a.arguments().iter().collect()) + .unwrap_or_default(); + match name { + "include" | "prepend" | "extend" => { + for arg in &args { + let target = constant_path_name(arg); + if target.is_empty() { + continue; + } + let entry = self.class_entry(scope, false); + let list = if name == "extend" && !singleton { + &mut entry.extends + } else if singleton && name == "include" { + // `class << self; include M` -- the same fact. + &mut entry.extends + } else { + &mut entry.includes + }; + list.push(ClassId(Symbol::new(&target))); + } + } + "attr_reader" | "attr_writer" | "attr_accessor" => { + let declared = sig + .and_then(|s| read_returns(s, true, aliases)) + .unwrap_or(Ty::Untyped); + for arg in &args { + let Some(attr) = symbol_name(arg) else { continue }; + if name != "attr_writer" { + self.insert(scope, &attr, singleton, reader_fn(declared.clone())); + } + if name != "attr_reader" { + self.insert( + scope, + &format!("{attr}="), + singleton, + writer_fn(declared.clone()), + ); + } + } + } + "alias_method" => { + if let [new, old] = args.as_slice() { + if let (Some(new), Some(old)) = (symbol_name(new), symbol_name(old)) { + self.declare_alias(scope, &new, &old, singleton); + } + } + } + // `private def x` / `private_class_method def self.x`: + // the def is the member, the wrapper only its + // visibility. + "private" | "protected" | "public" | "module_function" | "private_class_method" => { + for arg in &args { + if let Some(def) = arg.as_def_node() { + self.declare_def(scope, &def, sig, singleton, aliases); + } + } + } + _ => {} + } + } + } + + fn insert(&mut self, scope: &str, name: &str, class_side: bool, ty: Ty) { + let entry = self.class_entry(scope, false); + let table = if class_side { &mut entry.class_methods } else { &mut entry.instance_methods }; + table.insert(Symbol::new(name), ty); + } + + fn declare_def( + &mut self, + scope: &str, + def: &ruby_prism::DefNode<'_>, + sig: Option<&Node<'_>>, + singleton: bool, + aliases: &HashMap, + ) { + let name = constant_id_str(&def.name()); + let class_side = singleton || def.receiver().is_some(); + // A `def other.x` declares onto something that is not this + // class; only `def self.x` is ours. + if def.receiver().is_some_and(|r| r.as_self_node().is_none()) { + return; + } + if name == "method_missing" && !class_side { + self.class_entry(scope, false).dynamic = true; + } + let ty = lenient_signature(sig, def, !class_side, aliases); + self.insert(scope, name, class_side, ty); + } + + /// `alias_method :new, :old` gives `new` the type `old` already + /// has; an `old` this file has not declared (it was inherited, or + /// declared later) leaves `new` untyped rather than guessed. + fn declare_alias(&mut self, scope: &str, new: &str, old: &str, class_side: bool) { + let entry = self.class_entry(scope, false); + let table = if class_side { &mut entry.class_methods } else { &mut entry.instance_methods }; + let ty = table.get(&Symbol::new(old)).cloned().unwrap_or_else(untyped_fn); + table.insert(Symbol::new(new), ty); + } +} + +fn untyped_fn() -> Ty { + Ty::Fn { + params: vec![Param { name: Symbol::new("args"), ty: Ty::Untyped, kind: ParamKind::Rest }], + block: None, + ret: Box::new(Ty::Untyped), + effects: EffectSet::pure(), + } +} + +fn reader_fn(ret: Ty) -> Ty { + Ty::Fn { params: Vec::new(), block: None, ret: Box::new(ret), effects: EffectSet::pure() } +} + +fn writer_fn(ty: Ty) -> Ty { + Ty::Fn { + params: vec![Param { name: Symbol::new("value"), ty: ty.clone(), kind: ParamKind::Required }], + block: None, + ret: Box::new(ty), + effects: EffectSet::pure(), + } +} + +/// The links of a `sig { ... }` chain, outermost first. +fn sig_chain<'a>(sig: &Node<'a>) -> Vec> { + let mut out = Vec::new(); + let Some(call) = sig.as_call_node() else { return out }; + let Some(block) = call.block().and_then(|b| b.as_block_node()) else { return out }; + let Some(body) = block.body().and_then(|b| b.as_statements_node()) else { return out }; + let mut link = body.body().iter().next(); + while let Some(node) = link { + let Some(call) = node.as_call_node() else { break }; + link = call.receiver(); + out.push(call); + } + out +} + +/// The declared return of a `sig`, `None` when it declares none or the +/// type is outside the grammar. `void` is not a declared return here. +fn read_returns(sig: &Node<'_>, self_is_instance: bool, aliases: &HashMap) -> Option { + for call in sig_chain(sig) { + if constant_id_str(&call.name()) == "returns" { + let argument = call.arguments()?.arguments().iter().next()?; + return sorbet_ty(&argument, self_is_instance, aliases); + } + } + None +} + +/// `(params) -> ret` for a `def`, from its `sig` where there is one. +/// +/// The def supplies the parameter names, order and kinds (Ruby is the +/// authority on what `x` is); the sig supplies types, and any type it +/// does not supply or this grammar cannot read is `untyped`. +fn lenient_signature( + sig: Option<&Node<'_>>, + def: &ruby_prism::DefNode<'_>, + self_is_instance: bool, + aliases: &HashMap, +) -> Ty { + let mut declared: HashMap = HashMap::new(); + let mut ret = Ty::Untyped; + if let Some(sig) = sig { + for call in sig_chain(sig) { + match constant_id_str(&call.name()) { + "returns" => { + if let Some(argument) = call.arguments().and_then(|a| a.arguments().iter().next()) { + ret = sorbet_ty(&argument, self_is_instance, aliases).unwrap_or(Ty::Untyped); + } + } + "params" => { + let Some(arguments) = call.arguments() else { continue }; + for argument in arguments.arguments().iter() { + let Some(hash) = argument.as_keyword_hash_node() else { continue }; + for element in hash.elements().iter() { + let Some(assoc) = element.as_assoc_node() else { continue }; + let Some(name) = symbol_name(&assoc.key()) else { continue }; + let ty = sorbet_ty(&assoc.value(), self_is_instance, aliases) + .unwrap_or(Ty::Untyped); + declared.insert(name, ty); + } + } + } + // `void`, and every modifier (`override`, `abstract`, + // `type_parameters`, ...), say nothing readable here. + _ => {} + } + } + } + let positional_tail = def.parameters().is_some_and(|p| p.posts().iter().next().is_some()); + let listed = match def.parameters() { + None => Some(Vec::new()), + Some(_) if positional_tail => None, + Some(parameters) => def_parameters(¶meters), + }; + let params = match listed { + Some(listed) => listed + .into_iter() + .map(|(name, kind)| { + let ty = declared.remove(&name).unwrap_or(Ty::Untyped); + Param { name: Symbol::new(&name), ty, kind } + }) + .collect(), + // A parameter list this reader does not take apart: accept + // anything rather than assert an arity. + None => vec![Param { name: Symbol::new("args"), ty: Ty::Untyped, kind: ParamKind::Rest }], + }; + Ty::Fn { params, block: None, ret: Box::new(ret), effects: EffectSet::pure() } +} diff --git a/src/ingest/routes.rs b/src/ingest/routes.rs index 1f922b67a..561ed8dd3 100644 --- a/src/ingest/routes.rs +++ b/src/ingest/routes.rs @@ -1,7 +1,9 @@ //! `config/routes.rb` — parse the `Rails.application.routes.draw do … end` //! DSL into a `RouteTable`. Recognizes verb shortcuts (`get`/`post`/…), -//! `root`, `resources`/`resource`, `namespace`/`scope`, and -//! `draw(:name)` inclusion of `config/routes/.rb` split files. +//! `root`, `resources`/`resource`, `namespace`/`scope`, `concern`/`concerns`, +//! and the file-inclusion forms — `draw(:name)`, `load(path)`, +//! `instance_eval(File.read(path))` — over split files under +//! `config/routes/`. //! //! Recovery discipline: in survey mode an unsupported DSL construct //! (`mount`, `use_doorkeeper`, `devise_for`, …) records a gap and drops @@ -10,7 +12,8 @@ //! forces a recognizer. Not-modeled ≠ absent: a dropped entry is a //! ledger line, never a silently empty route table. -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use indexmap::IndexMap; use ruby_prism::Node; @@ -20,8 +23,8 @@ use crate::naming::camelize; use crate::{ClassId, Symbol}; use super::util::{ - constant_id_str, constant_path_of, find_call_named, flatten_statements, string_value, - symbol_list_value, symbol_or_string_value, symbol_value, + constant_id_str, constant_path_of, find_call_named, flatten_statements, string_value, symbol_list_value, + symbol_or_string_value, symbol_value, }; use super::{IngestError, IngestResult}; @@ -29,66 +32,141 @@ pub fn ingest_routes(source: &[u8], file: &str) -> IngestResult { ingest_routes_with_draws(source, file, &HashMap::new()) } -/// `draws` maps a `draw(:name)` symbol to the split file Rails loads -/// into the same DSL context (`config/routes/.rb`): name → -/// (source, path). The app ingester reads the directory; tests pass -/// maps directly. +/// `draws` maps a route-file key to the split file Rails loads into the +/// DSL: key → (source, path). The key is the file's path under +/// `config/routes/` without the extension (`draw(:admin)` → +/// `admin`; `load(root.join("config", "routes", "services", +/// "internal.rb"))` → `services/internal`). The app ingester reads the +/// directory; tests pass maps directly. pub fn ingest_routes_with_draws( source: &[u8], file: &str, draws: &HashMap, String)>, +) -> IngestResult { + ingest_routes_with_dsl(source, file, draws, &HashSet::new()) +} + +/// [`ingest_routes_with_draws`] plus the block-taking methods an app +/// added to the routing DSL: `block_wrappers` names methods of a module +/// the app prepends into `ActionDispatch::Routing::Mapper` +/// (`routing_method :main_pod do … end`) — each is a scope that runs its +/// block in the enclosing mapper without changing any path. +pub fn ingest_routes_with_dsl( + source: &[u8], + file: &str, + draws: &HashMap, String)>, + block_wrappers: &HashSet, ) -> IngestResult { super::sources::register(file, &String::from_utf8_lossy(source)); let result = super::prism::parse(source, file); let root = result.node(); + let cx = Ctx { + draws, + wrappers: block_wrappers, + concerns: RefCell::new(HashMap::new()), + active: RefCell::new(Vec::new()), + hoisted: RefCell::new(Vec::new()), + }; - // Every top-level `X.routes.draw do ... end` call — usually exactly - // one (`Rails.application.routes.draw` or `Procore::Application - // .routes.draw`), but an app can carry a second top-level block - // (legacy + Rails-native routing split across two calls in the same - // file). `find_call_named` on the whole root would find only the - // first (it returns on the first match), so each top-level - // statement is searched independently and every hit kept — a - // second block silently dropped would violate the "loud by design" - // discipline this module documents at the top. - let draw_calls = top_level_draw_calls(&root); + // Every `Rails.application.routes.draw do … end` in the file: Rails + // appends each to the same route set, and a components-style tree + // concatenates several. An ENGINE's route set + // (`RetailComponent::Engine.routes.draw`) is a different set — its + // helpers live on the engine, not on the app's `url_helpers` — so it + // is not folded into this table. + let mut draw_calls = top_level_app_draws(&root); if draw_calls.is_empty() { - return Ok(RouteTable::default()); + // A draw nested inside something else (a conditional, a + // wrapper) — the first one found, as before. + if let Some(call) = find_call_named(&root, "draw") { + draw_calls.push(call); + } } let mut entries = Vec::new(); - // Collected by a second walk rather than threaded through the - // recursive entry ingest: a `direct` is not a RouteSpec (it adds no - // path), so it has nowhere to ride in the `entries` return, and - // every recursive arm would otherwise need a mutable accumulator - // for a construct that appears a handful of times per app. let mut direct_helpers = Vec::new(); for draw_call in &draw_calls { let Some(block_node) = draw_call.block() else { continue }; let Some(block) = block_node.as_block_node() else { continue }; - let Some(body) = block.body() else { continue }; - direct_helpers.extend(collect_direct_helpers(&body, file)?); - entries.extend(ingest_route_body(body, file, None, draws)?); + if let Some(body) = block.body() { + entries.extend(ingest_route_body(body, file, None, &cx)?); + // Collected by a second walk rather than threaded through the + // recursive entry ingest: a `direct` is not a RouteSpec (it adds no + // path), so it has nowhere to ride in the `entries` return, and + // every recursive arm would otherwise need a mutable accumulator + // for a construct that appears a handful of times per app. + if let Some(body) = block.body() { + direct_helpers.extend(collect_direct_helpers(&body, file)?); + } + } } + // A `load`ed file carries its own `Rails.application.routes.draw`, + // which starts a fresh mapper: its routes sit at the top of the + // table whatever scope the `load` call was written inside. + entries.extend(cx.hoisted.take()); Ok(RouteTable { entries, direct_helpers, redirects: redirect_sink::drain() }) } -/// Every top-level `draw` call in `root` — one per `X.routes.draw do -/// ... end` block. Each top-level statement is itself the outer call -/// (its own name is `draw`, so `find_call_named` matches immediately -/// without descending into siblings), so searching statement-by- -/// statement rather than the whole tree at once is what makes a SECOND -/// top-level block visible instead of only the first. -fn top_level_draw_calls<'pr>(root: &Node<'pr>) -> Vec> { - let stmts: Vec> = if let Some(p) = root.as_program_node() { - p.statements().body().iter().collect() - } else if let Some(s) = root.as_statements_node() { - s.body().iter().collect() - } else { - return find_call_named(root, "draw").into_iter().collect(); - }; - stmts.iter().filter_map(|stmt| find_call_named(stmt, "draw")).collect() +/// The `draw do … end` calls that are statements of the program itself +/// and draw the APPLICATION's routes: `Rails.application.routes.draw` +/// (or any `.routes.draw` whose receiver is not an engine). +fn top_level_app_draws<'pr>(root: &Node<'pr>) -> Vec> { + let Some(program) = root.as_program_node() else { return Vec::new() }; + program + .statements() + .body() + .iter() + .filter_map(|stmt| stmt.as_call_node()) + .filter(|call| is_app_route_draw(call)) + .collect() +} + +/// `.routes.draw do … end` where `` is not an `…::Engine`. +fn is_app_route_draw(call: &ruby_prism::CallNode<'_>) -> bool { + if constant_id_str(&call.name()) != "draw" || call.block().is_none() { + return false; + } + let Some(recv) = call.receiver() else { return false }; + let Some(routes) = recv.as_call_node() else { return false }; + if constant_id_str(&routes.name()) != "routes" { + return false; + } + let Some(owner) = routes.receiver() else { return false }; + let is_engine = owner + .as_constant_path_node() + .and_then(|p| p.name()) + .map(|n| constant_id_str(&n).ends_with("Engine")) + .unwrap_or(false) + || owner + .as_constant_read_node() + .map(|c| constant_id_str(&c.name()).ends_with("Engine")) + .unwrap_or(false); + !is_engine +} + +/// What the DSL walk carries besides the syntax in front of it. +struct Ctx<'a> { + draws: &'a HashMap, String)>, + /// App-defined block-taking DSL methods (see [`ingest_routes_with_dsl`]). + wrappers: &'a HashSet, + /// `concern :name do |options| … end`, by name. A concern is a macro + /// re-run at each `concerns :name` call site, so its body is kept as + /// source and re-read there. + concerns: RefCell>, + /// Route files currently being included, so a file that loads itself + /// (directly or through another) ends the walk instead of the stack. + active: RefCell>, + /// Entries of `load`ed files that drew at top level. + hoisted: RefCell>, +} + +struct Concern { + /// The block parameter the options hash is bound to (`|options|`). + param: Option, + /// The block body, as source; `None` for an empty concern. + body: Option>, + file: String, } /// Every `direct :name do |…| … end` in the draw block, at any nesting @@ -188,21 +266,57 @@ fn ingest_route_body( body: Node<'_>, file: &str, parent: Option<&str>, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { - ingest_route_stmts(flatten_statements(body).into_iter(), file, parent, draws) + ingest_route_stmts(flatten_statements(body).into_iter(), file, parent, cx) } fn ingest_route_stmts<'pr>( stmts: impl Iterator>, file: &str, parent: Option<&str>, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { let mut entries = Vec::new(); for stmt in stmts { + // A conditional around routes (`if Rails.env.development?`) is + // decided at boot, not here: both arms are walked, so the + // helpers of every environment's table exist in the model. + if let Some(cond) = stmt.as_if_node() { + let mut arms: Vec> = Vec::new(); + if let Some(body) = cond.statements() { + arms.extend(body.body().iter()); + } + if let Some(rest) = cond.subsequent() { + arms.push(rest); + } + entries.extend(ingest_route_stmts(arms.into_iter(), file, parent, cx)?); + continue; + } + if let Some(cond) = stmt.as_unless_node() { + let mut arms: Vec> = Vec::new(); + if let Some(body) = cond.statements() { + arms.extend(body.body().iter()); + } + if let Some(rest) = cond.else_clause() { + arms.push(rest.as_node()); + } + entries.extend(ingest_route_stmts(arms.into_iter(), file, parent, cx)?); + continue; + } + if let Some(rest) = stmt.as_else_node() { + if let Some(body) = rest.statements() { + entries.extend(ingest_route_stmts(body.body().iter(), file, parent, cx)?); + } + continue; + } + // `prefix = options[:prefix] || ""` at the top of a concern. + if let Some(write) = stmt.as_local_variable_write_node() { + let value = eval::value(&write.value()).unwrap_or(eval::RVal::Unknown); + eval::bind(constant_id_str(&write.name()), value); + continue; + } let Some(call) = stmt.as_call_node() else { continue }; - // `Dir.glob('rest_routes/**/*.rb', base: 'config/routes').each // do |r| draw(r.sub(/\.rb$/, '')) end` (and `Dir[...]`) — the // idiom a Mastodon-class app uses to mass-`draw` a whole @@ -215,7 +329,7 @@ fn ingest_route_stmts<'pr>( if constant_id_str(&call.name()) == "each" { if let Some(recv) = call.receiver() { if let Some(pattern) = dir_glob_routes_pattern(&recv) { - match ingest_glob_draw_each(&call, &pattern, file, draws) { + match ingest_glob_draw_each(&call, &pattern, file, cx) { Ok(new_entries) => entries.extend(new_entries), Err(err) if super::survey::is_active() => super::survey::record(&err), Err(err) => return Err(err), @@ -252,18 +366,14 @@ fn ingest_route_stmts<'pr>( if let Some(block) = block_node.as_block_node() { if let Some(inner_body) = block.body() { let mut inner = - ingest_route_body(inner_body, file, parent, draws)?; + ingest_route_body(inner_body, file, parent, cx)?; let scope = match method.as_str() { "member" => Some(ResourceScope::Member), "collection" => Some(ResourceScope::Collection), _ => None, // constraints: no scope change }; if let Some(scope) = scope { - for entry in &mut inner { - if let RouteSpec::Explicit { scope: s, .. } = entry { - *s = scope; - } - } + retag_scope(&mut inner, scope); } entries.extend(inner); } @@ -275,7 +385,7 @@ fn ingest_route_stmts<'pr>( // Per-entry recovery: one `mount`/`use_doorkeeper` must not // zero the whole table. Survey mode records the gap and keeps // walking; strict mode still fails loud. - match ingest_route_call(&call, &method, file, parent, draws) { + match ingest_route_call(&call, &method, file, parent, cx) { Ok(Some(spec)) if method == "match" => match expand_match_via(&call, spec, file) { Ok(expanded) => entries.extend(expanded), Err(err) if super::survey::is_active() => super::survey::record(&err), @@ -290,6 +400,21 @@ fn ingest_route_stmts<'pr>( Ok(entries) } +/// Apply a `member do`/`collection do` scope to every explicit route in +/// `entries`, looking through the facet-less scopes a wrapper (`routing_method +/// :x do`, `concerns`, a conditional's arm) leaves in between. +fn retag_scope(entries: &mut [RouteSpec], scope: ResourceScope) { + for entry in entries { + match entry { + RouteSpec::Explicit { scope: s, .. } => *s = scope, + RouteSpec::Scope { path: None, module: None, as_prefix: None, entries, .. } => { + retag_scope(entries, scope) + } + _ => {} + } + } +} + /// Redirect routes collected during the entry walk. /// /// A thread-local sink rather than an accumulator threaded through @@ -530,7 +655,7 @@ fn ingest_route_call( method: &str, file: &str, parent: Option<&str>, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { // Verb shortcuts (`get "/p", to: "c#a"` and the hashrocket form // `get "/p" => "c#a"`). `ingest_explicit_route` returns Ok(None) @@ -574,10 +699,10 @@ fn ingest_route_call( } match method { "root" => ingest_root_route(call, file), - "resources" => ingest_resources_route(call, file, draws, false).map(Some), - "resource" => ingest_resources_route(call, file, draws, true).map(Some), - "namespace" => ingest_namespace_route(call, file, draws).map(Some), - "scope" => ingest_scope_route(call, file, draws).map(Some), + "resources" => ingest_resources_route(call, file, cx, false).map(Some), + "resource" => ingest_resources_route(call, file, cx, true).map(Some), + "namespace" => ingest_namespace_route(call, file, cx).map(Some), + "scope" => ingest_scope_route(call, file, cx).map(Some), // `nested do … end` — the explicit form of the nesting a // `resources` block already applies to a child `resources` or // verb call. It carries no facets of its own; what it does is @@ -591,9 +716,20 @@ fn ingest_route_call( as_prefix: None, defaults: IndexMap::new(), nest: true, - entries: block_entries(call, file, None, draws)?, + entries: block_entries(call, file, None, cx)?, })), - "draw" => ingest_draw_route(call, file, draws), + "draw" | "load" | "instance_eval" => ingest_route_file_include(call, &method, file, cx), + // `concern :name do |options| … end` defines a macro and adds no + // route; `concerns :name, k: v` runs it here. + "concern" => { + register_concern(call, file, cx); + Ok(None) + } + "concerns" => ingest_concerns(call, file, parent, cx), + // `resolve("Model") { |m| route_for(…) }` teaches `polymorphic_url` + // how to build a URL for a model. It names no route and defines + // no helper, so there is nothing for the table to hold. + "resolve" => Ok(None), // `mount SomeEngine, at: "/path"` — the mounted engine is // external code (mission_control, sidekiq-web, …), never part // of the transpiled app. Dropping the route is the modeled @@ -628,6 +764,17 @@ fn ingest_route_call( // ingest fails loud so the fixture that introduces them forces // a recognizer; survey callers get a per-entry ledger line // (see ingest_route_stmts). + // A block-taking method the app added to the mapper + // (`routing_method :x do … end`): a scope over the enclosing + // mapper that changes no path. + _ if cx.wrappers.contains(method) => Ok(Some(RouteSpec::Scope { + path: None, + module: None, + as_prefix: None, + defaults: IndexMap::new(), + nest: false, + entries: block_entries(call, file, parent, cx)?, + })), _ => Err(IngestError::Unsupported { file: file.into(), message: format!("unsupported routes DSL: `{method}`"), @@ -766,10 +913,10 @@ fn http_method_from(name: &str) -> Option { fn first_name_arg(call: &ruby_prism::CallNode<'_>) -> Option { let args = call.arguments()?; for arg in args.arguments().iter() { - if let Some(s) = symbol_value(&arg) { + if let Some(s) = rsymbol(&arg) { return Some(s); } - if let Some(s) = string_value(&arg) { + if let Some(s) = rstring(&arg) { return Some(s); } // Keyword hash → options-only call (`scope module: :web`). @@ -784,12 +931,12 @@ fn block_entries( call: &ruby_prism::CallNode<'_>, file: &str, parent: Option<&str>, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { match call.block() { Some(block_node) => match block_node.as_block_node() { Some(block) => match block.body() { - Some(body) => ingest_route_body(body, file, parent, draws), + Some(body) => ingest_route_body(body, file, parent, cx), None => Ok(Vec::new()), }, None => Ok(Vec::new()), @@ -805,7 +952,7 @@ fn block_entries( fn ingest_namespace_route( call: &ruby_prism::CallNode<'_>, file: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult { let Some(name) = first_name_arg(call) else { return Err(IngestError::Unsupported { @@ -813,7 +960,7 @@ fn ingest_namespace_route( message: "namespace without a name".into(), }); }; - let entries = block_entries(call, file, None, draws)?; + let entries = block_entries(call, file, None, cx)?; Ok(RouteSpec::Scope { path: Some(name.clone()), module: Some(name.clone()), @@ -830,7 +977,7 @@ fn ingest_namespace_route( fn ingest_scope_route( call: &ruby_prism::CallNode<'_>, file: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult { let mut path = first_name_arg(call); let mut module: Option = None; @@ -843,7 +990,7 @@ fn ingest_scope_route( let Some(assoc) = el.as_assoc_node() else { continue }; let Some(key) = symbol_value(&assoc.key()) else { continue }; let value = assoc.value(); - let val = symbol_value(&value).or_else(|| string_value(&value)); + let val = rname(&value); match key.as_str() { "path" => path = val.or(path), "module" => module = val, @@ -876,32 +1023,187 @@ fn ingest_scope_route( } } } - let entries = block_entries(call, file, None, draws)?; + let entries = block_entries(call, file, None, cx)?; Ok(RouteSpec::Scope { path, module, as_prefix, defaults, nest: false, entries }) } -/// `draw(:admin)` — Rails loads `config/routes/admin.rb` into the same -/// DSL context. The split file's top-level statements are route DSL -/// directly (no `routes.draw` wrapper). Included entries ride a -/// facet-less Scope so the flattener composes them transparently. -fn ingest_draw_route( +/// `draw(:admin)` / `load(root.join("config", "routes", "admin.rb"))` / +/// `instance_eval(File.read(root.join("config/routes/admin.rb")))` — +/// Rails runs another route file. All three name a file under +/// `config/routes/`, and the ingester keys those by their path below +/// that directory (`admin`, `services/internal`). +/// +/// `draw(:name)` runs the file inside the CURRENT mapper, so its +/// statements are route DSL directly and inherit the enclosing scope: +/// included entries ride a facet-less Scope the flattener composes +/// transparently. `load` runs the file as plain Ruby, and a routes file +/// written for it carries its own `Rails.application.routes.draw do … +/// end` — a fresh mapper, so its routes belong at the top of the table +/// wherever the `load` was written. +fn ingest_route_file_include( call: &ruby_prism::CallNode<'_>, + method: &str, file: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { - let Some(name) = first_name_arg(call) else { + let Some(args) = call.arguments() else { + return Err(IngestError::Unsupported { + file: file.into(), + message: format!("{method} without a route-file name"), + }); + }; + let first = args.arguments().iter().next(); + let key = first.as_ref().and_then(|arg| { + if method == "draw" { + // `draw(:admin)`; a path-shaped argument also names a file. + symbol_or_string_value(arg).or_else(|| route_file_key(arg)) + } else { + route_file_key(arg) + } + }); + let Some(key) = key else { return Err(IngestError::Unsupported { file: file.into(), - message: "draw without a route-file name".into(), + message: format!("{method} of a route file whose path is not a literal"), }); }; - let Some((source, path)) = resolve_draw_name(&name, draws) else { + let Some((source, path)) = resolve_draw_name(&key, cx.draws) else { return Err(IngestError::Unsupported { file: file.into(), - message: format!("draw(:{name}) — config/routes/{name}.rb not found"), + message: format!("{method}(:{key}) — config/routes/{key}.rb not found"), }); }; - let entries = ingest_routes_file_entries(source, path, draws)?; + if cx.active.borrow().iter().any(|k| k == &key) { + return Ok(None); + } + super::sources::register(path, &String::from_utf8_lossy(source)); + let result = super::prism::parse(source, path); + let root = result.node(); + let Some(program) = root.as_program_node() else { + return Err(IngestError::Parse { + file: path.clone(), + message: "route file is not a program".into(), + }); + }; + cx.active.borrow_mut().push(key); + let included = (|| -> IngestResult> { + let drawn = top_level_app_draws(&root); + if method != "draw" && !drawn.is_empty() { + let mut entries = Vec::new(); + for draw_call in &drawn { + let Some(block) = draw_call.block().and_then(|b| b.as_block_node()) else { + continue; + }; + if let Some(body) = block.body() { + entries.extend(ingest_route_body(body, path, None, cx)?); + } + } + cx.hoisted.borrow_mut().extend(entries); + return Ok(None); + } + let entries = + ingest_route_stmts(program.statements().body().iter(), path, None, cx)?; + Ok(Some(RouteSpec::Scope { + path: None, + module: None, + as_prefix: None, + defaults: IndexMap::new(), + nest: false, + entries, + })) + })(); + cx.active.borrow_mut().pop(); + included +} + +/// The route-file key a path expression names: the literal fragments of +/// `root.join("config", "routes", "services", "internal.rb").to_s`, +/// `Rails.root.join("config/routes/x.rb")`, `File.read(…)` of either, +/// read below the last `config/routes/` and without the extension. +fn route_file_key(node: &Node<'_>) -> Option { + let mut parts = Vec::new(); + path_fragments(node, &mut parts); + let joined = parts.join("/"); + let below = joined.rsplit_once("config/routes/")?.1; + let key = below.strip_suffix(".rb").unwrap_or(below); + (!key.is_empty()).then(|| key.to_string()) +} + +fn path_fragments(node: &Node<'_>, out: &mut Vec) { + if let Some(s) = string_value(node) { + out.push(s.trim_matches('/').to_string()); + } else if let Some(sym) = symbol_value(node) { + out.push(sym); + } else if let Some(call) = node.as_call_node() { + if let Some(recv) = call.receiver() { + path_fragments(&recv, out); + } + if let Some(args) = call.arguments() { + for arg in args.arguments().iter() { + path_fragments(&arg, out); + } + } + } else if let Some(parens) = node.as_parentheses_node() { + if let Some(body) = parens.body() { + for stmt in flatten_statements(body) { + path_fragments(&stmt, out); + } + } + } +} + +/// `concern :graphql_routes do |options| … end`. +fn register_concern(call: &ruby_prism::CallNode<'_>, file: &str, cx: &Ctx<'_>) { + let Some(name) = call + .arguments() + .and_then(|args| args.arguments().iter().next()) + .and_then(|arg| symbol_or_string_value(&arg)) + else { + return; + }; + let Some(block) = call.block().and_then(|b| b.as_block_node()) else { return }; + let param = block + .parameters() + .and_then(|p| p.as_block_parameters_node()) + .and_then(|p| p.parameters()) + .and_then(|p| { + p.requireds() + .iter() + .next() + .and_then(|r| r.as_required_parameter_node().map(|r| constant_id_str(&r.name()).to_string())) + .or_else(|| { + p.optionals().iter().next().and_then(|o| { + o.as_optional_parameter_node() + .map(|o| constant_id_str(&o.name()).to_string()) + }) + }) + }); + let body = block.body().map(|b| b.location().as_slice().to_vec()); + cx.concerns + .borrow_mut() + .insert(name, Concern { param, body, file: file.to_string() }); +} + +/// `concerns :a, :b, k: v` — run each named concern here, its options +/// bound to the block's parameter. Rails builds a fresh options hash per +/// concern from the trailing keywords. +fn ingest_concerns( + call: &ruby_prism::CallNode<'_>, + file: &str, + parent: Option<&str>, + cx: &Ctx<'_>, +) -> IngestResult> { + let Some(args) = call.arguments() else { return Ok(None) }; + let mut names = Vec::new(); + let mut options = eval::RVal::Hash(Vec::new()); + for arg in args.arguments().iter() { + if let Some(name) = symbol_or_string_value(&arg) { + names.push(name); + } else if arg.as_keyword_hash_node().is_some() || arg.as_hash_node().is_some() { + options = eval::value(&arg).unwrap_or(eval::RVal::Hash(Vec::new())); + } + } + let entries = run_concerns(&names, &options, file, parent, cx)?; Ok(Some(RouteSpec::Scope { path: None, module: None, @@ -912,6 +1214,237 @@ fn ingest_draw_route( })) } +/// Replay each named concern's body where it is used, `options` bound to +/// the block's parameter. Shared by `concerns :a, :b` and the +/// `resources :x, concerns: :a` option, which Rails routes through the +/// same `concerns` call inside the resource's scope. +fn run_concerns( + names: &[String], + options: &eval::RVal, + file: &str, + parent: Option<&str>, + cx: &Ctx<'_>, +) -> IngestResult> { + let mut entries = Vec::new(); + for name in names { + let (param, body, def_file) = { + let concerns = cx.concerns.borrow(); + let Some(concern) = concerns.get(name) else { + return Err(IngestError::Unsupported { + file: file.into(), + message: format!("concerns :{name} — no such concern"), + }); + }; + (concern.param.clone(), concern.body.clone(), concern.file.clone()) + }; + let Some(body) = body else { continue }; + if cx.active.borrow().iter().any(|k| k == &format!("concern:{name}")) { + continue; + } + let result = super::prism::parse(&body, &def_file); + let root = result.node(); + let Some(program) = root.as_program_node() else { continue }; + eval::push_frame(); + if let Some(param) = ¶m { + eval::bind(param, options.clone()); + } + cx.active.borrow_mut().push(format!("concern:{name}")); + let ran = ingest_route_stmts(program.statements().body().iter(), &def_file, parent, cx); + cx.active.borrow_mut().pop(); + eval::pop_frame(); + entries.extend(ran?); + } + Ok(entries) +} + +/// The values a route file computes with — the small slice of Ruby a +/// `concern` needs (`options[:prefix] || ""`, `"#{prefix}x"`), evaluated +/// where the DSL reads a name or a path. Anything else is unknown, and +/// an unknown name drops its route exactly as before. +mod eval { + use std::cell::RefCell; + use std::collections::HashMap; + + use ruby_prism::Node; + + use crate::ingest::util::{constant_id_str, flatten_statements}; + + #[derive(Clone, Debug)] + pub(super) enum RVal { + Str(String), + Sym(String), + Nil, + Hash(Vec<(String, RVal)>), + Unknown, + } + + thread_local! { + static FRAMES: RefCell>> = const { RefCell::new(Vec::new()) }; + } + + pub(super) fn push_frame() { + FRAMES.with(|f| f.borrow_mut().push(HashMap::new())); + } + + pub(super) fn pop_frame() { + FRAMES.with(|f| { + f.borrow_mut().pop(); + }); + } + + pub(super) fn bind(name: &str, value: RVal) { + FRAMES.with(|f| { + if let Some(top) = f.borrow_mut().last_mut() { + top.insert(name.to_string(), value); + } + }); + } + + fn lookup(name: &str) -> Option { + FRAMES.with(|f| f.borrow().last().and_then(|top| top.get(name).cloned())) + } + + fn text(v: &RVal) -> Option { + match v { + RVal::Str(s) | RVal::Sym(s) => Some(s.clone()), + RVal::Nil => Some(String::new()), + _ => None, + } + } + + /// Evaluate `node`; `None` for anything outside the slice. + pub(super) fn value(node: &Node<'_>) -> Option { + if let Some(s) = node.as_string_node() { + return Some(RVal::Str(String::from_utf8_lossy(s.unescaped()).into_owned())); + } + if let Some(s) = node.as_symbol_node() { + let loc = s.value_loc()?; + return Some(RVal::Sym(String::from_utf8_lossy(loc.as_slice()).into_owned())); + } + if node.as_nil_node().is_some() { + return Some(RVal::Nil); + } + if let Some(read) = node.as_local_variable_read_node() { + return lookup(constant_id_str(&read.name())); + } + if let Some(interp) = node.as_interpolated_string_node() { + return interpolate(interp.parts().iter()).map(RVal::Str); + } + if let Some(interp) = node.as_interpolated_symbol_node() { + return interpolate(interp.parts().iter()).map(RVal::Sym); + } + if let Some(or) = node.as_or_node() { + let left = value(&or.left())?; + return match left { + RVal::Nil => value(&or.right()), + other => Some(other), + }; + } + if let Some(parens) = node.as_parentheses_node() { + let body = parens.body()?; + let stmts = flatten_statements(body); + return match stmts.as_slice() { + [only] => value(only), + _ => None, + }; + } + if let Some(call) = node.as_call_node() { + // Concern bodies are reparsed without the block-parameter + // declaration, so a bound parameter like `options` parses as a + // receiverless CallNode. Resolve it through the bound frame. + if call.receiver().is_none() && call.arguments().is_none() && call.block().is_none() { + if let Some(bound) = lookup(constant_id_str(&call.name())) { + return Some(bound); + } + } + // `options[:key]` + if constant_id_str(&call.name()) == "[]" { + let recv = value(&call.receiver()?)?; + let args = call.arguments()?; + let key = value(&args.arguments().iter().next()?)?; + let key = text(&key)?; + return match recv { + RVal::Hash(pairs) => Some( + pairs + .into_iter() + .find(|(k, _)| *k == key) + .map(|(_, v)| v) + .unwrap_or(RVal::Nil), + ), + RVal::Nil => Some(RVal::Nil), + _ => None, + }; + } + return None; + } + if let Some(hash) = node.as_keyword_hash_node() { + return pairs(hash.elements().iter()); + } + if let Some(hash) = node.as_hash_node() { + return pairs(hash.elements().iter()); + } + None + } + + fn pairs<'pr>(elements: impl Iterator>) -> Option { + let mut out = Vec::new(); + for el in elements { + let assoc = el.as_assoc_node()?; + let key = text(&value(&assoc.key())?)?; + out.push((key, value(&assoc.value()).unwrap_or(RVal::Unknown))); + } + Some(RVal::Hash(out)) + } + + fn interpolate<'pr>(parts: impl Iterator>) -> Option { + let mut out = String::new(); + for part in parts { + if let Some(s) = part.as_string_node() { + out.push_str(&String::from_utf8_lossy(s.unescaped())); + } else if let Some(embedded) = part.as_embedded_statements_node() { + let stmts = flatten_statements(embedded.statements()?.as_node()); + let [only] = stmts.as_slice() else { return None }; + out.push_str(&text(&value(only)?)?); + } else { + return None; + } + } + Some(out) + } + + /// A string: a literal, or an expression that evaluates to one. + pub(super) fn string(node: &Node<'_>) -> Option { + match value(node)? { + RVal::Str(s) => Some(s), + _ => None, + } + } + + /// A symbol: a literal, or an expression that evaluates to one. + pub(super) fn symbol(node: &Node<'_>) -> Option { + match value(node)? { + RVal::Sym(s) => Some(s), + _ => None, + } + } +} + +/// [`string_value`], plus a string the surrounding concern computed. +fn rstring(node: &Node<'_>) -> Option { + string_value(node).or_else(|| eval::string(node)) +} + +/// [`symbol_value`], plus a symbol the surrounding concern computed +/// (`as: :"#{route_prefix}web_pixel_javascript"`). +fn rsymbol(node: &Node<'_>) -> Option { + symbol_value(node).or_else(|| eval::symbol(node)) +} + +/// [`symbol_or_string_value`], with the same allowance. +fn rname(node: &Node<'_>) -> Option { + rsymbol(node).or_else(|| rstring(node)) +} + /// Resolve a `draw(:name)` argument against the `config/routes/` file /// map. `draws` is keyed by the path RELATIVE TO `config/routes/` /// (without `.rb`), so a subdirectory-nested file's full relative name @@ -946,7 +1479,7 @@ fn resolve_draw_name<'a>( fn ingest_routes_file_entries( source: &[u8], path: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { super::sources::register(path, &String::from_utf8_lossy(source)); let result = super::prism::parse(source, path); @@ -957,7 +1490,13 @@ fn ingest_routes_file_entries( message: "route file is not a program".into(), }); }; - ingest_route_stmts(program.statements().body().iter(), path, None, draws) + if cx.active.borrow().iter().any(|active| active == path) { + return Ok(Vec::new()); + } + cx.active.borrow_mut().push(path.to_string()); + let result = ingest_route_stmts(program.statements().body().iter(), path, None, cx); + cx.active.borrow_mut().pop(); + result } /// Recognize the receiver of a top-level `.each do |v| draw(...) @@ -1024,7 +1563,7 @@ fn ingest_glob_draw_each( call: &ruby_prism::CallNode<'_>, pattern: &str, file: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, ) -> IngestResult> { let Some(block) = call.block().and_then(|b| b.as_block_node()) else { return Err(IngestError::Unsupported { @@ -1047,19 +1586,19 @@ fn ingest_glob_draw_each( } let mut matched: Vec<&String> = - draws.keys().filter(|key| glob_matches(pattern, &format!("{key}.rb"))).collect(); + cx.draws.keys().filter(|key| glob_matches(pattern, &format!("{key}.rb"))).collect(); matched.sort(); let mut entries = Vec::with_capacity(matched.len()); for key in matched { - let (source, path) = &draws[key]; + let (source, path) = &cx.draws[key]; entries.push(RouteSpec::Scope { path: None, module: None, as_prefix: None, defaults: IndexMap::new(), nest: false, - entries: ingest_routes_file_entries(source, path, draws)?, + entries: ingest_routes_file_entries(source, path, cx)?, }); } Ok(entries) @@ -1138,6 +1677,7 @@ fn ingest_explicit_route( let mut redirect_target: Option<(String, u16, bool)> = None; let mut as_name: Option = None; let mut action_kwarg: Option = None; + let mut controller_kwarg: Option = None; // The INLINE spelling of `member do … end` / `collection do … end`. // Rails accepts both, and campfire writes `delete :clear, on: // :collection`; only the block form was recognized, so the route was @@ -1147,7 +1687,7 @@ fn ingest_explicit_route( let mut constraints: IndexMap = IndexMap::new(); for arg in args_node.arguments().iter() { - if let Some(s) = string_value(&arg) { + if let Some(s) = rstring(&arg) { // Positional string arg — the path: `get "/p", to: "c#a"`. if path.is_none() { path = Some(s); @@ -1182,7 +1722,7 @@ fn ingest_explicit_route( if let Some(key_str) = string_value(&key_node) { if path.is_none() { path = Some(key_str); - if let Some(v) = string_value(value) { + if let Some(v) = rstring(value) { to = Some(v); } else { // `get "/p" => redirect("/q")` — the @@ -1201,7 +1741,7 @@ fn ingest_explicit_route( let Some(key_sym) = symbol_value(&key_node) else { continue }; match key_sym.as_str() { "to" => { - if let Some(v) = string_value(value) { + if let Some(v) = rstring(value) { to = Some(v); } else if let Some(r) = redirect_literal(value) { redirect_target = Some(r); @@ -1219,9 +1759,9 @@ fn ingest_explicit_route( // name (`show_path` for `user_path`), leaving every // `user_path`/`tag_path`/… call unresolved. "as" => { - as_name = symbol_value(value) + as_name = rsymbol(value) .map(Symbol::from) - .or_else(|| string_value(value).map(Symbol::from)); + .or_else(|| rstring(value).map(Symbol::from)); } "on" => { on_scope = match symbol_value(value).as_deref() { @@ -1232,10 +1772,11 @@ fn ingest_explicit_route( } // `post "suggest", :action => "submit_suggestions"` — // the action override for a resource-scoped shortcut. - "action" => { - action_kwarg = - string_value(value).or_else(|| symbol_value(value)); - } + "action" => action_kwarg = rname(value), + // `match :graphql, controller: "one/graphql", action: + // :query, via: :post` — the target spelled as two + // options where `to: "one/graphql#query"` is one. + "controller" => controller_kwarg = rname(value), // `via:` picks the verbs of a `match`; read by // `expand_match_via` once the entry is built. Other // string-value options become routing constraints. @@ -1307,6 +1848,15 @@ fn ingest_explicit_route( let (controller, action) = match to.as_deref().and_then(|s| s.split_once('#')) { Some((c, a)) => (c.to_string(), a.to_string()), + None if controller_kwarg.is_some() => { + // `controller:` names the class and `action:` the method; + // without an `action:` Rails takes the path's last segment. + let Some(p) = path.as_deref() else { return Ok(None) }; + let stem = p.trim_matches('/').rsplit('/').next().unwrap_or("").to_string(); + let action = action_kwarg.or((!stem.is_empty()).then_some(stem)); + let Some(action) = action else { return Ok(None) }; + (controller_kwarg.unwrap_or_default(), action) + } None => { // No `to:` and no hashrocket target — a resource-scoped // shortcut (`get "suggest"` / `post "suggest", :action => @@ -1365,7 +1915,7 @@ fn ingest_root_route( let mut redirect_target: Option<(String, u16, bool)> = None; if let Some(args_node) = call.arguments() { for arg in args_node.arguments().iter() { - if let Some(s) = string_value(&arg) { + if let Some(s) = rstring(&arg) { if target.is_none() { target = Some(s); } @@ -1374,7 +1924,7 @@ fn ingest_root_route( let Some(assoc) = el.as_assoc_node() else { continue }; let Some(key_sym) = symbol_value(&assoc.key()) else { continue }; if key_sym.as_str() == "to" { - if let Some(v) = string_value(&assoc.value()) { + if let Some(v) = rstring(&assoc.value()) { target = Some(v); } else if let Some(r) = redirect_literal(&assoc.value()) { redirect_target = Some(r); @@ -1423,7 +1973,7 @@ fn ingest_root_route( fn ingest_resources_route( call: &ruby_prism::CallNode<'_>, file: &str, - draws: &HashMap, String)>, + cx: &Ctx<'_>, singular: bool, ) -> IngestResult { let Some(args_node) = call.arguments() else { @@ -1440,7 +1990,7 @@ fn ingest_resources_route( })?; // `resources "tours"` is `resources :tours` — Rails `to_sym`s the // name (#85). - let name_str = symbol_or_string_value(&first).ok_or_else(|| IngestError::Unsupported { + let name_str = rname(&first).ok_or_else(|| IngestError::Unsupported { file: file.into(), message: "resources name must be a symbol or string".into(), })?; @@ -1452,6 +2002,7 @@ fn ingest_resources_route( let mut controller: Option = None; let mut param: Option = None; let mut path: Option = None; + let mut concern_names: Vec = Vec::new(); let mut only_none = false; for arg in iter { let Some(kh) = arg.as_keyword_hash_node() else { continue }; @@ -1505,14 +2056,14 @@ fn ingest_resources_route( // it named those helpers `mod_mails_path`, which both // missed every call site and collided with the top-level // `resources :mod_mails`. - "as" => as_name = symbol_or_string_value(&value).map(|s| Symbol::from(s.as_str())), + "as" => as_name = rname(&value).map(|s| Symbol::from(s.as_str())), // `controller:` moves the CLASS and nothing else — the // path still comes from the resource name and so do the // helpers. campfire's bot API is `resources :messages, // controller: "messages/by_bots"`, and dropping this // pointed five routes at `MessagesController`, which // answers them with the human HTML flow. - "controller" => controller = symbol_or_string_value(&value), + "controller" => controller = rname(&value), // `param: :task_id` renames the MEMBER SEGMENT: the // path binds `:task_id` and the controller reads // `params[:task_id]`. Dropped, the path bound `:id` @@ -1559,6 +2110,14 @@ fn ingest_resources_route( } path = Some(segment.to_string()); } + // `concerns: :c` / `concerns: [:a, :b]` replays those + // concerns inside the resource's scope, after its block. + "concerns" => { + concern_names = match value.as_array_node() { + Some(a) => a.elements().iter().filter_map(|e| rname(&e)).collect(), + None => rname(&value).into_iter().collect(), + }; + } // `shallow:` lands when a fixture demands it. _ => {} } @@ -1567,13 +2126,17 @@ fn ingest_resources_route( if only_none { only.clear(); - except = ["index", "new", "create", "show", "edit", "update", "destroy"] + except = ALL_RESOURCE_ACTIONS.iter().copied() .into_iter() .map(Symbol::from) .collect(); } - let nested = block_entries(call, file, Some(name_str.as_str()), draws)?; + let mut nested = block_entries(call, file, Some(name_str.as_str()), cx)?; + if !concern_names.is_empty() { + let none = eval::RVal::Hash(Vec::new()); + nested.extend(run_concerns(&concern_names, &none, file, Some(name_str.as_str()), cx)?); + } Ok(RouteSpec::Resources { name, @@ -1598,3 +2161,7 @@ fn controller_class_name(short: &str) -> String { s.push_str("Controller"); s } + +/// Every action `resources`/`resource` can generate a route for. +const ALL_RESOURCE_ACTIONS: [&str; 7] = + ["index", "show", "new", "create", "edit", "update", "destroy"]; diff --git a/src/ingest/singleton_class.rs b/src/ingest/singleton_class.rs new file mode 100644 index 000000000..a19b3506b --- /dev/null +++ b/src/ingest/singleton_class.rs @@ -0,0 +1,227 @@ +//! `class << self … end` inside a class body: the class-side half of the +//! class, written as a block. +//! +//! Ruby evaluates the body with `self` set to the class's singleton +//! class, so: +//! +//! * a `def` defines a CLASS method (`Room.create_for`); +//! * `attr_accessor` / `attr_reader` / `attr_writer` define class-level +//! accessors over the class's own instance variables; +//! * `alias_method` aliases a class method; +//! * `include Mod` mixes `Mod` into the singleton class — `Mod`'s +//! instance methods become class methods, the same thing `extend Mod` +//! says in the class body proper; +//! * a constant assignment lands in the singleton class's lexical +//! scope, which the block's own methods read by bare name; +//! * `sig`, `abstract!`, `private_constant`, and the visibility markers +//! carry no behavior the flattened method list can hold. +//! +//! Models and controllers both walk their class body one statement at a +//! time, and a `class << self` is one statement that stands for many, so +//! both expand it through [`ingest_singleton_body`]. (Library classes +//! keep their own richer walk in `library_class::walk_decl_body`.) +//! +//! ## Recovery +//! +//! A statement this walk cannot read costs itself. Before, the first +//! one aborted the whole block, and with it every `def` the block +//! declared: on Shop, Order and Product a single `RATE = 5` or `sig { … }` +//! erased the class's entire class-side API, and every call into it was +//! reported as a missing method. Strict mode still refuses the first +//! unreadable statement; survey mode records it and keeps reading. + +use ruby_prism::Node; + +use crate::dialect::{MethodDef, MethodReceiver}; +use crate::expr::{Expr, ExprNode}; +use crate::ident::{ClassId, Symbol}; + +use super::expr::ingest_expr; +use super::library_class::{ + alias_source, is_sorbet_annotation_mixin, is_sorbet_type_alias, synth_attr_reader, + synth_attr_writer, SORBET_ANNOTATIONS, +}; +use super::util::{constant_id_str, flatten_statements, symbol_value}; +use super::{IngestError, IngestResult}; + +/// What one `class << self` block declares. +#[derive(Default)] +pub(super) struct SingletonBody { + /// The block's methods, all `MethodReceiver::Class`, in source order. + pub methods: Vec, + /// Statements to replay in the enclosing class body: the block's + /// constant assignments, and `extend Mod` for each `include Mod`. + /// Held as raw expressions, exactly like any other class-body + /// statement the enclosing walk does not classify. + pub class_body: Vec, +} + +const VISIBILITY: &[&str] = + &["private", "protected", "public", "private_class_method", "public_class_method"]; + +/// Expand one `class << self` block. `ingest_def` reads a `def` the way +/// the enclosing walk reads its own methods, so a controller's class +/// methods and a model's carry the parameter model of their side. +pub(super) fn ingest_singleton_body( + sc: &ruby_prism::SingletonClassNode<'_>, + owner: &ClassId, + file: &str, + ingest_def: &dyn Fn(&ruby_prism::DefNode<'_>) -> IngestResult, +) -> IngestResult { + let mut out = SingletonBody::default(); + // Only `class << self` is the enclosing class's own singleton. The + // `class << other_object` form opens somebody else's. + if sc.expression().as_self_node().is_none() { + return unsupported(file, "unsupported `class << `: only `class << self` is modeled") + .map(|()| out); + } + let Some(body) = sc.body() else { return Ok(out) }; + for stmt in flatten_statements(body) { + walk_statement(&stmt, owner, file, ingest_def, &mut out)?; + } + Ok(out) +} + +fn walk_statement( + stmt: &Node<'_>, + owner: &ClassId, + file: &str, + ingest_def: &dyn Fn(&ruby_prism::DefNode<'_>) -> IngestResult, + out: &mut SingletonBody, +) -> IngestResult<()> { + if let Some(def) = stmt.as_def_node() { + return push_def(&def, ingest_def, out); + } + if let Some(cw) = stmt.as_constant_write_node() { + // `Result = T.type_alias { … }` is a type, not a value. + if is_sorbet_type_alias(&cw.value()) { + return Ok(()); + } + return match ingest_expr(stmt, file) { + Ok(expr) => { + out.class_body.push(expr); + Ok(()) + } + Err(err) => recover(err), + }; + } + let Some(call) = stmt.as_call_node() else { + return unsupported(file, &format!("unsupported statement inside `class << self`: {stmt:?}")); + }; + let name = constant_id_str(&call.name()).to_string(); + let receiverless = call.receiver().is_none(); + + // `sig { … }` and `T::Sig::WithoutRuntime.sig { … }` — types, read + // by `sorbet_sig` already. + if name == "sig" || (receiverless && SORBET_ANNOTATIONS.contains(&name.as_str())) { + return Ok(()); + } + if !receiverless { + return unsupported( + file, + &format!("unsupported statement inside `class << self`: call to `{name}` on a receiver"), + ); + } + let args: Vec> = call + .arguments() + .map(|a| a.arguments().iter().collect()) + .unwrap_or_default(); + + // Visibility: `private` alone changes nothing this list carries; + // `private def x` and `private_class_method def x` also define x. + if VISIBILITY.contains(&name.as_str()) { + for arg in &args { + if let Some(def) = arg.as_def_node() { + push_def(&def, ingest_def, out)?; + } + } + return Ok(()); + } + if matches!(name.as_str(), "private_constant" | "public_constant") { + return Ok(()); + } + // Deprecation annotates an existing method without changing the + // class-side surface, just as it does in the library-class walk. + if name == "deprecate" && call.block().is_none() { + return Ok(()); + } + + match name.as_str() { + "attr_reader" | "attr_writer" | "attr_accessor" => { + for arg in &args { + let Some(sym) = symbol_value(arg) else { + return unsupported( + file, + &format!("unsupported statement inside `class << self`: `{name}` with a non-symbol name"), + ); + }; + let sym = Symbol::from(sym.as_str()); + if name != "attr_writer" { + out.methods.push(synth_attr_reader(owner, &sym, MethodReceiver::Class)); + } + if name != "attr_reader" { + out.methods.push(synth_attr_writer(owner, &sym, MethodReceiver::Class)); + } + } + Ok(()) + } + // Copies the method as it stands, like the library-class walk. + "alias_method" => match alias_source(&call, &out.methods, true) { + Some((to, source)) => { + let mut copy = out.methods[source].clone(); + copy.name = Symbol::from(to.as_str()); + out.methods.push(copy); + Ok(()) + } + None => unsupported( + file, + "unsupported statement inside `class << self`: `alias_method` of a method the block does not define", + ), + }, + "include" | "extend" if is_sorbet_annotation_mixin(&call) => Ok(()), + // The singleton class including `Mod` is the class extending it. + "include" => match ingest_expr(stmt, file) { + Ok(mut expr) => { + if let ExprNode::Send { method, .. } = &mut *expr.node { + *method = Symbol::from("extend"); + } + out.class_body.push(expr); + Ok(()) + } + Err(err) => recover(err), + }, + _ => unsupported( + file, + &format!("unsupported statement inside `class << self`: call to `{name}`"), + ), + } +} + +fn push_def( + def: &ruby_prism::DefNode<'_>, + ingest_def: &dyn Fn(&ruby_prism::DefNode<'_>) -> IngestResult, + out: &mut SingletonBody, +) -> IngestResult<()> { + match ingest_def(def) { + Ok(mut method) => { + method.receiver = MethodReceiver::Class; + out.methods.push(method); + Ok(()) + } + Err(err) => recover(err), + } +} + +/// Survey mode records the gap and reads on; strict mode refuses. +fn recover(err: IngestError) -> IngestResult<()> { + if super::survey::is_active() { + super::survey::record(&err); + Ok(()) + } else { + Err(err) + } +} + +fn unsupported(file: &str, message: &str) -> IngestResult<()> { + recover(IngestError::Unsupported { file: file.into(), message: message.into() }) +} diff --git a/src/ingest/sorbet_sig.rs b/src/ingest/sorbet_sig.rs index 3d8c57627..2db85877d 100644 --- a/src/ingest/sorbet_sig.rs +++ b/src/ingest/sorbet_sig.rs @@ -31,12 +31,26 @@ use super::util::constant_id_str; /// Method signatures declared with `sig` in one Ruby file, keyed by the /// enclosing class's qualified name. pub fn ingest_sorbet_signatures(source: &[u8]) -> HashMap> { + ingest_sorbet_declarations(source).0 +} + +/// Signatures plus the names declared `sig { abstract… }` per class or +/// module. An abstract method is a declaration with no implementation to +/// carry: the includer supplies it. +pub fn ingest_sorbet_declarations( + source: &[u8], +) -> ( + HashMap>, + HashMap>, +) { let result = ruby_prism::parse(source); let node = result.node(); let Some(program) = node.as_program_node() else { - return HashMap::new(); + return (HashMap::new(), HashMap::new()); }; let mut out: HashMap> = HashMap::new(); + let mut abstracts = HashMap::new(); + let mut sides = MethodSides::new(); walk( &program.statements().body().iter().collect::>(), None, @@ -44,11 +58,32 @@ pub fn ingest_sorbet_signatures(source: &[u8]) -> HashMap; + /// `in_singleton_class` is true while walking a `class << self` body. /// The methods there are the enclosing class's SINGLETON methods even /// though their `def` carries no receiver, which is the one thing a @@ -61,6 +96,8 @@ fn walk( aliases: &HashMap, rbs_aliases: &crate::rbs::AliasTable, out: &mut HashMap>, + abstracts: &mut HashMap>, + sides: &mut MethodSides, source: &[u8], ) { // The `sig` immediately above a `def` is the one that applies to @@ -92,7 +129,7 @@ fn walk( // matter; an enclosing scope's aliases stay visible. let inner = collect_type_aliases(&statements, aliases); let inner_rbs = scoped_rbs_aliases(source, class.location(), &statements, rbs_aliases); - walk(&statements, Some(&name), false, &inner, &inner_rbs, out, source); + walk(&statements, Some(&name), false, &inner, &inner_rbs, out, abstracts, sides, source); } } pending = None; @@ -105,7 +142,7 @@ fn walk( let statements = body.body().iter().collect::>(); let inner = collect_type_aliases(&statements, aliases); let inner_rbs = scoped_rbs_aliases(source, module.location(), &statements, rbs_aliases); - walk(&statements, Some(&name), false, &inner, &inner_rbs, out, source); + walk(&statements, Some(&name), false, &inner, &inner_rbs, out, abstracts, sides, source); } } pending = None; @@ -120,14 +157,30 @@ fn walk( if let Some(singleton) = statement.as_singleton_class_node() { if let Some(body) = singleton.body() { if let Some(body) = body.as_statements_node() { - walk(&body.body().iter().collect::>(), scope, true, aliases, rbs_aliases, out, source); + walk(&body.body().iter().collect::>(), scope, true, aliases, rbs_aliases, out, abstracts, sides, source); } } pending = None; continue; } if let Some(def) = statement.as_def_node() { + if let Some(scope) = scope { + let entry = sides + .entry((ClassId(Symbol::new(scope)), Symbol::new(constant_id_str(&def.name())))) + .or_default(); + if in_singleton_class || def.receiver().is_some() { + entry.1 = true; + } else { + entry.0 = true; + } + } if let (Some(sig), Some(scope)) = (pending.take(), scope) { + if !in_singleton_class && def.receiver().is_none() && sig_is_abstract(&statements[sig]) { + abstracts + .entry(ClassId(Symbol::new(scope))) + .or_default() + .insert(Symbol::new(constant_id_str(&def.name()))); + } if let Some(ty) = signature_ty(&statements[sig], &def, in_singleton_class, aliases) { out.entry(ClassId(Symbol::new(scope))) .or_default() @@ -292,18 +345,36 @@ fn literal_ty(node: &Node<'_>) -> Option { None } -fn symbol_name(node: &Node<'_>) -> Option { +pub(super) fn symbol_name(node: &Node<'_>) -> Option { let symbol = node.as_symbol_node()?; Some(String::from_utf8_lossy(symbol.value_loc()?.as_slice()).into_owned()) } -fn is_sig_call(call: &ruby_prism::CallNode<'_>) -> bool { +/// Does the `sig` chain carry the `abstract` modifier? +fn sig_is_abstract(sig: &Node<'_>) -> bool { + let Some(call) = sig.as_call_node() else { return false }; + let Some(block) = call.block() else { return false }; + let Some(block) = block.as_block_node() else { return false }; + let Some(body) = block.body() else { return false }; + let Some(body) = body.as_statements_node() else { return false }; + let mut link = body.body().iter().next(); + while let Some(node) = link { + let Some(call) = node.as_call_node() else { return false }; + if constant_id_str(&call.name()) == "abstract" { + return true; + } + link = call.receiver(); + } + false +} + +pub(super) fn is_sig_call(call: &ruby_prism::CallNode<'_>) -> bool { let name = call.name(); constant_id_str(&name) == "sig" && call.receiver().is_none() && call.block().is_some() } /// `Foo`, `Foo::Bar` — the class's own path as written. -fn constant_path_name(node: &Node<'_>) -> String { +pub(super) fn constant_path_name(node: &Node<'_>) -> String { if let Some(read) = node.as_constant_read_node() { return constant_id_str(&read.name()).to_string(); } @@ -320,7 +391,7 @@ fn constant_path_name(node: &Node<'_>) -> String { String::new() } -fn qualify(scope: Option<&str>, name: &str) -> String { +pub(super) fn qualify(scope: Option<&str>, name: &str) -> String { match scope { Some(scope) if !name.is_empty() => format!("{scope}::{name}"), _ => name.to_string(), @@ -386,7 +457,11 @@ fn signature_ty( } // Modifiers carry no type: `override`, `overridable`, // `abstract`, `final`, `checked(:never)`, `type_parameters`. - "override" | "overridable" | "abstract" | "final" | "checked" => {} + // `type_parameters(:U)` only DECLARES the variables a + // `T.type_parameter(:U)` below names; `bind(Foo)` says what + // `self` is inside a block the method takes. + "override" | "overridable" | "abstract" | "final" | "checked" | "type_parameters" + | "bind" => {} _ => return None, } link = call.receiver(); @@ -396,9 +471,24 @@ fn signature_ty( } let mut params = Vec::new(); + let mut block = None; if let Some(parameters) = def.parameters() { for (name, kind) in def_parameters(¶meters)? { - let ty = declared.remove(&name)?; + let mut ty = declared.remove(&name)?; + // The block parameter is the method's block: `T.proc…`, or + // `T.nilable(T.proc…)` when a caller may omit it. Recorded + // both as the block and as the parameter, the way the RBS + // reader does, holding the callable itself. + if kind == ParamKind::Block { + if let Ty::Union { variants } = &ty { + if let [only @ Ty::Fn { .. }, Ty::Nil] = variants.as_slice() { + ty = only.clone(); + } + } + if matches!(ty, Ty::Fn { .. }) { + block = Some(Box::new(ty.clone())); + } + } params.push(Param { name: Symbol::new(&name), ty, kind }); } } @@ -410,7 +500,7 @@ fn signature_ty( Some(Ty::Fn { params, - block: None, + block, ret: Box::new(returns?), effects: EffectSet::pure(), }) @@ -419,7 +509,7 @@ fn signature_ty( /// The def's own parameters, in order, with the kind Ruby gives them — /// the sig names them all the same way, so the def is what says whether /// `x` is positional, optional or keyword. -fn def_parameters( +pub(super) fn def_parameters( parameters: &ruby_prism::ParametersNode<'_>, ) -> Option> { let mut out = Vec::new(); @@ -473,7 +563,7 @@ fn def_parameters( /// parameter declared with one typed as a class nobody defines. The /// emit already drops these constants for having no runtime; this is /// the other half, reading what they were for. -fn collect_type_aliases( +pub(super) fn collect_type_aliases( statements: &[Node<'_>], outer: &HashMap, ) -> HashMap { @@ -518,7 +608,7 @@ fn collect_type_aliases( /// cannot spell, so it stays unread. `T.attached_class` needs no such /// test — it MEANS "an instance of the attached class" and sorbet only /// admits it where that is what it is. -fn sorbet_ty( +pub(super) fn sorbet_ty( node: &Node<'_>, self_is_instance: bool, aliases: &HashMap, @@ -543,13 +633,69 @@ fn sorbet_ty( } return Some(named_ty(&name)); } + // `{ max_per_pod: Integer, in: Duration }` — a shape, the hash + // whose keys are known. The same thing RBS spells `{ key: T }`. + if let Some(hash) = node.as_hash_node() { + let mut fields = indexmap::IndexMap::new(); + for element in hash.elements().iter() { + let assoc = element.as_assoc_node()?; + let key = assoc.key(); + let key = key.as_symbol_node()?; + let name = String::from_utf8_lossy(key.value_loc()?.as_slice()).into_owned(); + fields.insert( + Symbol::new(&name), + sorbet_ty(&assoc.value(), self_is_instance, aliases)?, + ); + } + return Some(Ty::Record { row: crate::ty::Row { fields, rest: None } }); + } + // `[Integer, String]` — a tuple. + if let Some(array) = node.as_array_node() { + let elems: Vec = array + .elements() + .iter() + .map(|e| sorbet_ty(&e, self_is_instance, aliases)) + .collect::>()?; + return Some(Ty::Tuple { elems }); + } + // `(Foo)` around a type is the type. + if let Some(parens) = node.as_parentheses_node() { + let body = parens.body()?.as_statements_node()?; + let mut statements = body.body().iter(); + let only = statements.next()?; + return if statements.next().is_none() { + sorbet_ty(&only, self_is_instance, aliases) + } else { + None + }; + } // `T::Array[String]`, `T::Hash[Symbol, Integer]`, `T::Set[X]` if let Some(index) = node.as_call_node() { let name = index.name(); let method = constant_id_str(&name).to_string(); + // `T.proc.params(a: A).returns(R)` / `.void` — a block or + // callable, the type RBS spells `^(A) -> R`. `bind(Foo)` says + // what `self` is inside it and `checked` how hard runtime + // checks it; neither is a type. + if let Some(fn_ty) = sorbet_proc_ty(&index, self_is_instance, aliases) { + return fn_ty; + } if method == "[]" { let receiver = index.receiver()?; let container = constant_path_name(&receiver); + // A generic app or gem class: `Result[Ok, Err]` + // is an instance of that class whatever its parameters; a + // parameter this grammar cannot read is `untyped`, not a + // reason to lose the class. + if !container.is_empty() && !container.starts_with("T::") { + let args = index + .arguments()? + .arguments() + .iter() + .map(|a| sorbet_ty(&a, self_is_instance, aliases).unwrap_or(Ty::Untyped)) + .collect(); + return Some(Ty::Class { id: ClassId(Symbol::new(&container)), args }); + } let args: Vec = index .arguments()? .arguments() @@ -571,6 +717,33 @@ fn sorbet_ty( // it. `dispatch` substitutes it there. "attached_class" => Some(Ty::SelfInstance), "self_type" if self_is_instance => Some(Ty::SelfInstance), + // A class object. `Ty` has no class-object type — a + // constant read types as the class itself and dispatch + // consults both sides — so the class stands for it, named + // as written (`T.class_of(String)` is the class String). + "class_of" => { + let argument = index.arguments()?.arguments().iter().next()?; + let id = constant_path_name(&argument); + (!id.is_empty()).then(|| Ty::Class { id: ClassId(Symbol::new(&id)), args: Vec::new() }) + } + // What `raise` returns: no value. + "noreturn" => Some(Ty::Bottom), + // The supertype of everything; nothing can be called on it, + // so `untyped` answers the same. + "anything" => Some(Ty::Untyped), + // `T.type_parameter(:U)`, declared by `type_parameters(:U)`. + // The analyzer does not instantiate a signature's variables + // per call: an unmodelled boundary, so `untyped`. + "type_parameter" => Some(Ty::Untyped), + "all" => { + let members: Vec = index + .arguments()? + .arguments() + .iter() + .map(|a| sorbet_ty(&a, self_is_instance, aliases)) + .collect::>()?; + (!members.is_empty()).then(|| crate::rbs::intersection_ty(members)) + } "nilable" => { let inner = sorbet_ty(&index.arguments()?.arguments().iter().next()?, self_is_instance, aliases)?; @@ -591,6 +764,75 @@ fn sorbet_ty( None } +/// A `T.proc` chain read as `Ty::Fn`. `Some(None)` when the chain is a +/// `T.proc` this grammar cannot read (the caller drops the signature), +/// `None` when the call is not a `T.proc` chain at all. +fn sorbet_proc_ty( + call: &ruby_prism::CallNode<'_>, + self_is_instance: bool, + aliases: &HashMap, +) -> Option> { + // Walk to the root first: only `T.proc` heads such a chain. + let mut chain = vec![call.as_node().as_call_node()?]; + loop { + let receiver = chain.last()?.receiver()?; + match receiver.as_call_node() { + Some(next) => chain.push(next), + None => { + let root = chain.pop()?; + let names_t = receiver + .as_constant_read_node() + .is_some_and(|c| constant_id_str(&c.name()) == "T"); + if !names_t || constant_id_str(&root.name()) != "proc" { + return None; + } + break; + } + } + } + let read = || -> Option { + let mut params = Vec::new(); + let mut ret: Option = None; + // Outermost link first; the inner `params` is written first, so + // walk from the root outward. + for link in chain.iter().rev() { + match constant_id_str(&link.name()) { + "params" => { + for argument in link.arguments()?.arguments().iter() { + let hash = argument.as_keyword_hash_node()?; + for element in hash.elements().iter() { + let assoc = element.as_assoc_node()?; + let key = assoc.key(); + let key = key.as_symbol_node()?; + let name = + String::from_utf8_lossy(key.value_loc()?.as_slice()).into_owned(); + params.push(Param { + name: Symbol::new(&name), + ty: sorbet_ty(&assoc.value(), self_is_instance, aliases)?, + kind: ParamKind::Required, + }); + } + } + } + "returns" => { + let argument = link.arguments()?.arguments().iter().next()?; + ret = Some(sorbet_ty(&argument, self_is_instance, aliases)?); + } + "void" => ret = Some(Ty::Nil), + "bind" | "checked" => {} + _ => return None, + } + } + Some(Ty::Fn { + params, + block: None, + ret: Box::new(ret.unwrap_or(Ty::Untyped)), + effects: EffectSet::default(), + }) + }; + Some(read()) +} + /// A constant in type position, mapped the way the RBS reader maps the /// same names so both sources agree. fn named_ty(name: &str) -> Ty { @@ -598,6 +840,8 @@ fn named_ty(name: &str) -> Ty { "Integer" => Ty::Int, "Float" => Ty::Float, "String" => Ty::Str, + "Hash" => Ty::Hash { key: Box::new(Ty::Untyped), value: Box::new(Ty::Untyped) }, + "Array" => Ty::Array { elem: Box::new(Ty::Untyped) }, "Symbol" => Ty::Sym, "TrueClass" | "FalseClass" => Ty::Bool, "NilClass" => Ty::Nil, @@ -792,3 +1036,9 @@ fn rbs_comment_text(source: &[u8], def_start: usize) -> Option { } (!sig.is_empty()).then_some(sig) } + +/// A Sorbet type expression (`T.nilable(Foo)`, `T::Array[String]`, ...) +/// read as a `Ty`, for `T.let(x, Type)`. +pub(super) fn sorbet_type_node(node: &Node<'_>) -> Option { + sorbet_ty(node, true, &HashMap::new()) +} diff --git a/src/ingest/sources.rs b/src/ingest/sources.rs index e7de69425..fe0cfdb11 100644 --- a/src/ingest/sources.rs +++ b/src/ingest/sources.rs @@ -96,6 +96,13 @@ pub(super) fn relative_path(path: &str) -> String { }) } +/// Guard the positional Rubydex answers in release as well as debug builds. +/// Length alone does not protect FileIds against replacement or reordering. +pub(super) fn assert_snapshot_matches(snapshot: &[SourceFile], drained: &[SourceFile]) { + assert!(snapshot == drained, + "source identities changed after the Rubydex snapshot; snapshot after the complete source walk"); +} + /// Record a source file and return its `FileId` (1-based). Idempotent /// by path: a second registration of the same path returns the /// existing id and keeps the first text. @@ -189,6 +196,28 @@ pub(super) fn generated_local_is_reserved(location: &ruby_prism::Location<'_>, n .any(|word| word == name.as_bytes()) } +/// The text registered for `path` during this ingest, if any. +pub fn text_of(path: &str) -> Option { + SOURCES.with(|s| { + let reg = s.borrow(); + let id = reg.by_path.get(path)?; + let i = (id.0 as usize).checked_sub(1)?; + reg.files.get(i).map(|f| f.text.clone()) + }) +} + +/// Run `f` on the text registered for `path`, without copying it. +/// For lookups made per expression, where `text_of`'s clone of the +/// whole file would make ingest quadratic in file size. +pub fn with_text(path: &str, f: impl FnOnce(&str) -> R) -> Option { + SOURCES.with(|s| { + let reg = s.borrow(); + let id = reg.by_path.get(path)?; + let i = (id.0 as usize).checked_sub(1)?; + reg.files.get(i).map(|file| f(&file.text)) + }) +} + /// The registered path for a `FileId`; `None` for the synthetic /// sentinel or an id from another ingest. pub fn path_of(id: FileId) -> Option { @@ -302,6 +331,22 @@ mod tests { assert_eq!(file_id("a.rb"), FileId(0)); } + #[test] + fn source_readers_follow_fresh_ids_after_drain() { + reset(); + register("a.rb", "1"); + let first = drain(); + let late = register("late.rb", "2"); + assert_eq!(late, FileId(1)); + assert_eq!(path_of(late).as_deref(), Some("late.rb")); + assert_eq!(text_of("late.rb").as_deref(), Some("2")); + assert_eq!(with_text("late.rb", str::to_owned).as_deref(), Some("2")); + assert_eq!(line_at("late.rb", 0), Some(1)); + assert_eq!(first[0].path, "a.rb"); + let current = drain(); + assert_eq!(current[late.0 as usize - 1].path, "late.rb"); + } + #[test] fn a_bracketed_label_is_refused_a_real_id() { reset(); @@ -341,3 +386,32 @@ mod tests { assert_eq!(files.len(), 2); } } + +#[cfg(test)] +mod rubydex_snapshot_tests { + use super::*; + + #[test] + fn rubydex_snapshot_rejects_late_real_source_in_release_too() { + reset(); + register("app/services/first.rb", "class First; end"); + let before = snapshot(); + register("components/payments/test/test_helper.rb", "class Late; end"); + let after = drain(); + assert!(std::panic::catch_unwind(|| assert_snapshot_matches(&before, &after)).is_err()); + reset(); + } + + #[test] + fn rubydex_snapshot_preserves_ids_through_generated_passes() { + reset(); + let id = register("components/payments/lib/probe.rb", "class Probe; end"); + let before = snapshot(); + assert_eq!(register("", "class Generated; end"), FileId(0)); + assert_eq!(register("components/payments/lib/probe.rb", "ignored"), id); + let after = drain(); + assert_snapshot_matches(&before, &after); + assert_eq!(after[id.0 as usize - 1].path, before[id.0 as usize - 1].path); + reset(); + } +} diff --git a/src/ingest/type_ascription.rs b/src/ingest/type_ascription.rs new file mode 100644 index 000000000..4c740a4cb --- /dev/null +++ b/src/ingest/type_ascription.rs @@ -0,0 +1,194 @@ +//! Type ascriptions written in the source: `T.let(x, Type)` / +//! `T.cast(x, Type)` and the RBS inline forms `x = value #: Type` / +//! `x = value #: as Type`. +//! +//! All of them say, in the author's own words, what the value at that +//! spot is. Ingest used to throw the words away and keep the value +//! (sorbet-runtime's assertions evaluate to their first argument), so a +//! value the analyzer could not type stayed untyped even though the +//! source declared it — every read of `@preview_token` after +//! `@preview_token = T.let(result.ok_value, T.nilable(Token))` reported +//! `ivar_unresolved`. The declared type now rides along as an +//! [`ExprNode::Cast`], which the typer already reads as "this +//! expression has this type". + +use std::cell::RefCell; + +use ruby_prism::Node; + +use crate::expr::{Expr, ExprNode}; +use crate::ty::Ty; + +use super::sorbet_sig::sorbet_type_node; +use super::sources; + +thread_local! { + /// End offsets of ancestors that will consume a trailing `#:` at that + /// offset. A child ending at the same place must not also ascribe. + static OUTER_ASCRIPTION_ENDS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Enter an expression that may claim a trailing `#:` at `end`. Returns +/// whether this call is the outermost claimant (no ancestor already +/// covers the same offset). Pair with [`pop_trailing_ascription_claim`]. +pub(super) fn push_trailing_ascription_claim(end: usize) -> bool { + OUTER_ASCRIPTION_ENDS.with(|stack| { + let outermost = !stack.borrow().contains(&end); + stack.borrow_mut().push(end); + outermost + }) +} + +pub(super) fn pop_trailing_ascription_claim() { + OUTER_ASCRIPTION_ENDS.with(|stack| { + stack.borrow_mut().pop(); + }); +} + +/// Wrap `value` in a `Cast` to `ty`, unless the type is unreadable +/// (`None` or an open inference variable), in which case `value` is +/// returned as is. `untyped` IS a reading: `T.unsafe(x)` and +/// `x #: as untyped` are the author's signed escape hatch, so the value +/// leaves the type system rather than keeping the type inferred for it. +pub(super) fn ascribe(value: Expr, ty: Option) -> Expr { + match ty { + Some(ty) if !ty.is_open() => { + let span = value.span; + let mut cast = Expr::new(span, ExprNode::Cast { value, target_ty: ty }); + cast.decisions |= crate::expr::SOURCE_TYPE_ASCRIPTION; + cast + } + _ => value, + } +} + +/// The declared type of a `T.let(x, Type)` / `T.cast(x, Type)` call. +pub(super) fn sorbet_declared_type(node: &Node<'_>) -> Option { + let call = node.as_call_node()?; + let method = call.name(); + let method = std::str::from_utf8(method.as_slice()).ok()?; + // `T.unsafe(x)` is the escape hatch: no second argument, the value + // is untyped from there on. + if method == "unsafe" { + return Some(Ty::Untyped); + } + if !matches!(method, "let" | "cast") { + return None; + } + let args = call.arguments()?.arguments(); + let ty = args.iter().nth(1)?; + sorbet_type_node(&ty) +} + +/// What a trailing `#:` comment on a line says about the expression it +/// follows. +pub(super) enum Trailing { + /// `#: as Type` / `#: Type` — the value has this type. + Type(Ty), + /// `#: as !nil` — RBS inline's `T.must`: the value, with nil ruled out. + NotNil, +} + +/// The `#:` comment that follows the expression ending at byte `end` +/// on the same line, read as [`Trailing`]. A comma may sit between them: +/// the comment on an argument written one per line +/// (`delivery.lines.first, #: as !nil`) belongs to that argument. +pub(super) fn trailing_ascription(file: &str, end: usize) -> Option { + // Only the comment text is copied out, and only when there is one. + let comment = sources::with_text(file, |text| { + let line = text.get(end..)?.split('\n').next()?.trim_start(); + let line = line.strip_prefix(',').map_or(line, str::trim_start); + Some(line.strip_prefix("#:")?.trim().to_string()) + })??; + let comment = comment.strip_prefix("as ").unwrap_or(&comment).trim(); + if comment == "!nil" { + return Some(Trailing::NotNil); + } + rbs_type(comment).map(Trailing::Type) +} + +/// Apply a trailing ascription to `value`: a `Cast` to the declared +/// type, or `value.not_nil!` for `#: as !nil` — the method core writes +/// instead of `T.must`, which the analyzer already reads as "the +/// receiver, nil removed". +pub(super) fn ascribe_trailing(value: Expr, trailing: Option) -> Expr { + match trailing { + Some(Trailing::Type(ty)) => ascribe(value, Some(ty)), + Some(Trailing::NotNil) => not_nil(value), + None => value, + } +} + +/// A generated non-nil assertion carries a refusal until nil-check semantics +/// have a shared executable implementation. +pub(super) fn not_nil(value: Expr) -> Expr { + let span = value.span; + let mut expr = Expr::new( + span, + ExprNode::Send { + recv: Some(value), + method: crate::ident::Symbol::from("not_nil!"), + args: Vec::new(), + block: None, + parenthesized: false, + }, + ); + expr.diagnostic = Some(crate::diagnostic::DiagnosticKind::Unsupported { + target: None, + construct: crate::ident::Symbol::from("non-nil assertion"), + detail: "requires a shared nil-check implementation".into(), + }); + expr +} + +/// The `#:` assertion written between a call's receiver and the +/// leading-dot line that continues the call: +/// +/// ```text +/// self #: as untyped +/// .before_update(prepend: true) { ... } +/// ``` +/// +/// The comment ends the receiver's own line, so it is read exactly as +/// [`trailing_ascription`] reads one after any expression (`as T`, +/// `T`, `!nil`) -- but only when the next non-blank line continues with +/// `.` or `&.`; a comment after a receiver on the same line as its +/// method (`x.foo #: as T`) never gets this far, because the text after +/// the receiver is then the method, not the comment. Receivers that +/// read their own trailing comment in `ingest_expr_strict` (calls, +/// variable reads, parentheses) do not come here, so no assertion is +/// applied twice; this covers the rest (`self`, constants, literals). +pub(super) fn receiver_rbs_assertion(file: &str, end: usize) -> Option { + let continues = sources::with_text(file, |text| { + let rest = text.get(end..)?; + let mut lines = rest.split('\n'); + lines.next()?.trim_start().strip_prefix("#:")?; + Some( + lines + .find(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#')) + .is_some_and(|l| { + let l = l.trim_start(); + l.starts_with('.') || l.starts_with("&.") + }), + ) + })??; + if !continues { + return None; + } + trailing_ascription(file, end) +} + +/// Parse one RBS type expression by wrapping it in a method signature +/// the RBS reader already understands. +pub(crate) fn rbs_type(text: &str) -> Option { + if text.is_empty() || text.contains('\n') || text.starts_with('!') { + return None; + } + let src = format!("class X\n def m: () -> {text}\nend\n"); + let sigs = crate::rbs::parse_signatures(&src).ok()?; + let (_, sig) = sigs.methods.into_iter().next()?; + match sig { + Ty::Fn { ret, .. } => Some(*ret), + _ => None, + } +} diff --git a/src/ingest/util.rs b/src/ingest/util.rs index 4ec8e9523..277969d4b 100644 --- a/src/ingest/util.rs +++ b/src/ingest/util.rs @@ -291,6 +291,56 @@ fn module_has_direct_def(m: &ruby_prism::ModuleNode<'_>) -> bool { body_has_direct_method_decl(m.body()) || body_has_included_block(m.body()) || body_has_constant_decl(m.body()) + || body_has_route_helpers_include(m.body()) +} + +/// A module whose whole content is the route-helper mixin — core's +/// `UrlHelpers`, `class << self` around `self.include(Rails.application. +/// routes.url_helpers)` — is the app's handle on the route table. +/// Callers reach it as `UrlHelpers._url`, so it must surface for +/// the include marker to reach the analyzer and give it the helper set. +fn body_has_route_helpers_include(body: Option>) -> bool { + let Some(body) = body else { return false }; + flatten_statements(body).iter().any(|stmt| { + if let Some(sc) = stmt.as_singleton_class_node() { + return body_has_route_helpers_include(sc.body()); + } + let Some(call) = stmt.as_call_node() else { return false }; + constant_id_str(&call.name()) == "include" + && call.receiver().is_none_or(|r| r.as_self_node().is_some()) + && call + .arguments() + .is_some_and(|a| a.arguments().iter().any(|arg| is_rails_url_helpers_chain(&arg))) + }) +} + +/// Match the `Rails.application.routes.url_helpers` receiver chain (a +/// nested CallNode ladder rooted at the `Rails` constant). +pub(super) fn is_rails_url_helpers_chain(node: &ruby_prism::Node<'_>) -> bool { + let mut expected = ["url_helpers", "routes", "application"].iter(); + let mut cur = match node.as_call_node() { + Some(c) => c, + None => return false, + }; + loop { + let Some(want) = expected.next() else { return false }; + if cur.name().as_slice() != want.as_bytes() { + return false; + } + match cur.receiver() { + Some(r) => { + if let Some(cr) = r.as_constant_read_node() { + return expected.next().is_none() + && cr.name().as_slice() == b"Rails"; + } + match r.as_call_node() { + Some(next) => cur = next, + None => return false, + } + } + None => return false, + } + } } /// A module whose only content is a CONSTANT is still app state the diff --git a/src/lib.rs b/src/lib.rs index 1ebe42d88..a96c6e3b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod fixtures; /// dependency classified (framework / stdlib / modeled / infrastructure / /// unknown). Shared by every analysis skin; the unknown list feeds /// [`analyze::attribution`]. +pub mod gem_boundary; pub mod gems; pub mod haml; pub mod slim; diff --git a/src/lower/attachable.rs b/src/lower/attachable.rs index 3c6ccd66a..bdf2f5f5c 100644 --- a/src/lower/attachable.rs +++ b/src/lower/attachable.rs @@ -303,7 +303,7 @@ pub fn attachable_partial_bindings(app: &App) -> Vec { let content_type = lc .methods .iter() - .find_map(|m| literal_or_constant_of(m, "attachable_content_type", &lc.constants)); + .find_map(|m| literal_or_constant_of(m, "attachable_content_type", &lc.constants, Some(&lc.name))); // Only a class the framework could hand a node to: one the // sgid names (a model, handled above) or one that builds // itself from the node by content type. A library class with @@ -337,20 +337,23 @@ fn element_of(class: &ClassId) -> String { } fn literal_of(m: &MethodDef, name: &str) -> Option { - literal_or_constant_of(m, name, &[]) + literal_or_constant_of(m, name, &[], None) } /// A body that is one String literal, or one bare constant the class /// declares as one — campfire's `attachable_content_type` answers /// `OPENGRAPH_EMBED_CONTENT_TYPE`, declared two lines up. -fn literal_or_constant_of(m: &MethodDef, name: &str, constants: &[(Symbol, Expr)]) -> Option { +fn literal_or_constant_of(m: &MethodDef, name: &str, constants: &[(Symbol, Expr)], owner: Option<&ClassId>) -> Option { if m.name.as_str() != name || m.receiver != MethodReceiver::Instance { return None; } match &*m.body.node { ExprNode::Lit { value: Literal::Str { value } } => Some(value.clone()), - ExprNode::Const { path } if path.len() == 1 => constants.iter().find_map(|(n, e)| { - if *n != path[0] { + ExprNode::Const { path } => constants.iter().find_map(|(n, e)| { + let written = path.iter().map(Symbol::as_str).collect::>().join("::"); + let matches = written == n.as_str() + || owner.is_some_and(|owner| written == format!("{}::{n}", owner.0)); + if !matches { return None; } match &*e.node { diff --git a/src/lower/broadcast_calls.rs b/src/lower/broadcast_calls.rs index 700126ed2..7c1f1a313 100644 --- a/src/lower/broadcast_calls.rs +++ b/src/lower/broadcast_calls.rs @@ -23,7 +23,7 @@ //! the qualified name to know which associations exist and whose partial //! the payload renders. A concern nested under a model can only belong to //! that model — the lexical nesting IS the ownership, which -//! `qualify_relative_model_includes` already relies on. +//! `qualify_relative_includes` already relies on. use crate::app::App; use crate::dialect::{ControllerBodyItem, Model, ModelBodyItem}; diff --git a/src/lower/class_body_new.rs b/src/lower/class_body_new.rs index c9128cecb..66f1c19c5 100644 --- a/src/lower/class_body_new.rs +++ b/src/lower/class_body_new.rs @@ -301,15 +301,12 @@ fn rewrite(expr: &mut Expr, owner: &str) { let span = expr.span; let path: Vec = owner.split("::").map(Symbol::from).collect(); let mut konst = Expr::new(span, ExprNode::Const { path }); - // The receiver is the class ITSELF, which is what the expression's - // own type already says the result is — no new type is invented - // here, and the send keeps whatever analyze stamped on it. - konst.ty = expr.ty.clone().map(|t| match t { - crate::ty::Ty::Class { id, args } => crate::ty::Ty::Class { id, args }, - _ => crate::ty::Ty::Untyped, + // Receiver identity is independent of the constructor's result: the + // result may be SelfInstance or an overridden constructor's other class. + // Later keyword expansion resolves initialize against this receiver. + konst.ty = Some(crate::ty::Ty::Class { + id: crate::ident::ClassId(Symbol::from(owner)), + args: Vec::new(), }); - if matches!(konst.ty, Some(crate::ty::Ty::Untyped)) { - konst.ty = None; - } *recv = Some(konst); } diff --git a/src/lower/controller_to_library/mod.rs b/src/lower/controller_to_library/mod.rs index 0d86d9aeb..7e86eb4ce 100644 --- a/src/lower/controller_to_library/mod.rs +++ b/src/lower/controller_to_library/mod.rs @@ -1286,6 +1286,9 @@ fn build_methods( } } + // Class-side methods are already seeded at the start of build_methods; + // do not append them again (duplicate defs break several emitters). + methods } diff --git a/src/lower/mod.rs b/src/lower/mod.rs index b14c525a6..befd25447 100644 --- a/src/lower/mod.rs +++ b/src/lower/mod.rs @@ -1223,6 +1223,10 @@ pub(crate) fn for_each_owned_hook_body( } f(&mut action.body) } + crate::dialect::ControllerBodyItem::ClassMethod { method, .. } => { + visit_param_defaults(&mut method.params, f); + f(&mut method.body) + } crate::dialect::ControllerBodyItem::Unknown { expr, .. } => f(expr), // A filter's `if:` / `unless:` lambda body is spliced into // the dispatcher as written (`process_action`), so it is @@ -1346,6 +1350,10 @@ pub(crate) fn for_each_hook_body_ref( } f(&action.body) } + crate::dialect::ControllerBodyItem::ClassMethod { method, .. } => { + visit_param_defaults(&method.params, f); + f(&method.body) + } crate::dialect::ControllerBodyItem::Unknown { expr, .. } => f(expr), crate::dialect::ControllerBodyItem::Filter { filter, .. } => { if let Some(c) = &filter.if_cond_expr { diff --git a/src/lower/model_to_library/mod.rs b/src/lower/model_to_library/mod.rs index 3d8dfbbbc..db42da18b 100644 --- a/src/lower/model_to_library/mod.rs +++ b/src/lower/model_to_library/mod.rs @@ -237,7 +237,12 @@ pub(crate) fn lower_models_inner( // Preserve original source inputs for late derivations // (e.g. raw helpers) without treating them as framework // claims. Both kinds traverse the canonical Arel/typer. - methods.extend(model.methods().filter(|m| !m.name_span.is_synthetic()).cloned()); + methods.extend(model.methods().filter(|m| !m.name_span.is_synthetic()).cloned().map(|mut method| { + // Match build_methods' provenance backfill even when a source + // method was retained beside the generated probe surface. + method.body.inherit_span(model.span); + method + })); methods } }; @@ -599,9 +604,9 @@ fn report_unclaimed_unknowns(model: &Model) { { continue; } - // Literal table names are consumed by ingest::model; other - // class settings remain unsupported unless a recognizer claims them. - if name == "table_name=" + // Literal table settings are consumed by ingest::model's + // explicit_class_setting; dynamic values remain unsupported. + if matches!(name, "table_name=" | "table_name_prefix=") && args.len() == 1 && matches!(&*args[0].node, ExprNode::Lit { value: Literal::Str { .. } | Literal::Sym { .. } }) { diff --git a/src/lower/model_to_library/schema.rs b/src/lower/model_to_library/schema.rs index 730a046a6..5889e2e9d 100644 --- a/src/lower/model_to_library/schema.rs +++ b/src/lower/model_to_library/schema.rs @@ -98,7 +98,6 @@ pub(super) fn push_schema_methods( for col in &table.columns { methods.push(synth_attr_reader(owner, col, model)); if model.enums.contains_key(&col.name) { - methods.push(synth_before_type_cast(owner, col)); methods.push(synth_enum_storage_writer(owner, col)); } if is_generic_json_col(col, model) { @@ -1048,28 +1047,6 @@ fn json_dump_value(col: &Column, value: Expr) -> Expr { ) } -// Not the assigned input: Rails answers the label an unsaved write was given, but a saved or loaded record answers the stored value, which is what this holds. -fn synth_before_type_cast(owner: &ClassId, col: &Column) -> MethodDef { - let slot = super::ty_of_column_slot(col); - MethodDef { - visibility: crate::dialect::MethodVisibility::Public, - unsupported_formals: None, - has_anonymous_block: false, - name_span: crate::span::Span::synthetic(), - name: Symbol::from(format!("{}_before_type_cast", col.name.as_str())), - receiver: MethodReceiver::Instance, - params: Vec::new(), - body: with_ty(Expr::new(Span::synthetic(), ExprNode::Ivar { name: col_storage_name(col) }), slot.clone()), - signature: Some(fn_sig(vec![], slot)), - effects: EffectSet::default(), - enclosing_class: Some(owner.0.clone()), - kind: AccessorKind::Method, - is_async: false, - mutates_self: false, - block_param: None, - } -} - /// True for a stored-text column with native Date or Time accessors. fn is_temporal_col(col: &Column) -> bool { matches!( @@ -2416,6 +2393,20 @@ fn synth_initialize(owner: &ClassId, table: &Table, model: &Model, models: &[Mod let nullable = matches!(super::ty_of_column_slot(col), Ty::Union { .. }) && schema_default.is_none(); let default = schema_default.unwrap_or_else(|| default_literal_for_ty(&col_ty)); + // Defaults are stored database values, not assignments through + // the enum type. In particular, a base constructor must not + // dispatch its default through a child's different enum writer. + // Only explicit input goes through the public writer. + if crate::dialect::enum_reads_label(model, &col.name) { + let slot_ty = super::ty_of_column_slot(col); + let raw_default = if nullable { nil_lit() } else { default }; + stmts.push(with_ty(Expr::new(Span::synthetic(), ExprNode::Assign { + target: LValue::Ivar { name: col_storage_name(col) }, + value: with_ty(raw_default, slot_ty.clone()), + }), slot_ty)); + stmts.push(given_value_assign(col, &attrs, enum_label_cast(model, col, lookup.clone()).unwrap_or_else(|| Expr::new(Span::synthetic(), ExprNode::Cast { value: lookup, target_ty: super::ty_of_column_slot(col) })))); + continue; + } // is_id_column reference retained as a feature flag for // future per-column override hooks; today every column flows // through the same default-lookup shape. diff --git a/src/lower/send_dispatch.rs b/src/lower/send_dispatch.rs index 8901e5563..88be769da 100644 --- a/src/lower/send_dispatch.rs +++ b/src/lower/send_dispatch.rs @@ -793,19 +793,25 @@ impl HashProviders { } } +// Match complete source-resolved names, never a last-segment alias. +fn constant_key(path: &[Symbol]) -> String { + path.iter().map(Symbol::as_str).collect::>().join("::") + .trim_start_matches("::").to_string() +} + fn collect_hash_providers(app: &App) -> HashProviders { let mut by_class_method = HashMap::new(); - let mut register = |class: &str, consts: &HashMap<&str, &Expr>, name: &Symbol, body: &Expr| { + let mut register = |class: &str, consts: &HashMap, name: &Symbol, body: &Expr| { if let Some(keysets) = hash_return_key_sets(body, consts) { by_class_method.insert((Symbol::from(class), name.clone()), keysets); } }; for lc in &app.library_classes { // Constants visible to this class's method bodies. - let consts: HashMap<&str, &Expr> = lc + let consts: HashMap = lc .constants .iter() - .map(|(n, e)| (n.as_str(), e)) + .flat_map(|(n, e)| [(n.as_str().to_string(), e), (format!("{}::{n}", lc.name.0), e)]) .collect(); for m in &lc.methods { register(lc.name.0.as_str(), &consts, &m.name, &m.body); @@ -813,8 +819,8 @@ fn collect_hash_providers(app: &App) -> HashProviders { } for model in &app.models { let constants = super::model_to_library::collect_model_constants(model); - let consts: HashMap<&str, &Expr> = - constants.iter().map(|(n, e)| (n.as_str(), e)).collect(); + let consts: HashMap = + constants.iter().flat_map(|(n, e)| [(n.as_str().to_string(), e), (format!("{}::{n}", model.name.0), e)]).collect(); for item in &model.body { if let ModelBodyItem::Method { method, .. } = item { register(model.name.0.as_str(), &consts, &method.name, &method.body); @@ -956,7 +962,7 @@ fn walk_provider_origins( /// value is provably a string set in *every* returned literal survive. fn hash_return_key_sets( body: &Expr, - consts: &HashMap<&str, &Expr>, + consts: &HashMap, ) -> Option>> { let mut literals: Vec<&Expr> = Vec::new(); collect_return_positions(body, &mut literals)?; @@ -972,8 +978,8 @@ fn hash_return_key_sets( // A tail naming a hash CONSTANT answers that constant's literal — // lobsters' `time_interval` returns `PLACEHOLDER` on bad input. let lit: &Expr = match &*lit.node { - ExprNode::Const { path } if path.len() == 1 => { - unwrap_freeze(consts.get(path[0].as_str())?) + ExprNode::Const { path } => { + unwrap_freeze(consts.get(&constant_key(path))?) } _ => lit, }; @@ -1066,7 +1072,7 @@ fn collect_early_returns<'e>(e: &'e Expr, out: &mut Vec<&'e Expr>) -> bool { /// no write at all (a parameter). fn string_values_in( e: &Expr, - consts: &HashMap<&str, &Expr>, + consts: &HashMap, body: &Expr, depth: usize, ) -> Option> { @@ -1109,7 +1115,7 @@ fn string_values_in( fn slot_values( v: &Expr, i: usize, - consts: &HashMap<&str, &Expr>, + consts: &HashMap, body: &Expr, depth: usize, ) -> Option> { @@ -1151,17 +1157,14 @@ fn visit_local_writes<'e>(e: &'e Expr, name: &Symbol, f: &mut dyn FnMut(LocalWri e.node.for_each_child(&mut |c| visit_local_writes(c, name, f)); } -fn string_values_const(e: &Expr, consts: &HashMap<&str, &Expr>) -> Option> { +fn string_values_const(e: &Expr, consts: &HashMap) -> Option> { match &*e.node { ExprNode::Lit { value: Literal::Str { value } } => { Some(std::iter::once(value.clone()).collect()) } ExprNode::Send { recv: Some(r), method, .. } if method.as_str() == "[]" => { let ExprNode::Const { path } = &*r.node else { return None }; - if path.len() != 1 { - return None; - } - let cval = consts.get(path[0].as_str())?; + let cval = consts.get(&constant_key(path))?; let hash = unwrap_freeze(cval); let ExprNode::Hash { entries, .. } = &*hash.node else { return None }; let mut out = BTreeSet::new(); diff --git a/src/lower/test_module_to_library/mod.rs b/src/lower/test_module_to_library/mod.rs index a39558541..0c9bb7209 100644 --- a/src/lower/test_module_to_library/mod.rs +++ b/src/lower/test_module_to_library/mod.rs @@ -90,6 +90,11 @@ pub fn lower_test_modules_with_inner( // RouteHelpers entries into each test class's instance_methods so // bare-name dispatch resolves. crate::lower::view_to_library::insert_framework_stubs(&mut classes); + // Stdlib exceptions (`StandardError`, `ActiveRecord::RecordNotFound`, …) + // so retyping test bodies keeps `CLASS_OBJECT_VALUE` on those Const + // refs — otherwise `Child < Parent` collapses to Incompatible and + // TS/Kotlin/Swift framework `errors_test` throw at emit. + crate::analyze::register_stdlib_classes(&mut classes); insert_minitest_test_baseline(&mut classes); insert_cookie_jar_baseline(&mut classes); @@ -166,7 +171,7 @@ pub fn lower_test_modules_with_inner( // setup. Lets dispatch on `self` resolve when one test method // calls a setup helper (or when frameworks evolve to support // shared utility methods). - for lc in &all_lcs { + for (idx, lc) in all_lcs.iter().enumerate() { let mut info = ClassInfo::default(); for m in &lc.methods { if let Some(sig) = &m.signature { @@ -174,6 +179,12 @@ pub fn lower_test_modules_with_inner( info.instance_method_kinds.insert(m.name.clone(), m.kind); } } + // Spliced test-helper constants (DnsTestHelper → WEB_PUSH_…) so + // `type_method_body` can seed ConstScope and Value qualify keeps + // the bare name rather than re-expanding to the helper module. + for (name, _) in &test_modules[idx].constants { + info.constants.entry(name.clone()).or_insert(Ty::Untyped); + } // Inherit Minitest::Test assertion methods so `self.assert(...)` // dispatch resolves through the registry. for (name, sig) in MINITEST_INSTANCE_METHODS.iter() { diff --git a/src/lower/typing.rs b/src/lower/typing.rs index 7ac1b1b35..f1d15614f 100644 --- a/src/lower/typing.rs +++ b/src/lower/typing.rs @@ -108,10 +108,21 @@ pub fn type_method_body( } } if let Some(enclosing) = &method.enclosing_class { + let id = ClassId(enclosing.clone()); ctx.self_ty = Some(Ty::Class { - id: ClassId(enclosing.clone()), + id: id.clone(), args: vec![], }); + // Seed class constants so Value Const resolution can see + // locally-owned bare names (test-helper splice). Without this, + // `qualify_resolved_path` expands `WEB_PUSH_PUBLIC_TEST_IP` back + // to `DnsTestHelper::WEB_PUSH_PUBLIC_TEST_IP`. + if let Some(info) = classes.get(&id) { + if !info.constants.is_empty() { + let global = crate::analyze::ConstScope::default(); + ctx.constants = global.with_own(info.constants.clone()); + } + } } if matches!(method.receiver, crate::dialect::MethodReceiver::Instance) { for (name, ty) in ivar_bindings { diff --git a/src/mcp.rs b/src/mcp.rs index e08203803..d5b646519 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -460,6 +460,7 @@ impl Server { crate::gems::GemFate::Framework, crate::gems::GemFate::Stdlib, crate::gems::GemFate::Infrastructure, + crate::gems::GemFate::InRepo, ] { for g in census.gems.iter().filter(|g| g.fate == fate) { let version = g.version.as_deref().map(|v| format!(" {v}")).unwrap_or_default(); diff --git a/src/project.rs b/src/project.rs index fd874e59f..08027d560 100644 --- a/src/project.rs +++ b/src/project.rs @@ -6170,9 +6170,8 @@ fn spin_shape(files: Vec<(String, String)>) -> Result, Str let counted = if in_lane { match test_class_and_count(&entry.1, &entry.0) { Ok(counted) => Some(counted), - // A test program the snapshot runner cannot shape is - // dropped with a note rather than failing the project: - // a large app's suite has files outside the lane shape. + // Preserve recovery for app tests outside the snapshot + // runner's supported shape, with their sidecars removed too. Err(e) => { eprintln!("roundhouse: {e}; dropped"); dropped.push(entry.0.clone()); diff --git a/src/rbs.rs b/src/rbs.rs index e032a67de..bf9a5fac9 100644 --- a/src/rbs.rs +++ b/src/rbs.rs @@ -880,6 +880,44 @@ fn ty_from_node(node: &Node<'_>, ctx: TyCtx<'_>) -> Result { .cloned() .ok_or_else(|| format!("unresolved RBS type alias: {written}")) } + // `singleton(Foo)` is the class OBJECT. `Ty` has no class-object + // type: a constant read types as the class itself and dispatch + // consults both sides, so the class stands for it. Read by name + // rather than through `map_class_instance`, since + // `singleton(String)` is the class String, not a string. + Node::ClassSingletonType(class_type) => Ok(Ty::Class { + id: ClassId(Symbol::new(&qualify_class_ref(&class_type.name(), ctx.scope))), + args: Vec::new(), + }), + // `bot` is what `raise` and `exit` return: no value at all. + Node::BottomType(_) => Ok(Ty::Bottom), + // `top` is the supertype of everything, on which RBS lets you + // call nothing. Nothing dispatches on it, so `untyped` answers + // the same without inventing a class. + Node::TopType(_) => Ok(Ty::Untyped), + // A method-level type variable (`[T] (T) -> T`). The analyzer + // does not instantiate a signature's variables per call, so the + // variable is an unmodelled boundary: `untyped`, not a class + // called `T`. Stated here so it is one place to change. + Node::VariableType(_) => Ok(Ty::Untyped), + // `A & B`: see `intersection_ty`. + Node::IntersectionType(inter) => { + let members: Vec = inter + .types() + .iter() + .map(|n| ty_from_node(&n, ctx)) + .collect::>()?; + Ok(intersection_ty(members)) + } + // A literal type (`:draft`, `"x"`, `1`, `true`) is a value of + // its class. + Node::LiteralType(lit) => match lit.literal() { + Node::Integer(_) => Ok(Ty::Int), + Node::String(_) => Ok(Ty::Str), + Node::Symbol(_) => Ok(Ty::Sym), + Node::Bool(_) => Ok(Ty::Bool), + _ => Err("unsupported RBS literal type".to_string()), + }, other => Err(format!( "unsupported RBS type node: {}", type_node_kind(other) @@ -887,6 +925,27 @@ fn ty_from_node(node: &Node<'_>, ctx: TyCtx<'_>) -> Result { } } +/// `A & B` (RBS) / `T.all(A, B)` (Sorbet): a value with both surfaces. +/// `Ty` has no intersection. `Kernel` and `Object` add nothing every +/// value lacks (`Enumerable[X] & Kernel` is how the proto generator +/// spells "a collection"), so they drop out and a single remaining +/// member is the type. Two real members would claim one surface and +/// reject calls to the other, so that is `untyped`. +pub(crate) fn intersection_ty(members: Vec) -> Ty { + let real: Vec<&Ty> = members + .iter() + .filter(|t| { + !matches!(t, Ty::Class { id, .. } + if matches!(id.0.as_str(), "Kernel" | "Object" | "BasicObject")) + }) + .collect(); + match real.as_slice() { + [only] => (*only).clone(), + [] => members.into_iter().next().unwrap_or(Ty::Untyped), + _ => Ty::Untyped, + } +} + /// Read the `type name = ...` declarations among `members` to `Ty`, /// layered over the enclosing scope's (`outer`), so a nested class sees /// the aliases of the classes around it and can shadow them. @@ -984,6 +1043,10 @@ fn map_class_instance(name: &str, args: Vec) -> Ty { key: Box::new(key.clone()), value: Box::new(value.clone()), }, + // A bare `Hash` / `Array` is the unparameterized container, not a + // class named Hash with nothing to call on it. + ("Hash", []) => Ty::Hash { key: Box::new(Ty::Untyped), value: Box::new(Ty::Untyped) }, + ("Array", []) => Ty::Array { elem: Box::new(Ty::Untyped) }, _ => Ty::Class { id: ClassId(Symbol::new(name)), args, diff --git a/src/ty.rs b/src/ty.rs index b9953d221..f0035080c 100644 --- a/src/ty.rs +++ b/src/ty.rs @@ -231,6 +231,64 @@ impl Ty { } } + /// The same type with every class name it mentions rewritten by + /// `f`. Structure (containers, unions, signatures) is preserved; + /// only `Ty::Class` ids change. + pub fn map_class_ids(&self, f: &dyn Fn(&ClassId) -> ClassId) -> Ty { + match self { + Ty::Class { id, args } => Ty::Class { + id: f(id), + args: args.iter().map(|t| t.map_class_ids(f)).collect(), + }, + Ty::Array { elem } => Ty::Array { elem: Box::new(elem.map_class_ids(f)) }, + Ty::Hash { key, value } => Ty::Hash { + key: Box::new(key.map_class_ids(f)), + value: Box::new(value.map_class_ids(f)), + }, + Ty::Tuple { elems } => { + Ty::Tuple { elems: elems.iter().map(|t| t.map_class_ids(f)).collect() } + } + Ty::Union { variants } => { + Ty::Union { variants: variants.iter().map(|t| t.map_class_ids(f)).collect() } + } + Ty::Fn { params, block, ret, effects } => Ty::Fn { + params: params + .iter() + .map(|p| Param { name: p.name.clone(), ty: p.ty.map_class_ids(f), kind: p.kind.clone() }) + .collect(), + block: block.as_ref().map(|b| Box::new(b.map_class_ids(f))), + ret: Box::new(ret.map_class_ids(f)), + effects: effects.clone(), + }, + other => other.clone(), + } + } + + /// True when an unknown ([`Ty::is_unknown`]) sits anywhere inside + /// this type: `untyped`, `Array[untyped]`, `Hash[Symbol, untyped]`, + /// `String | untyped`, a signature with an untyped parameter. A + /// type that does NOT is fully spelled out, which is what makes a + /// declaration the last word rather than a hint. + pub fn mentions_unknown(&self) -> bool { + match self { + Ty::Var { .. } | Ty::Untyped => true, + Ty::Class { args, .. } => args.iter().any(Ty::mentions_unknown), + Ty::Array { elem } => elem.mentions_unknown(), + Ty::Hash { key, value } => key.mentions_unknown() || value.mentions_unknown(), + Ty::Tuple { elems } => elems.iter().any(Ty::mentions_unknown), + Ty::Union { variants } => variants.iter().any(Ty::mentions_unknown), + Ty::Record { row } => { + row.fields.values().any(Ty::mentions_unknown) || row.rest.is_some() + } + Ty::Fn { params, block, ret, .. } => { + params.iter().any(|p| p.ty.mentions_unknown()) + || block.as_ref().is_some_and(|b| b.mentions_unknown()) + || ret.mentions_unknown() + } + _ => false, + } + } + /// True for the two "no known type" variants: [`Ty::Var`] (the /// analyzer couldn't infer a type) and [`Ty::Untyped`] (an /// author-signed gradual-typing opt-out). Both mean "don't reason diff --git a/src/vfs.rs b/src/vfs.rs index bfaef6397..d7aafdd73 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -25,6 +25,11 @@ pub trait Vfs { fn is_dir(&self, path: &Path) -> bool; /// Return whether `path` is a symbolic link, without following it. fn is_symlink(&self, path: &Path) -> bool; + /// The real location of `path` after resolving symlinks, when the + /// backing store has such a notion. An in-memory tree does not. + fn canonical(&self, _path: &Path) -> Option { + None + } } /// Real-filesystem-backed `Vfs`. Used by the CLI and tests. @@ -69,6 +74,10 @@ impl Vfs for FsVfs { fn is_symlink(&self, path: &Path) -> bool { std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) } + + fn canonical(&self, path: &Path) -> Option { + std::fs::canonicalize(path).ok() + } } /// In-memory `Vfs` backed by a flat path → bytes map. Directories diff --git a/tests/abstract_hook_return.rs b/tests/abstract_hook_return.rs new file mode 100644 index 000000000..86a9ec8e8 --- /dev/null +++ b/tests/abstract_hook_return.rs @@ -0,0 +1,40 @@ +//! A method that only raises `NotImplementedError` is an abstract hook. +//! +//! Shopify core's concerns declare the seams an includer fills in as +//! `def set_class; raise NotImplementedError, "..."; end`. The body never +//! returns, so the typer harvested `Bottom`, and every call on the +//! result (`set_class.new(...)`, `record.send(:nameserver_payload)` +//! mapped over a relation) was reported as an unknown method on `bot`. +//! What the hook returns is what the override returns, which the concern +//! cannot see: gradual, not diverging. + +use std::process::Command; + +fn check(files: &[(&str, &str)]) -> String { + let dir = std::env::temp_dir().join(format!("rh_abstract_hook_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for (path, src) in files { + let full = dir.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, src).unwrap(); + } + let out = Command::new(env!("CARGO_BIN_EXE_roundhouse")) + .args(["check", "--continue"]) + .arg(&dir) + .output() + .expect("spawn roundhouse"); + let _ = std::fs::remove_dir_all(&dir); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +const CONTROLLER: &str = "class ApplicationController < ActionController::Base\n def index\n set = set_class.new(1)\n set.rows\n head :ok\n end\n\n def set_class\n raise NotImplementedError, \"must implement\"\n end\nend\n"; +const ROUTES: &str = "Rails.application.routes.draw do\n root to: \"application#index\"\nend\n"; + +#[test] +fn a_raise_not_implemented_hook_is_not_bottom() { + let err = check(&[ + ("app/controllers/application_controller.rb", CONTROLLER), + ("config/routes.rb", ROUTES), + ]); + assert!(err.contains("0 error(s)"), "{err}"); +} diff --git a/tests/abstract_sig_concern_methods.rs b/tests/abstract_sig_concern_methods.rs new file mode 100644 index 000000000..6f61174ae --- /dev/null +++ b/tests/abstract_sig_concern_methods.rs @@ -0,0 +1,78 @@ +//! A concern's `sig { abstract… }` methods are contracts, not bodies. +//! +//! Core's controller concerns declare what the includer must provide: +//! +//! ```ruby +//! module Privacy::CmpCookies +//! extend T::Helpers +//! abstract! +//! +//! sig { abstract.returns(T.untyped) } +//! def request; end +//! +//! sig { abstract.returns(ActionController::Parameters) } +//! def params; end +//! end +//! ``` +//! +//! The empty body reads as `nil`. Splicing that copy into the includer +//! shadowed the framework's `request` / `params` with `nil`, so every +//! `request.host` / `params[:id]` in the controller (and in the concern +//! itself) failed with "no known method on nil" - ~1000 errors on core. +//! An abstract method is not copied into the includer, and a `sig` on a +//! non-abstract concern method also describes the spliced copy. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, Analyzer}; +use roundhouse::ingest::ingest_app_from_tree; + +fn failures(concern: &str) -> Vec { + let files: [(&str, &str); 3] = [ + ("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n"), + ("app/controllers/concerns/cmp.rb", concern), + ( + "app/controllers/things_controller.rb", + "class ThingsController < ApplicationController\n include Cmp\n def index\n request.host\n request.headers[\"X\"]\n params[:a]\n bar.host\n end\nend\n", + ), + ]; + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .iter() + .filter(|d| d.code() == "send_dispatch_failed") + .map(|d| format!("{d:?}")) + .collect() +} + +const CONCERN: &str = r#"module Cmp + extend T::Helpers + abstract! + + sig { abstract.returns(T.untyped) } + def request; end + + sig { abstract.returns(ActionController::Parameters) } + def params; end + + sig { returns(T.untyped) } + def bar; nil; end + + def go + request.host + params[:x] + bar.host + end +end +"#; + +#[test] +fn abstract_declarations_do_not_shadow_the_framework_in_the_includer() { + let failed = failures(CONCERN); + assert!(failed.is_empty(), "unexpected dispatch failures: {failed:?}"); +} diff --git a/tests/abstract_sig_stub.rs b/tests/abstract_sig_stub.rs new file mode 100644 index 000000000..752fe8195 --- /dev/null +++ b/tests/abstract_sig_stub.rs @@ -0,0 +1,61 @@ +//! A `sig { abstract… }` method in a concern is a declaration, not a body. +//! +//! Shopify's controller concerns state what they need from the includer: +//! +//! ```ruby +//! sig { abstract.returns(ActionController::Parameters) } +//! def params; end +//! ``` +//! +//! The empty `def` was spliced into every including controller as a real +//! method answering `nil`, ahead of the `params` the controller inherits, +//! so `params.fetch_type(...)` failed with "no known method on nil" in +//! every class that included the concern (and in the concern itself). + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, DiagnosticKind}; +use roundhouse::ingest::ingest_app_from_tree; + +#[test] +fn an_abstract_stub_does_not_shadow_the_includers_implementation() { + let files: &[(&str, &str)] = &[ + ("db/schema.rb", "ActiveRecord::Schema.define do\nend\n"), + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\nend\n"), + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ( + "app/controllers/concerns/cm.rb", + "module Cm\n extend T::Helpers\n abstract!\n\n def go\n params.permit(:a)\n end\n\n sig { abstract.returns(ActionController::Parameters) }\n def params; end\nend\n", + ), + ( + "app/controllers/things_controller.rb", + "class ThingsController < ApplicationController\n include Cm\n def index\n params.fetch_type({ a: String }, nil)\n render plain: \"x\"\n end\nend\n", + ), + ( + "config/routes.rb", + "Rails.application.routes.draw do\n get \"/things\", to: \"things#index\"\nend\n", + ), + ]; + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + roundhouse::session::analyze_and_lower(&mut app); + let failures: Vec = diagnose(&app) + .into_iter() + .filter_map(|d| match d.kind { + DiagnosticKind::SendDispatchFailed { method, recv_ty } => Some(format!( + "{}#{}", + roundhouse::ide::render_ty(&recv_ty), + method.as_str() + )), + _ => None, + }) + .collect(); + assert!(failures.is_empty(), "dispatch failures: {failures:?}"); +} diff --git a/tests/action_controller_parameters.rs b/tests/action_controller_parameters.rs new file mode 100644 index 000000000..ab11a9be0 --- /dev/null +++ b/tests/action_controller_parameters.rs @@ -0,0 +1,58 @@ +//! `params` is an `ActionController::Parameters`, not a `Hash[Symbol, String]`. +//! +//! Shopify's controllers reach for the typed_parameters gem's extensions +//! on it (`params.fetch_type({ token: String }, nil)`, `require_type`, +//! `require_hash`, `permit_types`) and for the strong-parameters chain on +//! what `params[:order]` holds (`params[:order].permit(...)`). Typed as a +//! String-valued Hash, every one of them failed dispatch ("no known +//! method `require_type` on Hash[Symbol, String]"), and `params[:order]` +//! was a `String?` that could not be permitted. A `Parameters.new(...)` +//! local had no methods at all. The hash conversions on a permitted +//! `to_h` (`symbolize_keys`) were the same kind of gap on Hash itself. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, DiagnosticKind}; +use roundhouse::ingest::ingest_app_from_tree; + +fn failures(files: &[(&str, &str)]) -> Vec { + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + roundhouse::session::analyze_and_lower(&mut app); + diagnose(&app) + .into_iter() + .filter_map(|d| match d.kind { + DiagnosticKind::SendDispatchFailed { method, recv_ty } => Some(format!( + "{}#{}", + roundhouse::ide::render_ty(&recv_ty), + method.as_str() + )), + _ => None, + }) + .collect() +} + +#[test] +fn parameters_answer_the_typed_parameters_and_strong_parameters_surface() { + let f = failures(&[ + ("db/schema.rb", "ActiveRecord::Schema.define do\nend\n"), + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\nend\n"), + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ( + "app/controllers/things_controller.rb", + "class ThingsController < ApplicationController\n def index\n a = params.require_type(mode: String)\n b = params[:order].permit_types(line_items: [Hash])[:line_items]\n c = params[:customer]&.permit_types(x: String)\n d = params.permit(:fts).to_h.symbolize_keys\n p = ActionController::Parameters.new(a: 1)\n p[:b] = 2\n p.merge(c: 1)\n e = params.require_hash(:k)\n g = params.fetch_type({ t: String }, nil)\n h = params.permit(:x).to_unsafe_h\n render plain: \"x\"\n end\nend\n", + ), + ( + "config/routes.rb", + "Rails.application.routes.draw do\n get \"/things\", to: \"things#index\"\nend\n", + ), + ]); + assert!(f.is_empty(), "params dispatch failures: {f:?}"); +} diff --git a/tests/alba.rs b/tests/alba.rs index e673d1451..63a7b3a5e 100644 --- a/tests/alba.rs +++ b/tests/alba.rs @@ -319,7 +319,7 @@ fn constructor_reader_evidence_requires_unmodified_required_parameters() { } #[test] -fn unused_resources_and_unrelated_library_diagnostics_are_unchanged() { +fn unused_resources_leave_unresolved_library_constants_reported() { let source = SOURCE.split("class SurveyProbe").next().unwrap().to_owned() + "\nclass Unrelated; def call; UnknownVendor.unknown; end; end\n"; let mut app = ingest_app_from_tree(HashMap::from([( @@ -328,12 +328,16 @@ fn unused_resources_and_unrelated_library_diagnostics_are_unchanged() { )])) .unwrap(); Analyzer::new(&app).analyze(&mut app); - assert!(diagnose(&app).is_empty()); + let diagnostics = diagnose(&app); + assert_eq!(diagnostics.len(), 1, "{diagnostics:?}"); + assert_eq!(diagnostics[0].code(), "unsupported"); + assert!(diagnostics[0].message.contains("UnknownVendor"), "{diagnostics:?}"); + assert_eq!(diagnostics[0].severity, roundhouse::diagnostic::Severity::Error); } #[test] fn nullable_and_false_properties_remain_outside_the_named_contract() { - for expression in ["nil", "false", "[9]", "@author", "AlbaAuthor"] { + for expression in ["nil", "false", "[9]", "AlbaAuthor"] { let source = SOURCE.replace("@author || raise(\"author required\")", expression); let mut app = app(&source); Analyzer::new(&app).analyze(&mut app); @@ -342,6 +346,14 @@ fn nullable_and_false_properties_remain_outside_the_named_contract() { "accepted {expression}" ); } + // A direct readonly getter is safe when each represented constructor + // supplies a proven object. A nil construction must still be rejected, + // even if another site's observations infer a non-null joined type. + let source = SOURCE.replace("@author || raise(\"author required\")", "@author") + .replace("AlbaArticle.new(7, \"Syn\", author)", "AlbaArticle.new(7, \"Syn\", nil)"); + let mut app = app(&source); + Analyzer::new(&app).analyze(&mut app); + assert!(diagnose(&app).iter().any(|d| d.code() == "unsupported")); } #[test] diff --git a/tests/analyze.rs b/tests/analyze.rs index 50bbf8317..0bd41c557 100644 --- a/tests/analyze.rs +++ b/tests/analyze.rs @@ -124,22 +124,20 @@ fn params_resolves_via_implicit_self_in_action_body() { assert_eq!(method.as_str(), "[]"); let params_recv = recv.as_ref().expect("bracket has a receiver"); - // `params` (implicit self Send) — now resolved via ctx.self_ty to Hash. + // `params` (implicit self Send) — now resolved via ctx.self_ty to ActionController::Parameters. match params_recv.ty.as_ref().expect("params ty populated") { - Ty::Hash { key, value } => { - assert!(matches!(**key, Ty::Sym)); - assert!(matches!(**value, Ty::Str)); - } - other => panic!("expected Hash, got {other:?}"), + Ty::Class { id, .. } => assert_eq!(id.0.as_str(), "ActionController::Parameters"), + other => panic!("expected ActionController::Parameters, got {other:?}"), } - // `params[:id]` resolves to Union. + // `params[:id]` resolves to the element union: a String among + // Array / Parameters arms, or nil. match bracket_send.ty.as_ref().expect("bracket ty populated") { Ty::Union { variants } => { assert!(variants.iter().any(|v| matches!(v, Ty::Str))); assert!(variants.iter().any(|v| matches!(v, Ty::Nil))); } - other => panic!("expected Union, got {other:?}"), + other => panic!("expected the params element union, got {other:?}"), } } diff --git a/tests/association_named_order.rs b/tests/association_named_order.rs new file mode 100644 index 000000000..112a44f18 --- /dev/null +++ b/tests/association_named_order.rs @@ -0,0 +1,33 @@ +//! `belongs_to :order` on a model: `self.order` is the association +//! reader, not `ActiveRecord::QueryMethods#order`. Refund, Return and +//! several other core models belong to an Order, and reading it through +//! `T.must(self.order)` was typed as a `Relation[Refund]`, so every +//! following `order.line_items` / `order.id` was a send_dispatch_failed. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::ingest::ingest_app_from_tree; + +#[test] +fn a_belongs_to_named_order_reads_as_the_association() { + let files: Vec<(&str, &str)> = vec![ + ("db/schema.rb", "ActiveRecord::Schema.define do\n create_table \"orders\", force: :cascade do |t|\n t.string \"name\"\n end\n create_table \"refunds\", force: :cascade do |t|\n t.integer \"order_id\"\n end\nend\n"), + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\nend\n"), + ("app/models/order.rb", "class Order < ApplicationRecord\n has_many :refunds\nend\n"), + ("app/models/refund.rb", "class Refund < ApplicationRecord\n belongs_to :order\n\n def go\n o = self.order\n o.name\n p = order\n p.name\n end\nend\n"), + ]; + let tree: HashMap> = files + .into_iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + let residue = roundhouse::session::analyze_and_lower(&mut app); + let diags: Vec = residue + .iter() + .chain(roundhouse::analyze::diagnose(&app).iter()) + .map(roundhouse::diagnostic::Diagnostic::to_string) + .filter(|d| d.contains("send_dispatch_failed")) + .collect(); + assert!(diags.is_empty(), "{diags:?}"); +} diff --git a/tests/bare_constant_reach.rs b/tests/bare_constant_reach.rs new file mode 100644 index 000000000..bf8de5c3f --- /dev/null +++ b/tests/bare_constant_reach.rs @@ -0,0 +1,171 @@ +//! A bare constant reaches a class the way Ruby reaches it: through the +//! lexical scope, then the ancestors, then the top level -- not by +//! sharing a last segment with some class buried in the app. +//! +//! The analyzer used to fall back to "the one registered class whose +//! name ends in `::Name`". For the framework catalog that is a fair +//! stand-in for top-level aliases (`HashWithIndifferentAccess`), but for +//! the app's own classes it captured a gem's top-level constant: with +//! `Apps::Listeners::Monorail` declared somewhere in the app, every bare +//! `Monorail.track(...)` (the gem's `Monorail`) was typed as that +//! unrelated class, and the calls failed against its methods. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, Analyzer}; +use roundhouse::ingest::ingest_app_from_tree; + +fn diagnostics(extra: &[(&str, &str)], body: &str) -> Vec { + let mut files: Vec<(String, String)> = vec![ + ( + "db/schema.rb".into(), + "ActiveRecord::Schema.define do\n create_table \"things\", force: :cascade do |t|\n t.string \"name\"\n end\nend\n".into(), + ), + ( + "app/models/application_record.rb".into(), + "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n".into(), + ), + ( + "app/controllers/application_controller.rb".into(), + "class ApplicationController < ActionController::Base\nend\n".into(), + ), + ( + "config/routes.rb".into(), + "Rails.application.routes.draw do\n get \"/things\", to: \"things#show\"\nend\n".into(), + ), + ( + "app/controllers/things_controller.rb".into(), + format!("class ThingsController < ApplicationController\n def show\n{body}\n end\nend\n"), + ), + ]; + files.extend(extra.iter().map(|(p, c)| (p.to_string(), c.to_string()))); + let tree: HashMap> = + files.into_iter().map(|(p, c)| (PathBuf::from(p), c.into_bytes())).collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .iter() + .filter(|d| matches!(d.code(), "send_dispatch_failed" | "unsupported")) + .map(|d| d.message.clone()) + .collect() +} + +const LISTENER: (&str, &str) = ( + "app/services/apps/listeners/monorail.rb", + "module Apps\n module Listeners\n class Monorail\n def self.publish; 1; end\n end\n end\nend\n", +); + +#[test] +fn a_bare_name_is_not_captured_by_a_nested_app_class_of_that_name() { + // The read is not inside `Apps::Listeners`, so `Monorail` is the top + // level's, not that class -- and `track` is not its method either. + let found = diagnostics(&[LISTENER], " @a = Monorail.track"); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].contains("constant not supported (all targets): Monorail"), "{found:?}"); + assert!(!found[0].contains("Apps::Listeners"), "{found:?}"); +} + +#[test] +fn a_sibling_in_the_lexical_scope_still_resolves() { + let found = diagnostics( + &[ + LISTENER, + ( + "app/services/apps/listeners/relay.rb", + "module Apps\n module Listeners\n class Relay\n def go; Monorail.publish; end\n end\n end\nend\n", + ), + ], + " @a = Apps::Listeners::Relay.new.go", + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn a_subclass_reads_a_class_nested_in_its_superclass() { + let found = diagnostics( + &[ + ( + "app/services/base_job.rb", + "class BaseJob\n class Helper\n def self.help; 1; end\n end\nend\n", + ), + ( + "app/services/sub_job.rb", + "class SubJob < BaseJob\n def go; Helper.help; end\nend\n", + ), + ], + " @a = SubJob.new.go", + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn a_class_nested_in_a_mixin_of_a_mixin_resolves() { + // `Parser` is read inside a concern method, which is typed in the + // controller that includes `WithCarts`; `WithCarts` includes + // `HeadersHelper`, which declares `Parser`. Ruby reaches it through + // the controller's ancestors, the transitive ones included. + let files: Vec<(&str, &str)> = vec![ + ( + "app/controllers/concerns/headers_helper.rb", + "module HeadersHelper\n extend ActiveSupport::Concern\n\n def header_value\n Parser.parse(\"?1\")\n end\n\n class Parser\n class << self\n def parse(value)\n value == \"?1\"\n end\n end\n end\nend\n", + ), + ( + "app/controllers/concerns/with_carts.rb", + "module WithCarts\n include(HeadersHelper)\n\n def cart_header\n header_value\n end\nend\n", + ), + ( + "app/controllers/carts_controller.rb", + "class CartsController < ApplicationController\n include WithCarts\n\n def show\n @a = cart_header\n end\nend\n", + ), + ]; + let found = diagnostics(&files, " @a = 1"); + assert!(found.iter().all(|d| !d.contains("parse")), "{found:?}"); +} + +#[test] +fn a_mixin_method_reads_constants_from_the_mixins_own_namespace() { + // `OneTimeCode#verify` is typed in `Auth::SessionsController`, but + // Ruby resolves `Validator` where the method was written, inside + // `Auth::Concerns`, so it is `Auth::Concerns::Validator`. + let files: Vec<(&str, &str)> = vec![ + ( + "app/models/auth/concerns/validator.rb", + "module Auth\n module Concerns\n module Validator\n class << self\n def ok?(email)\n email.present?\n end\n end\n end\n end\nend\n", + ), + ( + "app/controllers/auth/concerns/one_time_code.rb", + "module Auth\n module Concerns\n module OneTimeCode\n def verify(email)\n Validator.ok?(email)\n end\n end\n end\nend\n", + ), + ( + "app/controllers/auth/sessions_controller.rb", + "module Auth\n class SessionsController < ApplicationController\n include Concerns::OneTimeCode\n\n def show\n @a = verify(\"a@b.c\")\n end\n end\nend\n", + ), + ]; + let found = diagnostics(&files, " @a = 1"); + assert!(found.iter().all(|d| !d.contains("ok?")), "{found:?}"); +} + +#[test] +fn a_top_level_class_beats_a_class_in_a_mixins_namespace() { + // `Thing` inside `Thing#sibling` is the top-level model, even though + // the model includes `Catalog::Types::Lookup` and `Catalog::Types` + // declares a `Thing` of its own (core's `OnlineStoreEditor::Session` + // reading `Theme` while including `OnlineStore::Types::Lens`). + let files: Vec<(&str, &str)> = vec![ + ( + "app/models/thing.rb", + "class Thing < ApplicationRecord\n include Catalog::Types::Lookup\n\n def self.fetch_by_name(name); find_by(name: name); end\n\n def sibling\n Thing.fetch_by_name(\"x\")\n end\nend\n", + ), + ( + "app/models/catalog/types/thing.rb", + "module Catalog\n module Types\n module Thing\n def label; \"t\"; end\n end\n end\nend\n", + ), + ( + "app/models/catalog/types/lookup.rb", + "module Catalog\n module Types\n module Lookup\n def look; 1; end\n end\n end\nend\n", + ), + ]; + let found = diagnostics(&files, " @a = Thing.new.sibling"); + assert!(found.iter().all(|d| !d.contains("fetch_by_name")), "{found:?}"); +} diff --git a/tests/binop_attribute_guard.rs b/tests/binop_attribute_guard.rs new file mode 100644 index 000000000..abd29a73f --- /dev/null +++ b/tests/binop_attribute_guard.rs @@ -0,0 +1,111 @@ +//! A guard on an attribute of `self` speaks for the reads it guards. +//! +//! Rails models are full of nullable columns compared right after the check +//! that makes the comparison safe: +//! +//! ```ruby +//! def expired? +//! expires_at.present? && expires_at <= Time.now.utc +//! end +//! def overlaps?(other) +//! start_at.present? && end_at.present? && start_at >= end_at +//! end +//! ``` +//! +//! `expires_at` here is `self.expires_at`: a column reader, no different from a +//! local for the length of the condition. The nil arm of `Time?` has to go on +//! the guarded side, or every such guard is reported as `Time? <= Time`. +//! +//! Two things make that sound rather than a blanket "trust the guard": only a +//! receiver-less, zero-argument read of a declared attribute (a column, +//! `attr_accessor`) is narrowed, so `find(1)`-like calls and methods that +//! merely happen to be nilable are still reported, and a compound condition +//! only claims what it proves (`a && b` false says nothing about either). + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn binops(model: &str) -> Vec { + let tree: std::collections::HashMap> = [ + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\nend\n", + ), + ("app/models/thing.rb", model), + ( + "db/schema.rb", + r#"ActiveRecord::Schema.define(version: 1) do + create_table "things", force: :cascade do |t| + t.datetime "expires_at" + t.datetime "start_at" + t.datetime "end_at" + t.integer "position" + end +end +"#, + ), + ] + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| matches!(d.kind, DiagnosticKind::IncompatibleBinop { .. })) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +#[test] +fn a_guarded_column_reader_is_not_nilable_in_the_guarded_read() { + let found = binops( + r#"class Thing < ApplicationRecord + def expired? + expires_at.present? && expires_at <= Time.now.utc + end + def live? + expires_at.nil? || expires_at > Time.now.utc + end + def positive? + !position.nil? && position > 0 + end + def in_order? + return false if start_at.blank? || end_at.blank? + start_at < end_at + end +end +"#, + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn every_conjunct_of_a_guard_narrows() { + let found = binops( + "class Thing < ApplicationRecord\n def a\n start_at.present? && end_at.present? && start_at >= end_at\n end\nend\n", + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn an_unguarded_or_wrongly_guarded_column_is_still_reported() { + let found = binops( + r#"class Thing < ApplicationRecord + def a + expires_at <= Time.now.utc + end + def b + expires_at.present? || expires_at > Time.now.utc + end + def c + expires_at.present? && start_at < Time.now.utc + end + def d + return false if expires_at.nil? && start_at.nil? + expires_at > Time.now.utc + end +end +"#, + ); + assert_eq!(found.len(), 4, "{found:?}"); +} diff --git a/tests/binop_class_operators.rs b/tests/binop_class_operators.rs new file mode 100644 index 000000000..309aae6c7 --- /dev/null +++ b/tests/binop_class_operators.rs @@ -0,0 +1,57 @@ +//! Binary operators whose receiver is not a builtin value type. +//! +//! Core code does arithmetic on `Money`, `MultiCurrency::MoneyBag` and +//! `Gem::Version` (their classes define `+ - * <=>`), adds seconds to a +//! `Time` (`Time.now.utc + 10`), and subtracts arrays of unlike element +//! types (`[:a, "b"] - ["b"]`). All are valid Ruby; the classifiers used to +//! read every non-Int/Float/Str pair as a guaranteed TypeError. + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn binops(model: &str) -> Vec { + let tree: std::collections::HashMap> = [ + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base +end +"), + ("app/models/thing.rb", model), + ] + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| matches!(d.kind, DiagnosticKind::IncompatibleBinop { .. })) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +#[test] +fn class_owned_and_time_operators_are_not_type_errors() { + let found = binops( + r#"class Thing < ApplicationRecord + TTL = 10 + def expires = Time.now.utc + TTL + def later = Time.now + 1.5 + def net(a, b) + total = Money.new(0, "USD") + total - Money.new(1, "USD") + end + def mixed(x) + [x.to_s, :sym] - ["y"] + end + def score(score) + (score || 5) <= 2.5 + end +end +"#, + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn genuine_type_errors_are_still_reported() { + let found = binops("class Thing < ApplicationRecord\n def bad = 1 + \"x\"\nend\n"); + assert_eq!(found.len(), 1, "{found:?}"); +} diff --git a/tests/binop_literal_extremum.rs b/tests/binop_literal_extremum.rs new file mode 100644 index 000000000..fbb7288f4 --- /dev/null +++ b/tests/binop_literal_extremum.rs @@ -0,0 +1,44 @@ +//! `[x, LIMIT].min` / `.max` on an array literal. +//! +//! The clamp idiom (`limit = [limit, MAX].min`) is everywhere in +//! pagination code. A literal has at least one element, so the extremum is +//! an element, never the nil `Enumerable#min` gives an empty collection; +//! typing it `T?` made `MAX_OFFSET - restrict_limit(l)` look like +//! arithmetic on nil (`Integer?? - Integer`). + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn binops(model: &str) -> Vec { + let tree: std::collections::HashMap> = [ + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base +end +"), + ("app/models/thing.rb", model), + ] + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| matches!(d.kind, DiagnosticKind::IncompatibleBinop { .. })) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +#[test] +fn clamp_idiom_is_not_nilable() { + let found = binops( + r#"class Thing < ApplicationRecord + def clamp(limit) + limit = [limit, 5].min + [limit, 1].max + end + def offset = 100 - clamp(3) + def direct = 100 - [3, 4].min +end +"#, + ); + assert!(found.is_empty(), "{found:?}"); +} diff --git a/tests/binop_present_guard.rs b/tests/binop_present_guard.rs new file mode 100644 index 000000000..a2e026bb8 --- /dev/null +++ b/tests/binop_present_guard.rs @@ -0,0 +1,58 @@ +//! `x.present?` / `x.blank?` as nil guards on a local. +//! +//! `id = params[:id]&.to_i; id.present? && id > 0` is the ubiquitous +//! Rails guard. A present value is never nil, so the true side of +//! `present?` (and the false side of `blank?`) drops the nil arm. The +//! other sides say nothing: `blank?` is also true for "" and false. + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn binops(model: &str) -> Vec { + let tree: std::collections::HashMap> = [ + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base +end +"), + ("app/models/thing.rb", model), + ] + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| matches!(d.kind, DiagnosticKind::IncompatibleBinop { .. })) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +#[test] +fn present_and_blank_guard_a_nilable_local() { + let found = binops( + r#"class Thing < ApplicationRecord + def a(x) + v = x ? 1 : nil + v.present? && v > 0 + end + def b(x) + v = x ? 1 : nil + v.blank? || v > 0 + end + def c(x) + v = x ? 1 : nil + return 0 if v.blank? + v + 1 + end +end +"#, + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn blank_side_of_present_does_not_narrow() { + let found = binops( + "class Thing < ApplicationRecord\n def a(x)\n v = x ? 1 : nil\n v.present? || v > 0\n end\nend\n", + ); + assert_eq!(found.len(), 1, "{found:?}"); +} diff --git a/tests/case_in_patterns.rs b/tests/case_in_patterns.rs new file mode 100644 index 000000000..119146a7d --- /dev/null +++ b/tests/case_in_patterns.rs @@ -0,0 +1,174 @@ +//! Native `case … in` retains Ruby matching semantics through IR and emission. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::emit::ruby; +use roundhouse::ingest::ingest_app_from_tree; + +fn emit(body: &str) -> String { + let files: Vec<(&str, String)> = vec![ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table \"accounts\" do |t|\n t.string \"name\"\n end\nend\n".to_string(), + ), + ("app/helpers/matcher.rb", body.to_string()), + ]; + let tree: HashMap> = files + .into_iter() + .map(|(p, c)| (PathBuf::from(p), c.into_bytes())) + .collect(); + let app = ingest_app_from_tree(tree).expect("a `case/in` must ingest"); + let out = ruby::emit_library(&app) + .iter() + .find(|f| f.path.to_string_lossy().ends_with("matcher.rb")) + .map(|f| f.content.clone()) + .expect("matcher.rb"); + let errors: Vec = + ruby_prism::parse(out.as_bytes()).errors().map(|e| e.message().to_string()).collect(); + assert!(errors.is_empty(), "emitted Ruby does not parse: {errors:?}\n{out}"); + out +} + +fn assert_runs(out: &str, assertions: &str) { + let script = format!("{out}\n{assertions}"); + let run = std::process::Command::new("ruby").args(["-e", &script]).output().unwrap(); + assert!(run.status.success(), "{}\n{script}", String::from_utf8_lossy(&run.stderr)); +} + +#[test] +fn an_array_pattern_with_alternatives_remains_native() { + let out = emit( + r#"class Matcher + def check(level, parent) + case [level, parent] + in Ad, Ad | nil + 1 + in AdGroup, Campaign + 2 + else + 3 + end + end +end +"#, + ); + assert_runs(&out, "class Ad; end; class AdGroup; end; class Campaign; end; raise unless Matcher.new.check(Ad.new, nil) == 1; raise unless Matcher.new.check(AdGroup.new, Campaign.new) == 2; raise unless Matcher.new.check(nil, nil) == 3"); + assert!(out.contains("case [level, parent]") && out.contains("in [Ad, (Ad | nil)]") && out.contains("else"), "got:\n{out}"); +} + +#[test] +fn without_an_else_a_miss_raises_no_matching_pattern_error() { + let out = emit( + r#"class Matcher + def check(x) + case x + in Integer + 1 + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check(3) == 1; begin; Matcher.new.check(nil); raise 'miss accepted'; rescue NoMatchingPatternError; end"); + assert!(out.contains("in Integer") && !out.contains("else"), "native matching raises on a miss:\n{out}"); +} + +#[test] +fn captures_and_binds_are_assigned_before_the_body() { + let out = emit( + r#"class Matcher + def check(x) + case x + in [Integer => id, name] + [id, name] + else + nil + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check([3, :name]) == [3, :name]; raise unless Matcher.new.check([nil, :name]).nil?"); + assert!(out.contains("in [(Integer => id), name]") && out.contains("[id, name]"), "got:\n{out}"); +} + +#[test] +fn a_splat_binds_the_slice_between_the_fixed_elements() { + let out = emit( + r#"class Matcher + def check(x) + case x + in [first, *middle, last] + middle + else + nil + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check([1, 2, 3, 4]) == [2, 3]; raise unless Matcher.new.check([1]).nil?"); + assert!(out.contains("in [first, *middle, last]"), "got:\n{out}"); +} + +#[test] +fn a_hash_pattern_requires_its_keys_and_binds_the_shorthand() { + let out = emit( + r#"class Matcher + def check(x) + case x + in { type: :card, amount: } + amount + else + 0 + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check({type: :card, amount: 7}) == 7; raise unless Matcher.new.check({type: :card}) == 0"); + assert!(out.contains("in { type: :card, amount: }"), "got:\n{out}"); +} + +#[test] +fn a_guard_sees_the_bindings_and_a_failed_guard_falls_through() { + let out = emit( + r#"class Matcher + def check(x) + case x + in [n] if n > 1 + :big + in [_] + :small + else + :none + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check([3]) == :big; raise unless Matcher.new.check([1]) == :small; raise unless Matcher.new.check([]) == :none"); + assert!(out.contains("in [n] if n > 1"), "got:\n{out}"); + assert!(out.contains("n > 1"), "got:\n{out}"); + assert!(out.contains(":big") && out.contains(":small") && out.contains(":none"), "got:\n{out}"); +} + +#[test] +fn a_pin_compares_with_case_equality() { + let out = emit( + r#"class Matcher + def check(x, expected) + case x + in ^expected + 1 + else + 2 + end + end +end +"#, + ); + assert_runs(&out, "raise unless Matcher.new.check(3, 3) == 1; raise unless Matcher.new.check(3, 4) == 2"); + assert!(out.contains("in ^expected"), "got:\n{out}"); +} diff --git a/tests/class_body_declarations.rs b/tests/class_body_declarations.rs index f29e71332..e8722e1cf 100644 --- a/tests/class_body_declarations.rs +++ b/tests/class_body_declarations.rs @@ -149,7 +149,7 @@ fn class_eval_macro_also_ingests_substituted_has_one() { } #[test] -fn load_hook_class_methods_expand_without_mixing_in_instance_methods() { +fn load_hook_class_macros_expand_into_model_methods() { let files = tree(&[ ( "db/schema.rb", @@ -299,7 +299,7 @@ puts "class_eval heredoc accessors passed" } #[test] -fn emitted_load_hook_class_eval_accessors_run_without_mixin() { +fn emitted_load_hook_class_eval_accessors_and_literal_mixin_run() { emit_and_run::real_blog() .write( "lib/title_macro.rb", @@ -336,7 +336,7 @@ end a = Article.new a.headline = "Hello" raise "writer lost" unless a.headline == "Hello" -raise "hook mixed in instance methods" if a.respond_to?(:installer_marker, true) +raise "hook lost instance method" unless a.installer_marker == 37 puts "load-hook class_eval accessors passed" "#, ) diff --git a/tests/class_body_macro_expansion.rs b/tests/class_body_macro_expansion.rs index 22761dcca..96cee108a 100644 --- a/tests/class_body_macro_expansion.rs +++ b/tests/class_body_macro_expansion.rs @@ -808,8 +808,13 @@ fn readable_class_methods_store_keywords_blocks_and_filter_options() { } #[test] -fn finite_configuration_does_not_admit_unrelated_controller_singletons() { - assert!(configuration_app(WINDOW_SETTINGS, "def self.unrelated; eval('1'); end").is_err()); +fn configuration_keeps_unrelated_class_methods_out_of_its_macro_carriers() { + let mut app = configuration_app(WINDOW_SETTINGS, "def self.unrelated; eval('1'); end").expect("ordinary class methods are retained"); + roundhouse::analyze::Analyzer::new(&app).analyze(&mut app); + let diagnostics = roundhouse::analyze::diagnose(&app); + assert!(diagnostics.iter().any(|d| d.message.contains("eval")), "{diagnostics:?}"); + assert!(app.controllers.iter().flat_map(|c| &c.body).any(|item| matches!(item, + ControllerBodyItem::ClassMethod { method, configuration_slot: None, .. } if method.name.as_str() == "unrelated"))); } #[test] diff --git a/tests/class_body_new.rs b/tests/class_body_new.rs index 7f57f3cd5..e8b1bf48d 100644 --- a/tests/class_body_new.rs +++ b/tests/class_body_new.rs @@ -128,6 +128,39 @@ fn a_self_constructing_constant_still_defers_past_the_methods() { ); } +/// INDEX reads BUILTIN after index_by grounding qualifies it as +/// `Sound::BUILTIN`. Deferral must follow that spelling too — otherwise +/// INDEX stays eager, emits above BUILTIN, and campfire dies at load +/// with `uninitialized constant Sound::BUILTIN`. +const SOUND_WITH_INDEX: &str = r#"class Sound + attr_reader :name + + def initialize(name:) + @name = name + end + + BUILTIN = [ new(name: "bell"), new(name: "honk") ] + INDEX = BUILTIN.index_by(&:name) +end +"#; + +#[test] +fn a_constant_reading_a_deferred_own_class_const_also_defers() { + let src = emitted(SOUND_WITH_INDEX); + let builtin = src.find("BUILTIN =").expect("BUILTIN emitted"); + let index = src.find("INDEX =").expect("INDEX emitted"); + let init = src.find("def initialize").expect("initialize emitted"); + assert!( + builtin > init && index > init, + "both self-constructing BUILTIN and INDEX-that-reads-it must \ + follow the methods:\n{src}" + ); + assert!( + index > builtin, + "INDEX references Sound::BUILTIN, so it must emit after BUILTIN:\n{src}" + ); +} + /// THE OWNER IS NOT ALWAYS `self`. In a class-side method a SUBCLASS /// inherits, `self` at call time is the subclass — the class-side /// template method, which campfire writes as diff --git a/tests/class_side_new.rs b/tests/class_side_new.rs new file mode 100644 index 000000000..f88374d53 --- /dev/null +++ b/tests/class_side_new.rs @@ -0,0 +1,67 @@ +//! `new` inside a class-side method builds an instance of the class +//! that received the call. +//! +//! Shopify core's `YamlDb` keeps its memoized factory on the singleton +//! (`def load; @instance ||= new(file_path); end`) and `CurrencyDb`, +//! `CryptoCurrencyDb`, ... subclass it and add readers. `self` in +//! `load` is whichever subclass was called, but the typer answered the +//! class the `def` sits in, so `CurrencyDb.load.map { ... }` and +//! `CurrencyDb.load[code]` were reported as unknown methods on `YamlDb`. + +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static RUN: AtomicUsize = AtomicUsize::new(0); + +fn check(files: &[(&str, &str)]) -> String { + let dir = std::env::temp_dir().join(format!("rh_class_side_new_{}_{}", std::process::id(), RUN.fetch_add(1, Ordering::SeqCst))); + let _ = std::fs::remove_dir_all(&dir); + for (path, src) in files { + let full = dir.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, src).unwrap(); + } + let out = Command::new(env!("CARGO_BIN_EXE_roundhouse")) + .args(["check", "--continue"]) + .arg(&dir) + .output() + .expect("spawn roundhouse"); + let _ = std::fs::remove_dir_all(&dir); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +const BASE: &str = "class YamlDb\n class << self\n def load\n @instance ||= new(\"x\")\n end\n\n def fresh\n new(\"y\")\n end\n end\n\n def initialize(path)\n @path = path\n end\nend\n"; +const SUB: &str = "class CurrencyDb < YamlDb\n def codes\n [\"USD\"]\n end\nend\n"; +const CONTROLLER: &str = "class ApplicationController < ActionController::Base\n def index\n CurrencyDb.load.codes\n CurrencyDb.fresh.codes\n head :ok\n end\nend\n"; +const ROUTES: &str = "Rails.application.routes.draw do\n root to: \"application#index\"\nend\n"; + +#[test] +fn a_subclass_factory_answers_the_subclass() { + let err = check(&[ + ("app/models/yaml_db.rb", BASE), + ("app/models/currency_db.rb", SUB), + ("app/controllers/application_controller.rb", CONTROLLER), + ("config/routes.rb", ROUTES), + ]); + assert!(err.contains("0 error(s)"), "{err}"); +} + +/// The instance a class-side `new` builds is still an instance of the +/// class the `def` sits in, for the body that builds it: core's +/// `ClientDetails.from_checkout_client_details` assigns attributes onto +/// `details = new`, and `UriValidator.verify` runs `new(...).tap(&:valid?)`. +/// Each of those sends was reported as "no known method on instance". +#[test] +fn the_built_instance_answers_the_defining_class_inside_the_factory() { + let schema = "ActiveRecord::Schema.define do\n create_table \"client_details\" do |t|\n t.string \"user_agent\"\n end\nend\n"; + let record = "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n"; + let model = "class ClientDetails < ApplicationRecord\n def self.from_other(agent)\n details = new\n details.user_agent = agent\n details\n end\nend\n"; + let validator = "class UriValidator\n include ActiveModel::Validations\n def initialize(uri_string:)\n @uri_string = uri_string\n end\n class << self\n #: (uri_string: String) -> UriValidator\n def verify(uri_string:)\n new(uri_string:).tap(&:valid?)\n end\n end\nend\n"; + let err = check(&[ + ("db/schema.rb", schema), + ("app/models/application_record.rb", record), + ("app/models/client_details.rb", model), + ("app/models/uri_validator.rb", validator), + ]); + assert!(err.contains(" 0 error(s)"), "{err}"); +} diff --git a/tests/class_union_operator.rs b/tests/class_union_operator.rs new file mode 100644 index 000000000..1380286b2 --- /dev/null +++ b/tests/class_union_operator.rs @@ -0,0 +1,81 @@ +//! A union of classes answers its operators arm by arm. +//! +//! ```ruby +//! def total(flag) +//! amount = flag ? Money.new(5) : Money::Zero.new +//! amount + amount +//! end +//! ``` +//! +//! `+` on an instance of an app class is that class's own method, which the +//! binop classifier cannot see, so an operand that is one is left to +//! dispatch. A value that is one of several such classes was treated as a +//! mismatch instead of the same thing arm by arm; only a union with an arm +//! that owns no operator (`nil`) is a real mismatch. + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn binops(files: &[(&str, &str)]) -> Vec { + let mut all: Vec<(&str, &str)> = vec![ + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\nend\n"), + ("db/schema.rb", "ActiveRecord::Schema.define(version: 1) do\nend\n"), + ]; + all.extend_from_slice(files); + let tree: std::collections::HashMap> = all + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| matches!(d.kind, DiagnosticKind::IncompatibleBinop { .. })) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +const MONEY: (&str, &str) = ( + "app/models/money.rb", + "class Money\n include ActiveModel::Model\n def +(other)\n self\n end\n def <=>(other)\n 0\n end\nend\n", +); +const ZERO: (&str, &str) = ( + "app/models/money_zero.rb", + "class MoneyZero\n include ActiveModel::Model\n def +(other)\n other\n end\n def <=>(other)\n 0\n end\nend\n", +); + +#[test] +fn a_union_of_operator_owning_classes_is_left_to_dispatch() { + let found = binops(&[ + MONEY, + ZERO, + ( + "app/models/thing.rb", + r#"class Thing < ApplicationRecord + def total(flag) + amount = flag ? Money.new : MoneyZero.new + amount + amount + end +end +"#, + ), + ]); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn a_nil_arm_in_that_union_is_still_a_mismatch() { + let found = binops(&[ + MONEY, + ( + "app/models/thing.rb", + r#"class Thing < ApplicationRecord + def total(flag) + amount = flag ? Money.new : nil + amount + amount + end +end +"#, + ), + ]); + assert_eq!(found.len(), 1, "{found:?}"); +} diff --git a/tests/collection_predicates.rs b/tests/collection_predicates.rs new file mode 100644 index 000000000..d026c0026 --- /dev/null +++ b/tests/collection_predicates.rs @@ -0,0 +1,56 @@ +//! `Array#intersect?` and `Hash#all?`: collection predicates the +//! catalog was missing. +//! +//! Shopify's models test `roles.intersect?(REQUIRED)` (Ruby 3.1's +//! non-allocating `(a & b).any?`) and validate a settings hash with +//! `flags.all? { |key, on| on }`. `Array` had `exclude?` and `Hash` had +//! `any?`/`none?` but neither of these, so each failed dispatch as +//! "no known method `intersect?` on Array[String]" / "`all?` on +//! Hash[String, bool]". +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, DiagnosticKind}; +use roundhouse::ingest::ingest_app_from_tree; + +fn failures(body: &str) -> Vec { + let controller = format!("class ThingsController < ApplicationController\n{body}\nend\n"); + let files: Vec<(&str, &str)> = vec![ + ("db/schema.rb", "ActiveRecord::Schema.define do\nend\n"), + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\nend\n"), + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ("app/controllers/things_controller.rb", controller.as_str()), + ( + "config/routes.rb", + "Rails.application.routes.draw do\n get \"/things\", to: \"things#index\"\nend\n", + ), + ]; + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + roundhouse::session::analyze_and_lower(&mut app); + diagnose(&app) + .into_iter() + .filter_map(|d| match d.kind { + DiagnosticKind::SendDispatchFailed { method, recv_ty } => Some(format!( + "{}#{}", + roundhouse::ide::render_ty(&recv_ty), + method.as_str() + )), + _ => None, + }) + .collect() +} + +#[test] +fn an_array_intersects_and_a_hash_answers_all() { + let f = failures( + " def index\n a = %w[a b].intersect?(%w[b c])\n b = { \"x\" => true }.all? { |key, on| on }\n c = { \"x\" => 1 }.one?\n render plain: \"x\"\n end", + ); + assert!(f.is_empty(), "predicate dispatch failures: {f:?}"); +} diff --git a/tests/concern_ivar_writers.rs b/tests/concern_ivar_writers.rs new file mode 100644 index 000000000..b4df0576f --- /dev/null +++ b/tests/concern_ivar_writers.rs @@ -0,0 +1,157 @@ +//! A concern that lives in the includer's namespace, included by its +//! relative name, and the ivars it writes. +//! +//! Shopify's customer-authentication controllers keep their mixins +//! beside the controllers: +//! +//! ```ruby +//! module Auth +//! module Concerns +//! module ValidateRedirectUri +//! def validate_redirect_uri! +//! @redirect_uri = T.let(params[:redirect_uri], T.nilable(String)) +//! end +//! end +//! end +//! +//! class SessionsController < SectionController +//! include Concerns::ValidateRedirectUri # Auth::Concerns::… +//! end +//! end +//! ``` +//! +//! Ruby resolves `Concerns::ValidateRedirectUri` through the lexical +//! nesting (`Auth::SessionsController`, then `Auth`, then top level). +//! The include was kept as written, matched no ingested module, and +//! every ivar the concern assigns read `@redirect_uri has no known +//! type` in the controller. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::diagnose; +use roundhouse::ingest::ingest_app_from_tree; + +fn ivar_errors(files: &[(&str, &str)]) -> Vec { + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest tree"); + roundhouse::session::analyze_and_lower(&mut app); + diagnose(&app) + .into_iter() + .map(|d| d.to_string()) + .filter(|d| d.starts_with("error") && d.contains("ivar_unresolved")) + .collect() +} + +const APPLICATION: (&str, &str) = ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", +); +const ROUTES: (&str, &str) = ( + "config/routes.rb", + "Rails.application.routes.draw do\n get 'new' => 'auth/sessions#new'\nend\n", +); +const SECTION: (&str, &str) = ( + "app/controllers/auth/section_controller.rb", + "module Auth\n class SectionController < ApplicationController\n end\nend\n", +); + +const VALIDATE: (&str, &str) = ( + "app/controllers/auth/concerns/validate_redirect_uri.rb", + r#" +module Auth + module Concerns + module ValidateRedirectUri + extend T::Helpers + requires_ancestor { SectionController } + + def validate_redirect_uri! + @redirect_uri = T.let(params[:redirect_uri], T.nilable(String)) + end + end + end +end +"#, +); + +const REQUIRE_CLIENT: (&str, &str) = ( + "app/controllers/auth/concerns/require_client.rb", + r#" +module Auth + module Concerns + module RequireClient + #: (untyped client_id) -> void + def set_client_id!(client_id) + @client_id = client_id #: String? + end + end + end +end +"#, +); + +#[test] +fn ivars_written_by_a_relatively_included_concern_are_typed() { + let o = ivar_errors(&[ + APPLICATION, + ROUTES, + SECTION, + VALIDATE, + REQUIRE_CLIENT, + ( + "app/controllers/auth/sessions_controller.rb", + r#" +module Auth + class SessionsController < SectionController + include Concerns::ValidateRedirectUri + include Concerns::RequireClient + + before_action :validate_redirect_uri!, only: [:new] + + def new + redirect_to(@redirect_uri) + render plain: @client_id + end + end +end +"#, + ), + ]); + assert!(o.is_empty(), "{o:#?}"); +} + +/// A module's own `include` resolves the same way: `include Concerns::…` +/// from a sibling namespace's module is `Auth::Concerns::…`, so the +/// ivars reach the controller through the module chain. +#[test] +fn a_concern_including_a_sibling_concern_relatively_carries_its_ivars() { + let o = ivar_errors(&[ + APPLICATION, + ROUTES, + SECTION, + VALIDATE, + ( + "app/controllers/auth/concerns/bundle.rb", + "module Auth\n module Concerns\n module Bundle\n include ValidateRedirectUri\n\n def bundled; end\n end\n end\nend\n", + ), + ( + "app/controllers/auth/sessions_controller.rb", + r#" +module Auth + class SessionsController < SectionController + include Concerns::Bundle + + def new + validate_redirect_uri! + redirect_to(@redirect_uri) + end + end +end +"#, + ), + ]); + assert!(o.is_empty(), "{o:#?}"); +} diff --git a/tests/connection_adapter_surface.rs b/tests/connection_adapter_surface.rs new file mode 100644 index 000000000..6c57dfabf --- /dev/null +++ b/tests/connection_adapter_surface.rs @@ -0,0 +1,49 @@ +//! `connection` answers the adapter surface Rails documents, not only the +//! calls the runtime implements. +//! +//! The registered `ActiveRecord::Connection` is read from the runtime's +//! own signatures (`exec_query`, `quote`, `execute`, ...). Code that +//! reaches for the adapter directly also calls `quote_column_name`, +//! `quote_table_name`, `transaction_open?`, `select_rows`, `pool` ..., +//! which Rails' `AbstractAdapter` defines and the runtime never needed. +//! Each was reported as an unknown method of the connection. + +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static RUN: AtomicUsize = AtomicUsize::new(0); + +fn check(files: &[(&str, &str)]) -> String { + let dir = std::env::temp_dir() + .join(format!("rh_connection_adapter_{}_{}", std::process::id(), RUN.fetch_add(1, Ordering::SeqCst))); + let _ = std::fs::remove_dir_all(&dir); + for (path, src) in files { + let full = dir.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, src).unwrap(); + } + let out = Command::new(env!("CARGO_BIN_EXE_roundhouse")) + .args(["check", "--continue"]) + .arg(&dir) + .output() + .expect("spawn roundhouse"); + let _ = std::fs::remove_dir_all(&dir); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +const RECORD: &str = "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n"; + +#[test] +fn the_connection_answers_the_adapter_surface() { + let model = "class Widget < ApplicationRecord\n def self.go\n c = self.connection\n c.quote_column_name(\"x\").upcase\n c.quote_table_name(\"t\")\n c.transaction_open?\n c.select_rows(\"select 1\").first\n c.pool\n c.current_transaction\n c.tables.first.upcase\n end\nend\n"; + let err = check(&[("app/models/application_record.rb", RECORD), ("app/models/widget.rb", model)]); + assert!(err.contains(" 0 error(s)"), "{err}"); +} + +/// A name the adapter does not have is still a visible gap. +#[test] +fn a_name_the_adapter_lacks_is_still_reported() { + let model = "class Widget < ApplicationRecord\n def self.go\n self.connection.no_such_adapter_method\n end\nend\n"; + let err = check(&[("app/models/application_record.rb", RECORD), ("app/models/widget.rb", model)]); + assert!(err.contains("no_such_adapter_method"), "{err}"); +} diff --git a/tests/constant_holding_a_gem_value.rs b/tests/constant_holding_a_gem_value.rs new file mode 100644 index 000000000..17861e95e --- /dev/null +++ b/tests/constant_holding_a_gem_value.rs @@ -0,0 +1,47 @@ +//! A source constant holding a value from an undeclared gem remains +//! unsupported. It must not become a phantom class or turn its readers +//! into clean support without a declaration and runtime implementation. + +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static RUN: AtomicUsize = AtomicUsize::new(0); + +fn check(files: &[(&str, &str)]) -> String { + let dir = std::env::temp_dir() + .join(format!("rh_const_gem_value_{}_{}", std::process::id(), RUN.fetch_add(1, Ordering::SeqCst))); + let _ = std::fs::remove_dir_all(&dir); + for (path, src) in files { + let full = dir.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, src).unwrap(); + } + let out = Command::new(env!("CARGO_BIN_EXE_roundhouse")) + .args(["check", "--continue"]) + .arg(&dir) + .output() + .expect("spawn roundhouse"); + let _ = std::fs::remove_dir_all(&dir); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +const RECORD: &str = "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n"; + +#[test] +fn a_value_from_an_undeclared_gem_constant_is_unsupported() { + let model = "class Widget < ApplicationRecord\n MIN_PRICE = Money.new(1, \"USD\")\n\n def price\n MIN_PRICE.currency\n MIN_PRICE.value.positive?\n Widget::MIN_PRICE.value\n end\nend\n"; + let err = check(&[("app/models/application_record.rb", RECORD), ("app/models/widget.rb", model)]); + assert!(err.contains("error[unsupported]"), "{err}"); + assert!(err.contains("Widget::MIN_PRICE"), "{err}"); + assert!(!err.contains("send_dispatch_failed"), "{err}"); +} + +/// A written class name is still the class object: the gem class is +/// unregistered, so a constant read of it is not a value. +#[test] +fn a_written_class_name_is_still_a_class_object() { + let model = "class Widget < ApplicationRecord\n def price\n Money.no_such_class_method\n end\nend\n"; + let err = check(&[("app/models/application_record.rb", RECORD), ("app/models/widget.rb", model)]); + assert!(!err.contains("panicked"), "{err}"); + assert!(err.contains("constant not supported (all targets): Money"), "{err}"); +} diff --git a/tests/critic_admission.rs b/tests/critic_admission.rs new file mode 100644 index 000000000..eb707ddfe --- /dev/null +++ b/tests/critic_admission.rs @@ -0,0 +1,178 @@ +//! Regression cases where inference used to certify an unexecutable program. +//! Both CLI gates must refuse; passing `check` alone is not admission evidence. +use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static NEXT: AtomicUsize = AtomicUsize::new(0); + +fn refuses(body: &str, expected: &str) { + refuses_with(body, expected, &[]); +} + +fn refuses_with(body: &str, expected: &str, extra: &[(&str, &str)]) { + let root = std::env::temp_dir().join(format!("rh_admission_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::SeqCst))); + for (path, text) in [ + ("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n".to_string()), + ("app/models/application_record.rb", "class ApplicationRecord < ActiveRecord::Base\n self.abstract_class = true\nend\n".to_string()), + ("db/schema.rb", "ActiveRecord::Schema.define do\n create_table \"probes\" do |t|\n t.string \"title\"\n end\nend\n".to_string()), + ("app/models/probe.rb", format!("class Probe < ApplicationRecord\n validates :title, presence: true\n def probe\n {body}\n end\nend\n")), + ] { + let file = root.join(path); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, text).unwrap(); + } + for (path, text) in extra { + let file = root.join(path); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, text).unwrap(); + } + for args in [vec!["check"], vec!["--target", "ruby"]] { + let mut command = Command::new(env!("CARGO_BIN_EXE_roundhouse")); + command.args(&args).arg(&root); + if args[0] != "check" { command.arg("-o").arg(root.join("emitted")); } + let output = command.output().unwrap(); + let text = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(!output.status.success(), "{args:?} accepted {body}: {text}"); + assert!(text.contains(expected), "{args:?} lost named refusal {expected}: {text}"); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn module_protocol_requires_a_module_object() { + for body in ["self.define_method(:x) { 1 }", "define_method(:x) { 1 }", "class_eval { 2 }", "\"abc\".define_method(:x) { 1 }", "1.class_eval { 2 }", "self.const_get(:X)", "self.include?(1)"] { + refuses(body, "Module protocol"); + } +} + +#[test] +fn unresolved_declared_classes_are_errors() { + refuses("v = T.let(1, GhostMoney); v.imaginary_method", "GhostMoney"); + refuses("v = T.let(1, GhostMoney); v.message.upcase", "GhostMoney"); +} + +#[test] +fn new_errors_api_requires_runtime_support() { + for body in ["errors.added?(:title, :blank)", "errors.details", "errors.where(:title)", "errors.merge!(errors)", "errors.first.options", "errors.delete(:title); errors.size"] { + refuses(body, "ActiveModel::Errors"); + } +} + +#[test] +fn non_nil_assertions_require_runtime_support() { + refuses("T.must(1) + 1", "non-nil assertion"); + refuses("T.must_because(1) { \"present\" }", "non-nil assertion"); + refuses("1.not_nil!", "not_nil!"); +} + +#[test] +fn for_scope_is_not_a_block_scope() { + for body in [ + "x = 10; for x in [1, 2]; end; x", + "for x in []; end; [defined?(x), x]", + "for x in [1, 2]; y = x * 10; end; y", + "for p, q in [[1, 2], [3, 4]]; end; [p, q]", + "for x in []; y = 1; end; [defined?(y), y]", + ] { refuses(body, "for"); } +} + +#[test] +fn registry_signatures_do_not_supply_missing_runtime() { + for (body, name) in [ + ("Rails.event.notify(\"probe\")", "event"), + ("Rails.error.set_context(probe: true)", "error"), + ("ActiveRecord::Base.connected_to(role: :reading) { 7 }", "connected_to"), + ("Benchmark.realtime { 1 }", "Benchmark"), + ("\"2020-01-02\".to_date", "to_date"), + ("\"1.5\".to_d", "to_d"), + ("{}.to_sentence", "to_sentence"), + ] { refuses(body, name); } +} + +#[test] +fn application_tracers_are_not_global_stdlib() { + refuses("ShopifyTracer.in_span(\"probe\") { 42 }", "ShopifyTracer"); +} + +#[test] +fn a_nominal_class_does_not_prove_operator_ownership() { + refuses_with("Value.new + 1", "incompatible_binop", &[("app/lib/value.rb", "class Value\nend\n")]); +} + +#[test] +fn unresolved_includes_are_load_time_errors() { + refuses_with("Imported.new.zork", "GhostModule", &[("app/lib/imported.rb", "class Imported\n include GhostModule\n def zork; 1; end\nend\n")]); +} + +#[test] +fn identity_is_invalidated_by_reassignment_and_branch_writes() { + for body in [ + "k = Probe; k = Probe.new; k.define_method(:x) { 1 }", + "k = Probe; if title; k = Probe.new; end; k.define_method(:x) { 1 }", + "k = Probe; [1].each { |k| k.define_method(:x) { 1 } }", + "k = Probe; [1].each { |*k| k.define_method(:x) { 1 } }", + "k = Probe; k, x = [Probe.new, 1]; k.define_method(:x) { 1 }", + "k = Probe; [Probe.new] => [k]; k.define_method(:x) { 1 }", + "k = Probe; begin; raise StandardError; rescue => k; k.define_method(:x) { 1 }; end", + "Probe.new.class.define_method(:x) { 1 }", + ] { refuses(body, "Module protocol"); } +} + +#[test] +fn cross_date_time_comparison_requires_activesupport_runtime() { + refuses("Date.new(2020, 1, 1) < Time.utc(2020, 1, 2)", "incompatible_binop"); +} + +#[test] +fn instance_eval_changes_self_identity_in_class_side_methods() { + refuses_with("Probe.go", "Module protocol", &[("app/models/probe.rb", "class Probe < ApplicationRecord\n def self.go\n Probe.new.instance_eval { self.define_method(:x) { 1 } }\n end\nend\n")]); +} + +#[test] +fn unused_unresolved_model_include_is_not_silent() { + refuses_with("1", "GhostModule", &[("app/models/probe.rb", "class Probe < ApplicationRecord\n include GhostModule\nend\n")]); +} + +#[test] +fn object_extensions_without_runtime_are_refused() { + // `to_param` on an AR model is synthesized + registered; refuse it on + // a plain library object that has no runtime/lowering support. + for body in ["self.to_query", "self.instance_values", "self.acts_like?(:probe)", "self.presence_in([1])"] { + refuses(body, "Object extension"); + } + refuses_with( + "Value.new.to_param", + "Object extension", + &[("app/lib/value.rb", "class Value\nend\n")], + ); +} + +#[test] +fn lambda_parameters_cannot_borrow_outer_class_identity() { + refuses("k = Probe; f = ->(k) { k.define_method(:x) { 1 } }; f.call(Probe.new)", "Module protocol"); +} + +#[test] +fn dropped_block_capture_cannot_borrow_outer_identity() { + refuses("k = Probe; [1].each { |&k| k.define_method(:x) { 1 } }", "block parameter capture"); + refuses("k = Probe; f = ->(&k) { k.define_method(:x) { 1 } }; f.call", "block parameter capture"); +} + +#[test] +fn framework_prefix_does_not_prove_an_include_exists() { + refuses_with("1", "ActiveModel::Ghost", &[("app/models/probe.rb", "class Probe < ApplicationRecord\n include ActiveModel::Ghost\nend\n")]); +} + +#[test] +fn native_module_callback_contract_is_not_an_instance_escape() { + refuses_with("Hook.included(Probe.new)", "Module callback argument", &[("app/helpers/hook.rb", "module Hook\n def self.included(base)\n base.define_method(:hook_value) { 23 }\n end\nend\n")]); +} + +#[test] +fn library_nominal_operator_failures_are_not_hidden() { + refuses_with("1", "incompatible_binop", &[("app/helpers/value.rb", "class Value\n def bad\n Value.new + 1\n end\nend\n")]); + for value_definition in ["class Value\nend\n", "class Value\n def +(other); other; end\nend\n"] { + let library = format!("{value_definition}class Other\nend\nclass UnionOperator\n def bad(flag)\n value = flag ? Value.new : Other.new\n value + 1\n end\nend\n"); + refuses_with("1", "incompatible_binop", &[("app/helpers/value.rb", &library)]); + } +} diff --git a/tests/declared_block_params.rs b/tests/declared_block_params.rs new file mode 100644 index 000000000..9100a47c0 --- /dev/null +++ b/tests/declared_block_params.rs @@ -0,0 +1,80 @@ +//! A method that declares its block, `{ (Writer) -> void }` in RBS or +//! `T.proc.params(writer: Writer).void` in a sig, yields `Writer` to the +//! block. The block's own parameter list is the signature, so a block +//! with ONE parameter binds that parameter's type, not the whole +//! `^(Writer) -> void` function type (every call on the block variable +//! was then a dispatch on a proc). + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; +use roundhouse::ingest::ingest_app_from_tree; + +fn receivers(registry: &str, action: &str) -> Vec { + let controller = format!( + "class CartController < ApplicationController\n def show\n {action}\n end\nend\n" + ); + let files: [(&str, &str); 5] = [ + ("db/schema.rb", "ActiveRecord::Schema.define do\nend\n"), + ( + "app/services/registry.rb", + registry, + ), + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\nend\n", + ), + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ("app/controllers/cart_controller.rb", &controller), + ]; + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter_map(|d| match d.kind { + DiagnosticKind::SendDispatchFailed { recv_ty, .. } => Some(format!("{recv_ty:?}")), + _ => None, + }) + .collect() +} + +fn writer_class(recv: &str) -> bool { + recv.contains("Writer") && !recv.contains("Fn") +} + +#[test] +fn an_rbs_block_with_one_parameter_yields_that_parameter() { + let r = receivers( + "class Writer\nend\n\nclass Registry\n #: () { (Writer) -> void } -> void\n def self.with; end\nend\n", + "Registry.with { |writer| writer.bogus }", + ); + assert_eq!(r.len(), 1, "{r:?}"); + assert!(writer_class(&r[0]), "{r:?}"); +} + +#[test] +fn a_sorbet_proc_block_with_one_parameter_yields_that_parameter() { + let r = receivers( + "class Writer\nend\n\nclass Registry\n extend T::Sig\n\n sig { params(blk: T.proc.params(writer: Writer).void).void }\n def self.with(&blk); end\nend\n", + "Registry.with { |writer| writer.bogus }", + ); + assert_eq!(r.len(), 1, "{r:?}"); + assert!(writer_class(&r[0]), "{r:?}"); +} + +#[test] +fn a_block_that_yields_nothing_binds_nothing() { + let r = receivers( + "class Registry\n #: () { () -> void } -> void\n def self.with; end\nend\n", + "Registry.with { 1 }", + ); + assert!(r.is_empty(), "{r:?}"); +} diff --git a/tests/declared_param_types.rs b/tests/declared_param_types.rs new file mode 100644 index 000000000..854dc87db --- /dev/null +++ b/tests/declared_param_types.rs @@ -0,0 +1,102 @@ +//! A parameter's declared type versus what call sites were seen to pass. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; +use roundhouse::ingest::ingest_app_from_tree; + +fn dispatch_failures(files: &[(&str, &str)]) -> Vec { + let tree: HashMap> = files + .iter() + .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = ingest_app_from_tree(tree).expect("ingest"); + Analyzer::new(&app).analyze(&mut app); + let mut out: Vec = diagnose(&app) + .into_iter() + .filter_map(|d| match d.kind { + DiagnosticKind::SendDispatchFailed { method, .. } => Some(method.as_str().to_string()), + _ => None, + }) + .collect(); + out.sort(); + out +} + +const SCHEMA: (&str, &str) = ("db/schema.rb", "ActiveRecord::Schema.define do\nend\n"); + +/// The declared type of a parameter wins over what one caller was seen +/// passing. +/// +/// `login_with_shop_controller.rb` has `#: (FatalError error) -> void` +/// above a helper, and one caller hands it a value the analysis knows +/// only as `nil`. Observation used to win, so `error` was `nil` inside +/// the helper and `error.message` failed with "message on nil" even +/// though the signature says exactly what `error` is. +#[test] +fn a_fully_declared_parameter_keeps_its_type_when_a_caller_passes_nil() { + let failures = dispatch_failures(&[ + SCHEMA, + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ( + "app/controllers/x_controller.rb", + r#"class XController < ApplicationController + class FatalError < StandardError + def key + :k + end + end + + def show + render_fatal_error(nil) + end + + #: (FatalError error) -> void + def render_fatal_error(error) + error.key.bogus_from_key + end +end +"#, + ), + ]); + assert_eq!(failures, vec!["bogus_from_key".to_string()]); +} + +/// A signature is matched to its parameters by name, and by position +/// only among positional parameters. +/// +/// The table is keyed by method name alone, so a signature can belong to +/// a method of a different arity: a concern's `def memoize(use_caches: +/// false)` and the class method it contributes, `def memoize(variants, +/// use_caches: false)`. Falling back to "the parameter at the same index" +/// read the declared KEYWORD `use_caches` as the class method's first +/// positional `variants` and typed it `bool`. +#[test] +fn a_declared_keyword_is_never_read_as_a_positional_parameter() { + let failures = dispatch_failures(&[ + SCHEMA, + ( + "app/controllers/application_controller.rb", + "class ApplicationController < ActionController::Base\nend\n", + ), + ("sig/x.rbs", "class XController\n def memoize: (?use_caches: bool) -> void\nend\n"), + ( + "app/controllers/x_controller.rb", + r#"class XController < ApplicationController + def show + memoize([1]) + end + + def memoize(variants, use_caches: false) + variants.first + end +end +"#, + ), + ]); + assert!(failures.is_empty(), "{failures:?}"); +} diff --git a/tests/declared_untyped_param.rs b/tests/declared_untyped_param.rs new file mode 100644 index 000000000..f112541c7 --- /dev/null +++ b/tests/declared_untyped_param.rs @@ -0,0 +1,84 @@ +//! A parameter a signature declares `untyped` is not inferred from call sites. +//! +//! ```ruby +//! #: (untyped, untyped) -> bool +//! def compare(left, right) +//! left > right +//! end +//! ``` +//! +//! is handed Integers here, Strings there, and `nil` somewhere else. An +//! unannotated parameter is an inference gap the call sites may fill, but +//! `untyped` is the author saying the body must accept anything: unifying the +//! observed argument types into `Integer | String | nil` invents a +//! precondition the signature never made, and `left > right` was reported as +//! `Integer | String | nil > ...`. The unannotated neighbour keeps its +//! inferred type. + +use roundhouse::analyze::{diagnose, Analyzer, DiagnosticKind}; + +fn errors(model: &str) -> Vec { + let tree: std::collections::HashMap> = [ + ( + "app/models/application_record.rb", + "class ApplicationRecord < ActiveRecord::Base\nend\n", + ), + ("app/models/thing.rb", model), + ( + "db/schema.rb", + "ActiveRecord::Schema.define(version: 1) do\n create_table \"things\", force: :cascade do |t|\n t.string \"name\"\n end\nend\n", + ), + ] + .iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + let mut app = roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest tree"); + Analyzer::new(&app).analyze(&mut app); + diagnose(&app) + .into_iter() + .filter(|d| { + matches!( + d.kind, + DiagnosticKind::IncompatibleBinop { .. } | DiagnosticKind::SendDispatchFailed { .. } + ) + }) + .map(|d| format!("{:?}", d.kind)) + .collect() +} + +#[test] +fn an_untyped_parameter_accepts_whatever_the_call_sites_pass() { + let found = errors( + r#"class Thing < ApplicationRecord + #: (untyped, untyped) -> bool + def compare(left, right) + left > right + end + + def go + compare(1, 2) + compare("a", "b") + compare(nil, 3) + end +end +"#, + ); + assert!(found.is_empty(), "{found:?}"); +} + +#[test] +fn an_unannotated_parameter_still_takes_the_type_its_callers_pass() { + let found = errors( + r#"class Thing < ApplicationRecord + def plain(left) + left + 1 + end + + def go + plain("a") + end +end +"#, + ); + assert_eq!(found.len(), 1, "{found:?}"); +} diff --git a/tests/defined_constant.rs b/tests/defined_constant.rs index b97b6b71c..0c8717aae 100644 --- a/tests/defined_constant.rs +++ b/tests/defined_constant.rs @@ -107,3 +107,20 @@ fn argument_and_index_queries_stay_unsupported() { assert!(message.contains("defined? calls with arguments"), "{guard}: {message}"); } } + +#[test] +fn source_guards_and_generated_references_keep_distinct_intent() { + use roundhouse::expr::{GENERATED_CONST_REF, RESOLVED_DATA_FACTORY}; + use roundhouse::span::{FileId, Span}; + let body = method_body("class Probe; def check; defined?(MissingConstant); end; end"); + let operand = defined_operand(&body); + assert_eq!(operand.decisions & (GENERATED_CONST_REF | RESOLVED_DATA_FACTORY), 0); + + let mut generated = roundhouse::Expr::new(Span::synthetic(), ExprNode::Const { + path: vec!["Probe".into()], + }); + generated.inherit_span(Span { file: FileId(1), start: 1, end: 6 }); + let restored: roundhouse::Expr = serde_json::from_str(&serde_json::to_string(&generated).unwrap()).unwrap(); + assert_ne!(restored.decisions & GENERATED_CONST_REF, 0); + assert_eq!(restored.decisions & RESOLVED_DATA_FACTORY, 0); +} diff --git a/tests/defined_operands.rs b/tests/defined_operands.rs new file mode 100644 index 000000000..6728f57d6 --- /dev/null +++ b/tests/defined_operands.rs @@ -0,0 +1,65 @@ +//! `defined?` on operands other than a bareword. +//! +//! `defined?(Foo)` guards optional constants all over core +//! (`defined?(AdminAreaController)`, `defined?(Rails::Console)`), and +//! Method-call operands are preserved without evaluating the method. +//! Ingest once only knew barewords and ivars and dropped +//! the whole file on any other operand. + +use std::collections::HashMap; +use std::path::PathBuf; + +use roundhouse::emit::ruby; +use roundhouse::ingest::ingest_app_from_tree; + +fn emit(body: &str) -> String { + let files: Vec<(&str, String)> = vec![ + ( + "db/schema.rb", + "ActiveRecord::Schema.define do\n create_table \"accounts\" do |t|\n t.string \"name\"\n end\nend\n".to_string(), + ), + ("app/helpers/probe.rb", body.to_string()), + ]; + let tree: HashMap> = files + .into_iter() + .map(|(p, c)| (PathBuf::from(p), c.into_bytes())) + .collect(); + let app = ingest_app_from_tree(tree).expect("`defined?` on a supported operand must ingest"); + ruby::emit_library(&app) + .iter() + .find(|f| f.path.to_string_lossy().ends_with("probe.rb")) + .map(|f| f.content.clone()) + .expect("probe.rb") +} + +#[test] +fn a_constant_operand_is_kept() { + let out = emit( + r#"class Probe + def admin? + defined?(AdminAreaController) + end +end +"#, + ); + assert!(out.contains("defined?(AdminAreaController)"), "got:\n{out}"); +} + +#[test] +fn a_scoped_constant_operand_is_kept() { + let out = emit( + r#"class Probe + def console? + defined?(Rails::Console) + end +end +"#, + ); + assert!(out.contains("defined?(Rails::Console)"), "got:\n{out}"); +} + +#[test] +fn a_method_call_operand_is_kept() { + let out = emit("class Probe\n def responds?(obj)\n defined?(obj.meth)\n end\nend\n"); + assert!(out.contains("defined?(obj.meth)"), "got:\n{out}"); +} diff --git a/tests/dns_helper_const_splice.rs b/tests/dns_helper_const_splice.rs new file mode 100644 index 000000000..f1fb8cc9f --- /dev/null +++ b/tests/dns_helper_const_splice.rs @@ -0,0 +1,120 @@ +//! DnsTestHelper::CONST must become bare CONST after splice and stay bare +//! through analyze + test-module lowering (campfire push-subscription floor). +use roundhouse::expr::ExprNode; + +fn app() -> roundhouse::app::App { + let files: Vec<(&str, &str)> = vec![ + ( + "test/test_helper.rb", + "class ActiveSupport::TestCase\n include DnsTestHelper\nend\n", + ), + ( + "test/test_helpers/dns_test_helper.rb", + "module DnsTestHelper\n WEB_PUSH_PUBLIC_TEST_IP = \"1.2.3.4\"\n def stub_x\n WEB_PUSH_PUBLIC_TEST_IP\n end\nend\n", + ), + ( + "test/models/push_test.rb", + "class PushTest < ActiveSupport::TestCase\n test \"ip\" do\n DnsTestHelper::WEB_PUSH_PUBLIC_TEST_IP\n end\nend\n", + ), + ]; + let tree = files + .into_iter() + .map(|(p, c)| (std::path::PathBuf::from(p), c.as_bytes().to_vec())) + .collect(); + roundhouse::ingest::ingest_app_from_tree(tree).expect("ingest") +} + +fn walk(e: &roundhouse::expr::Expr, out: &mut Vec>) { + if let ExprNode::Const { path } = &*e.node { + out.push(path.iter().map(|s| s.as_str().to_string()).collect()); + } + e.node.for_each_child(&mut |c| walk(c, out)); +} + +fn assert_bare_web_push(paths: &[Vec], phase: &str) { + assert!( + !paths + .iter() + .any(|p| p.len() == 2 && p[0] == "DnsTestHelper"), + "{phase} re-introduced DnsTestHelper qualification: {paths:?}" + ); + assert!( + paths + .iter() + .any(|p| p.as_slice() == ["WEB_PUSH_PUBLIC_TEST_IP"]), + "{phase}: expected bare WEB_PUSH_PUBLIC_TEST_IP, got {paths:?}" + ); +} + +#[test] +fn dns_helper_qualified_const_is_unqualified_on_the_test_body() { + let app = app(); + let tm = app + .test_modules + .iter() + .find(|t| t.name.0.as_str() == "PushTest") + .expect("PushTest"); + assert!( + tm.constants + .iter() + .any(|(n, _)| n.as_str() == "WEB_PUSH_PUBLIC_TEST_IP"), + "expected spliced constant" + ); + let mut paths = Vec::new(); + for t in &tm.tests { + walk(&t.body, &mut paths); + } + assert_bare_web_push(&paths, "ingest"); + + let lcs = roundhouse::lower::lower_test_modules_to_library_classes( + &app.test_modules, + &app.fixtures, + &app.models, + Vec::new(), + &roundhouse::lower::routes::helper_id_segments(&app), + ); + let mut lowered_paths = Vec::new(); + for m in lcs.iter().flat_map(|lc| lc.methods.iter()) { + if m.name.as_str().starts_with("test_") { + walk(&m.body, &mut lowered_paths); + } + } + assert_bare_web_push(&lowered_paths, "lower"); +} + +/// Full analyze retypes Const via Rubydex; Value qualify must not undo +/// the splice when the test class already owns the bare constant. +#[test] +fn dns_helper_const_stays_bare_through_analyze() { + let mut app = app(); + roundhouse::session::analyze_and_lower(&mut app); + let tm = app + .test_modules + .iter() + .find(|t| t.name.0.as_str() == "PushTest") + .expect("PushTest"); + let mut paths = Vec::new(); + for t in &tm.tests { + walk(&t.body, &mut paths); + } + for h in &tm.helpers { + walk(&h.body, &mut paths); + } + assert_bare_web_push(&paths, "analyze"); + + // Re-lower after analyze — emitter reads these method bodies. + let lcs = roundhouse::lower::lower_test_modules_to_library_classes( + &app.test_modules, + &app.fixtures, + &app.models, + Vec::new(), + &roundhouse::lower::routes::helper_id_segments(&app), + ); + let mut lowered_paths = Vec::new(); + for m in lcs.iter().flat_map(|lc| lc.methods.iter()) { + if m.name.as_str().starts_with("test_") { + walk(&m.body, &mut lowered_paths); + } + } + assert_bare_web_push(&lowered_paths, "post-analyze lower"); +} diff --git a/tests/emit_and_run.rs b/tests/emit_and_run.rs index 5786b948a..bf0c29640 100644 --- a/tests/emit_and_run.rs +++ b/tests/emit_and_run.rs @@ -14,6 +14,132 @@ mod data_factory; #[path = "support/rails_root_join.rs"] mod rails_root_join; +#[test] +fn critic_corrections_preserve_class_objects_reflection_and_operators() { + emit_and_run::real_blog() + .write("app/helpers/protocol_control.rb", r#"class FirstOperand + def +(other) + other + 41 + end +end +class SecondOperand + def +(other) + other + 42 + end +end +class ProtocolControl + def initialize + @value = 7 + end + def self.implicit_eval + class_eval { 31 } + end + def union_operator_control(flag) + value = flag ? FirstOperand.new : SecondOperand.new + value + 1 + end + def reflection + instance_variable_get(:@value) + end + def include?(value) + value == 3 + end + def +(other) + @value + other + end +def reflective_override(value) + value +end +def override_control + ProtocolControl.new.reflective_override("ok").upcase +end +def operator_control + ProtocolControl.new + 2 + end + def install + klass = ProtocolControl + alias_klass = klass + alias_klass.define_method(:installed) { 19 } + ProtocolControl.new.installed + end +end +"#) + .run_ruby(r#" +control = ProtocolControl.new +raise "implicit class identity lost" unless ProtocolControl.implicit_eval == 31 +raise "first union operator lost" unless control.union_operator_control(true) == 42 +raise "second union operator lost" unless control.union_operator_control(false) == 43 +raise "reflection changed" unless control.reflection == 7 +raise "override changed" unless control.include?(3) +raise "app return inference changed" unless control.override_control == "OK" +raise "operator changed" unless control.operator_control == 9 +raise "class alias identity lost" unless control.install == 19 +raise "JSON support changed" unless control.to_json.is_a?(String) +puts "critic positive controls passed" +"#) + .assert_passes(); +} + +#[test] +fn critic_corrections_preserve_generated_model_narrowing() { + emit_and_run::real_blog() + .edit("app/models/article.rb", "class Article < ApplicationRecord", "class Article < ApplicationRecord\n def critic_item\n self\n end") + .write("app/views/articles/_critic_narrow.html.erb", "<% case article.critic_item %>\n<% when Article %>\n<%= link_to 'narrowed', article.critic_item %>\n<% end %>\n") + .run_ruby("article = Article.new(id: 7, title: 'narrowed title'); raise 'model narrowing changed' unless Views::Articles.critic_narrow(article).include?('/articles/7')") + .assert_passes(); +} + +#[test] +fn critic_corrections_preserve_native_module_callback_identity() { + emit_and_run::real_blog() + .write("app/models/concerns/native_hook.rb", "module NativeHook\n def self.included(base)\n base.define_method(:hook_value) { 23 }\n end\nend\n") + .edit("app/models/article.rb", "class Article < ApplicationRecord", "class Article < ApplicationRecord\n include NativeHook") + .run_ruby("raise 'native callback identity lost' unless Article.new.hook_value == 23") + .assert_passes(); +} + +#[test] +fn critic_corrections_preserve_errors_message_projections() { + emit_and_run::real_blog() + .edit("app/models/article.rb", "class Article < ApplicationRecord", "class Article < ApplicationRecord\n def critic_title_messages\n errors[:title]\n end\n def critic_full_messages\n errors.full_messages\n end") + .run_ruby(r#" +article = Article.new +article.valid? +raise "message indexing changed" unless article.critic_title_messages.include?("can't be blank") +raise "full messages changed" unless article.critic_full_messages.any? +puts "error message runtime controls passed" +"#) + .assert_passes(); +} + +#[test] +fn defined_method_operands_are_not_invoked() { + emit_and_run::empty_app() + .write("app/controllers/application_controller.rb", "class ApplicationController < ActionController::Base\nend\n") + .write("db/schema.rb", "ActiveRecord::Schema.define do\n create_table \"probes\" do |t|\n t.string \"name\"\n end\nend\n") + .write("app/helpers/defined_probe.rb", r#"class DefinedProbe + def present + raise "defined? invoked its operand" + end + + def present_definition + defined?(self.present) + end + + def missing_definition + defined?(self.missing) + end +end +"#) + .run_ruby(r#" +probe = DefinedProbe.new +raise "existing method lost" unless probe.present_definition == "method" +raise "missing method admitted" unless probe.missing_definition.nil? +puts "defined? did not invoke either operand" +"#) + .assert_passes(); +} + /// Build each query case independently: declaring a model class method /// must not accidentally open the old gate for the order/where.not cases. fn scope_free_query_app(action: &str) -> emit_and_run::Overlay { @@ -3342,7 +3468,7 @@ end /// Not a NoMethodError: an enum's `not_