diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9d48f20d4..c414780f1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,6 +11,11 @@ lto = true opt-level = 3 codegen-units = 1 +[profile.bench] +lto = true +opt-level = 3 +codegen-units = 1 + [profile.profiling] inherits = "release" debug = true diff --git a/rust/rubydex/Cargo.toml b/rust/rubydex/Cargo.toml index 0c6b9a249..6d5b8174f 100644 --- a/rust/rubydex/Cargo.toml +++ b/rust/rubydex/Cargo.toml @@ -63,5 +63,9 @@ tikv-jemalloc-ctl = { version = "0.7.0", features = ["stats"] } name = "graph_memory" harness = false +[[bench]] +name = "incremental_resolution" +harness = false + [lints] workspace = true diff --git a/rust/rubydex/benches/incremental_resolution.rs b/rust/rubydex/benches/incremental_resolution.rs new file mode 100644 index 000000000..c6cfede22 --- /dev/null +++ b/rust/rubydex/benches/incremental_resolution.rs @@ -0,0 +1,687 @@ +use std::{ + collections::HashSet, + error::Error, + io, + path::PathBuf, + process::ExitCode, + time::{Duration, Instant}, +}; + +use clap::Parser; +use rubydex::{ + indexing::{self, IndexerBackend, LanguageId}, + integrity, listing, + model::{ + declaration::{Ancestor, Ancestors, Namespace}, + graph::{Graph, Unit}, + ids::{ConstantReferenceId, DeclarationId, DefinitionId}, + name::NameRef, + }, + resolution::Resolver, +}; + +const MIXIN_A: &str = "RubydexIncrementalBenchmarkMixinA"; +const MIXIN_B: &str = "RubydexIncrementalBenchmarkMixinB"; +const MIXIN_URI: &str = "file:///__rubydex_incremental_benchmark__/mixins.rb"; + +type Result = std::result::Result>; + +#[derive(Debug, Parser)] +#[command(about = "Benchmark incremental resolution against an existing workspace graph")] +struct Args { + /// Added by `cargo bench` for harness-less benchmarks. + #[arg(long = "bench", hide = true)] + _bench: bool, + + /// Workspace root. Used for rubydex.toml and reporting. + workspace: PathBuf, + + /// File or directory to index. May be repeated. Defaults to the workspace root. + #[arg(long = "index-path")] + index_paths: Vec, + + /// Top-level class name to benchmark. May be repeated. + #[arg(long = "target")] + targets: Vec, + + /// Print light/typical/heavy class candidates and exit unless --target is also supplied. + #[arg(long)] + list_candidates: bool, + + /// Print unresolved graph state after the initial resolution and exit. + #[arg(long)] + resolution_stats: bool, + + /// Number of measured A -> B -> A cycles. + #[arg(long, default_value_t = 30)] + cycles: usize, +} + +#[derive(Clone)] +struct Candidate { + id: DeclarationId, + name: String, + descendants: usize, + ancestor_depth: usize, + definitions: usize, + direct_references: usize, +} + +#[derive(Clone, Copy)] +struct Sample { + index: Duration, + merge: Duration, + resolve: Duration, + total: Duration, +} + +struct PendingWorkSummary { + total: usize, + raw_definitions: usize, + raw_constant_references: usize, + raw_ancestors: usize, + definitions: HashSet, + constant_references: HashSet, + ancestors: HashSet, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("incremental resolution benchmark failed: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<()> { + let args = Args::parse(); + if args.cycles == 0 { + return Err(input_error("--cycles must be greater than zero")); + } + + let workspace = std::fs::canonicalize(&args.workspace)?; + if !workspace.is_dir() { + return Err(input_error(format!( + "workspace is not a directory: {}", + workspace.display() + ))); + } + + let index_paths = if args.index_paths.is_empty() { + vec![workspace.clone()] + } else { + args.index_paths + }; + + let mut graph = Graph::new(); + graph.set_workspace_path(workspace.clone()); + if let Err(error) = graph.load_config(None) { + eprintln!("warning: {error}"); + } + + let listing_started = Instant::now(); + let (file_paths, listing_errors) = listing::collect_file_paths( + index_paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + &graph.excluded_patterns(), + ); + ensure_no_errors("listing", &listing_errors)?; + let listing_elapsed = listing_started.elapsed(); + + let indexed_file_count = file_paths.len(); + let indexing_started = Instant::now(); + let indexing_errors = indexing::index_files(&mut graph, file_paths, IndexerBackend::RubyIndexer); + ensure_no_errors("indexing", &indexing_errors)?; + let indexing_elapsed = indexing_started.elapsed(); + + let resolution_started = Instant::now(); + Resolver::new(&mut graph).resolve(); + let resolution_elapsed = resolution_started.elapsed(); + + println!("Workspace: {}", workspace.display()); + println!("Indexed paths: {}", index_paths.len()); + println!("Indexed files: {indexed_file_count}"); + println!("Declarations: {}", graph.declarations().len()); + println!( + "Initial build: listing {:.3}s, indexing {:.3}s, resolution {:.3}s", + listing_elapsed.as_secs_f64(), + indexing_elapsed.as_secs_f64(), + resolution_elapsed.as_secs_f64(), + ); + + if args.resolution_stats { + print_resolution_stats(&mut graph); + return Ok(()); + } + + let candidates = collect_candidates(&graph, &workspace); + if candidates.is_empty() { + return Err(input_error("no class with complete ancestors was found")); + } + + if args.list_candidates { + print_candidates(&graph, &candidates); + if args.targets.is_empty() { + return Ok(()); + } + } + + if args.targets.is_empty() { + return Err(input_error("pass --list-candidates or at least one --target CLASS")); + } + if let Some(target) = args.targets.iter().find(|target| target.contains("::")) { + return Err(input_error(format!( + "target must be a top-level class so the virtual reopen is unambiguous: {target}" + ))); + } + + ensure_synthetic_names_are_available(&graph)?; + let baseline_declaration_count = graph.declarations().len(); + let baseline_integrity = integrity_messages(&graph); + + benchmark_noop(&mut graph, args.cycles); + + let mixin_source = format!("module {MIXIN_A}\nend\nmodule {MIXIN_B}\nend\n"); + apply_source(&mut graph, MIXIN_URI, &mixin_source); + + for (index, target) in args.targets.iter().enumerate() { + benchmark_target(&mut graph, target, index, args.cycles)?; + } + + if graph.delete_document(MIXIN_URI).is_none() { + return Err(input_error("synthetic mixin document disappeared before cleanup")); + } + Resolver::new(&mut graph).resolve(); + + if graph.declarations().len() != baseline_declaration_count { + return Err(input_error(format!( + "cleanup changed declaration count: expected {baseline_declaration_count}, got {}", + graph.declarations().len(), + ))); + } + + let final_integrity = integrity_messages(&graph); + if final_integrity != baseline_integrity { + return Err(input_error("cleanup changed graph integrity errors")); + } + + Ok(()) +} + +fn print_resolution_stats(graph: &mut Graph) { + let (unresolved_name_count, unresolved_name_uses) = graph + .names() + .values() + .filter(|name| matches!(name, NameRef::Unresolved(_))) + .fold((0, 0_u64), |(count, uses), name| { + (count + 1, uses + u64::from(name.ref_count())) + }); + + let unresolved_constant_references = graph + .constant_references() + .values() + .filter(|reference| matches!(graph.names().get(reference.name_id()), Some(NameRef::Unresolved(_)))) + .count(); + let unresolved_named_definitions = graph + .definitions() + .values() + .filter(|definition| { + definition + .name_id() + .is_some_and(|name_id| matches!(graph.names().get(name_id), Some(NameRef::Unresolved(_)))) + }) + .count(); + let unlinked_definitions = graph + .definitions() + .values() + .filter(|definition| graph.definition_to_declaration_id(definition).is_none()) + .count(); + + let mut complete_ancestors = 0; + let mut cyclic_ancestors = 0; + let mut partial_ancestors = 0; + let mut partial_ancestor_entries = 0; + for namespace in graph + .declarations() + .values() + .filter_map(|declaration| declaration.as_namespace()) + { + match namespace.ancestors() { + Ancestors::Complete(_) => complete_ancestors += 1, + Ancestors::Cyclic(_) => cyclic_ancestors += 1, + Ancestors::Partial(ancestors) => { + partial_ancestors += 1; + partial_ancestor_entries += ancestors + .iter() + .filter(|ancestor| matches!(ancestor, Ancestor::Partial(_))) + .count(); + } + } + } + + let pending = take_pending_work_summary(graph); + + println!(); + println!("Post-resolution graph state"); + println!( + "Names: {} total, {} unresolved unique, {unresolved_name_uses} unresolved uses", + graph.names().len(), + unresolved_name_count, + ); + println!( + "Constant references: {} total, {unresolved_constant_references} unresolved", + graph.constant_references().len(), + ); + println!( + "Definitions: {} total, {unresolved_named_definitions} unresolved named, {unlinked_definitions} unlinked", + graph.definitions().len(), + ); + println!( + "Namespace ancestors: {complete_ancestors} complete, {cyclic_ancestors} cyclic, \ + {partial_ancestors} partial ({partial_ancestor_entries} partial entries)", + ); + println!( + "Pending work: {} raw; definitions {} raw/{} unique, constant refs {} raw/{} unique, ancestors {} raw/{} unique", + pending.total, + pending.raw_definitions, + pending.definitions.len(), + pending.raw_constant_references, + pending.constant_references.len(), + pending.raw_ancestors, + pending.ancestors.len(), + ); +} + +fn take_pending_work_summary(graph: &mut Graph) -> PendingWorkSummary { + // This diagnostic mode exits immediately, so draining pending work does not affect a benchmark run. + let pending_work = graph.take_pending_work(); + let mut summary = PendingWorkSummary { + total: pending_work.len(), + raw_definitions: 0, + raw_constant_references: 0, + raw_ancestors: 0, + definitions: HashSet::new(), + constant_references: HashSet::new(), + ancestors: HashSet::new(), + }; + + for unit in pending_work { + match unit { + Unit::Definition(id) => { + summary.raw_definitions += 1; + summary.definitions.insert(id); + } + Unit::ConstantRef(id) => { + summary.raw_constant_references += 1; + summary.constant_references.insert(id); + } + Unit::Ancestors(id) => { + summary.raw_ancestors += 1; + summary.ancestors.insert(id); + } + } + } + + summary +} + +fn collect_candidates(graph: &Graph, workspace: &std::path::Path) -> Vec { + let excluded = ["BasicObject", "Object", "Module", "Class"]; + let mut candidates: Vec = graph + .declarations() + .iter() + .filter_map(|(id, declaration)| { + let namespace = declaration.as_namespace()?; + if !matches!(namespace, Namespace::Class(_)) + || !namespace.has_complete_ancestors() + || namespace.definitions().is_empty() + || !is_defined_in_workspace(graph, namespace, workspace) + || excluded.contains(&declaration.name()) + || declaration.name().contains("::") + || declaration.name().contains('<') + || declaration.name().contains('#') + { + return None; + } + + Some(Candidate { + id: *id, + name: declaration.name().to_string(), + descendants: namespace.descendants().len(), + ancestor_depth: namespace.ancestors().iter().count(), + definitions: namespace.definitions().len(), + direct_references: namespace.references().len(), + }) + }) + .collect(); + + candidates.sort_unstable_by(|a, b| (a.descendants, &a.name).cmp(&(b.descendants, &b.name))); + candidates +} + +fn is_defined_in_workspace(graph: &Graph, namespace: &Namespace, workspace: &std::path::Path) -> bool { + namespace.definitions().iter().any(|id| { + graph + .definitions() + .get(id) + .and_then(|definition| graph.documents().get(definition.uri_id())) + .and_then(rubydex::model::document::Document::file_path) + .is_some_and(|path| path.starts_with(workspace)) + }) +} + +fn print_candidates(graph: &Graph, candidates: &[Candidate]) { + println!(); + println!("Top-level class candidates by transitive descendant count"); + println!("bucket\tdescendants\tancestor_depth\tdefinitions\tdirect_refs\tsubtree_refs\tclass"); + + let structural: Vec<&Candidate> = candidates + .iter() + .filter(|candidate| candidate.descendants > 1) + .collect(); + let mut printed = HashSet::new(); + + print_candidate(graph, "light", &candidates[0], &mut printed); + + let population: Vec<&Candidate> = if structural.is_empty() { + candidates.iter().collect() + } else { + structural + }; + for (label, percentile) in [ + ("typical_structural", 50), + ("heavy", 90), + ("very_heavy", 99), + ("max", 100), + ] { + let index = (population.len() - 1) * percentile / 100; + print_candidate(graph, label, population[index], &mut printed); + } + + println!(); + println!("Top workspace top-level classes by transitive descendant count"); + println!("descendants\tancestor_depth\tdefinitions\tdirect_refs\tsubtree_refs\tclass"); + for candidate in candidates.iter().rev().take(10) { + println!( + "{}\t{}\t{}\t{}\t{}\t{}", + candidate.descendants, + candidate.ancestor_depth, + candidate.definitions, + candidate.direct_references, + subtree_reference_count(graph, candidate), + candidate.name, + ); + } +} + +fn print_candidate(graph: &Graph, label: &str, candidate: &Candidate, printed: &mut HashSet) { + if printed.insert(candidate.id) { + println!( + "{label}\t{}\t{}\t{}\t{}\t{}\t{}", + candidate.descendants, + candidate.ancestor_depth, + candidate.definitions, + candidate.direct_references, + subtree_reference_count(graph, candidate), + candidate.name, + ); + } +} + +fn subtree_reference_count(graph: &Graph, candidate: &Candidate) -> usize { + let Some(target) = graph + .declarations() + .get(&candidate.id) + .and_then(|declaration| declaration.as_namespace()) + else { + return 0; + }; + + std::iter::once(&candidate.id) + .chain(target.descendants()) + .filter_map(|id| { + graph + .declarations() + .get(id) + .and_then(|declaration| declaration.as_namespace()) + }) + .map(|namespace| namespace.references().len()) + .sum() +} + +fn benchmark_noop(graph: &mut Graph, cycles: usize) { + let mut durations = Vec::with_capacity(cycles); + for _ in 0..cycles { + let started = Instant::now(); + Resolver::new(graph).resolve(); + durations.push(started.elapsed()); + } + + println!(); + println!("No-op resolve baseline"); + println!("Measured cycles: {cycles}"); + print_duration_summary("noop", "resolve", durations); +} + +fn benchmark_target(graph: &mut Graph, target: &str, index: usize, cycles: usize) -> Result<()> { + let target_id = DeclarationId::from(target); + let (original_ancestors, original_descendants, descendant_count) = { + let declaration = graph + .declarations() + .get(&target_id) + .ok_or_else(|| input_error(format!("target class does not exist: {target}")))?; + let namespace = declaration + .as_namespace() + .filter(|namespace| matches!(namespace, Namespace::Class(_))) + .ok_or_else(|| input_error(format!("target is not a class: {target}")))?; + ( + namespace.ancestors().iter().copied().collect::>(), + namespace.descendants().iter().copied().collect::>(), + namespace.descendants().len(), + ) + }; + + let uri = format!("file:///__rubydex_incremental_benchmark__/target_{index}.rb"); + let source_a = format!("class {target}\n include ::{MIXIN_A}\nend\n"); + let source_b = format!("class {target}\n include ::{MIXIN_B}\nend\n"); + debug_assert_eq!(source_a.len(), source_b.len()); + + apply_source(graph, &uri, &source_a); + assert_mixin(graph, target, target_id, &original_descendants, MIXIN_A, MIXIN_B)?; + + let mut a_to_b = Vec::with_capacity(cycles); + let mut b_to_a = Vec::with_capacity(cycles); + for _ in 0..cycles { + a_to_b.push(apply_source(graph, &uri, &source_b)); + b_to_a.push(apply_source(graph, &uri, &source_a)); + } + + assert_mixin(graph, target, target_id, &original_descendants, MIXIN_A, MIXIN_B)?; + // Validate both directions outside the measured samples so the checks do not affect timings. + apply_source(graph, &uri, &source_b); + assert_mixin(graph, target, target_id, &original_descendants, MIXIN_B, MIXIN_A)?; + apply_source(graph, &uri, &source_a); + assert_mixin(graph, target, target_id, &original_descendants, MIXIN_A, MIXIN_B)?; + + println!(); + println!("Target: {target}"); + println!("Transitive descendants: {descendant_count}"); + println!("Measured cycles: {cycles}"); + print_samples("A_to_B", &a_to_b); + print_samples("B_to_A", &b_to_a); + + if graph.delete_document(&uri).is_none() { + return Err(input_error(format!("synthetic target document disappeared: {uri}"))); + } + Resolver::new(graph).resolve(); + + let restored = graph + .declarations() + .get(&target_id) + .and_then(|declaration| declaration.as_namespace()) + .ok_or_else(|| input_error(format!("target class disappeared during cleanup: {target}")))?; + let restored_ancestors: Vec = restored.ancestors().iter().copied().collect(); + + let descendants_restored = restored.descendants().len() == original_descendants.len() + && restored + .descendants() + .iter() + .all(|id| original_descendants.contains(id)); + if restored_ancestors != original_ancestors || !descendants_restored { + return Err(input_error(format!( + "target graph state was not restored after benchmark: {target}" + ))); + } + + Ok(()) +} + +fn apply_source(graph: &mut Graph, uri: &str, source: &str) -> Sample { + let total_started = Instant::now(); + + let index_started = Instant::now(); + let local_graph = + indexing::build_local_graph(uri.to_string(), source, &LanguageId::Ruby, IndexerBackend::RubyIndexer); + let index = index_started.elapsed(); + + let merge_started = Instant::now(); + graph.consume_document_changes(local_graph); + let merge = merge_started.elapsed(); + + let resolve_started = Instant::now(); + Resolver::new(graph).resolve(); + let resolve = resolve_started.elapsed(); + + Sample { + index, + merge, + resolve, + total: total_started.elapsed(), + } +} + +fn assert_mixin( + graph: &Graph, + target: &str, + target_id: DeclarationId, + expected_descendants: &HashSet, + expected: &str, + absent: &str, +) -> Result<()> { + let expected_id = DeclarationId::from(expected); + let absent_id = DeclarationId::from(absent); + let target_namespace = graph + .declarations() + .get(&target_id) + .and_then(|declaration| declaration.as_namespace()) + .ok_or_else(|| input_error("target class disappeared while applying synthetic mixin"))?; + + let descendants_rebuilt = target_namespace.descendants().len() == expected_descendants.len() + && target_namespace + .descendants() + .iter() + .all(|id| expected_descendants.contains(id)); + if !descendants_rebuilt { + return Err(input_error(format!( + "target {target} descendants were not rebuilt after switching from {absent} to {expected}" + ))); + } + + for affected_id in std::iter::once(target_id).chain(expected_descendants.iter().copied()) { + let declaration = graph + .declarations() + .get(&affected_id) + .ok_or_else(|| input_error(format!("affected declaration disappeared: {affected_id:?}")))?; + let namespace = declaration.as_namespace().ok_or_else(|| { + input_error(format!( + "affected declaration is not a namespace: {}", + declaration.name() + )) + })?; + let has_expected = namespace + .ancestors() + .iter() + .any(|ancestor| matches!(ancestor, Ancestor::Complete(id) if *id == expected_id)); + let has_absent = namespace + .ancestors() + .iter() + .any(|ancestor| matches!(ancestor, Ancestor::Complete(id) if *id == absent_id)); + + if !has_expected || has_absent { + return Err(input_error(format!( + "affected namespace {} did not switch ancestors from {absent} to {expected}", + declaration.name(), + ))); + } + } + + Ok(()) +} + +fn print_samples(direction: &str, samples: &[Sample]) { + for (phase, durations) in [ + ("index", samples.iter().map(|sample| sample.index).collect()), + ("merge", samples.iter().map(|sample| sample.merge).collect()), + ("resolve", samples.iter().map(|sample| sample.resolve).collect()), + ("total", samples.iter().map(|sample| sample.total).collect()), + ] { + print_duration_summary(direction, phase, durations); + } +} + +fn print_duration_summary(direction: &str, phase: &str, mut durations: Vec) { + durations.sort_unstable(); + let median = durations[(durations.len() - 1) / 2]; + let p95 = durations[(durations.len() - 1) * 95 / 100]; + let min = durations[0]; + let max = durations[durations.len() - 1]; + + println!( + "RESULT direction={direction} phase={phase} median_ms={:.3} p95_ms={:.3} min_ms={:.3} max_ms={:.3}", + milliseconds(median), + milliseconds(p95), + milliseconds(min), + milliseconds(max), + ); +} + +fn milliseconds(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn ensure_synthetic_names_are_available(graph: &Graph) -> Result<()> { + for name in [MIXIN_A, MIXIN_B] { + if graph.declarations().contains_key(&DeclarationId::from(name)) { + return Err(input_error(format!("synthetic benchmark name already exists: {name}"))); + } + } + Ok(()) +} + +fn ensure_no_errors(stage: &str, errors: &[rubydex::errors::Errors]) -> Result<()> { + if errors.is_empty() { + return Ok(()); + } + + for error in errors { + eprintln!("{stage}: {error}"); + } + Err(input_error(format!("{stage} produced {} error(s)", errors.len()))) +} + +fn integrity_messages(graph: &Graph) -> Vec { + let mut messages: Vec = integrity::check_integrity(graph) + .into_iter() + .map(|error| error.to_string()) + .collect(); + messages.sort_unstable(); + messages +} + +fn input_error(message: impl Into) -> Box { + io::Error::other(message.into()).into() +} diff --git a/utils/bench-incremental-resolution b/utils/bench-incremental-resolution new file mode 100755 index 000000000..dfc56bf8f --- /dev/null +++ b/utils/bench-incremental-resolution @@ -0,0 +1,113 @@ +#!/bin/bash + +# Benchmark incremental resolution against a real workspace without changing its files. +# +# Usage: +# utils/bench-incremental-resolution --candidates [workspace] +# utils/bench-incremental-resolution --resolution-stats [workspace] +# utils/bench-incremental-resolution --target Foo [--target Bar] [workspace] +# +# If workspace is omitted, DEFAULT_BENCH_WORKSPACE is used. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +RUST_DIR="$PROJECT_ROOT/rust" + +TARGET="" +BENCH_ARGS=() + +while [ "$#" -gt 0 ]; do + case "$1" in + --candidates) + BENCH_ARGS+=(--list-candidates) + shift + ;; + --resolution-stats) + BENCH_ARGS+=(--resolution-stats) + shift + ;; + --target|--cycles) + if [ "$#" -lt 2 ]; then + echo "Error: $1 requires a value" >&2 + exit 1 + fi + BENCH_ARGS+=("$1" "$2") + shift 2 + ;; + -h|--help) + cat <<'USAGE' +Usage: + utils/bench-incremental-resolution --candidates [workspace] + utils/bench-incremental-resolution --resolution-stats [workspace] + utils/bench-incremental-resolution --target CLASS [--target CLASS] [options] [workspace] + +Options: + --candidates Print class candidates by transitive descendant count + --resolution-stats Print unresolved graph state after initial resolution + --target CLASS Benchmark this top-level class (repeatable) + --cycles N Measured A -> B -> A cycles (default: 30) + +The workspace files are never modified. The benchmark reopens the target class in a +virtual document and switches between two empty included modules. +USAGE + exit 0 + ;; + --*) + echo "Error: unknown option $1" >&2 + exit 1 + ;; + *) + if [ -n "$TARGET" ]; then + echo "Error: multiple workspace paths provided" >&2 + exit 1 + fi + TARGET="$1" + shift + ;; + esac +done + +if [ -z "$TARGET" ]; then + TARGET="${DEFAULT_BENCH_WORKSPACE:-}" +fi + +if [ -z "$TARGET" ]; then + echo "Error: workspace is required and DEFAULT_BENCH_WORKSPACE is not set" >&2 + exit 1 +fi + +if [ ! -d "$TARGET" ]; then + echo "Error: workspace directory does not exist: $TARGET" >&2 + exit 1 +fi + +TARGET="$(cd "$TARGET" && pwd)" + +if ! /bin/bash -lc 'cd "$1" && bundle check 2>/dev/null' bash "$TARGET"; then + echo "Error: dependencies are not installed for the workspace at $TARGET" >&2 + echo "Please run 'bundle install' there and try again." >&2 + exit 1 +fi + +INDEX_PATHS=() +while IFS= read -r line; do + [ -e "$line" ] && INDEX_PATHS+=("$line") +done < <(/bin/bash -lc 'cd "$1" && bundle exec ruby -e "require \"rubydex\"; puts Rubydex::Graph.new.workspace_paths" 2>/dev/null' bash "$TARGET") + +if [ "${#INDEX_PATHS[@]}" -eq 0 ]; then + echo "Error: no workspace or dependency paths were discovered" >&2 + exit 1 +fi + +echo "Benchmarking workspace with ${#INDEX_PATHS[@]} index paths" + +COMMAND=(cargo bench --bench incremental_resolution -- "$TARGET") +for path in "${INDEX_PATHS[@]}"; do + COMMAND+=(--index-path "$path") +done +COMMAND+=("${BENCH_ARGS[@]}") + +cd "$RUST_DIR" +"${COMMAND[@]}"