use regex::Regex;
use crate::resolution::strip_comments::{CommentLang, strip_comments_for_regex};
use crate::resolution::types::{
FrameworkExtractionResult, FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef,
line_for_byte, make_node, make_reference, resolve_by_name_and_kind,
};
use crate::types::{Language, NodeKind, ReferenceKind};
use crate::utils::utf8_byte_window;
const CONTROLLER_DIRS: &[&str] = &["/Controllers/"];
const SERVICE_DIRS: &[&str] = &["/Services/", "/Service/", "/Application/"];
const REPO_DIRS: &[&str] = &[
"/Repositories/",
"/Repository/",
"/Data/",
"/Infrastructure/",
];
const MODEL_DIRS: &[&str] = &["/Models/", "/Model/", "/Entities/", "/Entity/", "/Domain/"];
const VIEWMODEL_DIRS: &[&str] = &["/ViewModels/", "/ViewModel/", "/DTOs/", "/Dto/"];
const CLASS_KINDS: &[NodeKind] = &[NodeKind::Class];
const SERVICE_KINDS: &[NodeKind] = &[NodeKind::Class, NodeKind::Interface];
pub struct AspNetResolver;
pub const ASPNET_RESOLVER: AspNetResolver = AspNetResolver;
impl FrameworkResolver for AspNetResolver {
fn name(&self) -> &'static str {
"aspnet"
}
fn languages(&self) -> Option<&'static [Language]> {
Some(&[Language::CSharp])
}
fn detect(&self, context: &mut dyn ResolutionContext) -> bool {
for file in context.get_all_files() {
if file.ends_with(".csproj")
&& let Some(content) = context.read_file(&file)
&& (content.contains("Microsoft.AspNetCore")
|| content.contains("Microsoft.NET.Sdk.Web")
|| content.contains("System.Web.Mvc"))
{
return true;
}
}
if let Some(program) = context.read_file("Program.cs")
&& (program.contains("WebApplication")
|| program.contains("CreateHostBuilder")
|| program.contains("UseStartup"))
{
return true;
}
if context.file_exists("Startup.cs") {
return true;
}
for file in context.get_all_files() {
if !(file.ends_with("Controller.cs")
|| file.ends_with("Program.cs")
|| file.ends_with("Startup.cs"))
{
continue;
}
if let Some(content) = context.read_file(&file)
&& (content.contains("[ApiController")
|| content.contains("[Route")
|| content.contains("[HttpGet")
|| content.contains("[HttpPost")
|| content.contains("[HttpPut")
|| content.contains("[HttpPatch")
|| content.contains("[HttpDelete")
|| content.contains("ControllerBase")
|| content.contains(": Controller")
|| content.contains("MapControllers")
|| content.contains("WebApplication")
|| content.contains("Microsoft.AspNetCore"))
{
return true;
}
}
false
}
fn resolve(
&self,
reference: &UnresolvedRef,
context: &mut dyn ResolutionContext,
) -> Option<ResolvedRef> {
if reference.reference_name.ends_with("Controller")
&& let Some(target) = resolve_by_name_and_kind(
&reference.reference_name,
CLASS_KINDS,
CONTROLLER_DIRS,
context,
)
{
return Some(ResolvedRef::framework(reference, target, 0.85));
}
if (reference.reference_name.ends_with("Service")
|| (reference.reference_name.starts_with('I') && reference.reference_name.len() > 1))
&& let Some(target) = resolve_by_name_and_kind(
&reference.reference_name,
SERVICE_KINDS,
SERVICE_DIRS,
context,
)
{
return Some(ResolvedRef::framework(reference, target, 0.85));
}
if reference.reference_name.ends_with("Repository")
&& let Some(target) = resolve_by_name_and_kind(
&reference.reference_name,
SERVICE_KINDS,
REPO_DIRS,
context,
)
{
return Some(ResolvedRef::framework(reference, target, 0.85));
}
if is_pascal_word(&reference.reference_name)
&& let Some(target) = resolve_by_name_and_kind(
&reference.reference_name,
CLASS_KINDS,
MODEL_DIRS,
context,
)
{
return Some(ResolvedRef::framework(reference, target, 0.7));
}
if (reference.reference_name.ends_with("ViewModel")
|| reference.reference_name.ends_with("Dto"))
&& let Some(target) = resolve_by_name_and_kind(
&reference.reference_name,
CLASS_KINDS,
VIEWMODEL_DIRS,
context,
)
{
return Some(ResolvedRef::framework(reference, target, 0.8));
}
None
}
fn extract(&self, file_path: &str, content: &str) -> FrameworkExtractionResult {
if !file_path.ends_with(".cs") {
return FrameworkExtractionResult::default();
}
let safe = strip_comments_for_regex(content, CommentLang::CSharp);
let mut result = FrameworkExtractionResult::default();
let class_prefix = Regex::new(r#"\[Route\s*\(\s*"([^"]+)""#)
.ok()
.and_then(|re| re.captures(&safe))
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.unwrap_or_default();
let attr_re = Regex::new(r#"\[(HttpGet|HttpPost|HttpPut|HttpPatch|HttpDelete)(?:\s*\(\s*"([^"]+)"[^)]*\))?\s*\]"#).unwrap();
let method_re =
Regex::new(r"(?:public|private|protected|internal)\s+[\w<>,\s\[\]?.]+?\s+(\w+)\s*\(")
.unwrap();
for caps in attr_re.captures_iter(&safe) {
let whole = caps.get(0).unwrap();
let verb = caps.get(1).unwrap().as_str();
let method = verb.trim_start_matches("Http").to_ascii_uppercase();
let route_path =
join_cs_path(&class_prefix, caps.get(2).map(|m| m.as_str()).unwrap_or(""));
let line = line_for_byte(&safe, 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::CSharp,
line,
None,
None,
));
let tail = utf8_byte_window(&safe, whole.end(), 600);
if let Some(method_caps) = method_re.captures(tail) {
let handler = method_caps.get(1).unwrap().as_str();
result.references.push(make_reference(
route_id,
handler,
ReferenceKind::References,
line,
0,
file_path,
Language::CSharp,
));
}
}
let minimal_re =
Regex::new(r#"\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"\s*,\s*([^,)]+)"#)
.unwrap();
for caps in minimal_re.captures_iter(&safe) {
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 line = line_for_byte(&safe, 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::CSharp,
line,
None,
None,
));
if let Some(handler) = extract_csharp_tail_ident(caps.get(3).unwrap().as_str()) {
result.references.push(make_reference(
route_id,
handler,
ReferenceKind::References,
line,
0,
file_path,
Language::CSharp,
));
}
}
result
}
}
fn join_cs_path(prefix: &str, sub: &str) -> String {
let parts = [prefix, sub]
.into_iter()
.map(|part| part.trim_matches('/'))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
format!("/{}", parts.join("/"))
}
fn extract_csharp_tail_ident(expr: &str) -> Option<String> {
let cleaned = expr
.chars()
.filter(|ch| !ch.is_whitespace())
.collect::<String>();
cleaned
.rsplit(['.', '(', ')'])
.find(|part| is_ident(part))
.map(ToOwned::to_owned)
}
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())
}
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())
}