use std::collections::{BTreeMap, BTreeSet};
use ruby_prism::{
AndNode, BeginNode, CallNode, CaseMatchNode, CaseNode, DefNode, ForNode, IfNode, Location,
Node, OrNode, RescueModifierNode, RescueNode, StatementsNode, UnlessNode, UntilNode, Visit,
WhileNode,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::{
coverage_analysis::PointKind,
coverage_report::{
BranchAlternativeMeta, BranchMeta, CoverageManifest, DecisionMeta, PointMeta,
},
};
pub const RUBY_PROBE_PLAN_VERSION: u32 = 1;
pub const RUBY_PROBE_RECEIVER: &str = "$__supercov";
const ITERATORS: &[&[u8]] = &[
b"each",
b"each_with_index",
b"each_with_object",
b"each_pair",
b"each_key",
b"each_value",
b"each_char",
b"each_byte",
b"each_line",
b"each_slice",
b"each_cons",
b"each_entry",
b"each_index",
b"reverse_each",
b"map",
b"collect",
b"flat_map",
b"collect_concat",
b"filter_map",
b"select",
b"filter",
b"reject",
b"find",
b"detect",
b"find_index",
b"find_all",
b"all?",
b"any?",
b"none?",
b"one?",
b"count",
b"sum",
b"min_by",
b"max_by",
b"sort_by",
b"group_by",
b"partition",
b"inject",
b"reduce",
b"take_while",
b"drop_while",
b"times",
b"upto",
b"downto",
b"step",
];
pub const BEGIN_BODY_LIMITATION: &str = "ruby-begin-completion-unmeasured";
pub const RACTOR_BLOCK_LIMITATION: &str = "ruby-ractor-block-unprobed";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RubyInstrumenterError {
Parse(String),
InvalidRange,
}
impl std::fmt::Display for RubyInstrumenterError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(error) => write!(formatter, "Ruby parse failed: {error}"),
Self::InvalidRange => write!(formatter, "Ruby parser returned an invalid range"),
}
}
}
impl std::error::Error for RubyInstrumenterError {}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum KeyKind {
List,
Node,
Point,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(from = "[[usize; 2]; 2]", into = "[[usize; 2]; 2]")]
pub struct PlanSpan {
pub start: [usize; 2],
pub end: [usize; 2],
}
impl From<[[usize; 2]; 2]> for PlanSpan {
fn from(value: [[usize; 2]; 2]) -> Self {
Self {
start: value[0],
end: value[1],
}
}
}
impl From<PlanSpan> for [[usize; 2]; 2] {
fn from(value: PlanSpan) -> Self {
[value.start, value.end]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Edit {
pub offset: usize,
pub text: String,
pub rank: String,
pub scope: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StdlibKey {
pub group: String,
pub branch: String,
pub kind: KeyKind,
pub span: PlanSpan,
pub unshifted: PlanSpan,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StdlibDecision {
pub id: String,
pub value: bool,
pub outcome: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BranchKeyPlan {
pub key: StdlibKey,
pub hits: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<StdlibDecision>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MethodKeyPlan {
pub span: PlanSpan,
pub unshifted: PlanSpan,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ImpliedPlan {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hits: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub decisions: Vec<StdlibDecision>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CaseClausePlan {
pub key: StdlibKey,
pub missed: String,
pub selected: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CaseNoMatchPlan {
pub key: StdlibKey,
pub matched: String,
pub unmatched: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CasePlan {
pub clauses: Vec<CaseClausePlan>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_match: Option<CaseNoMatchPlan>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConditionTree {
Leaf(usize),
Node {
op: String,
items: Vec<ConditionTree>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
negate: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DerivedLogical {
pub previous_leaves: Vec<usize>,
pub operand_leaves: Vec<usize>,
pub short_circuit: String,
pub evaluated: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LoopTarget {
pub id: String,
pub zero: String,
pub entered: String,
pub until: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HandlerTarget {
pub id: String,
pub missed: String,
pub selected: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum ProbeTarget {
#[serde(rename_all = "camelCase")]
Statement { id: String },
#[serde(rename_all = "camelCase")]
Decision {
id: String,
width: usize,
not: Vec<bool>,
tree: ConditionTree,
outcome_true: String,
outcome_false: String,
logical: Vec<DerivedLogical>,
#[serde(rename = "loop", skip_serializing_if = "Option::is_none")]
loop_: Option<LoopTarget>,
},
#[serde(rename_all = "camelCase")]
For {
id: String,
zero: String,
entered: String,
},
#[serde(rename_all = "camelCase")]
Logical {
op: String,
short_circuit: String,
evaluated: String,
},
#[serde(rename_all = "camelCase")]
Arrival {
short_circuit: String,
evaluated: String,
},
#[serde(rename_all = "camelCase")]
SafeNavigation { nil: String, called: String },
#[serde(rename_all = "camelCase")]
Hits { ids: Vec<String> },
#[serde(rename_all = "camelCase")]
Try {
id: String,
success: String,
raised: String,
handlers: Vec<HandlerTarget>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RubyFilePlan {
pub edits: Vec<Edit>,
pub lines: BTreeMap<usize, String>,
pub statement_offsets: BTreeMap<String, [usize; 2]>,
pub branches: Vec<BranchKeyPlan>,
pub methods: Vec<MethodKeyPlan>,
pub cases: Vec<CasePlan>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub implied: BTreeMap<String, ImpliedPlan>,
#[serde(default)]
pub probe_obligations: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ractor_blocks: Vec<[usize; 2]>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RubyProbePlan {
pub version: u32,
pub root: String,
pub receiver: String,
pub files: BTreeMap<String, RubyFilePlan>,
pub probes: BTreeMap<u64, ProbeTarget>,
#[serde(default)]
pub probe_obligations: Vec<String>,
}
impl RubyProbePlan {
pub fn probe_obligations(&self) -> Vec<String> {
let mut ids = self
.files
.values()
.flat_map(|file| file.probe_obligations.iter().cloned())
.collect::<Vec<_>>();
ids.sort();
ids.dedup();
ids
}
}
fn probe_obligations_of(probes: &BTreeMap<u64, ProbeTarget>) -> Vec<String> {
{
let mut ids = BTreeSet::new();
for target in probes.values() {
match target {
ProbeTarget::Statement { id } => {
ids.insert(id.clone());
}
ProbeTarget::Decision {
id,
outcome_true,
logical,
loop_,
..
} => {
ids.insert(id.clone());
ids.insert(branch_of(outcome_true));
for derived in logical {
ids.insert(branch_of(&derived.short_circuit));
}
if let Some(loop_) = loop_ {
ids.insert(loop_.id.clone());
}
}
ProbeTarget::For { id, .. } => {
ids.insert(id.clone());
}
ProbeTarget::Logical { short_circuit, .. } => {
ids.insert(branch_of(short_circuit));
}
ProbeTarget::Arrival { short_circuit, .. } => {
ids.insert(branch_of(short_circuit));
}
ProbeTarget::SafeNavigation { nil, .. } => {
ids.insert(branch_of(nil));
}
ProbeTarget::Hits { ids: proven } => {
for id in proven {
ids.insert(obligation_of(id));
}
}
ProbeTarget::Try { id, handlers, .. } => {
ids.insert(id.clone());
for handler in handlers {
ids.insert(handler.id.clone());
}
}
}
}
ids.into_iter().collect()
}
}
fn probe_keys_in(text: &str) -> Vec<u64> {
let mut keys = Vec::new();
let mut rest = text;
while let Some(position) = rest.find(RUBY_PROBE_RECEIVER) {
rest = &rest[position + RUBY_PROBE_RECEIVER.len()..];
let Some(open) = rest.find('(') else { break };
let digits: String = rest[open + 1..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
if let Ok(key) = digits.parse() {
keys.push(key);
}
rest = &rest[open + 1..];
}
keys
}
fn target_ids(target: &ProbeTarget) -> Vec<String> {
match target {
ProbeTarget::Statement { id } => vec![id.clone()],
ProbeTarget::Decision {
id,
outcome_true,
outcome_false,
logical,
loop_,
..
} => {
let mut ids = vec![id.clone(), outcome_true.clone(), outcome_false.clone()];
for derived in logical {
ids.push(derived.short_circuit.clone());
ids.push(derived.evaluated.clone());
}
if let Some(loop_) = loop_ {
ids.push(loop_.zero.clone());
ids.push(loop_.entered.clone());
}
ids
}
ProbeTarget::For { id, zero, entered } => vec![id.clone(), zero.clone(), entered.clone()],
ProbeTarget::Logical {
short_circuit,
evaluated,
..
}
| ProbeTarget::Arrival {
short_circuit,
evaluated,
} => vec![short_circuit.clone(), evaluated.clone()],
ProbeTarget::Try {
id,
success,
raised,
handlers,
} => {
let mut ids = vec![id.clone(), success.clone(), raised.clone()];
for handler in handlers {
ids.push(handler.id.clone());
ids.push(handler.missed.clone());
ids.push(handler.selected.clone());
}
ids
}
ProbeTarget::SafeNavigation { nil, called } => vec![nil.clone(), called.clone()],
ProbeTarget::Hits { ids } => ids.clone(),
}
}
fn branch_of(alternative: &str) -> String {
alternative
.rsplit_once(':')
.map(|(branch, _)| branch.to_owned())
.unwrap_or_else(|| alternative.to_owned())
}
fn obligation_of(id: &str) -> String {
if id.matches(':').count() >= 3 {
branch_of(id)
} else {
id.to_owned()
}
}
fn stdlib_provable(
branches: &[BranchKeyPlan],
cases: &[CasePlan],
methods: &[MethodKeyPlan],
) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
for branch in branches {
for hit in &branch.hits {
ids.insert(obligation_of(hit));
}
if let Some(decision) = &branch.decision {
ids.insert(decision.id.clone());
ids.insert(obligation_of(&decision.outcome));
}
}
for case in cases {
for clause in &case.clauses {
ids.insert(obligation_of(&clause.missed));
ids.insert(obligation_of(&clause.selected));
}
if let Some(no_match) = &case.no_match {
ids.insert(obligation_of(&no_match.matched));
ids.insert(obligation_of(&no_match.unmatched));
}
}
for method in methods {
ids.insert(method.id.clone());
}
ids
}
#[derive(Debug, Clone, PartialEq)]
pub struct RubyFileObligations {
pub manifest: CoverageManifest,
pub plan: RubyFilePlan,
pub probes: BTreeMap<u64, ProbeTarget>,
}
fn stable_id(file: &str, kind: &str, start: usize, end: usize, suffix: &str) -> String {
let mut hash = Sha256::new();
for value in [file, kind, &start.to_string(), &end.to_string(), suffix] {
hash.update(value.as_bytes());
hash.update([0]);
}
let digest = hash.finalize();
let mut encoded = String::with_capacity(24);
for byte in &digest[..12] {
use std::fmt::Write as _;
write!(&mut encoded, "{byte:02x}").expect("writing to a string cannot fail");
}
format!("rb:{kind}:{encoded}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum EditRank {
Clause,
StatementProbe,
Opener,
Closer,
}
#[derive(Debug, Clone)]
struct PendingEdit {
offset: usize,
rank: EditRank,
order: i64,
sequence: usize,
text: String,
scope: usize,
}
#[derive(Debug, Clone)]
enum KeyProof {
Probe,
Implied(String),
}
#[derive(Debug, Clone)]
struct KeyStatement {
index: usize,
proof: KeyProof,
}
#[derive(Debug, Default)]
struct DecisionProof {
true_statements: Vec<String>,
false_statements: Vec<String>,
}
struct Collector<'a> {
file: &'a str,
source: &'a [u8],
line_starts: Vec<usize>,
manifest: CoverageManifest,
lines: BTreeMap<usize, String>,
statement_offsets: BTreeMap<String, [usize; 2]>,
branches: Vec<BranchKeyPlan>,
methods: Vec<MethodKeyPlan>,
cases: Vec<CasePlan>,
implied: BTreeMap<String, ImpliedPlan>,
probes: BTreeMap<u64, ProbeTarget>,
edits: Vec<PendingEdit>,
next_probe: &'a mut u64,
point_ids: BTreeSet<String>,
decision_ids: BTreeSet<String>,
branch_ids: BTreeSet<String>,
claimed_lines: BTreeSet<usize>,
key_statements: BTreeMap<usize, KeyStatement>,
endless_bodies: std::collections::BTreeSet<usize>,
tree_logicals: BTreeSet<usize>,
expression_lists: BTreeSet<usize>,
guard_nodes: BTreeSet<usize>,
elsif_nodes: BTreeSet<usize>,
depth: i64,
begin_unmeasured: Vec<(String, usize)>,
ractor_blocks: Vec<(usize, usize)>,
error: Option<RubyInstrumenterError>,
}
impl<'a> Collector<'a> {
fn new(file: &'a str, source: &'a [u8], next_probe: &'a mut u64) -> Self {
let mut line_starts = vec![0];
line_starts.extend(
source
.iter()
.enumerate()
.filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
);
Self {
file,
source,
line_starts,
manifest: CoverageManifest {
unmeasured: Vec::new(),
decisions: Vec::new(),
points: Vec::new(),
branches: Vec::new(),
limitations: Vec::new(),
scope: None,
},
lines: BTreeMap::new(),
statement_offsets: BTreeMap::new(),
branches: Vec::new(),
methods: Vec::new(),
cases: Vec::new(),
implied: BTreeMap::new(),
probes: BTreeMap::new(),
edits: Vec::new(),
next_probe,
point_ids: BTreeSet::new(),
decision_ids: BTreeSet::new(),
branch_ids: BTreeSet::new(),
claimed_lines: BTreeSet::new(),
key_statements: BTreeMap::new(),
endless_bodies: std::collections::BTreeSet::new(),
tree_logicals: BTreeSet::new(),
expression_lists: BTreeSet::new(),
guard_nodes: BTreeSet::new(),
elsif_nodes: BTreeSet::new(),
depth: 0,
begin_unmeasured: Vec::new(),
ractor_blocks: Vec::new(),
error: None,
}
}
fn line_column(&self, offset: usize) -> (usize, usize) {
let line_index = self.line_starts.partition_point(|start| *start <= offset) - 1;
(line_index + 1, offset - self.line_starts[line_index])
}
fn span(&self, start: usize, end: usize) -> PlanSpan {
let (start_line, start_column) = self.line_column(start);
let (end_line, end_column) = self.line_column(end);
PlanSpan {
start: [start_line, start_column],
end: [end_line, end_column],
}
}
fn point_span(&self, offset: usize) -> PlanSpan {
let (line, column) = self.line_column(offset);
PlanSpan {
start: [line, column],
end: [line, column],
}
}
fn location_span(&self, location: &Location<'_>) -> PlanSpan {
self.span(location.start_offset(), location.end_offset())
}
fn node_span(&self, node: &Node<'_>) -> PlanSpan {
self.location_span(&node.location())
}
fn text(&self, start: usize, end: usize) -> String {
String::from_utf8_lossy(&self.source[start.min(end)..end.min(self.source.len())])
.trim()
.to_owned()
}
fn statements_span(&self, statements: &Option<StatementsNode<'_>>) -> Option<PlanSpan> {
statements
.as_ref()
.map(|statements| self.location_span(&statements.location()))
}
fn edit(&mut self, offset: usize, rank: EditRank, text: String, scope: usize) {
debug_assert!(!text.contains('\n'));
let order = match rank {
EditRank::Opener => self.depth,
EditRank::Closer => -self.depth,
_ => 0,
};
let sequence = self.edits.len();
self.edits.push(PendingEdit {
offset,
rank,
order,
sequence,
text,
scope,
});
}
fn probe_key(&mut self, target: ProbeTarget) -> u64 {
let key = *self.next_probe;
*self.next_probe += 1;
self.probes.insert(key, target);
key
}
fn wrap(&mut self, start: usize, end: usize, opener: String) {
self.edit(start, EditRank::Opener, opener, end);
self.edit(end, EditRank::Closer, "))".into(), start);
}
fn push_point(
&mut self,
id: &str,
start: usize,
end: usize,
kind: PointKind,
label: Option<String>,
) {
let (line, column) = self.line_column(start);
self.manifest.points.push(PointMeta {
id: id.into(),
kind,
file: self.file.into(),
line,
column,
source: self.text(start, end),
label,
});
}
fn branch<const N: usize>(
&mut self,
start: usize,
end: usize,
kind: &str,
alternatives: [(&str, &str); N],
) -> Option<String> {
let id = stable_id(self.file, "branch", start, end, kind);
let source = self.text(start, end);
self.branch_with_id(id, start, end, kind, source, alternatives)
}
fn branch_with_id<const N: usize>(
&mut self,
id: String,
start: usize,
_end: usize,
kind: &str,
source: String,
alternatives: [(&str, &str); N],
) -> Option<String> {
if !self.branch_ids.insert(id.clone()) {
return None;
}
let (line, column) = self.line_column(start);
self.manifest.branches.push(BranchMeta {
id: id.clone(),
kind: kind.into(),
file: self.file.into(),
line,
column,
source,
alternatives: alternatives
.into_iter()
.map(|(suffix, label)| BranchAlternativeMeta {
id: format!("{id}:{suffix}"),
label: label.into(),
})
.collect(),
});
Some(id)
}
fn stdlib(
&mut self,
group: &str,
branch: &str,
span: PlanSpan,
kind: KeyKind,
hits: Vec<String>,
) -> usize {
self.branches.push(BranchKeyPlan {
key: StdlibKey {
group: group.into(),
branch: branch.into(),
kind,
span,
unshifted: span,
},
hits,
decision: None,
});
self.branches.len() - 1
}
fn statements(&mut self, statements: &StatementsNode<'_>) {
for statement in statements.body().iter() {
self.statement(&statement);
}
}
fn statement(&mut self, node: &Node<'_>) {
let location = node.location();
let (start, end) = (location.start_offset(), location.end_offset());
let id = stable_id(self.file, "statement", start, end, "");
if !self.point_ids.insert(id.clone()) {
return;
}
self.push_point(&id, start, end, PointKind::Statement, None);
let (line, _) = self.line_column(start);
if let Some(key) = self.key_statements.get(&start).cloned() {
self.branches[key.index].hits.push(id.clone());
if let KeyProof::Implied(by) = key.proof {
self.claimed_lines.insert(line);
self.implied.entry(by).or_default().hits.push(id);
return;
}
}
if self.needs_probe(node) {
self.claimed_lines.insert(line);
let key = self.probe_key(ProbeTarget::Statement { id });
self.statement_probe(start, end, key);
} else if self.claimed_lines.insert(line) {
self.lines.insert(line, id.clone());
self.statement_offsets.insert(id, [start, end]);
} else {
let key = self.probe_key(ProbeTarget::Statement { id });
self.statement_probe(start, end, key);
}
}
fn statement_probe(&mut self, start: usize, end: usize, key: u64) {
if self.endless_bodies.contains(&start) {
self.depth += 1;
self.edit(
start,
EditRank::Opener,
format!("({RUBY_PROBE_RECEIVER}.s({key}); "),
end,
);
self.edit(end, EditRank::Closer, ")".into(), start);
self.depth -= 1;
} else {
self.edit(
start,
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.s({key}); "),
end,
);
}
}
fn needs_probe(&self, node: &Node<'_>) -> bool {
let value = if let Some(write) = node.as_local_variable_write_node() {
Some(write.value())
} else if let Some(write) = node.as_instance_variable_write_node() {
Some(write.value())
} else if let Some(write) = node.as_class_variable_write_node() {
Some(write.value())
} else if let Some(write) = node.as_global_variable_write_node() {
Some(write.value())
} else if let Some(write) = node.as_constant_write_node() {
Some(write.value())
} else {
node.as_multi_write_node().map(|write| write.value())
};
value.is_some_and(|value| {
value
.as_begin_node()
.is_some_and(|begin| begin.begin_keyword_loc().is_some())
|| value.as_parentheses_node().is_some()
})
}
fn key_body(&mut self, statements: Option<StatementsNode<'_>>, index: usize, proof: KeyProof) {
if let Some(first) = statements.and_then(|statements| statements.body().iter().next()) {
self.key_statements.insert(
first.location().start_offset(),
KeyStatement { index, proof },
);
}
}
fn claim_line_at(&mut self, offset: usize) {
let (line, _) = self.line_column(offset);
self.claimed_lines.insert(line);
}
fn first_statement_id(&self, statements: &Option<StatementsNode<'_>>) -> Option<String> {
let first = statements.as_ref()?.body().iter().next()?;
let location = first.location();
Some(stable_id(
self.file,
"statement",
location.start_offset(),
location.end_offset(),
"",
))
}
fn body_proves(
&mut self,
statements: &Option<StatementsNode<'_>>,
ids: Vec<String>,
offset: usize,
before: &str,
after: &str,
) {
match self.first_statement_id(statements) {
Some(first) => self.implied.entry(first).or_default().hits.extend(ids),
None => {
let key = self.probe_key(ProbeTarget::Hits { ids });
self.edit(
offset,
EditRank::Closer,
format!("{before}{RUBY_PROBE_RECEIVER}.hs({key}){after}"),
offset,
);
}
}
}
fn strip<'n>(&self, node: Node<'n>) -> (Node<'n>, usize) {
let mut current = node;
let mut not = 0;
loop {
if let Some(parens) = current.as_parentheses_node() {
if !parens.is_multiple_statements()
&& let Some(body) = parens.body()
&& let Some(statements) = body.as_statements_node()
&& statements.body().iter().count() == 1
&& let Some(inner) = statements.body().iter().next()
{
current = inner;
continue;
}
break;
}
if let Some(call) = current.as_call_node()
&& call.name().as_slice() == b"!"
&& call.arguments().is_none()
&& call.block().is_none()
&& let Some(receiver) = call.receiver()
{
not += 1;
current = receiver;
continue;
}
break;
}
(current, not)
}
fn tree(
&mut self,
node: Node<'_>,
leaves: &mut Vec<(usize, usize, usize)>,
logicals: &mut Vec<(String, Vec<Vec<usize>>)>,
) -> ConditionTree {
let (operand, not) = self.strip(node);
let logical: Option<(&str, Node<'_>, Node<'_>, usize)> =
if let Some(and) = operand.as_and_node() {
Some((
"and",
and.left(),
and.right(),
operand.location().start_offset(),
))
} else if let Some(or) = operand.as_or_node() {
Some((
"or",
or.left(),
or.right(),
operand.location().start_offset(),
))
} else {
None
};
if let Some((op, left, right, offset)) = logical {
self.tree_logicals.insert(offset);
let first = leaves.len();
let left_tree = self.tree(left, leaves, logicals);
let middle = leaves.len();
let right_tree = self.tree(right, leaves, logicals);
logicals.push((
op.into(),
vec![(first..middle).collect(), (middle..leaves.len()).collect()],
));
return ConditionTree::Node {
op: op.into(),
items: vec![left_tree, right_tree],
negate: not % 2 == 1,
};
}
let location = operand.location();
leaves.push((location.start_offset(), location.end_offset(), not));
ConditionTree::Leaf(leaves.len() - 1)
}
fn probe_decision(
&mut self,
predicate: Node<'_>,
kind: &str,
loop_: Option<LoopTarget>,
wrapper: &str,
) -> Option<u64> {
let location = predicate.location();
let (start, end) = (location.start_offset(), location.end_offset());
let id = stable_id(self.file, "decision", start, end, kind);
if !self.decision_ids.insert(id.clone()) {
return None;
}
let mut leaves = Vec::new();
let mut logicals = Vec::new();
let tree = self.tree(predicate, &mut leaves, &mut logicals);
let conditions = leaves
.iter()
.map(|(leaf_start, leaf_end, not)| {
let text = self.text(*leaf_start, *leaf_end);
if not % 2 == 1 {
format!("!{text}")
} else {
text
}
})
.collect::<Vec<_>>();
let (line, column) = self.line_column(start);
let source = self.text(start, end);
self.manifest.decisions.push(DecisionMeta {
id: id.clone(),
file: self.file.into(),
line,
column,
source: source.clone(),
conditions,
kind: kind.into(),
});
let outcome_id = format!("{id}:outcome");
self.branch_with_id(
outcome_id.clone(),
start,
end,
kind,
source,
[("true", "true"), ("false", "false")],
);
let mut derived = Vec::new();
for (op, groups) in logicals {
let right_leaf = groups[1].first().copied().unwrap_or(0);
let (right_start, right_end, _) = leaves[right_leaf];
if let Some(branch_id) = self.branch(
right_start,
right_end,
&format!("logical-{op}"),
[
("short-circuit", "short-circuited"),
("evaluated", "right operand evaluated"),
],
) {
derived.push(DerivedLogical {
previous_leaves: groups[0].clone(),
operand_leaves: groups[1].clone(),
short_circuit: format!("{branch_id}:short-circuit"),
evaluated: format!("{branch_id}:evaluated"),
});
}
}
let key = self.probe_key(ProbeTarget::Decision {
id,
width: leaves.len(),
not: leaves.iter().map(|(_, _, not)| not % 2 == 1).collect(),
tree,
outcome_true: format!("{outcome_id}:true"),
outcome_false: format!("{outcome_id}:false"),
logical: derived,
loop_,
});
self.depth += 1;
self.wrap(
start,
end,
format!("{RUBY_PROBE_RECEIVER}.{wrapper}({key}, ("),
);
self.depth += 1;
if leaves.len() > 1 {
for (index, (leaf_start, leaf_end, _)) in leaves.iter().enumerate() {
self.wrap(
*leaf_start,
*leaf_end,
format!("{RUBY_PROBE_RECEIVER}.c({key}, {index}, ("),
);
}
}
self.depth -= 2;
Some(key)
}
fn stdlib_decision(
&mut self,
predicate: Node<'_>,
kind: &str,
then_index: usize,
else_index: usize,
then_is_true: bool,
proof: DecisionProof,
) {
let location = predicate.location();
let (start, end) = (location.start_offset(), location.end_offset());
let id = stable_id(self.file, "decision", start, end, kind);
if !self.decision_ids.insert(id.clone()) {
return;
}
let (operand, not) = self.strip(predicate);
let operand_location = operand.location();
let mut condition = self.text(
operand_location.start_offset(),
operand_location.end_offset(),
);
if not % 2 == 1 {
condition = format!("!{condition}");
}
let (line, column) = self.line_column(start);
let source = self.text(start, end);
self.manifest.decisions.push(DecisionMeta {
id: id.clone(),
file: self.file.into(),
line,
column,
source: source.clone(),
conditions: vec![condition],
kind: kind.into(),
});
let outcome_id = format!("{id}:outcome");
self.branch_with_id(
outcome_id.clone(),
start,
end,
kind,
source,
[("true", "true"), ("false", "false")],
);
let (true_index, false_index) = if then_is_true {
(then_index, else_index)
} else {
(else_index, then_index)
};
self.branches[true_index].decision = Some(StdlibDecision {
id: id.clone(),
value: true,
outcome: format!("{outcome_id}:true"),
});
self.branches[false_index].decision = Some(StdlibDecision {
id: id.clone(),
value: false,
outcome: format!("{outcome_id}:false"),
});
if !proof.true_statements.is_empty() && !proof.false_statements.is_empty() {
for (statements, value) in [
(&proof.true_statements, true),
(&proof.false_statements, false),
] {
for statement in statements {
self.implied
.entry(statement.clone())
.or_default()
.decisions
.push(StdlibDecision {
id: id.clone(),
value,
outcome: format!("{outcome_id}:{value}"),
});
}
}
} else {
let key = self.probe_key(ProbeTarget::Decision {
id,
width: 1,
not: vec![not % 2 == 1],
tree: ConditionTree::Leaf(0),
outcome_true: format!("{outcome_id}:true"),
outcome_false: format!("{outcome_id}:false"),
logical: Vec::new(),
loop_: None,
});
self.depth += 1;
self.wrap(start, end, format!("{RUBY_PROBE_RECEIVER}.d({key}, ("));
self.depth -= 1;
}
}
fn decision_outcomes(&self, predicate: &Node<'_>, kind: &str) -> (String, String) {
let location = predicate.location();
let id = stable_id(
self.file,
"decision",
location.start_offset(),
location.end_offset(),
kind,
);
(format!("{id}:outcome:true"), format!("{id}:outcome:false"))
}
fn chain_false_statements(&self, subsequent: Option<Node<'_>>) -> Option<Vec<String>> {
let mut ids = Vec::new();
let mut current = subsequent;
loop {
let node = current?;
if let Some(elsif) = node.as_if_node() {
ids.push(self.first_statement_id(&elsif.statements())?);
current = elsif.subsequent();
} else if let Some(else_node) = node.as_else_node() {
ids.push(self.first_statement_id(&else_node.statements())?);
return Some(ids);
} else {
return None;
}
}
}
fn literal_truth(&self, predicate: Node<'_>) -> Option<bool> {
let mut node = predicate;
loop {
let inner = node.as_parentheses_node().and_then(|parens| {
let body = parens.body()?;
let statements = body.as_statements_node()?;
let mut iter = statements.body().iter();
let only = iter.next()?;
iter.next().is_none().then_some(only)
});
match inner {
Some(inner) => node = inner,
None => break,
}
}
if node.as_true_node().is_some()
|| node.as_integer_node().is_some()
|| node.as_float_node().is_some()
|| node.as_rational_node().is_some()
|| node.as_imaginary_node().is_some()
|| node.as_string_node().is_some()
|| node.as_symbol_node().is_some()
{
Some(true)
} else if node.as_false_node().is_some() || node.as_nil_node().is_some() {
Some(false)
} else if let Some(and) = node.as_and_node() {
match (
self.literal_truth(and.left()),
self.literal_truth(and.right()),
) {
(Some(false), _) | (_, Some(false)) => Some(false),
(Some(true), Some(true)) => Some(true),
_ => None,
}
} else if let Some(or) = node.as_or_node() {
match (
self.literal_truth(or.left()),
self.literal_truth(or.right()),
) {
(Some(true), _) | (_, Some(true)) => Some(true),
(Some(false), Some(false)) => Some(false),
_ => None,
}
} else {
None
}
}
fn is_compound(&self, predicate: Node<'_>) -> bool {
let (operand, _) = self.strip(predicate);
operand.as_and_node().is_some() || operand.as_or_node().is_some()
}
fn predicate_decision<'n>(
&mut self,
predicate: impl Fn() -> Node<'n>,
kind: &str,
then_index: usize,
else_index: usize,
then_is_true: bool,
proof: DecisionProof,
) {
if self.is_compound(predicate()) {
self.probe_decision(predicate(), kind, None, "d");
} else {
self.stdlib_decision(
predicate(),
kind,
then_index,
else_index,
then_is_true,
proof,
);
}
}
fn if_node(&mut self, node: &IfNode<'_>, kind: &str) {
let location = node.location();
let node_span = self.location_span(&location);
let then_statements = self.statements_span(&node.statements());
let (then_span, then_kind) = match then_statements {
Some(span) => (span, KeyKind::List),
None => (
self.point_span(node.predicate().location().end_offset()),
KeyKind::Point,
),
};
let (else_span, else_kind) = match node.subsequent() {
Some(subsequent) => match subsequent.as_else_node() {
Some(else_node) => match self.statements_span(&else_node.statements()) {
Some(span) => (span, KeyKind::List),
None => (self.node_span(&subsequent), KeyKind::Node),
},
None => (self.node_span(&subsequent), KeyKind::Node),
},
None => (node_span, KeyKind::Node),
};
let then_index = self.stdlib("if", "then", then_span, then_kind, Vec::new());
let else_index = self.stdlib("if", "else", else_span, else_kind, Vec::new());
if kind == "ternary" {
if let Some(statements) = node.statements() {
self.expression_lists
.insert(statements.location().start_offset());
}
if let Some(subsequent) = node.subsequent()
&& let Some(else_node) = subsequent.as_else_node()
&& let Some(statements) = else_node.statements()
{
self.expression_lists
.insert(statements.location().start_offset());
}
} else {
let modifier = node.statements().is_some_and(|statements| {
statements.location().start_offset() < node.predicate().location().start_offset()
});
let then_proof = if modifier {
KeyProof::Implied(self.decision_outcomes(&node.predicate(), kind).0)
} else {
KeyProof::Probe
};
self.claim_line_at(node.predicate().location().end_offset());
self.key_body(node.statements(), then_index, then_proof);
if let Some(subsequent) = node.subsequent()
&& let Some(else_node) = subsequent.as_else_node()
{
self.claim_line_at(else_node.else_keyword_loc().end_offset());
self.key_body(else_node.statements(), else_index, KeyProof::Probe);
}
}
let proof = if kind == "ternary" {
DecisionProof::default()
} else {
DecisionProof {
true_statements: self
.first_statement_id(&node.statements())
.into_iter()
.collect(),
false_statements: self
.chain_false_statements(node.subsequent())
.unwrap_or_default(),
}
};
self.predicate_decision(
|| node.predicate(),
kind,
then_index,
else_index,
true,
proof,
);
}
fn unless_node(&mut self, node: &UnlessNode<'_>) {
let node_span = self.location_span(&node.location());
let then_statements = self.statements_span(&node.statements());
let (then_span, then_kind) = match then_statements {
Some(span) => (span, KeyKind::List),
None => (
self.point_span(node.predicate().location().end_offset()),
KeyKind::Point,
),
};
let (else_span, else_kind) = match node.else_clause() {
Some(else_node) => match self.statements_span(&else_node.statements()) {
Some(span) => (span, KeyKind::List),
None => (self.location_span(&else_node.location()), KeyKind::Node),
},
None => (node_span, KeyKind::Node),
};
let then_index = self.stdlib("unless", "then", then_span, then_kind, Vec::new());
let else_index = self.stdlib("unless", "else", else_span, else_kind, Vec::new());
let modifier = node.statements().is_some_and(|statements| {
statements.location().start_offset() < node.predicate().location().start_offset()
});
let then_proof = if modifier {
KeyProof::Implied(self.decision_outcomes(&node.predicate(), "unless").1)
} else {
KeyProof::Probe
};
self.claim_line_at(node.predicate().location().end_offset());
self.key_body(node.statements(), then_index, then_proof);
if let Some(else_node) = node.else_clause() {
self.claim_line_at(else_node.else_keyword_loc().end_offset());
self.key_body(else_node.statements(), else_index, KeyProof::Probe);
}
let proof = DecisionProof {
true_statements: node
.else_clause()
.and_then(|else_node| self.first_statement_id(&else_node.statements()))
.into_iter()
.collect(),
false_statements: self
.first_statement_id(&node.statements())
.into_iter()
.collect(),
};
self.predicate_decision(
|| node.predicate(),
"unless",
then_index,
else_index,
false,
proof,
);
}
fn loop_node(
&mut self,
location: &Location<'_>,
predicate: Node<'_>,
statements: Option<StatementsNode<'_>>,
begin_modifier: bool,
until: bool,
) {
let kind = if until { "until" } else { "while" };
let (start, end) = (location.start_offset(), location.end_offset());
let loop_target = if begin_modifier {
None
} else {
self.branch(
start,
end,
kind,
[("zero", "zero iterations"), ("entered", "entered")],
)
.map(|id| LoopTarget {
zero: format!("{id}:zero"),
entered: format!("{id}:entered"),
id,
until,
})
};
if let Some(body_span) = self.statements_span(&statements) {
let index = self.stdlib(kind, "body", body_span, KeyKind::List, Vec::new());
let modifier = !begin_modifier
&& statements.as_ref().is_some_and(|statements| {
statements.location().start_offset() < predicate.location().start_offset()
});
let proof = match (&loop_target, modifier) {
(Some(target), true) => KeyProof::Implied(target.entered.clone()),
_ => KeyProof::Probe,
};
self.key_body(statements, index, proof);
}
self.probe_decision(predicate, kind, loop_target, "w");
}
fn for_node(&mut self, node: &ForNode<'_>) {
let location = node.location();
let Some(id) = self.branch(
location.start_offset(),
location.end_offset(),
"for",
[("zero", "zero iterations"), ("entered", "entered")],
) else {
return;
};
let key = self.probe_key(ProbeTarget::For {
zero: format!("{id}:zero"),
entered: format!("{id}:entered"),
id: id.clone(),
});
let collection = node.collection().location();
self.depth += 1;
self.wrap(
collection.start_offset(),
collection.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.f({key}, ("),
);
self.depth -= 1;
match node
.statements()
.and_then(|statements| statements.body().iter().next())
{
Some(first) => self.edit(
first.location().start_offset(),
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.fb({key}); "),
first.location().end_offset(),
),
None => {
self.manifest.unmeasured.push(format!("{id}:entered"));
self.manifest.unmeasured.push(format!("{id}:zero"));
}
}
}
fn iterator_loop(
&mut self,
node: &CallNode<'_>,
receiver: &Node<'_>,
block: &ruby_prism::BlockNode<'_>,
) {
let location = node.location();
let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
let Some(id) = self.branch(
location.start_offset(),
location.end_offset(),
&format!("iterator-{}", name.trim_end_matches(['?', '!'])),
[("zero", "zero iterations"), ("entered", "entered")],
) else {
return;
};
let first = block.body().and_then(|body| {
if let Some(statements) = body.as_statements_node() {
statements.body().iter().next()
} else if let Some(begin) = body.as_begin_node() {
begin
.statements()
.and_then(|statements| statements.body().iter().next())
} else {
None
}
});
let Some(first) = first else {
self.manifest.unmeasured.push(format!("{id}:entered"));
self.manifest.unmeasured.push(format!("{id}:zero"));
return;
};
let key = self.probe_key(ProbeTarget::For {
zero: format!("{id}:zero"),
entered: format!("{id}:entered"),
id,
});
let receiver_location = receiver.location();
self.depth += 1;
self.wrap(
receiver_location.start_offset(),
receiver_location.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.f({key}, ("),
);
self.depth -= 1;
self.edit(
first.location().start_offset(),
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.fb({key}); "),
first.location().end_offset(),
);
}
fn case_node(&mut self, node: &CaseNode<'_>) {
let node_span = self.location_span(&node.location());
let (start, end) = (node.location().start_offset(), node.location().end_offset());
let no_match_id = node
.else_clause()
.is_none()
.then(|| stable_id(self.file, "branch", start, end, "case-no-match"));
let else_id = node.else_clause().map(|else_node| {
stable_id(
self.file,
"branch",
else_node.location().start_offset(),
else_node.location().end_offset(),
"case-else",
)
});
let mut clauses = Vec::new();
let mut clause_ids = Vec::new();
for (index, condition) in node.conditions().iter().enumerate() {
let Some(when) = condition.as_when_node() else {
continue;
};
let Some(id) = self.branch(
when.location().start_offset(),
when.location().end_offset(),
&format!("case-when-{index}"),
[("missed", "not selected"), ("selected", "selected")],
) else {
return;
};
let statements = self.statements_span(&when.statements());
let span = statements.unwrap_or_else(|| self.location_span(&when.location()));
let key_index = self.stdlib(
"case",
"when",
span,
if statements.is_some() {
KeyKind::List
} else {
KeyKind::Node
},
vec![format!("{id}:selected")],
);
self.key_body(when.statements(), key_index, KeyProof::Probe);
let proven =
self.clause_proof(&id, &clause_ids, no_match_id.as_deref(), else_id.as_deref());
let (offset, before) = match when.then_keyword_loc() {
Some(then) => (then.end_offset(), " "),
None => (
when.conditions()
.iter()
.last()
.map(|condition| condition.location().end_offset())
.unwrap_or_else(|| when.location().end_offset()),
" then ",
),
};
self.claim_line_at(offset);
self.body_proves(&when.statements(), proven, offset, before, "");
clauses.push(CaseClausePlan {
key: self.branches[key_index].key.clone(),
missed: format!("{id}:missed"),
selected: format!("{id}:selected"),
});
clause_ids.push(id);
}
let no_match = match node.else_clause() {
Some(else_node) => {
let Some(id) = self.branch(
else_node.location().start_offset(),
else_node.location().end_offset(),
"case-else",
[("missed", "not selected"), ("selected", "selected")],
) else {
return;
};
let statements = self.statements_span(&else_node.statements());
let span = statements.unwrap_or_else(|| self.location_span(&else_node.location()));
let key_index = self.stdlib(
"case",
"else",
span,
if statements.is_some() {
KeyKind::List
} else {
KeyKind::Node
},
vec![format!("{id}:selected")],
);
self.key_body(else_node.statements(), key_index, KeyProof::Probe);
let proven = self.clause_proof(&id, &clause_ids, None, None);
self.claim_line_at(else_node.else_keyword_loc().end_offset());
self.body_proves(
&else_node.statements(),
proven,
else_node.else_keyword_loc().end_offset(),
" ",
"",
);
clauses.push(CaseClausePlan {
key: self.branches[key_index].key.clone(),
missed: format!("{id}:missed"),
selected: format!("{id}:selected"),
});
None
}
None => self
.branch(
start,
end,
"case-no-match",
[
("matched", "some clause matched"),
("unmatched", "no clause matched"),
],
)
.map(|id| {
let key_index = self.stdlib(
"case",
"else",
node_span,
KeyKind::Node,
vec![format!("{id}:unmatched")],
);
self.no_match_probe(&id, &clause_ids, node.end_keyword_loc().start_offset());
CaseNoMatchPlan {
key: self.branches[key_index].key.clone(),
matched: format!("{id}:matched"),
unmatched: format!("{id}:unmatched"),
}
}),
};
self.cases.push(CasePlan { clauses, no_match });
}
fn clause_proof(
&self,
id: &str,
earlier: &[String],
no_match_id: Option<&str>,
else_id: Option<&str>,
) -> Vec<String> {
let mut ids = vec![format!("{id}:selected")];
ids.extend(earlier.iter().map(|clause| format!("{clause}:missed")));
if let Some(no_match) = no_match_id {
ids.push(format!("{no_match}:matched"));
}
if let Some(else_id) = else_id {
ids.push(format!("{else_id}:missed"));
}
ids
}
fn no_match_probe(&mut self, id: &str, clauses: &[String], end_keyword: usize) {
let mut ids = vec![format!("{id}:unmatched")];
ids.extend(clauses.iter().map(|clause| format!("{clause}:missed")));
let key = self.probe_key(ProbeTarget::Hits { ids });
self.edit(
end_keyword,
EditRank::StatementProbe,
format!("else {RUBY_PROBE_RECEIVER}.hs({key}); "),
end_keyword,
);
}
fn case_match_node(&mut self, node: &CaseMatchNode<'_>) {
let node_span = self.location_span(&node.location());
let (start, end) = (node.location().start_offset(), node.location().end_offset());
let no_match_id = node
.else_clause()
.is_none()
.then(|| stable_id(self.file, "branch", start, end, "case-no-match"));
let else_id = node.else_clause().map(|else_node| {
stable_id(
self.file,
"branch",
else_node.location().start_offset(),
else_node.location().end_offset(),
"case-else",
)
});
let mut clauses = Vec::new();
let mut clause_ids = Vec::new();
for (index, condition) in node.conditions().iter().enumerate() {
let Some(in_node) = condition.as_in_node() else {
continue;
};
let Some(id) = self.branch(
in_node.location().start_offset(),
in_node.location().end_offset(),
&format!("case-in-{index}"),
[("missed", "not selected"), ("selected", "selected")],
) else {
return;
};
let statements = self.statements_span(&in_node.statements());
let span = statements.unwrap_or_else(|| self.location_span(&in_node.location()));
let key_index = self.stdlib(
"case",
"in",
span,
if statements.is_some() {
KeyKind::List
} else {
KeyKind::Node
},
vec![format!("{id}:selected")],
);
self.key_body(in_node.statements(), key_index, KeyProof::Probe);
let proven =
self.clause_proof(&id, &clause_ids, no_match_id.as_deref(), else_id.as_deref());
let (offset, before) = match in_node.then_loc() {
Some(then) => (then.end_offset(), " "),
None => (in_node.pattern().location().end_offset(), " then "),
};
self.claim_line_at(offset);
self.body_proves(&in_node.statements(), proven, offset, before, "");
clauses.push(CaseClausePlan {
key: self.branches[key_index].key.clone(),
missed: format!("{id}:missed"),
selected: format!("{id}:selected"),
});
clause_ids.push(id);
let pattern = in_node.pattern();
if let Some(guard) = pattern.as_if_node() {
self.guard_nodes.insert(pattern.location().start_offset());
self.probe_decision(guard.predicate(), "in-guard", None, "d");
} else if let Some(guard) = pattern.as_unless_node() {
self.guard_nodes.insert(pattern.location().start_offset());
self.probe_decision(guard.predicate(), "in-guard-unless", None, "d");
}
}
let no_match = match node.else_clause() {
Some(else_node) => {
let Some(id) = self.branch(
else_node.location().start_offset(),
else_node.location().end_offset(),
"case-else",
[("missed", "not selected"), ("selected", "selected")],
) else {
return;
};
let statements = self.statements_span(&else_node.statements());
let span = statements.unwrap_or_else(|| self.location_span(&else_node.location()));
let key_index = self.stdlib(
"case",
"else",
span,
if statements.is_some() {
KeyKind::List
} else {
KeyKind::Node
},
vec![format!("{id}:selected")],
);
self.key_body(else_node.statements(), key_index, KeyProof::Probe);
let proven = self.clause_proof(&id, &clause_ids, None, None);
self.claim_line_at(else_node.else_keyword_loc().end_offset());
self.body_proves(
&else_node.statements(),
proven,
else_node.else_keyword_loc().end_offset(),
" ",
"",
);
clauses.push(CaseClausePlan {
key: self.branches[key_index].key.clone(),
missed: format!("{id}:missed"),
selected: format!("{id}:selected"),
});
None
}
None => self
.branch(
start,
end,
"case-no-match",
[
("matched", "some pattern matched"),
("unmatched", "no pattern matched"),
],
)
.map(|id| {
let key_index = self.stdlib(
"case",
"else",
node_span,
KeyKind::Node,
vec![format!("{id}:unmatched")],
);
self.no_match_probe(&id, &clause_ids, node.end_keyword_loc().start_offset());
CaseNoMatchPlan {
key: self.branches[key_index].key.clone(),
matched: format!("{id}:matched"),
unmatched: format!("{id}:unmatched"),
}
}),
};
self.cases.push(CasePlan { clauses, no_match });
}
fn safe_navigation(&mut self, node: &CallNode<'_>) {
let location = node.location();
let Some(id) = self.branch(
location.start_offset(),
location.end_offset(),
"safe-navigation",
[("nil", "receiver nil"), ("called", "method called")],
) else {
return;
};
let end = match node.arguments() {
Some(arguments) => node
.closing_loc()
.map(|closing| closing.end_offset())
.unwrap_or_else(|| arguments.location().end_offset()),
None => node
.message_loc()
.map(|message| message.end_offset())
.unwrap_or_else(|| location.end_offset()),
};
let (start_line, start_column) = self.line_column(location.start_offset());
let (end_line, end_column) = self.line_column(end);
let span = PlanSpan {
start: [start_line, start_column],
end: [end_line, end_column],
};
self.stdlib(
"&.",
"then",
span,
KeyKind::Node,
vec![format!("{id}:called")],
);
self.stdlib("&.", "else", span, KeyKind::Node, vec![format!("{id}:nil")]);
if let Some(receiver) = node.receiver() {
let key = self.probe_key(ProbeTarget::SafeNavigation {
nil: format!("{id}:nil"),
called: format!("{id}:called"),
});
let receiver = receiver.location();
self.depth += 1;
self.wrap(
receiver.start_offset(),
receiver.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.n({key}, ("),
);
self.depth -= 1;
}
}
fn value_logical(&mut self, op: &str, left: &Node<'_>, node_start: usize, node_end: usize) {
let Some(id) = self.branch(
node_start,
node_end,
&format!("logical-{op}"),
[
("short-circuit", "short-circuited"),
("evaluated", "right operand evaluated"),
],
) else {
return;
};
let key = self.probe_key(ProbeTarget::Logical {
op: op.into(),
short_circuit: format!("{id}:short-circuit"),
evaluated: format!("{id}:evaluated"),
});
let left = left.location();
self.depth += 1;
self.wrap(
left.start_offset(),
left.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.l({key}, ("),
);
self.depth -= 1;
}
fn op_assign(
&mut self,
op: &str,
node_start: usize,
node_end: usize,
name: Option<&[u8]>,
value: &Node<'_>,
) {
let Some(id) = self.branch(
node_start,
node_end,
&format!("{op}-assign"),
[
("short-circuit", "assignment skipped"),
("evaluated", "value evaluated and assigned"),
],
) else {
return;
};
match name {
Some(name) => {
let key = self.probe_key(ProbeTarget::Logical {
op: op.into(),
short_circuit: format!("{id}:short-circuit"),
evaluated: format!("{id}:evaluated"),
});
let name = String::from_utf8_lossy(name);
self.depth += 1;
self.edit(
node_start,
EditRank::Opener,
format!("({RUBY_PROBE_RECEIVER}.l({key}, {name}); "),
node_end,
);
self.edit(node_end, EditRank::Closer, ")".into(), node_start);
self.depth -= 1;
}
None => {
let key = self.probe_key(ProbeTarget::Arrival {
short_circuit: format!("{id}:short-circuit"),
evaluated: format!("{id}:evaluated"),
});
let value_location = value.location();
self.depth += 1;
self.edit(
node_start,
EditRank::Opener,
format!("({RUBY_PROBE_RECEIVER}.pre({key}); "),
node_end,
);
self.edit(node_end, EditRank::Closer, ")".into(), node_start);
self.depth += 1;
self.edit(
value_location.start_offset(),
EditRank::Opener,
format!("({RUBY_PROBE_RECEIVER}.es({key}); "),
value_location.end_offset(),
);
self.edit(
value_location.end_offset(),
EditRank::Closer,
")".into(),
value_location.start_offset(),
);
self.depth -= 2;
}
}
}
fn begin_node(&mut self, node: &BeginNode<'_>, close: Option<usize>) {
let has_rescue = node.rescue_clause().is_some();
let has_ensure = node.ensure_clause().is_some();
if !has_rescue && !has_ensure {
return;
}
let location = node.location();
let (start, end) = (location.start_offset(), location.end_offset());
let Some(id) = self.branch(
start,
end,
"begin",
[
("success", "body completed"),
("raised", "exception raised"),
],
) else {
return;
};
let mut handlers = Vec::new();
let mut handler_edits = Vec::new();
let mut rescue = node.rescue_clause();
let mut index = 0;
while let Some(clause) = rescue {
let clause_location = clause.location();
let Some(handler_id) = self.branch(
clause_location.start_offset(),
clause_location.end_offset(),
&format!("rescue-{index}"),
[("missed", "not selected"), ("selected", "selected")],
) else {
return;
};
handlers.push(HandlerTarget {
missed: format!("{handler_id}:missed"),
selected: format!("{handler_id}:selected"),
id: handler_id,
});
handler_edits.push(self.handler_probe_position(&clause));
rescue = clause.subsequent();
index += 1;
}
let key = self.probe_key(ProbeTarget::Try {
success: format!("{id}:success"),
raised: format!("{id}:raised"),
id: id.clone(),
handlers,
});
for (index, (offset, leading, scope_end)) in handler_edits.into_iter().enumerate() {
let text = if leading {
format!("; {RUBY_PROBE_RECEIVER}.h({key}, {index})")
} else {
format!("{RUBY_PROBE_RECEIVER}.h({key}, {index}); ")
};
self.edit(offset, EditRank::StatementProbe, text, scope_end);
}
let clause_offset = node
.else_clause()
.map(|clause| clause.else_keyword_loc().start_offset())
.or_else(|| {
node.ensure_clause()
.map(|clause| clause.ensure_keyword_loc().start_offset())
})
.or_else(|| node.end_keyword_loc().map(|loc| loc.start_offset()))
.or(close);
match clause_offset {
Some(offset) => self.edit(
offset,
EditRank::Clause,
format!(
"rescue Exception => __supercov_e; {RUBY_PROBE_RECEIVER}.p({key}); raise; "
),
offset,
),
None => {
self.manifest.unmeasured.push(format!("{id}:raised"));
let (line, _) = self.line_column(start);
self.begin_unmeasured.push((id.clone(), line));
}
}
if let Some(else_clause) = node.else_clause() {
match else_clause
.statements()
.and_then(|statements| statements.body().iter().next())
{
Some(first) => self.edit(
first.location().start_offset(),
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
first.location().end_offset(),
),
None => self.edit(
else_clause.else_keyword_loc().end_offset(),
EditRank::StatementProbe,
format!(" {RUBY_PROBE_RECEIVER}.ok0({key});"),
else_clause.else_keyword_loc().end_offset(),
),
}
return;
}
let last = node
.statements()
.and_then(|statements| statements.body().iter().last());
match last {
Some(last) => {
if !self.completion_probe(last, key) {
self.manifest.unmeasured.push(format!("{id}:success"));
let (line, _) = self.line_column(start);
self.begin_unmeasured.push((id, line));
}
}
None => {
self.manifest.unmeasured.push(format!("{id}:success"));
let (line, _) = self.line_column(start);
self.begin_unmeasured.push((id, line));
}
}
}
fn handler_probe_position(&self, clause: &RescueNode<'_>) -> (usize, bool, usize) {
if let Some(first) = clause
.statements()
.and_then(|statements| statements.body().iter().next())
{
return (
first.location().start_offset(),
false,
first.location().end_offset(),
);
}
let offset = if let Some(then_keyword) = clause.then_keyword_loc() {
then_keyword.end_offset()
} else if let Some(reference) = clause.reference() {
reference.location().end_offset()
} else if let Some(last) = clause.exceptions().iter().last() {
last.location().end_offset()
} else {
clause.keyword_loc().end_offset()
};
(offset, true, offset)
}
fn jump_exposed(&self, node: &Node<'_>) -> bool {
if Self::is_jump(node) {
return true;
}
if let Some(if_node) = node.as_if_node() {
let else_exposed = match if_node.subsequent() {
Some(subsequent) => match subsequent.as_else_node() {
Some(else_node) => self.arm_jump_exposed(else_node.statements()),
None => self.jump_exposed(&subsequent),
},
None => false,
};
return else_exposed || self.arm_jump_exposed(if_node.statements());
}
if let Some(unless_node) = node.as_unless_node() {
let else_exposed = match unless_node.else_clause() {
Some(else_node) => self.arm_jump_exposed(else_node.statements()),
None => false,
};
return else_exposed || self.arm_jump_exposed(unless_node.statements());
}
if let Some(begin) = node.as_begin_node() {
if self.arm_jump_exposed(begin.statements())
|| begin
.else_clause()
.is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()))
{
return true;
}
let mut rescue = begin.rescue_clause();
while let Some(clause) = rescue {
if self.arm_jump_exposed(clause.statements()) {
return true;
}
rescue = clause.subsequent();
}
return false;
}
if let Some(case_node) = node.as_case_node() {
return case_node
.conditions()
.iter()
.any(|condition| match condition.as_when_node() {
Some(when_node) => self.arm_jump_exposed(when_node.statements()),
None => false,
})
|| case_node
.else_clause()
.is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()));
}
if let Some(case_node) = node.as_case_match_node() {
return case_node
.conditions()
.iter()
.any(|condition| match condition.as_in_node() {
Some(in_node) => self.arm_jump_exposed(in_node.statements()),
None => false,
})
|| case_node
.else_clause()
.is_some_and(|else_node| self.arm_jump_exposed(else_node.statements()));
}
if let Some(parentheses) = node.as_parentheses_node() {
return match parentheses.body() {
Some(body) => match body.as_statements_node() {
Some(statements) => self.arm_jump_exposed(Some(statements)),
None => self.jump_exposed(&body),
},
None => false,
};
}
false
}
fn arm_jump_exposed(&self, statements: Option<StatementsNode<'_>>) -> bool {
match statements.and_then(|statements| statements.body().iter().last()) {
Some(last) => self.jump_exposed(&last),
None => false,
}
}
fn probe_targets<'n>(&self, node: Node<'n>) -> Option<Vec<Node<'n>>> {
if Self::is_jump(&node) {
return Some(vec![node]);
}
if node.as_multi_write_node().is_some()
|| node.as_alias_method_node().is_some()
|| node.as_alias_global_variable_node().is_some()
|| node.as_undef_node().is_some()
{
return None;
}
if !self.jump_exposed(&node) {
return Some(vec![node]);
}
if let Some(if_node) = node.as_if_node() {
let mut targets = self.arm_targets(if_node.statements())?;
match if_node.subsequent() {
Some(subsequent) => match subsequent.as_else_node() {
Some(else_node) => targets.extend(self.arm_targets(else_node.statements())?),
None => targets.extend(self.probe_targets(subsequent)?),
},
None => return None,
}
return Some(targets);
}
if let Some(unless_node) = node.as_unless_node() {
let mut targets = self.arm_targets(unless_node.statements())?;
let else_node = unless_node.else_clause()?;
targets.extend(self.arm_targets(else_node.statements())?);
return Some(targets);
}
if let Some(case_node) = node.as_case_node() {
let mut targets = Vec::new();
for condition in case_node.conditions().iter() {
let when_node = condition.as_when_node()?;
targets.extend(self.arm_targets(when_node.statements())?);
}
targets.extend(self.arm_targets(case_node.else_clause()?.statements())?);
return Some(targets);
}
if let Some(case_node) = node.as_case_match_node() {
let mut targets = Vec::new();
for condition in case_node.conditions().iter() {
let in_node = condition.as_in_node()?;
targets.extend(self.arm_targets(in_node.statements())?);
}
targets.extend(self.arm_targets(case_node.else_clause()?.statements())?);
return Some(targets);
}
if let Some(begin) = node.as_begin_node() {
let mut targets = match begin.else_clause() {
Some(else_node) => self.arm_targets(else_node.statements())?,
None => self.arm_targets(begin.statements())?,
};
let mut rescue = begin.rescue_clause();
while let Some(clause) = rescue {
targets.extend(self.arm_targets(clause.statements())?);
rescue = clause.subsequent();
}
return Some(targets);
}
if let Some(parentheses) = node.as_parentheses_node() {
let body = parentheses.body()?;
return match body.as_statements_node() {
Some(statements) => self.arm_targets(Some(statements)),
None => self.probe_targets(body),
};
}
None
}
fn arm_targets<'n>(&self, statements: Option<StatementsNode<'n>>) -> Option<Vec<Node<'n>>> {
let last = statements.and_then(|statements| statements.body().iter().last())?;
self.probe_targets(last)
}
fn completion_probe(&mut self, last: Node<'_>, key: u64) -> bool {
let Some(targets) = self.probe_targets(last) else {
return false;
};
for target in &targets {
self.wrap_completion(target, key);
}
true
}
fn wrap_completion(&mut self, last: &Node<'_>, key: u64) {
let location = last.location();
let (start, end) = (location.start_offset(), location.end_offset());
let arguments = if let Some(node) = last.as_return_node() {
Some((node.keyword_loc(), node.arguments()))
} else if let Some(node) = last.as_break_node() {
Some((node.keyword_loc(), node.arguments()))
} else {
last.as_next_node()
.map(|node| (node.keyword_loc(), node.arguments()))
};
if let Some((keyword, arguments)) = arguments {
match arguments {
Some(arguments) => {
let arguments_location = arguments.location();
let multiple = arguments.arguments().iter().count() > 1
|| arguments
.arguments()
.iter()
.any(|argument| argument.as_splat_node().is_some());
let (open, close) = if multiple {
(format!("{RUBY_PROBE_RECEIVER}.ok({key}, ["), "])")
} else {
(format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("), "))")
};
self.depth += 1;
self.edit(
arguments_location.start_offset(),
EditRank::Opener,
open,
arguments_location.end_offset(),
);
self.edit(
arguments_location.end_offset(),
EditRank::Closer,
close.into(),
arguments_location.start_offset(),
);
self.depth -= 1;
}
None => self.edit(
keyword.start_offset(),
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
end,
),
}
return;
}
if last.as_redo_node().is_some() || last.as_retry_node().is_some() {
self.edit(
start,
EditRank::StatementProbe,
format!("{RUBY_PROBE_RECEIVER}.ok0({key}); "),
end,
);
return;
}
self.depth += 1;
self.wrap(start, end, format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("));
self.depth -= 1;
}
fn rescue_modifier(&mut self, node: &RescueModifierNode<'_>) {
let location = node.location();
let (start, end) = (location.start_offset(), location.end_offset());
let Some(id) = self.branch(
start,
end,
"rescue-modifier",
[
("success", "expression completed"),
("raised", "fallback used"),
],
) else {
return;
};
let key = self.probe_key(ProbeTarget::Try {
success: format!("{id}:success"),
raised: format!("{id}:raised"),
id,
handlers: Vec::new(),
});
let expression = node.expression();
let fallback_node = node.rescue_expression();
let fallback = fallback_node.location();
self.depth += 1;
if Self::is_jump(&expression) {
self.completion_probe(expression, key);
} else {
let expression = expression.location();
self.wrap(
expression.start_offset(),
expression.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.ok({key}, ("),
);
}
if Self::is_jump(&fallback_node) {
self.edit(
fallback.start_offset(),
EditRank::Opener,
format!("({RUBY_PROBE_RECEIVER}.hm0({key}); "),
fallback.end_offset(),
);
self.edit(
fallback.end_offset(),
EditRank::Closer,
")".into(),
fallback.start_offset(),
);
} else {
self.wrap(
fallback.start_offset(),
fallback.end_offset(),
format!("{RUBY_PROBE_RECEIVER}.hm({key}, ("),
);
}
self.depth -= 1;
}
fn is_jump(node: &Node<'_>) -> bool {
node.as_return_node().is_some()
|| node.as_break_node().is_some()
|| node.as_next_node().is_some()
|| node.as_redo_node().is_some()
|| node.as_retry_node().is_some()
}
fn def_node(&mut self, node: &DefNode<'_>) {
let location = node.location();
let (start, end) = (location.start_offset(), location.end_offset());
let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
let id = stable_id(self.file, "function", start, end, &name);
if !self.point_ids.insert(id.clone()) {
return;
}
self.push_point(&id, start, end, PointKind::Function, Some(name));
let span = self.location_span(&location);
self.methods.push(MethodKeyPlan {
span,
unshifted: span,
id: id.clone(),
});
let body_statements = node.body().and_then(|body| {
if let Some(statements) = body.as_statements_node() {
Some(statements)
} else {
body.as_begin_node().and_then(|begin| begin.statements())
}
});
let signature_end = node
.rparen_loc()
.map(|rparen| rparen.end_offset())
.or_else(|| {
node.parameters()
.map(|parameters| parameters.location().end_offset())
})
.unwrap_or_else(|| node.name_loc().end_offset());
self.body_proves(&body_statements, vec![id], signature_end, "; ", "");
if let Some(body) = node.body()
&& let Some(begin) = body.as_begin_node()
{
self.begin_node(&begin, node.end_keyword_loc().map(|loc| loc.start_offset()));
}
if node.equal_loc().is_some()
&& let Some(body) = node.body()
&& let Some(statements) = body.as_statements_node()
{
for statement in statements.body().iter() {
self.endless_bodies
.insert(statement.location().start_offset());
}
}
}
fn shifted(&self, span: PlanSpan, kind: KeyKind, edits: &[PendingEdit]) -> PlanSpan {
let start_offset = self.line_starts[span.start[0] - 1] + span.start[1];
let end_offset = self.line_starts[span.end[0] - 1] + span.end[1];
let mut start_shift = 0;
let mut end_shift = 0;
for edit in edits {
let (line, _) = self.line_column(edit.offset);
let moves_start = edit.offset < start_offset
|| (edit.offset == start_offset
&& match (edit.rank, kind) {
(_, KeyKind::Point) => true,
(EditRank::Closer, _) => false,
(EditRank::Opener, KeyKind::List) => end_offset < edit.scope,
(EditRank::Opener, KeyKind::Node) => end_offset <= edit.scope,
(_, KeyKind::List) => end_offset < edit.scope,
(_, KeyKind::Node) => true,
});
let moves_end = edit.offset < end_offset
|| (edit.offset == end_offset
&& match (edit.rank, kind) {
(_, KeyKind::Point) => true,
(EditRank::Closer, KeyKind::List) => edit.scope >= start_offset,
(EditRank::Closer, KeyKind::Node) => edit.scope > start_offset,
_ => false,
});
if line == span.start[0] && moves_start {
start_shift += edit.text.len();
}
if line == span.end[0] && moves_end {
end_shift += edit.text.len();
}
}
PlanSpan {
start: [span.start[0], span.start[1] + start_shift],
end: [span.end[0], span.end[1] + end_shift],
}
}
fn drop_ractor_insertions(
&mut self,
pending: Vec<PendingEdit>,
blocks: &[(usize, usize)],
) -> Vec<PendingEdit> {
let inside = |offset: usize| {
blocks
.iter()
.any(|(start, end)| offset >= *start && offset < *end)
};
let (dropped, kept): (Vec<_>, Vec<_>) =
pending.into_iter().partition(|edit| inside(edit.offset));
let mut keys = BTreeSet::new();
for edit in &dropped {
keys.extend(probe_keys_in(&edit.text));
}
let mut targets = BTreeMap::new();
for key in &keys {
if let Some(target) = self.probes.remove(key) {
targets.insert(*key, target);
}
}
let mut unmeasured = probe_obligations_of(&targets);
for target in targets.values() {
for named in target_ids(target) {
if let Some(implied) = self.implied.get(&named) {
unmeasured.extend(implied.hits.iter().map(|id| obligation_of(id)));
unmeasured.extend(implied.decisions.iter().map(|d| d.id.clone()));
}
}
}
unmeasured.sort();
unmeasured.dedup();
self.manifest.unmeasured.extend(unmeasured);
for (start, end) in blocks {
let (line, _) = self.line_column(*start);
let source = self
.text(*start, *end)
.lines()
.next()
.unwrap_or_default()
.to_owned();
self.manifest.limitations.push(limitation(
RACTOR_BLOCK_LIMITATION,
self.file,
line,
&source,
"a Ractor block cannot call Supercov's probes (a non-main Ractor cannot read the probe receiver), so what only a probe could prove inside it is unmeasured; its lines are still counted",
));
}
kept
}
fn finish(mut self) -> RubyFileObligations {
let mut pending = std::mem::take(&mut self.edits);
pending.sort_by(|left, right| {
left.offset
.cmp(&right.offset)
.then(left.rank.cmp(&right.rank))
.then(left.order.cmp(&right.order))
.then(left.sequence.cmp(&right.sequence))
});
let ractor_blocks = std::mem::take(&mut self.ractor_blocks);
if !ractor_blocks.is_empty() {
pending = self.drop_ractor_insertions(pending, &ractor_blocks);
}
let branches = std::mem::take(&mut self.branches)
.into_iter()
.map(|mut branch| {
branch.key.span = self.shifted(branch.key.span, branch.key.kind, &pending);
branch
})
.collect::<Vec<_>>();
let cases: Vec<CasePlan> = std::mem::take(&mut self.cases)
.into_iter()
.map(|mut case| {
for clause in &mut case.clauses {
clause.key.span = self.shifted(clause.key.span, clause.key.kind, &pending);
}
if let Some(no_match) = &mut case.no_match {
no_match.key.span =
self.shifted(no_match.key.span, no_match.key.kind, &pending);
}
case
})
.collect();
let methods: Vec<MethodKeyPlan> = std::mem::take(&mut self.methods)
.into_iter()
.map(|mut method| {
method.span = self.shifted(method.span, KeyKind::Node, &pending);
method
})
.collect();
let edits = pending
.into_iter()
.map(|edit| Edit {
offset: edit.offset,
text: edit.text,
rank: match edit.rank {
EditRank::Clause => "clause",
EditRank::StatementProbe => "statement",
EditRank::Opener => "opener",
EditRank::Closer => "closer",
}
.into(),
scope: edit.scope,
})
.collect::<Vec<_>>();
if let Some((id, line)) = self.begin_unmeasured.first() {
let source = self
.manifest
.branches
.iter()
.find(|branch| &branch.id == id)
.map(|branch| branch.source.lines().next().unwrap_or_default().to_owned())
.unwrap_or_default();
self.manifest.limitations.push(limitation(
BEGIN_BODY_LIMITATION,
self.file,
*line,
&source,
"a begin body that is empty, or ends in a statement with no expression form, cannot have its completion observed",
));
}
self.manifest.unmeasured.sort();
self.manifest.unmeasured.dedup();
RubyFileObligations {
manifest: self.manifest,
plan: RubyFilePlan {
ractor_blocks: ractor_blocks
.iter()
.map(|(start, end)| [*start, *end])
.collect(),
probe_obligations: {
let keyed = stdlib_provable(&branches, &cases, &methods);
probe_obligations_of(&self.probes)
.into_iter()
.filter(|id| !keyed.contains(id))
.collect()
},
edits,
lines: self.lines,
statement_offsets: self.statement_offsets,
branches,
methods,
cases,
implied: self.implied,
},
probes: self.probes,
}
}
}
fn limitation(id: &str, file: &str, line: usize, source: &str, reason: &str) -> serde_json::Value {
json!({
"id": id,
"kind": "semantic-safety",
"file": file,
"line": line,
"column": 0,
"source": source,
"reason": reason
})
}
impl<'pr> Visit<'pr> for Collector<'_> {
fn visit_statements_node(&mut self, node: &StatementsNode<'pr>) {
if !self
.expression_lists
.contains(&node.location().start_offset())
{
self.statements(node);
}
ruby_prism::visit_statements_node(self, node);
}
fn visit_parentheses_node(&mut self, node: &ruby_prism::ParenthesesNode<'pr>) {
if let Some(body) = node.body() {
self.expression_lists.insert(body.location().start_offset());
}
ruby_prism::visit_parentheses_node(self, node);
}
fn visit_embedded_statements_node(&mut self, node: &ruby_prism::EmbeddedStatementsNode<'pr>) {
if let Some(statements) = node.statements() {
self.expression_lists
.insert(statements.location().start_offset());
}
ruby_prism::visit_embedded_statements_node(self, node);
}
fn visit_if_node(&mut self, node: &IfNode<'pr>) {
let offset = node.location().start_offset();
if self.guard_nodes.contains(&offset) {
self.depth += 1;
self.visit(&node.predicate());
self.depth -= 1;
return;
}
if self.elsif_nodes.contains(&offset) {
self.depth += 1;
ruby_prism::visit_if_node(self, node);
self.depth -= 1;
return;
}
if let Some(truthy) = self.literal_truth(node.predicate()) {
self.depth += 1;
self.visit(&node.predicate());
if truthy {
if let Some(statements) = node.statements() {
self.visit_statements_node(&statements);
}
} else if let Some(subsequent) = node.subsequent() {
match subsequent.as_if_node() {
Some(elsif) => self.visit_if_node(&elsif),
None => {
if let Some(else_node) = subsequent.as_else_node()
&& let Some(statements) = else_node.statements()
{
self.visit_statements_node(&statements);
}
}
}
}
self.depth -= 1;
return;
}
let kind = if node.if_keyword_loc().is_none() {
"ternary"
} else {
"if"
};
self.if_node(node, kind);
let mut subsequent = node.subsequent();
while let Some(next) = subsequent {
match next.as_if_node() {
Some(elsif) => {
self.elsif_nodes.insert(elsif.location().start_offset());
self.if_node(&elsif, "elsif");
subsequent = elsif.subsequent();
}
None => break,
}
}
self.depth += 1;
ruby_prism::visit_if_node(self, node);
self.depth -= 1;
}
fn visit_unless_node(&mut self, node: &ruby_prism::UnlessNode<'pr>) {
if self.guard_nodes.contains(&node.location().start_offset()) {
self.depth += 1;
self.visit(&node.predicate());
self.depth -= 1;
return;
}
if let Some(truthy) = self.literal_truth(node.predicate()) {
self.depth += 1;
self.visit(&node.predicate());
if truthy {
if let Some(else_node) = node.else_clause()
&& let Some(statements) = else_node.statements()
{
self.visit_statements_node(&statements);
}
} else if let Some(statements) = node.statements() {
self.visit_statements_node(&statements);
}
self.depth -= 1;
return;
}
self.unless_node(node);
self.depth += 1;
ruby_prism::visit_unless_node(self, node);
self.depth -= 1;
}
fn visit_while_node(&mut self, node: &WhileNode<'pr>) {
self.loop_node(
&node.location(),
node.predicate(),
node.statements(),
node.is_begin_modifier(),
false,
);
self.depth += 1;
ruby_prism::visit_while_node(self, node);
self.depth -= 1;
}
fn visit_until_node(&mut self, node: &UntilNode<'pr>) {
self.loop_node(
&node.location(),
node.predicate(),
node.statements(),
node.is_begin_modifier(),
true,
);
self.depth += 1;
ruby_prism::visit_until_node(self, node);
self.depth -= 1;
}
fn visit_for_node(&mut self, node: &ForNode<'pr>) {
self.for_node(node);
self.depth += 1;
ruby_prism::visit_for_node(self, node);
self.depth -= 1;
}
fn visit_case_node(&mut self, node: &CaseNode<'pr>) {
self.case_node(node);
self.depth += 1;
ruby_prism::visit_case_node(self, node);
self.depth -= 1;
}
fn visit_case_match_node(&mut self, node: &CaseMatchNode<'pr>) {
self.case_match_node(node);
self.depth += 1;
ruby_prism::visit_case_match_node(self, node);
self.depth -= 1;
}
fn visit_and_node(&mut self, node: &AndNode<'pr>) {
let location = node.location();
if !self.tree_logicals.contains(&location.start_offset()) {
let left = node.left();
self.value_logical("and", &left, location.start_offset(), location.end_offset());
}
self.depth += 1;
ruby_prism::visit_and_node(self, node);
self.depth -= 1;
}
fn visit_or_node(&mut self, node: &OrNode<'pr>) {
let location = node.location();
if !self.tree_logicals.contains(&location.start_offset()) {
let left = node.left();
self.value_logical("or", &left, location.start_offset(), location.end_offset());
}
self.depth += 1;
ruby_prism::visit_or_node(self, node);
self.depth -= 1;
}
fn visit_call_node(&mut self, node: &CallNode<'pr>) {
if node.name().as_slice() == b"new"
&& node.receiver().is_some_and(|receiver| {
receiver
.as_constant_read_node()
.is_some_and(|constant| constant.name().as_slice() == b"Ractor")
})
&& let Some(block) = node.block()
&& block.as_block_node().is_some()
{
let location = block.location();
self.ractor_blocks
.push((location.start_offset(), location.end_offset()));
}
if node.is_safe_navigation() {
self.safe_navigation(node);
}
if let Some(block) = node.block()
&& let Some(block) = block.as_block_node()
&& let Some(receiver) = node.receiver()
&& !node.is_safe_navigation()
&& ITERATORS.contains(&node.name().as_slice())
{
self.iterator_loop(node, &receiver, &block);
}
self.depth += 1;
ruby_prism::visit_call_node(self, node);
self.depth -= 1;
}
fn visit_local_variable_or_write_node(
&mut self,
node: &ruby_prism::LocalVariableOrWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
Some(node.name().as_slice()),
&value,
);
self.depth += 1;
ruby_prism::visit_local_variable_or_write_node(self, node);
self.depth -= 1;
}
fn visit_local_variable_and_write_node(
&mut self,
node: &ruby_prism::LocalVariableAndWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"and",
location.start_offset(),
location.end_offset(),
Some(node.name().as_slice()),
&value,
);
self.depth += 1;
ruby_prism::visit_local_variable_and_write_node(self, node);
self.depth -= 1;
}
fn visit_instance_variable_or_write_node(
&mut self,
node: &ruby_prism::InstanceVariableOrWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
Some(node.name().as_slice()),
&value,
);
self.depth += 1;
ruby_prism::visit_instance_variable_or_write_node(self, node);
self.depth -= 1;
}
fn visit_instance_variable_and_write_node(
&mut self,
node: &ruby_prism::InstanceVariableAndWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"and",
location.start_offset(),
location.end_offset(),
Some(node.name().as_slice()),
&value,
);
self.depth += 1;
ruby_prism::visit_instance_variable_and_write_node(self, node);
self.depth -= 1;
}
fn visit_global_variable_or_write_node(
&mut self,
node: &ruby_prism::GlobalVariableOrWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
Some(node.name().as_slice()),
&value,
);
self.depth += 1;
ruby_prism::visit_global_variable_or_write_node(self, node);
self.depth -= 1;
}
fn visit_class_variable_or_write_node(
&mut self,
node: &ruby_prism::ClassVariableOrWriteNode<'pr>,
) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_class_variable_or_write_node(self, node);
self.depth -= 1;
}
fn visit_call_or_write_node(&mut self, node: &ruby_prism::CallOrWriteNode<'pr>) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_call_or_write_node(self, node);
self.depth -= 1;
}
fn visit_call_and_write_node(&mut self, node: &ruby_prism::CallAndWriteNode<'pr>) {
let location = node.location();
let value = node.value();
self.op_assign(
"and",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_call_and_write_node(self, node);
self.depth -= 1;
}
fn visit_index_or_write_node(&mut self, node: &ruby_prism::IndexOrWriteNode<'pr>) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_index_or_write_node(self, node);
self.depth -= 1;
}
fn visit_index_and_write_node(&mut self, node: &ruby_prism::IndexAndWriteNode<'pr>) {
let location = node.location();
let value = node.value();
self.op_assign(
"and",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_index_and_write_node(self, node);
self.depth -= 1;
}
fn visit_constant_or_write_node(&mut self, node: &ruby_prism::ConstantOrWriteNode<'pr>) {
let location = node.location();
let value = node.value();
self.op_assign(
"or",
location.start_offset(),
location.end_offset(),
None,
&value,
);
self.depth += 1;
ruby_prism::visit_constant_or_write_node(self, node);
self.depth -= 1;
}
fn visit_def_node(&mut self, node: &DefNode<'pr>) {
self.def_node(node);
self.depth += 1;
ruby_prism::visit_def_node(self, node);
self.depth -= 1;
}
fn visit_begin_node(&mut self, node: &BeginNode<'pr>) {
if node.begin_keyword_loc().is_some() {
self.begin_node(node, None);
}
self.depth += 1;
ruby_prism::visit_begin_node(self, node);
self.depth -= 1;
}
fn visit_block_node(&mut self, node: &ruby_prism::BlockNode<'pr>) {
if let Some(body) = node.body()
&& let Some(begin) = body.as_begin_node()
{
self.begin_node(&begin, Some(node.closing_loc().start_offset()));
}
self.depth += 1;
ruby_prism::visit_block_node(self, node);
self.depth -= 1;
}
fn visit_lambda_node(&mut self, node: &ruby_prism::LambdaNode<'pr>) {
if let Some(body) = node.body()
&& let Some(begin) = body.as_begin_node()
{
self.begin_node(&begin, Some(node.closing_loc().start_offset()));
}
self.depth += 1;
ruby_prism::visit_lambda_node(self, node);
self.depth -= 1;
}
fn visit_rescue_modifier_node(&mut self, node: &RescueModifierNode<'pr>) {
self.rescue_modifier(node);
self.depth += 1;
ruby_prism::visit_rescue_modifier_node(self, node);
self.depth -= 1;
}
}
pub fn build_ruby_obligations(
file: &str,
source: &[u8],
next_probe: &mut u64,
) -> Result<RubyFileObligations, RubyInstrumenterError> {
let result = ruby_prism::parse(source);
let errors = result
.errors()
.map(|error| error.message().to_owned())
.collect::<Vec<_>>();
if !errors.is_empty() {
return Err(RubyInstrumenterError::Parse(errors.join("; ")));
}
let mut collector = Collector::new(file, source, next_probe);
collector.visit(&result.node());
if let Some(error) = collector.error.take() {
return Err(error);
}
Ok(collector.finish())
}
pub fn apply_edits(source: &[u8], edits: &[Edit]) -> Vec<u8> {
let mut output =
Vec::with_capacity(source.len() + edits.iter().map(|e| e.text.len()).sum::<usize>());
let mut cursor = 0;
for edit in edits {
output.extend_from_slice(&source[cursor..edit.offset]);
output.extend_from_slice(edit.text.as_bytes());
cursor = edit.offset;
}
output.extend_from_slice(&source[cursor..]);
output
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = r#"class Shapes
def classify(a, b, c)
if a && (b || c)
:yes
elsif a
:half
else
:no
end
end
def loops(items, flag)
total = 0
items.each { |i| total += i if i > 2 && flag }
while total > 100
total -= 50
end
for x in items do total += x end
total
end
def logical(a, b)
x = a || b
@cache ||= {}
@cache[a] ||= b
y = a ? 1 : 2; z = a&.size
[x, y, z]
end
def guarded(s)
Integer(s)
rescue ArgumentError
-1
ensure
@done = true
end
def cases(v)
case v
when 0 then :zero
else :other
end
v.to_s rescue "bad"
end
end
"#;
#[test]
fn discovers_obligations_with_stable_ids_and_newline_free_edits() {
let mut probe = 0;
let first = build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
let mut probe = 0;
let second =
build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
assert_eq!(first, second);
let manifest = &first.manifest;
let functions = manifest
.points
.iter()
.filter(|point| point.kind == PointKind::Function)
.map(|point| point.label.clone().unwrap())
.collect::<Vec<_>>();
assert_eq!(
functions,
["classify", "loops", "logical", "guarded", "cases"]
);
let compound = manifest
.decisions
.iter()
.find(|decision| decision.source == "a && (b || c)")
.unwrap();
assert_eq!(compound.conditions, ["a", "b", "c"]);
assert_eq!(compound.kind, "if");
assert!(
manifest
.decisions
.iter()
.any(|d| d.kind == "elsif" && d.conditions == ["a"])
);
assert!(manifest.decisions.iter().any(|d| d.kind == "ternary"));
assert!(manifest.decisions.iter().any(|d| d.kind == "while"));
for kind in [
"for",
"logical-or",
"or-assign",
"safe-navigation",
"begin",
"rescue-0",
"case-when-0",
"case-else",
"rescue-modifier",
] {
assert!(
manifest.branches.iter().any(|branch| branch.kind == kind),
"missing branch kind {kind}"
);
}
for edit in &first.plan.edits {
assert!(!edit.text.contains('\n'));
}
assert!(
first
.plan
.edits
.windows(2)
.all(|pair| pair[0].offset <= pair[1].offset)
);
assert!(manifest.unmeasured.is_empty());
assert!(manifest.limitations.is_empty());
}
#[test]
fn transformed_source_keeps_line_count_and_carries_probes() {
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/shapes.rb", SOURCE.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(SOURCE.as_bytes(), &obligations.plan.edits)).unwrap();
assert_eq!(transformed.lines().count(), SOURCE.lines().count());
assert!(
transformed.contains("if $__supercov.d(0, ($__supercov.c(0, 0, (a)) && ($__supercov.c(0, 1, (b)) || $__supercov.c(0, 2, (c)))))"),
"{transformed}"
);
assert!(transformed.contains("while $__supercov.w("));
assert!(transformed.contains("for x in $__supercov.f("));
assert!(transformed.contains("do $__supercov.fb("));
assert!(transformed.contains("x = $__supercov.l("));
assert!(transformed.contains("($__supercov.pre("));
assert!(transformed.contains("||= ($__supercov.es("));
assert!(transformed.contains("rescue Exception => __supercov_e; $__supercov.p("));
assert!(transformed.contains("$__supercov.h("));
assert!(transformed.contains("$__supercov.ok("));
assert!(transformed.contains("rescue $__supercov.hm("));
assert!(transformed.contains("; $__supercov.s("));
assert!(
obligations
.plan
.branches
.iter()
.any(|branch| !branch.hits.is_empty() && branch.key.group == "if")
);
}
#[test]
fn jumps_and_endless_bodies_take_wrapped_probes_and_literal_predicates_fold() {
let source = "def inc(x) = x + 1\n\
[1].each { |v| y = Integer(v) rescue next }\n\
if false\n dead\nelse\n live\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/x.rb", source.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
assert!(
transformed.contains("def inc(x) = ($__supercov.s("),
"{transformed}"
);
assert!(
transformed.contains("rescue ($__supercov.hm0("),
"{transformed}"
);
assert!(
obligations
.plan
.branches
.iter()
.all(|branch| branch.key.group != "if")
);
assert!(
obligations
.manifest
.points
.iter()
.all(|point| point.source != "dead")
);
assert!(
obligations
.manifest
.points
.iter()
.any(|point| point.source == "live")
);
}
#[test]
fn void_valued_last_statements_are_probed_arm_by_arm() {
let source =
"def m(c)\n if c\n return 1\n else\n return 2\n end\nrescue\n nil\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/v.rb", source.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
assert_eq!(
transformed.matches("$__supercov.ok(").count(),
2,
"{transformed}"
);
assert!(
transformed.contains("return $__supercov.ok("),
"{transformed}"
);
assert!(
!obligations
.manifest
.unmeasured
.iter()
.any(|id| id.ends_with(":success")),
"{:?}",
obligations.manifest.unmeasured
);
assert!(!obligations.plan.probe_obligations.is_empty());
}
#[test]
fn expressions_that_can_return_are_never_wrapped() {
let source = "def m(a, d)\n begin\n if a\n return d\n end\n ensure\n unlock\n end\n d\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/e.rb", source.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
assert!(!transformed.contains("$__supercov.ok"), "{transformed}");
assert!(
obligations
.manifest
.unmeasured
.iter()
.any(|id| id.ends_with(":success")),
"the begin's completion is declared instead"
);
let both = "def m(a, d)\n begin\n if a\n return d\n else\n d + 1\n end\n ensure\n unlock\n end\nend\n";
let mut probe = 0;
let obligations = build_ruby_obligations("lib/f.rb", both.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(both.as_bytes(), &obligations.plan.edits)).unwrap();
assert!(
transformed.contains("return $__supercov.ok("),
"{transformed}"
);
assert!(
!obligations
.manifest
.unmeasured
.iter()
.any(|id| id.ends_with(":success")),
"{:?}",
obligations.manifest.unmeasured
);
}
#[test]
fn stdlib_keys_shift_with_insertions_on_their_line() {
let source = "def f(a, b)\n x = 1 if a && b\nend\n";
let mut probe = 0;
let obligations = build_ruby_obligations("m.rb", source.as_bytes(), &mut probe).unwrap();
let then_key = obligations
.plan
.branches
.iter()
.find(|branch| branch.key.branch == "then")
.unwrap();
assert_eq!(then_key.key.span.start, [2, 2]);
assert_eq!(then_key.key.span.end, [2, 7]);
let else_key = obligations
.plan
.branches
.iter()
.find(|branch| branch.key.branch == "else")
.unwrap();
let inserted: usize = obligations
.plan
.edits
.iter()
.map(|edit| edit.text.len())
.sum();
assert_eq!(else_key.key.span.end, [2, 17 + inserted]);
assert!(
then_key.hits.len() == 1,
"modifier body statement proven by the then key"
);
}
#[test]
fn line_events_prove_bodies_and_probes_prove_the_rest() {
let source = "def both(a)\n if a\n 1\n else\n 2\n end\nend\n\ndef guard(a)\n return 0 if a\n a&.size\nend\n\ndef pick(v)\n case v\n when 1 then :one\n when 2\n :two\n end\nend\n\ndef short = 3\n\ndef empty; end\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
let plan = &obligations.plan;
let manifest = &obligations.manifest;
let function = |name: &str| {
manifest
.points
.iter()
.find(|point| {
point.kind == PointKind::Function && point.label.as_deref() == Some(name)
})
.unwrap()
.id
.clone()
};
let statement_on = |line: usize| plan.lines.get(&line).cloned().unwrap();
let transformed = String::from_utf8(apply_edits(source.as_bytes(), &plan.edits)).unwrap();
assert_eq!(transformed.lines().count(), source.lines().count());
assert_eq!(
plan.implied[&statement_on(2)].hits,
[function("both")],
"the `if` statement opens the method"
);
let three = &plan.implied[&statement_on(3)];
assert!(three.hits.is_empty());
assert_eq!(three.decisions.len(), 1);
assert!(three.decisions[0].value);
let five = &plan.implied[&statement_on(5)];
assert!(!five.decisions[0].value);
assert!(!transformed.contains(".d(") || transformed.matches(".d(").count() == 1);
let guard_decision = manifest
.decisions
.iter()
.find(|d| d.kind == "if" && d.conditions == ["a"] && d.line == 10)
.unwrap();
let outcome_true = format!("{}:outcome:true", guard_decision.id);
assert!(
plan.implied.contains_key(&outcome_true),
"modifier body implied by its outcome"
);
assert!(transformed.contains("return 0 if $__supercov.d("));
assert!(
!transformed.contains(".c("),
"a lone condition needs no wrapper"
);
assert!(transformed.contains("$__supercov.n("));
assert!(transformed.contains("else $__supercov.hs("));
assert!(transformed.contains("when 1 then $__supercov.s("));
assert!(transformed.contains("def short = ($__supercov.s("));
assert!(transformed.contains("def empty; $__supercov.hs("));
let empty_probe = plan
.edits
.iter()
.find(|edit| edit.text.contains(".hs(") && edit.text.starts_with("; "))
.unwrap();
assert!(empty_probe.text.contains("hs"));
for id in [
guard_decision.id.clone(),
function("empty"),
function("short"),
] {
assert!(
!plan.probe_obligations.contains(&id),
"{id} is key-provable on 3.3"
);
}
let safe = manifest
.branches
.iter()
.find(|b| b.kind == "safe-navigation")
.unwrap();
assert!(!plan.probe_obligations.contains(&safe.id));
}
#[test]
fn ractor_blocks_get_no_probes_and_declare_what_only_probes_could_prove() {
let source = "def inside(a)\n Ractor.new(a) { |v| v ? 1 : 2 }.take\nend\n\ndef outside(a)\n a ? 1 : 2\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/r.rb", source.as_bytes(), &mut probe).unwrap();
let plan = &obligations.plan;
let manifest = &obligations.manifest;
let transformed = String::from_utf8(apply_edits(source.as_bytes(), &plan.edits)).unwrap();
let block_line = transformed.lines().nth(1).unwrap();
assert!(
!block_line.contains("$__supercov"),
"no probe inside the Ractor block: {block_line}"
);
assert!(
transformed
.lines()
.nth(5)
.unwrap()
.contains("$__supercov.d("),
"the ternary outside is probed"
);
let inside = manifest.decisions.iter().find(|d| d.line == 2).unwrap();
assert!(
manifest.unmeasured.contains(&inside.id),
"the block's ternary leaves the denominator"
);
let outside = manifest.decisions.iter().find(|d| d.line == 6).unwrap();
assert!(!manifest.unmeasured.contains(&outside.id));
assert_eq!(plan.ractor_blocks.len(), 1);
assert!(
manifest
.limitations
.iter()
.any(|l| l["id"] == RACTOR_BLOCK_LIMITATION && l["line"] == 2)
);
assert!(!plan.probe_obligations.contains(&inside.id));
}
#[test]
fn an_empty_guarded_in_body_takes_its_probe_after_the_guard_wrapper() {
let source = "def f(data)\n case data\n in String then data = 1\n in Array if data.all? { _1 > 0 }\n else\n raise TypeError\n end\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/g.rb", source.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
let guard_line = transformed.lines().nth(3).unwrap();
assert!(
guard_line.contains(")) then $__supercov.hs("),
"probe after the wrapper: {guard_line}"
);
assert!(
!guard_line.contains("then $__supercov.hs(")
|| !guard_line.contains("hs(")
|| guard_line.matches(')').count() == guard_line.matches('(').count(),
"balanced: {guard_line}"
);
}
#[test]
fn a_class_variable_or_assignment_is_never_read_before_it_exists() {
let source = "class C\n def self.ext\n @@ext ||= {}\n end\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/c.rb", source.as_bytes(), &mut probe).unwrap();
let transformed =
String::from_utf8(apply_edits(source.as_bytes(), &obligations.plan.edits)).unwrap();
let line = transformed.lines().nth(2).unwrap();
assert!(
!line.contains(".l("),
"no read of @@ext before the assignment: {line}"
);
assert!(
line.contains(".pre(") && line.contains(".es("),
"arrival form: {line}"
);
}
#[test]
fn a_predicate_ruby_folds_leaves_no_obligations_in_the_dead_arm() {
let source = "def f(x)\n if x and false\n x ? 1 : 2\n end\n if x or true\n 3\n else\n 4\n end\nend\n";
let mut probe = 0;
let obligations =
build_ruby_obligations("lib/f.rb", source.as_bytes(), &mut probe).unwrap();
let manifest = &obligations.manifest;
assert!(
!manifest.decisions.iter().any(|d| d.kind == "ternary"),
"the dead arm's ternary is not an obligation"
);
assert!(
!manifest.decisions.iter().any(|d| d.kind == "if"),
"a folded predicate is no decision"
);
assert!(
!manifest.points.iter().any(|p| p.line == 8),
"the dead else arm holds no statement"
);
assert!(
manifest.points.iter().any(|p| p.line == 6),
"the live arm does"
);
}
#[test]
fn rejects_invalid_ruby() {
let mut probe = 0;
assert!(matches!(
build_ruby_obligations("m.rb", b"def x(\n", &mut probe),
Err(RubyInstrumenterError::Parse(_))
));
}
}