use regex::Regex;
use crate::resolution::strip_comments::{CommentLang, strip_comments_for_regex};
use crate::resolution::types::{
FrameworkExtractionResult, FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef,
make_node, make_reference,
};
use crate::types::{Language, NodeKind, ReferenceKind};
use crate::utils::line_number_at_byte;
const PLURAL_ACTIONS: &[&str] = &[
"index", "create", "new", "show", "edit", "update", "destroy",
];
const SINGULAR_ACTIONS: &[&str] = &["create", "new", "show", "edit", "update", "destroy"];
pub struct RailsResolver;
pub const RAILS_RESOLVER: RailsResolver = RailsResolver;
impl FrameworkResolver for RailsResolver {
fn name(&self) -> &'static str {
"rails"
}
fn languages(&self) -> Option<&'static [Language]> {
Some(&[Language::Ruby])
}
fn claims_reference(&self, name: &str) -> bool {
let Some((controller, action)) = name.split_once('#') else {
return false;
};
!controller.is_empty() && is_ident(action)
}
fn detect(&self, context: &mut dyn ResolutionContext) -> bool {
context
.read_file("Gemfile")
.is_some_and(|gemfile| gemfile.contains("'rails'") || gemfile.contains("\"rails\""))
|| context.file_exists("config/application.rb")
|| context.file_exists("app/controllers/application_controller.rb")
|| context.file_exists("config/routes.rb")
}
fn resolve(
&self,
reference: &UnresolvedRef,
context: &mut dyn ResolutionContext,
) -> Option<ResolvedRef> {
if let Some((controller_path, action)) = reference.reference_name.split_once('#') {
if let Some(target) = resolve_controller_action(controller_path, action, context) {
return Some(ResolvedRef::framework(reference, target, 0.85));
}
return None;
}
if is_pascal_word(&reference.reference_name)
&& let Some(target) = resolve_model(&reference.reference_name, context)
{
return Some(ResolvedRef::framework(reference, target, 0.8));
}
if reference.reference_name.ends_with("Controller")
&& let Some(target) = resolve_controller(&reference.reference_name, context)
{
return Some(ResolvedRef::framework(reference, target, 0.85));
}
if reference.reference_name.ends_with("Helper")
&& let Some(target) = resolve_helper(&reference.reference_name, context)
{
return Some(ResolvedRef::framework(reference, target, 0.8));
}
if (reference.reference_name.ends_with("Service")
|| reference.reference_name.ends_with("Job"))
&& let Some(target) = resolve_service(&reference.reference_name, context)
{
return Some(ResolvedRef::framework(reference, target, 0.8));
}
None
}
fn extract(&self, file_path: &str, content: &str) -> FrameworkExtractionResult {
if !file_path.ends_with(".rb") {
return FrameworkExtractionResult::default();
}
let safe = strip_comments_for_regex(content, CommentLang::Ruby);
let content = safe.as_str();
let mut result = FrameworkExtractionResult::default();
let route_re = Regex::new(r#"\b(get|post|put|patch|delete|match)\s+['"]([^'"]+)['"]\s*(?:,\s*to:\s*|=>\s*)['"]([^#'"]+)#([^'"]+)['"]"#).unwrap();
for caps in route_re.captures_iter(content) {
let whole = caps.get(0).unwrap();
let method = caps.get(1).unwrap().as_str().to_ascii_uppercase();
let route_path = caps.get(2).unwrap().as_str();
let ctrl = caps.get(3).unwrap().as_str();
let action = caps.get(4).unwrap().as_str();
let line = line_for_offset(content, whole.start());
let route_id = format!("route:{file_path}:{line}:{method}:{route_path}");
result.nodes.push(make_node(
route_id.clone(),
NodeKind::Route,
format!("{method} {route_path}"),
format!("{file_path}::route:{route_path}"),
file_path,
Language::Ruby,
line,
None,
None,
));
result.references.push(make_reference(
route_id,
format!("{ctrl}#{action}"),
ReferenceKind::References,
line,
0,
file_path,
Language::Ruby,
));
}
let res_re = Regex::new(r"\b(resources?)\s+:(\w+)([^\n]*)").unwrap();
for caps in res_re.captures_iter(content) {
let whole = caps.get(0).unwrap();
let plural = caps.get(1).unwrap().as_str() == "resources";
let res_name = caps.get(2).unwrap().as_str();
let tail = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let mut actions = if plural {
PLURAL_ACTIONS.to_vec()
} else {
SINGULAR_ACTIONS.to_vec()
};
if let Some(only) = list_option(tail, "only") {
actions.retain(|action| only.iter().any(|item| item == action));
} else if let Some(except) = list_option(tail, "except") {
actions.retain(|action| !except.iter().any(|item| item == action));
}
let ctrl = if plural {
res_name.to_string()
} else {
pluralize(res_name)
};
let line = line_for_offset(content, whole.start());
for action in actions {
let (method, path) = restful_route(action, res_name);
let route_id = format!("route:{file_path}:{line}:{method}:{ctrl}#{action}");
result.nodes.push(make_node(
route_id.clone(),
NodeKind::Route,
format!("{method} {path}"),
format!("{file_path}::route:{ctrl}#{action}"),
file_path,
Language::Ruby,
line,
None,
None,
));
result.references.push(make_reference(
route_id,
format!("{ctrl}#{action}"),
ReferenceKind::References,
line,
0,
file_path,
Language::Ruby,
));
}
}
result
}
}
fn restful_route(action: &str, resource: &str) -> (&'static str, String) {
match action {
"index" => ("GET", format!("/{resource}")),
"create" => ("POST", format!("/{resource}")),
"new" => ("GET", format!("/{resource}/new")),
"show" => ("GET", format!("/{resource}/:id")),
"edit" => ("GET", format!("/{resource}/:id/edit")),
"update" => ("PATCH", format!("/{resource}/:id")),
"destroy" => ("DELETE", format!("/{resource}/:id")),
_ => ("GET", format!("/{resource}")),
}
}
fn pluralize(word: &str) -> String {
if word.ends_with('y')
&& !matches!(word.chars().rev().nth(1), Some('a' | 'e' | 'i' | 'o' | 'u'))
{
format!("{}ies", &word[..word.len() - 1])
} else if ["s", "x", "z", "ch", "sh"]
.iter()
.any(|suffix| word.ends_with(suffix))
{
format!("{word}es")
} else {
format!("{word}s")
}
}
fn camelize(value: &str) -> String {
value
.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<String>()
}
fn resolve_controller_action(
ctrl_path: &str,
action: &str,
context: &mut dyn ResolutionContext,
) -> Option<String> {
let direct = format!("app/controllers/{ctrl_path}_controller.rb");
if context.file_exists(&direct)
&& let Some(method) = find_method_in_file(&direct, action, context)
{
return Some(method);
}
let cls = format!(
"{}Controller",
camelize(ctrl_path.rsplit('/').next().unwrap_or(ctrl_path))
);
for ctrl in context
.get_nodes_by_name(&cls)
.into_iter()
.filter(|node| node.kind == NodeKind::Class)
{
if let Some(method) = find_method_in_file(&ctrl.file_path, action, context) {
return Some(method);
}
}
None
}
fn resolve_model(name: &str, context: &mut dyn ResolutionContext) -> Option<String> {
let snake = snake_case(name);
for path in [
format!("app/models/{snake}.rb"),
format!("app/models/concerns/{snake}.rb"),
] {
if context.file_exists(&path)
&& let Some(node) = context
.get_nodes_in_file(&path)
.into_iter()
.find(|node| node.kind == NodeKind::Class && node.name == name)
{
return Some(node.id);
}
}
context
.get_nodes_by_name(name)
.into_iter()
.find_map(|node| {
(node.kind == NodeKind::Class && node.file_path.contains("app/models/"))
.then_some(node.id)
})
}
fn resolve_controller(name: &str, context: &mut dyn ResolutionContext) -> Option<String> {
let snake = snake_case(name);
for path in [
format!("app/controllers/{snake}.rb"),
format!("app/controllers/api/{snake}.rb"),
format!("app/controllers/api/v1/{snake}.rb"),
] {
if context.file_exists(&path)
&& let Some(node) = context
.get_nodes_in_file(&path)
.into_iter()
.find(|node| node.kind == NodeKind::Class && node.name == name)
{
return Some(node.id);
}
}
context
.get_nodes_by_name(name)
.into_iter()
.find_map(|node| {
(node.kind == NodeKind::Class && node.file_path.contains("controllers/"))
.then_some(node.id)
})
}
fn resolve_helper(name: &str, context: &mut dyn ResolutionContext) -> Option<String> {
let path = format!("app/helpers/{}.rb", snake_case(name));
context
.get_nodes_in_file(&path)
.into_iter()
.find_map(|node| {
(context.file_exists(&path) && node.kind == NodeKind::Module && node.name == name)
.then_some(node.id)
})
}
fn resolve_service(name: &str, context: &mut dyn ResolutionContext) -> Option<String> {
let snake = snake_case(name);
for path in [
format!("app/services/{snake}.rb"),
format!("app/jobs/{snake}.rb"),
format!("app/workers/{snake}.rb"),
] {
if !context.file_exists(&path) {
continue;
}
if let Some(node) = context
.get_nodes_in_file(&path)
.into_iter()
.find(|node| node.kind == NodeKind::Class && node.name == name)
{
return Some(node.id);
}
}
None
}
fn find_method_in_file(
path: &str,
action: &str,
context: &mut dyn ResolutionContext,
) -> Option<String> {
context
.get_nodes_in_file(path)
.into_iter()
.find_map(|node| {
(matches!(node.kind, NodeKind::Method | NodeKind::Function) && node.name == action)
.then_some(node.id)
})
}
fn snake_case(value: &str) -> String {
let mut out = String::new();
for (idx, ch) in value.chars().enumerate() {
if ch.is_ascii_uppercase() && idx > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
}
out
}
fn list_option(tail: &str, key: &str) -> Option<Vec<String>> {
let needle = format!("{key}:");
let start = tail.find(&needle)? + needle.len();
let bracket_start = tail[start..].find('[')? + start;
let bracket_end = tail[bracket_start..].find(']')? + bracket_start;
Some(
tail[bracket_start + 1..bracket_end]
.split(',')
.map(|item| item.trim().trim_start_matches(':').to_string())
.filter(|item| !item.is_empty())
.collect(),
)
}
fn line_for_offset(content: &str, offset: usize) -> u64 {
line_number_at_byte(content, offset)
}
fn is_pascal_word(value: &str) -> bool {
let mut chars = value.chars();
matches!(chars.next(), Some('A'..='Z')) && chars.all(|ch| ch.is_ascii_alphabetic())
}
fn is_ident(value: &str) -> bool {
let mut chars = value.chars();
matches!(chars.next(), Some('_') | Some('A'..='Z') | Some('a'..='z'))
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}