diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 88c977648..bb1895015 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ ".", + "herb-analysis", "herb-config", "herb-printer" ] diff --git a/rust/bin/herb-analysis b/rust/bin/herb-analysis new file mode 100755 index 000000000..8e403800c --- /dev/null +++ b/rust/bin/herb-analysis @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +RUST_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" + +BINARY_PATH="$RUST_DIR/target/debug/herb-analysis" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: herb-analysis binary not found at $BINARY_PATH" + echo "Please run 'make build' in the rust/ directory first." + + exit 1 +fi + +exec "$BINARY_PATH" "$@" diff --git a/rust/herb-analysis/Cargo.toml b/rust/herb-analysis/Cargo.toml new file mode 100644 index 000000000..5e37ce036 --- /dev/null +++ b/rust/herb-analysis/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "herb-analysis" +version = "0.10.3" +edition = "2021" +authors = ["Marco Roth "] +description = "Cross-file static analysis of Ruby and Action View sources for Herb" +license = "MIT" +repository = "https://github.com/marcoroth/herb" +publish = false + +[lib] +name = "herb_analysis" +path = "src/lib.rs" + +[[bin]] +name = "herb-analysis" +path = "src/bin/herb_analysis.rs" + +[dependencies] +colored = "3" +herb = { path = ".." } +rubydex = "=0.2.5" + +[dev-dependencies] +insta = "1.40" diff --git a/rust/herb-analysis/README.md b/rust/herb-analysis/README.md new file mode 100644 index 000000000..aae314788 --- /dev/null +++ b/rust/herb-analysis/README.md @@ -0,0 +1,78 @@ +# herb-analysis + +## Requirements + +- Rust **1.89.0+** (rubydex declares `rust-version = "1.89.0"`) + +## Build and run + +```bash +cd rust && cargo build -p herb-analysis +./bin/herb-analysis --help +``` + +``` +Usage: herb-analysis [path] [options] + +Commands: + helpers List everything a template can call, grouped by origin + audit Cross-check Herb's Action View helper registry against real sources + ancestors Show a module's ancestor chain and whether it is complete + constants List constants, or resolve one against a lexical nesting + stats Show index counts and per-phase timings +``` + +Paths default to the current directory and must start with `.` or `/`, which is how the +arg parser tells them from names. Without `--roots`, every indexed `*Helper` module is +used, so `herb-analysis helpers .` lists everything callable in the app you are standing in. + +`audit` cross-checks `herb::action_view_helpers`, the registry generated from +`config/action_view_helpers/` into the `herb` crate, against what rubydex finds in real gem +sources. Disagreements are actionable in both directions: a registry entry nothing defines +usually means a wrong `source:` field, and a helper the registry has never heard of is a +candidate to add. It already found two wrong `source:` values (`button` and `submit` are +`FormBuilder` methods, not `FormHelper` ones). + +`--gem` narrows the registry to one gem. Internal entries are excluded by default, since +the registry also records config accessors no template calls; `--include-internal` opts +back in. + +```bash +ACTIONVIEW=$(ls -d ../vendor/bundle/ruby/*/gems/actionview-*/lib | head -1) +TURBO=$(ls -d ../vendor/bundle/ruby/*/gems/turbo-rails-* | head -1) + +./bin/herb-analysis ancestors $ACTIONVIEW --roots ActionView::Base +./bin/herb-analysis audit $ACTIONVIEW --roots ActionView::Base --gem actionview +./bin/herb-analysis audit $TURBO --roots Turbo::FramesHelper,Turbo::StreamsHelper \ + --gem turbo-rails +./bin/herb-analysis stats ../lib +``` + +## What a template can call + +`helpers` answers "what can a template here call". Run it from a Rails app, or pass a path. +In an app (one with a `Gemfile.lock`) it resolves the app's gems and route helpers and +groups the result by origin; pointed anywhere else it lists what those sources define. +Origins can be requested individually: + +```bash +herb-analysis helpers # everything, grouped by origin +herb-analysis helpers --only app # helpers defined in app/helpers +herb-analysis helpers --only gem # helpers from gems in the Gemfile.lock +herb-analysis helpers --only rails # Action View built-ins +herb-analysis helpers --only route # route helpers from config/routes.rb +herb-analysis helpers --only app,gem # any combination +herb-analysis helpers ../some/gem # any directory, flat list +``` + +Narrower requests do less work. `--only route` reads `config/routes.rb` and indexes +nothing, and `--only app` skips gem sources entirely. + +Names a controller exposes with `helper_method :name` are included too. They never appear +in a view's ancestor chain, so they are found by reading the declarations directly, and are +reported with the controller they came from. Dynamic forms such as `helper_method(type)` +carry no symbol and are skipped. + +Route helpers are approximate. They are generated at boot from the routes DSL and have no +definition to find, so they are reconstructed from `root`, `resources`, `resource`, `as:` +and literal path segments. Nesting, `scope`, and `only:`/`except:` are not modelled. diff --git a/rust/herb-analysis/src/analysis.rs b/rust/herb-analysis/src/analysis.rs new file mode 100644 index 000000000..b5e0105a7 --- /dev/null +++ b/rust/herb-analysis/src/analysis.rs @@ -0,0 +1,337 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use rubydex::indexing::{self, IndexerBackend, LanguageId}; +use rubydex::listing; +use rubydex::model::built_in; +use rubydex::model::declaration::{Ancestor, Ancestors, Declaration, Namespace}; +use rubydex::model::graph::Graph; +use rubydex::resolution::Resolver; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainState { + Complete, + Partial, + Cyclic, +} + +impl ChainState { + pub fn as_str(&self) -> &'static str { + match self { + ChainState::Complete => "complete", + ChainState::Partial => "partial", + ChainState::Cyclic => "cyclic", + } + } +} + +#[derive(Debug, Clone)] +pub struct Ancestry { + pub state: ChainState, + pub names: Vec, + pub unresolved: usize, +} + +pub struct Analysis { + graph: Graph, + timings: Vec<(&'static str, Duration)>, + index_errors: Vec, + files_indexed: usize, +} + +impl Analysis { + pub fn index_sources(sources: &[(&str, &str)]) -> Self { + let mut graph = Graph::new(); + let started = Instant::now(); + + for (uri, source) in sources { + indexing::index_source(&mut graph, uri, source, &LanguageId::Ruby); + } + + Self { + graph, + timings: vec![("index", started.elapsed())], + index_errors: Vec::new(), + files_indexed: sources.len(), + } + } + + pub fn index_paths(paths: &[String], excluded: &HashSet) -> Self { + let mut graph = Graph::new(); + let mut timings = Vec::new(); + let mut index_errors = Vec::new(); + + let started = Instant::now(); + let (file_paths, listing_errors) = listing::collect_file_paths(paths.to_vec(), excluded); + timings.push(("listing", started.elapsed())); + + for error in listing_errors { + index_errors.push(format!("{error:?}")); + } + + let files_indexed = file_paths.len(); + + let started = Instant::now(); + let errors = indexing::index_files(&mut graph, file_paths, IndexerBackend::RubyIndexer); + timings.push(("indexing", started.elapsed())); + + for error in errors { + index_errors.push(format!("{error:?}")); + } + + Self { + graph, + timings, + index_errors, + files_indexed, + } + } + + #[must_use] + pub fn with_built_ins(mut self) -> Self { + let started = Instant::now(); + built_in::add_built_in_data(&mut self.graph); + self.timings.push(("built_ins", started.elapsed())); + + self + } + + pub fn resolve(&mut self) -> &mut Self { + let started = Instant::now(); + Resolver::new(&mut self.graph).resolve(); + self.timings.push(("resolution", started.elapsed())); + + self + } + + fn declaration(&self, name: &str) -> Option<&Declaration> { + let definitions = self.graph.get(name)?; + let definition = definitions.first()?; + let declaration_id = self.graph.definition_to_declaration_id(definition)?; + + self.graph.declarations().get(declaration_id) + } + + fn namespace(&self, name: &str) -> Option<&Namespace> { + self.declaration(name)?.as_namespace() + } + + fn declaration_name(&self, id: &rubydex::model::ids::DeclarationId) -> Option<&str> { + self.graph.declarations().get(id).map(Declaration::name) + } + + pub fn ancestors_of(&self, name: &str) -> Option { + let namespace = self.namespace(name)?; + let ancestors = namespace.ancestors(); + + let state = match ancestors { + Ancestors::Complete(_) => ChainState::Complete, + Ancestors::Partial(_) => ChainState::Partial, + Ancestors::Cyclic(_) => ChainState::Cyclic, + }; + + let mut names = Vec::new(); + let mut unresolved = 0; + + for ancestor in ancestors.iter() { + match ancestor { + Ancestor::Complete(id) => { + if let Some(name) = self.declaration_name(id) { + names.push(name.to_string()); + } + } + Ancestor::Partial(_) => { + unresolved += 1; + names.push("".to_string()); + } + } + } + + Some(Ancestry { state, names, unresolved }) + } + + pub fn methods_of(&self, name: &str) -> BTreeSet { + let mut methods = BTreeSet::new(); + + let Some(namespace) = self.namespace(name) else { + return methods; + }; + + for (string_id, declaration_id) in namespace.members() { + let Some(declaration) = self.graph.declarations().get(declaration_id) else { + continue; + }; + + if declaration.as_method().is_none() { + continue; + } + + if let Some(method_name) = self.graph.strings().get(string_id) { + methods.insert(method_name.trim_end_matches("()").to_string()); + } + } + + methods + } + + pub fn defines(&self, name: &str) -> bool { + self.declaration(name).is_some() + } + + pub fn constants(&self) -> BTreeMap { + let mut constants = BTreeMap::new(); + + for declaration in self.graph.declarations().values() { + if declaration.as_constant().is_some() || declaration.as_constant_alias().is_some() { + constants.insert(declaration.name().to_string(), declaration.kind().to_string()); + } + } + + constants + } + + pub fn helper_modules(&self) -> Vec { + let mut modules: Vec = self + .graph + .declarations() + .values() + .filter(|declaration| matches!(declaration.as_namespace(), Some(Namespace::Module(_)))) + .map(|declaration| declaration.name().to_string()) + .filter(|name| name.ends_with("Helper")) + .collect(); + + modules.sort(); + modules.dedup(); + + modules + } + + pub fn is_app_owned(&self, name: &str, path: &str) -> bool { + let Some(declaration) = self.declaration(name) else { + return false; + }; + + let uris: Vec<&str> = declaration + .definitions() + .iter() + .filter_map(|definition_id| self.graph.definitions().get(definition_id)) + .filter_map(|definition| self.graph.documents().get(definition.uri_id()).map(|document| document.uri())) + .collect(); + + !uris.is_empty() && uris.iter().all(|uri| uri.contains(path)) + } + + pub fn methods_with_visibility(&self, name: &str) -> BTreeMap { + let mut methods = BTreeMap::new(); + + let Some(namespace) = self.namespace(name) else { + return methods; + }; + + for (string_id, declaration_id) in namespace.members() { + let Some(declaration) = self.graph.declarations().get(declaration_id) else { + continue; + }; + + if declaration.as_method().is_none() { + continue; + } + + let Some(method_name) = self.graph.strings().get(string_id) else { + continue; + }; + + let visibility = self + .graph + .visibility(declaration_id) + .map_or_else(|| "unknown".to_string(), |visibility| format!("{visibility:?}")); + + methods.insert(method_name.trim_end_matches("()").to_string(), visibility); + } + + methods + } + + pub fn methods_with_ancestors(&self, name: &str) -> BTreeMap { + let mut methods = BTreeMap::new(); + + let Some(ancestry) = self.ancestors_of(name) else { + return methods; + }; + + let mut chain = vec![name.to_string()]; + chain.extend(ancestry.names.iter().filter(|n| *n != "").cloned()); + + for owner in chain { + for method in self.methods_of(&owner) { + methods.entry(method).or_insert_with(|| owner.clone()); + } + } + + methods + } + + pub fn view_visible_helpers(&self, roots: &[&str]) -> BTreeMap { + let mut helpers = BTreeMap::new(); + + for root in roots { + for (method, owner) in self.methods_with_ancestors(root) { + helpers.entry(method).or_insert(owner); + } + } + + helpers + } + + pub fn resolve_constant(&self, nesting: &[&str], name: &str) -> Option { + for depth in (0..=nesting.len()).rev() { + let mut candidate = nesting[..depth].join("::"); + + if !candidate.is_empty() { + candidate.push_str("::"); + } + + candidate.push_str(name); + + if let Some(declaration) = self.declaration(&candidate) { + return Some(declaration.name().to_string()); + } + } + + None + } + + pub fn update_document(&mut self, uri: &str, source: &str) { + self.graph.delete_document(uri); + indexing::index_source(&mut self.graph, uri, source, &LanguageId::Ruby); + } + + pub fn remove_document(&mut self, uri: &str) { + self.graph.delete_document(uri); + } + + pub fn graph(&self) -> &Graph { + &self.graph + } + + pub fn files_indexed(&self) -> usize { + self.files_indexed + } + + pub fn declaration_count(&self) -> usize { + self.graph.declarations().len() + } + + pub fn definition_count(&self) -> usize { + self.graph.definitions().len() + } + + pub fn timings(&self) -> &[(&'static str, Duration)] { + &self.timings + } + + pub fn index_errors(&self) -> &[String] { + &self.index_errors + } +} diff --git a/rust/herb-analysis/src/bin/herb_analysis.rs b/rust/herb-analysis/src/bin/herb_analysis.rs new file mode 100644 index 000000000..b7808645e --- /dev/null +++ b/rust/herb-analysis/src/bin/herb_analysis.rs @@ -0,0 +1,644 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process; + +use colored::*; + +use herb::action_view_helpers; + +use herb_analysis::{rails, report, Analysis}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Default)] +struct CLIOptions { + command: Option, + paths: Vec, + roots: Vec, + name: Option, + nesting: Option, + gem: Option, + include_internal: bool, + only: Option>, + built_ins: bool, + help: bool, + version: bool, + no_color: bool, +} + +fn main() { + process::exit(run()); +} + +fn run() -> i32 { + let options = parse_args(env::args().collect()); + + if options.no_color { + colored::control::set_override(false); + } + + if options.help || (options.command.is_none() && !options.version) { + print_usage(); + + return 0; + } + + if options.version { + println!("herb-analysis {VERSION}"); + + return 0; + } + + match options.command.as_deref() { + Some("helpers") => helpers(&options), + Some("audit") => audit(&options), + Some("ancestors") => ancestors(&options), + Some("constants") => constants(&options), + Some("stats") => stats(&options), + Some(other) => { + eprintln!("{}", format!("Unknown command: {other}").red()); + print_usage(); + + 1 + } + None => 0, + } +} + +fn parse_args(args: Vec) -> CLIOptions { + let mut options = CLIOptions::default(); + let mut index = 1; + + while index < args.len() { + let arg = &args[index]; + + match arg.as_str() { + "--help" | "-h" => options.help = true, + "--version" | "-v" => options.version = true, + "--no-color" => options.no_color = true, + "--built-ins" => options.built_ins = true, + "--roots" => { + index += 1; + if let Some(value) = args.get(index) { + options.roots = value.split(',').map(str::to_string).collect(); + } + } + "--nesting" => { + index += 1; + options.nesting = args.get(index).cloned(); + } + "--gem" => { + index += 1; + options.gem = args.get(index).cloned(); + } + "--include-internal" => options.include_internal = true, + "--only" => { + index += 1; + if let Some(value) = args.get(index) { + options.only = Some(value.split(',').map(str::to_string).collect()); + } + } + _ => { + if options.command.is_none() { + options.command = Some(arg.clone()); + } else if arg.starts_with('/') || arg.starts_with('.') { + options.paths.push(arg.clone()); + } else { + options.name = Some(arg.clone()); + } + } + } + + index += 1; + } + + options +} + +fn source_roots(path: &Path) -> (Vec, bool, rails::Gems) { + if path.join("Gemfile.lock").is_file() { + let gems = rails::gem_paths(path); + let mut roots = vec![path.join("app").to_string_lossy().to_string()]; + + roots.extend(gems.paths.clone()); + + (roots, true, gems) + } else { + (vec![path.to_string_lossy().to_string()], false, rails::Gems::default()) + } +} + +fn build(options: &CLIOptions) -> Analysis { + let paths = if options.paths.is_empty() { + vec![".".to_string()] + } else { + options.paths.clone() + }; + + let mut analysis = Analysis::index_paths(&paths, &HashSet::new()); + + if options.built_ins { + analysis = analysis.with_built_ins(); + } + + analysis.resolve(); + analysis +} + +fn audit(options: &CLIOptions) -> i32 { + let raw_root = options.paths.first().map_or_else(|| PathBuf::from("."), PathBuf::from); + + let Ok(path) = raw_root.canonicalize() else { + eprintln!("{}", format!("no such directory: {}", raw_root.display()).red()); + return 1; + }; + + let (source_paths, _, gems) = source_roots(&path); + let mut analysis = Analysis::index_paths(&source_paths, &HashSet::new()); + + analysis.resolve(); + + let exposed = rails::helper_methods(&source_paths); + + let mut root_names = if options.roots.is_empty() { + analysis.helper_modules() + } else { + options.roots.clone() + }; + + root_names.push("ActionView::Base".to_string()); + + let roots: Vec<&str> = root_names.iter().map(String::as_str).collect(); + let mut found = analysis.view_visible_helpers(&roots); + + for (name, file) in &exposed { + found + .entry(name.clone()) + .or_insert_with(|| format!("helper_method in {}", short_path(file, &path))); + } + + let expected = report::expected(options.gem.as_deref(), !options.include_internal); + let diff = herb_analysis::Diff::new(&found, &expected); + let agreement = format!("{:.1}% of registry entries found", diff.recall() * 100.0); + + println!(); + println!( + " {} {}", + "Registry audit".bold(), + format!( + "({} files, {} gems, registry gem={} visibility={})", + analysis.files_indexed(), + gems.resolved, + options.gem.as_deref().unwrap_or("*"), + if options.include_internal { "*" } else { "public" } + ) + .dimmed() + ); + println!(); + println!(" {}", format!("compared against {}", describe_roots(&root_names)).dimmed()); + println!(); + println!(" registry entries: {}", expected.len()); + println!(" found in source: {}", found.len()); + println!(" agreed: {}", diff.matched.len()); + println!(" {}", if diff.recall() >= 0.95 { agreement.green() } else { agreement.yellow() }); + println!(); + + if diff.missing.is_empty() { + println!(" {}", "no registry entries are unaccounted for".green()); + } else { + println!( + " {} {}", + "in registry, not found in source".bold(), + format!("({})", diff.missing.len()).dimmed() + ); + println!(" {}", "check the `source:` field, or the helper is defined via metaprogramming".dimmed()); + println!(); + + for name in &diff.missing { + let source = action_view_helpers::find_by_name(name).map_or("?", |entry| entry.source); + + println!(" {name} {}", format!("— registry says {source}").dimmed()); + println!(" {}", diagnose(&analysis, source, name, &exposed).dimmed()); + } + } + + println!(); + + if !diff.extra.is_empty() { + println!( + " {} {}", + "found in source, not in registry".bold(), + format!("({})", diff.extra.len()).dimmed() + ); + println!(" {}", "candidates to add, or methods that are not really view-callable".dimmed()); + println!(); + + for (name, owner) in diff.extra.iter().take(40) { + println!(" {name} {}", format!("— {owner}").dimmed()); + } + + if diff.extra.len() > 40 { + println!(" {}", format!("... and {} more", diff.extra.len() - 40).dimmed()); + } + } + + println!(); + + 0 +} + +const ORIGINS: [&str; 4] = ["app", "gem", "rails", "route"]; + +fn helpers(options: &CLIOptions) -> i32 { + let requested = options + .only + .clone() + .unwrap_or_else(|| ORIGINS.iter().map(|origin| (*origin).to_string()).collect()); + + for origin in &requested { + if !ORIGINS.contains(&origin.as_str()) { + eprintln!("{}", format!("unknown origin `{origin}`, expected one of {}", ORIGINS.join(", ")).red()); + return 1; + } + } + + let raw_root = options.paths.first().map_or_else(|| PathBuf::from("."), PathBuf::from); + + let Ok(app_root) = raw_root.canonicalize() else { + eprintln!("{}", format!("no such directory: {}", raw_root.display()).red()); + return 1; + }; + + let is_rails_app = app_root.join("Gemfile.lock").is_file(); + let has_routes = is_rails_app && app_root.join("config/routes.rb").is_file(); + + let routes = if has_routes && requested.iter().any(|origin| origin == "route") { + rails::route_helpers(&app_root) + } else { + BTreeSet::new() + }; + + let needs_index = requested.iter().any(|origin| origin != "route"); + + let mut by_origin: BTreeMap<&str, Vec<(String, String)>> = BTreeMap::new(); + let mut summary = String::new(); + let mut missing_gems = Vec::new(); + + if needs_index { + let wants_gems = is_rails_app && requested.iter().any(|origin| origin == "gem" || origin == "rails"); + let gems = if wants_gems { rails::gem_paths(&app_root) } else { rails::Gems::default() }; + let gem_count = if wants_gems { gems.resolved } else { 0 }; + + let app_path = if is_rails_app { + app_root.join("app").to_string_lossy().to_string() + } else { + app_root.to_string_lossy().to_string() + }; + + let mut roots = vec![app_path.clone()]; + + if wants_gems { + missing_gems = gems.missing.clone(); + roots.extend(gems.paths); + } + + let mut analysis = Analysis::index_paths(&roots, &HashSet::new()); + analysis.resolve(); + + let helper_modules = if options.roots.is_empty() { + analysis.helper_modules() + } else { + options.roots.clone() + }; + + let mut module_roots: Vec<&str> = helper_modules.iter().map(String::as_str).collect(); + + if requested.iter().any(|origin| origin == "rails") { + module_roots.push("ActionView::Base"); + } + + let mut discovered: Vec<(String, String)> = analysis.view_visible_helpers(&module_roots).into_iter().collect(); + + for (name, file) in rails::helper_methods(&roots) { + discovered.push((name, format!("helper_method in {}", short_path(&file, &app_root)))); + } + + for (method, owner) in discovered { + let origin = if owner.starts_with("helper_method in ") { + if owner.contains(&app_path) || !owner.contains("/gems/") { + "app" + } else { + "gem" + } + } else if owner.starts_with("ActionView") || owner.starts_with("ActionDispatch") || owner.starts_with("ActiveSupport") { + "rails" + } else if helper_modules.contains(&owner) && analysis.is_app_owned(&owner, &app_path) { + "app" + } else { + "gem" + }; + + let origin = if is_rails_app { origin } else { "found" }; + + if !is_rails_app || requested.iter().any(|wanted| wanted == origin) { + by_origin.entry(origin).or_default().push((method, owner)); + } + } + + summary = if wants_gems { + format!( + "{} files, {gem_count} gems, {}", + analysis.files_indexed(), + plural(helper_modules.len(), "helper module") + ) + } else { + format!("{} files, {}", analysis.files_indexed(), plural(helper_modules.len(), "helper module")) + }; + } + + println!(); + + if summary.is_empty() { + println!(" {} {}", "Helpers".bold(), "(config/routes.rb only)".dimmed()); + } else { + println!(" {} {}", "Helpers".bold(), format!("({summary})").dimmed()); + } + + println!(); + + let mut total = routes.len(); + + if !is_rails_app { + if let Some(entries) = by_origin.get_mut("found") { + entries.sort(); + total += entries.len(); + + for (method, owner) in entries { + println!(" {method} {}", format!("— {owner}").dimmed()); + } + + println!(); + } + } + + for origin in ORIGINS.iter().filter(|origin| is_rails_app && requested.iter().any(|wanted| wanted == *origin)) { + if *origin == "route" { + if !has_routes { + continue; + } + + println!(" {} {}", "route".bold(), format!("({}, approximate)", routes.len()).dimmed()); + + for name in &routes { + println!(" {name} {}", "— config/routes.rb".dimmed()); + } + + println!(); + + continue; + } + + let Some(entries) = by_origin.get_mut(origin) else { + continue; + }; + + entries.sort(); + total += entries.len(); + + println!(" {} {}", origin.bold(), format!("({})", entries.len()).dimmed()); + + for (method, owner) in entries { + println!(" {method} {}", format!("— {owner}").dimmed()); + } + + println!(); + } + + println!(" {}", format!("{total} total").dimmed()); + + if !missing_gems.is_empty() { + println!(); + println!( + " {} {}", + format!("{} locked gems are not installed for this Ruby", missing_gems.len()).yellow(), + "so their helpers are missing. Run `bundle install` in the app.".dimmed() + ); + println!( + " {}", + format!("e.g. {}", missing_gems.iter().take(6).cloned().collect::>().join(", ")).dimmed() + ); + } + + println!(); + + 0 +} + +fn ancestors(options: &CLIOptions) -> i32 { + let analysis = build(options); + + let targets: Vec = if options.roots.is_empty() { + options.name.clone().into_iter().collect() + } else { + options.roots.clone() + }; + + if targets.is_empty() { + eprintln!("{}", "ancestors needs a target, e.g. `ancestors . --roots ApplicationHelper`".red()); + return 1; + } + + println!(); + println!( + " {} {}", + "Ancestors".bold(), + format!("({} files, built_ins: {})", analysis.files_indexed(), options.built_ins).dimmed() + ); + println!(); + + for target in &targets { + match analysis.ancestors_of(target) { + Some(ancestry) => { + let state = format!("[{}]", ancestry.state.as_str()); + let state = if ancestry.unresolved == 0 { state.green() } else { state.yellow() }; + + println!( + " {} {} {}", + target.bold(), + state, + format!("{} ancestors, {} unresolved", ancestry.names.len(), ancestry.unresolved).dimmed() + ); + + for name in ancestry.names.iter().take(25) { + println!(" {}", name.dimmed()); + } + + println!(); + } + None => println!(" {}\n", format!("{target} did not resolve").red()), + } + } + + 0 +} + +fn constants(options: &CLIOptions) -> i32 { + let analysis = build(options); + + let nesting_owned: Vec = options + .nesting + .as_deref() + .unwrap_or("") + .split("::") + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + + let nesting: Vec<&str> = nesting_owned.iter().map(String::as_str).collect(); + + let Some(name) = options.name.clone() else { + let defined = analysis.constants(); + + println!(); + println!( + " {} {}", + "Constants".bold(), + format!("({} files, {})", analysis.files_indexed(), plural(defined.len(), "constant")).dimmed() + ); + println!(); + + for (constant, kind) in &defined { + println!(" {constant} {}", format!("— {kind}").dimmed()); + } + + println!(); + + return 0; + }; + + println!(); + println!(" {}", "Constant resolution".bold()); + println!(); + println!(" nesting: {nesting:?}"); + println!(" name: {name}"); + + match analysis.resolve_constant(&nesting, &name) { + Some(resolved) => println!(" {}", format!("resolved: {resolved}").green()), + None => println!(" {}", "unresolved".red()), + } + + println!(); + + 0 +} + +fn stats(options: &CLIOptions) -> i32 { + let analysis = build(options); + + println!(); + println!(" {}", "Index stats".bold()); + println!(); + println!(" files: {}", analysis.files_indexed()); + println!(" declarations: {}", analysis.declaration_count()); + println!(" definitions: {}", analysis.definition_count()); + println!(" errors: {}", analysis.index_errors().len()); + println!(); + + for (phase, duration) in analysis.timings() { + println!(" {phase:<12} {duration:>8.1?}"); + } + + println!(); + + 0 +} + +fn describe_roots(roots: &[String]) -> String { + let named: Vec<&String> = roots.iter().filter(|root| !root.ends_with("Helper")).collect(); + let helpers = roots.len() - named.len(); + + let mut parts: Vec = named.iter().map(|root| (*root).clone()).collect(); + + if helpers > 0 { + parts.push(plural(helpers, "*Helper module")); + } + + parts.join(" and ") +} + +fn diagnose(analysis: &Analysis, source: &str, name: &str, exposed: &BTreeMap) -> String { + let Some((owner, method)) = source.rsplit_once(['#', '.']) else { + return "no `source:` recorded".to_string(); + }; + + if exposed.contains_key(name) { + return format!("exposed to views by `helper_method :{name}`, not through the ancestry"); + } + + if !analysis.defines(owner) { + return format!("{owner} was not indexed, so its gem is probably not installed"); + } + + if analysis.methods_of(owner).contains(method) { + format!("defined in {owner}, which is not reachable from the roots above") + } else { + format!("not defined in {owner}, so the `source:` looks wrong") + } +} + +fn short_path(path: &Path, app_root: &Path) -> String { + path + .strip_prefix(app_root) + .map(|relative| relative.to_string_lossy().to_string()) + .unwrap_or_else(|_| { + path + .to_string_lossy() + .rsplit_once("/gems/") + .map_or_else(|| path.to_string_lossy().to_string(), |(_, gem)| gem.to_string()) + }) +} + +fn plural(count: usize, noun: &str) -> String { + if count == 1 { + format!("{count} {noun}") + } else { + format!("{count} {noun}s") + } +} + +fn print_usage() { + println!("herb-analysis {VERSION} - Cross-file static analysis for Ruby and Action View"); + println!(); + println!("Usage: herb-analysis [path] [options]"); + println!(); + println!("Arguments:"); + println!(" path Directory to index, defaults to the current directory"); + println!(" Must start with . or / so it is not read as a name"); + println!(); + println!("Commands:"); + println!(" helpers List everything a template can call, grouped by origin"); + println!(" audit Cross-check Herb's Action View helper registry against real sources"); + println!(" ancestors Show a module's ancestor chain and whether it is complete"); + println!(" constants List constants, or resolve one against a lexical nesting"); + println!(" stats Show index counts and per-phase timings"); + println!(); + println!("Options:"); + println!(" -h, --help show help"); + println!(" -v, --version show version"); + println!(" --roots limit to these modules instead of every *Helper module"); + println!(" --only limit `helpers` to app, gem, rails, and/or route"); + println!(" --gem limit the registry comparison in `audit` to one gem"); + println!(" --include-internal include registry entries marked internal in `audit`"); + println!(" --nesting lexical nesting to resolve a constant against"); + println!(" --built-ins seed core class data before resolving ancestors"); + println!(" --no-color disable colored output"); + println!(); + println!("Examples:"); + println!(" herb-analysis helpers # everything a template in this app can call"); + println!(" herb-analysis helpers --only app # just the app's own helpers"); + println!(" herb-analysis helpers ../some/gem # any directory, listed flat"); + println!(" herb-analysis audit --gem actionview # check the registry against Action View"); + println!(" herb-analysis ancestors ActionView::Base # what a view inherits from"); + println!(" herb-analysis constants CONFIG --nesting Admin::UsersController"); +} diff --git a/rust/herb-analysis/src/lib.rs b/rust/herb-analysis/src/lib.rs new file mode 100644 index 000000000..96972b8b9 --- /dev/null +++ b/rust/herb-analysis/src/lib.rs @@ -0,0 +1,17 @@ +pub mod analysis; +pub mod rails; +pub mod report; + +pub use analysis::{Analysis, Ancestry, ChainState}; +pub use report::{expected, Diff}; + +pub fn prism_link_check(erb: &str, ruby: &str) -> (usize, usize) { + let parsed = herb::parse(erb).expect("herb parse failed"); + let erb_children = parsed.value.children.len(); + + let mut graph = rubydex::model::graph::Graph::new(); + rubydex::indexing::index_source(&mut graph, "file:///link_check.rb", ruby, &rubydex::indexing::LanguageId::Ruby); + rubydex::resolution::Resolver::new(&mut graph).resolve(); + + (erb_children, graph.declarations().len()) +} diff --git a/rust/herb-analysis/src/rails.rs b/rust/herb-analysis/src/rails.rs new file mode 100644 index 000000000..4f11ba18a --- /dev/null +++ b/rust/herb-analysis/src/rails.rs @@ -0,0 +1,387 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Default)] +pub struct Gems { + pub paths: Vec, + pub resolved: usize, + pub missing: Vec, +} + +pub fn gem_paths(app_root: &Path) -> Gems { + let Ok(lockfile) = fs::read_to_string(app_root.join("Gemfile.lock")) else { + return Gems { + paths: Vec::new(), + resolved: 0, + missing: Vec::new(), + }; + }; + + let roots = gem_roots(app_root); + let checkout_roots = checkout_roots(app_root); + let mut paths = Vec::new(); + let mut missing = Vec::new(); + let mut resolved_count = 0; + + for spec in parse_lockfile(&lockfile) { + let resolved = match &spec.source { + Source::Registry { version } => roots + .iter() + .map(|root| root.join(format!("{}-{version}", spec.name))) + .find(|path| path.is_dir()), + Source::Git { repo, revision } => resolve_git(&checkout_roots, repo, revision, &spec.name), + Source::Path { remote } => { + let base = app_root.join(remote); + + [base.join(&spec.name), base].into_iter().find(|path| path.is_dir()) + } + }; + + match resolved { + Some(path) => { + paths.extend(source_dirs(&path)); + resolved_count += 1; + } + + None => missing.push(spec.name.clone()), + } + } + + paths.sort(); + paths.dedup(); + missing.sort(); + missing.dedup(); + + Gems { + paths, + resolved: resolved_count, + missing, + } +} + +fn source_dirs(gem_root: &Path) -> Vec { + ["lib", "app"] + .iter() + .map(|dir| gem_root.join(dir)) + .filter(|path| path.is_dir()) + .map(|path| path.to_string_lossy().to_string()) + .collect() +} + +fn resolve_git(roots: &[PathBuf], repo: &str, revision: &str, name: &str) -> Option { + let short = &revision[..revision.len().min(12)]; + + let exact = roots.iter().map(|root| root.join(format!("{repo}-{short}"))).find(|path| path.is_dir()); + + let checkout = exact.or_else(|| { + roots.iter().find_map(|root| { + fs::read_dir(root) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| path.is_dir() && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(&format!("{repo}-")))) + }) + })?; + + [checkout.join(name), checkout].into_iter().find(|path| path.is_dir()) +} + +enum Source { + Registry { version: String }, + Git { repo: String, revision: String }, + Path { remote: String }, +} + +struct Spec { + name: String, + source: Source, +} + +fn parse_lockfile(lockfile: &str) -> Vec { + let mut specs = Vec::new(); + let mut section = ""; + let mut remote = String::new(); + let mut revision = String::new(); + + for line in lockfile.lines() { + match line.trim_end() { + "GEM" => (section, remote, revision) = ("GEM", String::new(), String::new()), + "GIT" => (section, remote, revision) = ("GIT", String::new(), String::new()), + "PATH" => (section, remote, revision) = ("PATH", String::new(), String::new()), + other if other.starts_with(" remote: ") => remote = other.trim_start().trim_start_matches("remote: ").to_string(), + other if other.starts_with(" revision: ") => revision = other.trim_start().trim_start_matches("revision: ").to_string(), + other => { + let Some((name, version)) = spec_line(other) else { + continue; + }; + + let source = match section { + "GIT" if !revision.is_empty() => Source::Git { + repo: repo_name(&remote), + revision: revision.clone(), + }, + "PATH" => Source::Path { remote: remote.clone() }, + _ => Source::Registry { version }, + }; + + specs.push(Spec { name, source }); + } + } + } + + specs +} + +fn repo_name(remote: &str) -> String { + remote + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(remote) + .trim_end_matches(".git") + .to_string() +} + +fn spec_line(line: &str) -> Option<(String, String)> { + if !line.starts_with(" ") || line.starts_with(" ") { + return None; + } + + let (name, rest) = line.trim_start().split_once(" (")?; + let version = rest.strip_suffix(')')?; + + let valid = !name.is_empty() && !version.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + + valid.then(|| (name.to_string(), version.to_string())) +} + +fn checkout_roots(app_root: &Path) -> Vec { + gem_roots(app_root) + .iter() + .filter_map(|root| root.parent().map(|parent| parent.join("bundler/gems"))) + .filter(|root| root.is_dir()) + .collect() +} + +fn gem_roots(app_root: &Path) -> Vec { + let mut roots = Vec::new(); + + if let Ok(entries) = fs::read_dir(app_root.join("vendor/bundle/ruby")) { + for entry in entries.flatten() { + roots.push(entry.path().join("gems")); + } + } + + if let Ok(output) = Command::new("gem").arg("env").arg("gemdir").current_dir(app_root).output() { + if let Ok(dir) = String::from_utf8(output.stdout) { + roots.push(Path::new(dir.trim()).join("gems")); + } + } + + roots.into_iter().filter(|root| root.is_dir()).collect() +} + +pub fn route_helpers(app_root: &Path) -> BTreeSet { + let Ok(source) = fs::read_to_string(app_root.join("config/routes.rb")) else { + return BTreeSet::new(); + }; + + let mut names = BTreeSet::new(); + let mut namespaces: Vec = Vec::new(); + let mut depth_stack: Vec = Vec::new(); + + for line in source.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if trimmed == "end" { + if depth_stack.pop().is_some() { + namespaces.pop(); + } + + continue; + } + + let prefix = if namespaces.is_empty() { + String::new() + } else { + format!("{}_", namespaces.join("_")) + }; + + if let Some(namespace) = symbol_after(trimmed, "namespace ") { + namespaces.push(namespace); + depth_stack.push(namespaces.len()); + + continue; + } + + if trimmed.starts_with("root ") { + insert_pair(&mut names, &format!("{prefix}root")); + + continue; + } + + if let Some(name) = symbol_after(trimmed, "resources ") { + let singular = singularize(&name); + + insert_pair(&mut names, &format!("{prefix}{name}")); + insert_pair(&mut names, &format!("{prefix}{singular}")); + insert_pair(&mut names, &format!("new_{prefix}{singular}")); + insert_pair(&mut names, &format!("edit_{prefix}{singular}")); + } else if let Some(name) = symbol_after(trimmed, "resource ") { + insert_pair(&mut names, &format!("{prefix}{name}")); + insert_pair(&mut names, &format!("new_{prefix}{name}")); + insert_pair(&mut names, &format!("edit_{prefix}{name}")); + } + + if let Some(alias_name) = value_after(trimmed, "as: :") { + insert_pair(&mut names, &format!("{prefix}{alias_name}")); + } else if let Some(literal) = literal_path(trimmed) { + insert_pair(&mut names, &format!("{prefix}{literal}")); + } + } + + names +} + +fn insert_pair(names: &mut BTreeSet, stem: &str) { + if stem.is_empty() { + return; + } + + names.insert(format!("{stem}_path")); + names.insert(format!("{stem}_url")); +} + +fn symbol_after(line: &str, keyword: &str) -> Option { + let rest = line.strip_prefix(keyword)?.trim_start(); + let rest = rest.strip_prefix(':')?; + + let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + + (!name.is_empty()).then_some(name) +} + +fn value_after(line: &str, marker: &str) -> Option { + let index = line.find(marker)?; + let rest = &line[index + marker.len()..]; + + let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + + (!name.is_empty()).then_some(name) +} + +fn literal_path(line: &str) -> Option { + let verb = ["get ", "post ", "put ", "patch ", "delete "].iter().find(|verb| line.starts_with(**verb))?; + + let rest = line[verb.len()..].trim_start(); + let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let rest = &rest[1..]; + let path = rest.split(quote).next()?; + + let segment = path.trim_matches('/'); + + if segment.is_empty() || segment.contains('/') || segment.contains(':') || segment.contains('*') { + return None; + } + + segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + .then(|| segment.replace('-', "_")) +} + +fn singularize(word: &str) -> String { + if let Some(stem) = word.strip_suffix("ies") { + return format!("{stem}y"); + } + + for suffix in ["ses", "xes", "zes", "ches", "shes"] { + if let Some(stem) = word.strip_suffix(suffix) { + return format!("{stem}{}", &suffix[..suffix.len() - 2]); + } + } + + word.strip_suffix('s').map_or_else(|| word.to_string(), str::to_string) +} + +pub fn helper_methods(roots: &[String]) -> BTreeMap { + let mut found = BTreeMap::new(); + + for root in roots { + collect_helper_methods(Path::new(root), &mut found); + } + + found +} + +fn collect_helper_methods(path: &Path, found: &mut BTreeMap) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + collect_helper_methods(&path, found); + + continue; + } + + if path.extension().is_none_or(|extension| extension != "rb") { + continue; + } + + let Ok(source) = fs::read_to_string(&path) else { + continue; + }; + + if !source.contains("helper_method") { + continue; + } + + for line in source.lines() { + let trimmed = line.trim_start(); + + if !trimmed.starts_with("helper_method") { + continue; + } + + for name in symbols_in(trimmed) { + found.entry(name).or_insert_with(|| path.clone()); + } + } + } +} + +fn symbols_in(line: &str) -> Vec { + let mut names = Vec::new(); + let mut rest = line; + + while let Some(index) = rest.find(':') { + rest = &rest[index + 1..]; + + if rest.starts_with(':') { + rest = &rest[1..]; + + continue; + } + + let name: String = rest + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '?' || *c == '!') + .collect(); + + if !name.is_empty() && name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c == '_') { + names.push(name); + } + } + + names +} diff --git a/rust/herb-analysis/src/report.rs b/rust/herb-analysis/src/report.rs new file mode 100644 index 000000000..708357f9e --- /dev/null +++ b/rust/herb-analysis/src/report.rs @@ -0,0 +1,52 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use herb::action_view_helpers; + +pub fn expected(gem: Option<&str>, public_only: bool) -> BTreeSet { + action_view_helpers::entries() + .iter() + .filter(|entry| gem.is_none_or(|gem| entry.gem == gem)) + .filter(|entry| !public_only || entry.visibility == "public") + .map(|entry| entry.name.to_string()) + .collect() +} + +pub struct Diff { + pub matched: BTreeSet, + pub missing: BTreeSet, + pub extra: BTreeMap, +} + +impl Diff { + pub fn new(found: &BTreeMap, expected: &BTreeSet) -> Self { + let mut matched = BTreeSet::new(); + let mut missing = BTreeSet::new(); + let mut extra = BTreeMap::new(); + + for name in expected { + if found.contains_key(name) { + matched.insert(name.clone()); + } else { + missing.insert(name.clone()); + } + } + + for (name, owner) in found { + if !expected.contains(name) { + extra.insert(name.clone(), owner.clone()); + } + } + + Self { matched, missing, extra } + } + + pub fn recall(&self) -> f64 { + let total = self.matched.len() + self.missing.len(); + + if total == 0 { + return 0.0; + } + + self.matched.len() as f64 / total as f64 + } +} diff --git a/rust/herb-analysis/tests/constants_test.rs b/rust/herb-analysis/tests/constants_test.rs new file mode 100644 index 000000000..98226335e --- /dev/null +++ b/rust/herb-analysis/tests/constants_test.rs @@ -0,0 +1,38 @@ +use std::collections::HashSet; + +use herb_analysis::Analysis; + +fn fixture() -> Analysis { + let mut analysis = Analysis::index_paths(&["tests/fixtures/ruby".to_string()], &HashSet::new()); + analysis.resolve(); + + analysis +} + +#[test] +fn lists_value_constants_with_their_fully_qualified_names() { + let constants = fixture().constants(); + + assert!(constants.contains_key("CONFIG"), "{constants:?}"); + assert!(constants.contains_key("Admin::CONFIG")); + assert!(constants.contains_key("Billing::Invoice::CONFIG")); + assert!(constants.contains_key("Status::ACTIVE")); +} + +#[test] +fn does_not_list_classes_and_modules() { + let constants = fixture().constants(); + + assert!(!constants.contains_key("Admin")); + assert!(!constants.contains_key("Admin::UsersController")); +} + +#[test] +fn resolution_still_works_alongside_listing() { + let analysis = fixture(); + + assert_eq!( + analysis.resolve_constant(&["Admin", "UsersController"], "CONFIG"), + Some("Admin::CONFIG".to_string()) + ); +} diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb new file mode 100644 index 000000000..ff9e51cca --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ApplicationHelper + include FormattingHelper + + def page_title(title) + content_tag(:h1, title) + end + + def current_year + Time.current.year + end + + private + + def internal_only_secret + "should never be callable from a template" + end +end diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb new file mode 100644 index 000000000..7ba88ef88 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module FormattingHelper + extend ActiveSupport::Concern + + def format_price(cents) + format("$%.2f", cents / 100.0) + end + + def format_date(date) + date.strftime("%B %-d, %Y") + end +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock b/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock new file mode 100644 index 000000000..a5109a945 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock @@ -0,0 +1,24 @@ +GIT + remote: https://github.com/example/fakerepo.git + revision: abc123456789deadbeefcafe + specs: + fakemono (9.9.9) + somedep (>= 1.0) + +PATH + remote: local_engine + specs: + localengine (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + fakegem (1.2.3) + othergem (>= 2.0) + notinstalledgem (4.5.6) + +PLATFORMS + arm64-darwin-24 + +DEPENDENCIES + fakegem diff --git a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb new file mode 100644 index 000000000..5ef128bd4 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + include Searchable + + protect_from_forgery with: :exception + + helper_method :current_user, :signed_in? + helper_method :page_title + + # not exposed to views + def internal_thing; end + + private + + attr_reader :current_user +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb new file mode 100644 index 000000000..d4d5ce23b --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Searchable + extend ActiveSupport::Concern + + included do + helper_method :search_query + helper_method :cookies if defined?(helper_method) + end + + # dynamic form carries no symbol and cannot be resolved statically + def self.expose(type) + helper_method(type) if respond_to?(:helper_method) + end +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb new file mode 100644 index 000000000..623c19e3e --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + root to: "home#index" + + # a comment that must not be parsed + get "/about", to: "pages#about" + get "/contact", to: "pages#contact", as: :reach_us + get "/posts/:id/preview", to: "posts#preview" + + resources :posts + resources :categories + resource :session + + namespace :admin do + resources :users + end +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb new file mode 100644 index 000000000..45fa18756 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +module Localengine; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb new file mode 100644 index 000000000..a96a378ef --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +module Fakemono; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb new file mode 100644 index 000000000..f7f05b1d2 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +module Fakegem; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb new file mode 100644 index 000000000..5aca234ed --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +module FakegemTestFixture; end diff --git a/rust/herb-analysis/tests/fixtures/ruby/nesting.rb b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb new file mode 100644 index 000000000..96bc40504 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +CONFIG = :top_level + +module Admin + CONFIG = :admin_level + + class UsersController + def show + ::CONFIG + end + end + + class Reports + def run + CONFIG + end + end +end + +module Status + ACTIVE = :active +end + +module Billing + class Invoice + CONFIG = :invoice_level + end +end diff --git a/rust/herb-analysis/tests/prism_link_test.rs b/rust/herb-analysis/tests/prism_link_test.rs new file mode 100644 index 000000000..9edbd95b5 --- /dev/null +++ b/rust/herb-analysis/tests/prism_link_test.rs @@ -0,0 +1,15 @@ +#[test] +fn herb_and_rubydex_both_work_in_one_binary() { + let (erb_children, declarations) = + herb_analysis::prism_link_check("
<%= @post.title %>
", "module Alpha\n class Beta\n def gamma; end\n end\nend\n"); + + assert!(erb_children > 0, "herb parsed no ERB children, so its Prism may have been displaced"); + assert!(declarations > 0, "rubydex produced no declarations, so its Prism may have been displaced"); +} + +#[test] +fn herb_still_parses_ruby_through_its_own_prism() { + let result = herb::parse("<%= user.name %>").expect("herb parse failed"); + + assert!(!result.value.children.is_empty()); +} diff --git a/rust/herb-analysis/tests/rails_test.rs b/rust/herb-analysis/tests/rails_test.rs new file mode 100644 index 000000000..199745b3a --- /dev/null +++ b/rust/herb-analysis/tests/rails_test.rs @@ -0,0 +1,176 @@ +use herb_analysis::rails; +use std::path::Path; + +fn app() -> &'static Path { + Path::new("tests/fixtures/rails_app") +} + +#[test] +fn resolves_all_three_lockfile_source_types() { + let gems = rails::gem_paths(app()); + let joined = gems.paths.join("\n"); + + assert!(joined.contains("gems/fakegem-1.2.3/lib"), "registry gem: {joined}"); + assert!(joined.contains("fakerepo-abc123456789/fakemono/lib"), "git monorepo gem: {joined}"); + assert!(joined.contains("local_engine/lib"), "path source: {joined}"); + assert_eq!(gems.resolved, 3); +} + +#[test] +fn reports_locked_gems_that_are_not_installed() { + let gems = rails::gem_paths(app()); + + assert_eq!(gems.missing, vec!["notinstalledgem".to_string()]); +} + +#[test] +fn indexes_only_lib_and_app_never_the_gem_root() { + let gems = rails::gem_paths(app()); + + assert!( + gems.paths.iter().all(|path| path.ends_with("/lib") || path.ends_with("/app")), + "every indexed path should be a lib/ or app/ dir: {:?}", + gems.paths + ); + assert!(!gems.paths.iter().any(|path| path.contains("/test")), "must not index a gem's test tree"); +} + +#[test] +fn dependency_lines_are_not_mistaken_for_specs() { + let gems = rails::gem_paths(app()); + + assert!(!gems.missing.iter().any(|name| name == "somedep" || name == "othergem"), "{:?}", gems.missing); +} + +#[test] +fn missing_lockfile_yields_nothing_rather_than_panicking() { + let gems = rails::gem_paths(Path::new("tests/fixtures/ruby")); + + assert!(gems.paths.is_empty()); + assert_eq!(gems.resolved, 0); +} + +#[test] +fn derives_root_and_literal_path_helpers() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("root_path")); + assert!(routes.contains("root_url")); + assert!(routes.contains("about_path")); + assert!(routes.contains("about_url")); +} + +#[test] +fn derives_plural_and_singular_resource_helpers() { + let routes = rails::route_helpers(app()); + + for name in ["posts_path", "post_path", "new_post_path", "edit_post_path"] { + assert!(routes.contains(name), "missing {name}"); + } + + for name in ["categories_path", "category_path", "new_category_path", "edit_category_path"] { + assert!(routes.contains(name), "missing {name}"); + } +} + +#[test] +fn singular_resource_has_no_plural_index_helper() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("session_path")); + assert!(routes.contains("new_session_path")); + assert!(!routes.contains("sessions_path"), "`resource :session` is singular"); +} + +#[test] +fn explicit_as_replaces_the_literal_derived_name() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("reach_us_path")); + assert!(!routes.contains("contact_path"), "`as:` overrides the path-derived name"); +} + +#[test] +fn namespaced_resources_use_rails_helper_ordering() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("admin_users_path")); + assert!(routes.contains("admin_user_path")); + assert!(routes.contains("new_admin_user_path")); + assert!(routes.contains("edit_admin_user_path")); + assert!(!routes.contains("admin_new_user_path"), "prefix ordering is new__"); +} + +#[test] +fn ignores_comments_and_parameterised_paths() { + let routes = rails::route_helpers(app()); + + assert!(!routes.iter().any(|name| name.contains("comment"))); + assert!( + !routes.iter().any(|name| name.starts_with("preview")), + "paths with :params are not conventional" + ); +} + +#[test] +fn namespace_does_not_leak_past_its_end() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("posts_path")); + assert!(!routes.contains("admin_posts_path")); +} + +#[test] +fn missing_routes_file_yields_nothing_rather_than_panicking() { + assert!(rails::route_helpers(Path::new("tests/fixtures/ruby")).is_empty()); +} + +fn exposed() -> std::collections::BTreeMap { + rails::helper_methods(&[app().to_string_lossy().to_string()]) +} + +#[test] +fn finds_methods_exposed_with_helper_method() { + let exposed = exposed(); + + for name in ["current_user", "signed_in?", "page_title"] { + assert!(exposed.contains_key(name), "missing {name}"); + } +} + +#[test] +fn finds_helper_method_inside_an_included_block() { + let exposed = exposed(); + + assert!(exposed.contains_key("search_query")); +} + +#[test] +fn handles_a_trailing_conditional_on_the_declaration() { + let exposed = exposed(); + + assert!(exposed.contains_key("cookies"), "`helper_method :cookies if defined?(...)` should still count"); +} + +#[test] +fn ignores_dynamic_helper_method_calls() { + let exposed = exposed(); + + assert!(!exposed.contains_key("type"), "`helper_method(type)` carries no symbol to resolve"); +} + +#[test] +fn does_not_treat_ordinary_controller_methods_as_exposed() { + let exposed = exposed(); + + assert!(!exposed.contains_key("internal_thing")); + assert!(!exposed.contains_key("expose")); +} + +#[test] +fn records_the_file_each_exposure_came_from() { + let exposed = exposed(); + + assert!(exposed["current_user"].ends_with("application_controller.rb")); + assert!(exposed["search_query"].ends_with("concerns/searchable.rb")); +} diff --git a/rust/herb-analysis/tests/usefulness_test.rs b/rust/herb-analysis/tests/usefulness_test.rs new file mode 100644 index 000000000..2c2a0d080 --- /dev/null +++ b/rust/herb-analysis/tests/usefulness_test.rs @@ -0,0 +1,133 @@ +use herb_analysis::Analysis; +use std::collections::HashSet; + +fn fixture_app() -> Analysis { + let mut analysis = Analysis::index_paths(&["tests/fixtures/app".to_string()], &HashSet::new()); + analysis.resolve(); + + analysis +} + +fn flat_def_scan() -> HashSet { + ["format_price", "format_date", "page_title", "current_year", "internal_only_secret"] + .iter() + .map(|name| (*name).to_string()) + .collect() +} + +#[test] +fn resolves_helpers_reaching_the_view_through_a_concern() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert_eq!(helpers.get("format_price").map(String::as_str), Some("FormattingHelper")); + assert_eq!(helpers.get("format_date").map(String::as_str), Some("FormattingHelper")); + + assert_eq!(helpers.get("page_title").map(String::as_str), Some("ApplicationHelper")); + assert_eq!(helpers.get("current_year").map(String::as_str), Some("ApplicationHelper")); +} + +#[test] +fn attributes_each_helper_to_its_owning_module() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert_ne!( + helpers.get("format_price"), + helpers.get("page_title"), + "helpers from different modules should not report the same owner" + ); +} + +#[test] +fn documents_that_private_helpers_are_still_over_reported() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!( + helpers.contains_key("internal_only_secret"), + "if this now fails, visibility filtering has been wired up, so tighten this test" + ); +} + +#[test] +fn does_not_resolve_typos_or_undefined_names() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!(!helpers.contains_key("page_titel")); + assert!(!helpers.contains_key("frmat_price")); +} + +#[test] +fn confirms_route_helpers_remain_a_gap() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!(!helpers.contains_key("users_path")); + assert!(!helpers.contains_key("root_url")); +} + +#[test] +fn compares_against_the_flat_def_scan() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + let flat = flat_def_scan(); + + let resolved: HashSet = helpers.keys().cloned().collect(); + + for name in &flat { + assert!(resolved.contains(name), "{name} was found by the flat scan but not by rubydex"); + } + + assert!(resolved.len() >= flat.len(), "rubydex should know at least as much as the flat scan"); +} + +#[test] +fn visibility_can_distinguish_private_helpers() { + let analysis = fixture_app(); + let visibility = analysis.methods_with_visibility("ApplicationHelper"); + + eprintln!("resolved visibility: {visibility:?}"); + + assert_eq!(visibility.get("page_title").map(String::as_str), Some("Public")); + assert_eq!(visibility.get("current_year").map(String::as_str), Some("Public")); + assert_eq!(visibility.get("internal_only_secret").map(String::as_str), Some("Private")); +} + +#[test] +fn discovers_helper_modules_by_convention() { + let analysis = fixture_app(); + let modules = analysis.helper_modules(); + + assert!(modules.contains(&"ApplicationHelper".to_string())); + assert!(modules.contains(&"FormattingHelper".to_string())); + assert!(modules.iter().all(|name| name.ends_with("Helper"))); +} + +#[test] +fn distinguishes_app_owned_modules_from_foreign_ones() { + let analysis = fixture_app(); + + assert!(analysis.is_app_owned("ApplicationHelper", "tests/fixtures/app")); + assert!(!analysis.is_app_owned("ApplicationHelper", "some/other/path")); + assert!(!analysis.is_app_owned("NoSuchHelper", "tests/fixtures/app")); +} + +#[test] +fn indexes_in_memory_sources_without_a_filesystem() { + let mut analysis = Analysis::index_sources(&[( + "file:///memory.rb", + "module Alpha\n class Beta\n def gamma; end\n def delta(a, b = 1, *rest, key:, **opts, &blk); end\n def self.epsilon; end\n end\nend\n", + )]); + + analysis.resolve(); + + assert!(analysis.ancestors_of("Alpha::Beta").is_some()); + + let methods = analysis.methods_of("Alpha::Beta"); + + assert!(methods.contains("gamma")); + assert!(methods.contains("delta")); + assert!(!methods.contains("epsilon")); +} diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index 4a0a89efc..0630723f9 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -12,6 +12,7 @@ remove_nested_parens = true # run using `cargo +nightly fmt` ignore = [ "herb-printer/src/printer_visitor.rs", + "src/action_view_helpers.rs", "src/ast/nodes.rs", "src/errors.rs", "src/nodes.rs", diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 701b3b9a9..03ad022dc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,3 +1,4 @@ +pub mod action_view_helpers; pub mod ast; pub mod bindings; pub mod convert;