1use std::collections::{HashMap, HashSet};
9
10use agentstatedeveloper_core::adapter::{
11 CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
12};
13use agentstatedeveloper_core::cross_service::{
14 DetectedEndpoint, Direction, Transport, http_contract,
15};
16use agentstatedeveloper_core::error::{AsdError, Result};
17use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
18use serde_json::json;
19use tree_sitter::{Node, Parser};
20
21#[derive(Debug, Default, Clone, Copy)]
23pub struct CSharpAdapter;
24
25impl CSharpAdapter {
26 pub fn new() -> Self {
27 Self
28 }
29}
30
31impl LanguageAdapter for CSharpAdapter {
32 fn language(&self) -> &str {
33 "csharp"
34 }
35
36 fn file_extensions(&self) -> &'static [&'static str] {
37 &["cs"]
38 }
39
40 fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
41 let mut parser = Parser::new();
42 parser
43 .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
44 .map_err(|e| AsdError::Parse(format!("failed to set c# language: {e}")))?;
45
46 let src_bytes = source.as_bytes();
47 let tree = parser
48 .parse(src_bytes, None)
49 .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
50
51 let root = tree.root_node();
52 let mut out = Vec::new();
53 walk(root, src_bytes, "", &mut out);
54 Ok(out)
55 }
56
57 fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
58 infer_effects_from_body(&symbol.body)
59 }
60
61 fn infer_service_endpoints(
62 &self,
63 file: &str,
64 source: &str,
65 symbols: &[ParsedSymbol],
66 ) -> Vec<DetectedEndpoint> {
67 infer_service_endpoints_in_csharp(file, source, symbols)
68 }
69
70 fn extract_call_edges(
71 &self,
72 file: &str,
73 source: &str,
74 symbols: &[ParsedSymbol],
75 workspace: &WorkspaceSymbols,
76 ) -> Vec<CallEdge> {
77 extract_call_edges_impl(file, source, symbols, workspace)
78 }
79}
80
81fn node_text<'a>(node: Node<'_>, src: &'a [u8]) -> &'a str {
86 node.utf8_text(src).unwrap_or("")
87}
88
89fn child_by_field<'a>(node: Node<'a>, field: &str) -> Option<Node<'a>> {
90 node.child_by_field_name(field)
91}
92
93fn join_qname(prefix: &str, name: &str) -> String {
94 if prefix.is_empty() {
95 name.to_string()
96 } else {
97 format!("{}.{}", prefix, name)
98 }
99}
100
101fn make_symbol(node: Node<'_>, src: &[u8], qname: String, kind: SymbolKind) -> ParsedSymbol {
102 make_symbol_sig(node, src, qname, kind, None)
103}
104
105fn make_symbol_sig(
106 node: Node<'_>,
107 src: &[u8],
108 qname: String,
109 kind: SymbolKind,
110 signature: Option<String>,
111) -> ParsedSymbol {
112 ParsedSymbol {
113 qname,
114 kind,
115 start_line: node.start_position().row as u32 + 1,
116 start_col: node.start_position().column as u32,
117 end_line: node.end_position().row as u32 + 1,
118 end_col: node.end_position().column as u32,
119 body: node_text(node, src).to_string(),
120 signature,
121 doc: None,
122 }
123}
124
125fn extract_sig_before_brace(node: Node<'_>, src: &[u8]) -> Option<String> {
127 let text = std::str::from_utf8(&src[node.start_byte()..node.end_byte()]).ok()?;
128 let bytes = text.as_bytes();
129 let mut depth: i32 = 0;
130 let mut i = 0;
131 let mut sig_end = text.len();
132 while i < bytes.len() {
133 match bytes[i] {
134 b'"' => {
135 i += 1;
136 while i < bytes.len() {
137 if bytes[i] == b'\\' {
138 i += 2;
139 continue;
140 }
141 if bytes[i] == b'"' {
142 break;
143 }
144 i += 1;
145 }
146 }
147 b'(' | b'[' => depth += 1,
148 b')' | b']' => {
149 if depth > 0 {
150 depth -= 1;
151 }
152 }
153 b'{' if depth == 0 => {
154 sig_end = i;
155 break;
156 }
157 _ => {}
158 }
159 i += 1;
160 }
161 let sig = text[..sig_end].trim().to_string();
162 if sig.is_empty() { None } else { Some(sig) }
163}
164
165fn walk(node: Node<'_>, src: &[u8], scope: &str, out: &mut Vec<ParsedSymbol>) {
170 match node.kind() {
171 "compilation_unit" => {
172 for i in 0..node.child_count() {
173 walk(node.child(i).unwrap(), src, scope, out);
174 }
175 }
176 "namespace_declaration" | "file_scoped_namespace_declaration" => {
177 let name = child_by_field(node, "name")
178 .map(|n| node_text(n, src))
179 .unwrap_or("");
180 if name.is_empty() {
181 return;
182 }
183 let ns_scope = join_qname(scope, name);
184 if let Some(body) = child_by_field(node, "body") {
186 for i in 0..body.child_count() {
187 walk(body.child(i).unwrap(), src, &ns_scope, out);
188 }
189 } else {
190 for i in 0..node.child_count() {
192 walk(node.child(i).unwrap(), src, &ns_scope, out);
193 }
194 }
195 }
196 "class_declaration"
197 | "interface_declaration"
198 | "struct_declaration"
199 | "enum_declaration"
200 | "record_declaration"
201 | "record_struct_declaration" => {
202 let name = child_by_field(node, "name")
203 .map(|n| node_text(n, src))
204 .unwrap_or("");
205 if name.is_empty() {
206 return;
207 }
208 let qname = join_qname(scope, name);
209 out.push(make_symbol(node, src, qname.clone(), SymbolKind::Class));
210 if let Some(body) = child_by_field(node, "body") {
212 for i in 0..body.child_count() {
213 walk(body.child(i).unwrap(), src, &qname, out);
214 }
215 }
216 }
217 "method_declaration" => {
218 let name = child_by_field(node, "name")
219 .map(|n| node_text(n, src))
220 .unwrap_or("");
221 if name.is_empty() {
222 return;
223 }
224 let qname = join_qname(scope, name);
225 let sig = extract_sig_before_brace(node, src);
226 out.push(make_symbol_sig(node, src, qname, SymbolKind::Method, sig));
227 }
228 "constructor_declaration" => {
229 let name = child_by_field(node, "name")
230 .map(|n| node_text(n, src))
231 .unwrap_or("");
232 if name.is_empty() {
233 return;
234 }
235 let qname = join_qname(scope, name);
236 let sig = extract_sig_before_brace(node, src);
237 out.push(make_symbol_sig(node, src, qname, SymbolKind::Function, sig));
238 }
239 _ => {
240 for i in 0..node.child_count() {
241 walk(node.child(i).unwrap(), src, scope, out);
242 }
243 }
244 }
245}
246
247fn find_occurrences(body: &str, needle: &str) -> Vec<usize> {
252 let mut out = Vec::new();
253 let mut from = 0;
254 while let Some(idx) = body[from..].find(needle) {
255 out.push(from + idx);
256 from += idx + needle.len();
257 }
258 out
259}
260
261fn extract_url_host(s: &str) -> Option<String> {
262 for scheme in ["https://", "http://"] {
263 if let Some(idx) = s.find(scheme) {
264 let tail = &s[idx + scheme.len()..];
265 let end = tail
266 .find(|c: char| c == '/' || c == '"' || c == ')' || c.is_whitespace())
267 .unwrap_or(tail.len());
268 let host = &tail[..end];
269 if !host.is_empty() {
270 return Some(host.to_string());
271 }
272 }
273 }
274 None
275}
276
277fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
278 for line in body.lines() {
279 for n in needles {
280 if line.contains(n) {
281 return Some(line.trim().to_string());
282 }
283 }
284 }
285 None
286}
287
288fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
289 for n in needles {
290 if body.contains(n) {
291 return first_matching_line(body, needles);
292 }
293 }
294 None
295}
296
297fn extract_first_string_literal(s: &str) -> Option<String> {
298 let trimmed = s.trim_start();
299 let bytes = trimmed.as_bytes();
300 if bytes.is_empty() {
301 return None;
302 }
303 if bytes[0] != b'"' {
304 return None;
305 }
306 let mut j = 1;
307 let mut out = String::new();
308 while j < bytes.len() {
309 let c = bytes[j];
310 if c == b'\\' && j + 1 < bytes.len() {
311 out.push(bytes[j + 1] as char);
312 j += 2;
313 continue;
314 }
315 if c == b'"' {
316 return Some(out);
317 }
318 out.push(c as char);
319 j += 1;
320 }
321 None
322}
323
324const HTTP_ATTRS: &[(&str, &str)] = &[
335 ("HttpGet", "GET"),
336 ("HttpPost", "POST"),
337 ("HttpPut", "PUT"),
338 ("HttpDelete", "DELETE"),
339 ("HttpPatch", "PATCH"),
340];
341
342fn infer_service_endpoints_in_csharp(
343 file: &str,
344 source: &str,
345 symbols: &[ParsedSymbol],
346) -> Vec<DetectedEndpoint> {
347 let prefix = cs_class_prefix(source);
348 let mut out = Vec::new();
349 for (idx, raw) in source.lines().enumerate() {
350 let line_no = (idx + 1) as u32;
351 let line = raw.trim();
352
353 for (method, sub) in cs_method_attrs(line) {
354 if let Some(owner) = owner_for_annotation(symbols, line_no) {
355 out.push(DetectedEndpoint {
356 transport: Transport::Http,
357 direction: Direction::Inbound,
358 contract: http_contract(&method, &join_path(&prefix, &sub)),
359 owner_qname: owner.qname.clone(),
360 file: file.to_string(),
361 line: line_no,
362 confidence: 0.9,
363 note: None,
364 });
365 }
366 }
367 for (method, path) in cs_minimal_api(line) {
368 if let Some(owner) = owner_for_body_line(symbols, line_no) {
369 out.push(DetectedEndpoint {
370 transport: Transport::Http,
371 direction: Direction::Inbound,
372 contract: http_contract(&method, &path),
373 owner_qname: owner.qname.clone(),
374 file: file.to_string(),
375 line: line_no,
376 confidence: 0.9,
377 note: None,
378 });
379 }
380 }
381 for (method, url) in cs_clients(line) {
382 if let Some(owner) = owner_for_body_line(symbols, line_no) {
383 out.push(DetectedEndpoint {
384 transport: Transport::Http,
385 direction: Direction::Outbound,
386 contract: http_contract(&method, &url),
387 owner_qname: owner.qname.clone(),
388 file: file.to_string(),
389 line: line_no,
390 confidence: 0.85,
391 note: None,
392 });
393 }
394 }
395 }
396 out
397}
398
399fn owner_for_body_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
400 symbols
401 .iter()
402 .filter(|s| s.start_line <= line && line <= s.end_line)
403 .max_by_key(|s| s.start_line)
404}
405
406fn owner_for_annotation(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
407 if let Some(s) = owner_for_body_line(symbols, line) {
408 return Some(s);
409 }
410 symbols
411 .iter()
412 .filter(|s| s.start_line > line && s.start_line <= line + 6)
413 .min_by_key(|s| s.start_line)
414}
415
416fn cs_class_prefix(source: &str) -> String {
419 let controller = cs_controller_name(source);
420 for line in source.lines() {
421 let t = line.trim();
422 if let Some(rest) = t.strip_prefix("[Route(") {
423 if let Some(p) = first_cs_string(rest) {
424 return p.replace("[controller]", &controller);
425 }
426 }
427 if t.contains("class ") {
428 break;
429 }
430 }
431 String::new()
432}
433
434fn cs_controller_name(source: &str) -> String {
435 for line in source.lines() {
436 if let Some(pos) = line.find("class ") {
437 let name: String = line[pos + 6..]
438 .chars()
439 .take_while(|c| c.is_alphanumeric() || *c == '_')
440 .collect();
441 return name.strip_suffix("Controller").unwrap_or(&name).to_string();
442 }
443 }
444 String::new()
445}
446
447fn cs_method_attrs(line: &str) -> Vec<(String, String)> {
449 let mut out = Vec::new();
450 for (attr, method) in HTTP_ATTRS {
451 if line.starts_with(&format!("[{attr}")) {
452 let sub = line
453 .find('(')
454 .and_then(|o| first_cs_string(&line[o + 1..]))
455 .unwrap_or_default();
456 out.push((method.to_string(), sub));
457 }
458 }
459 out
460}
461
462fn cs_minimal_api(line: &str) -> Vec<(String, String)> {
464 let mut out = Vec::new();
465 for (m, method) in [
466 ("MapGet", "GET"),
467 ("MapPost", "POST"),
468 ("MapPut", "PUT"),
469 ("MapDelete", "DELETE"),
470 ("MapPatch", "PATCH"),
471 ] {
472 if let Some(args) = cs_call_args(line, m) {
473 if let Some(path) = first_cs_string(args) {
474 if path.starts_with('/') {
475 out.push((method.to_string(), path));
476 }
477 }
478 }
479 }
480 out
481}
482
483fn cs_clients(line: &str) -> Vec<(String, String)> {
486 let mut out = Vec::new();
487 let methods = [
488 ("GetAsync", "GET"),
489 ("GetStringAsync", "GET"),
490 ("GetFromJsonAsync", "GET"),
491 ("PostAsync", "POST"),
492 ("PostAsJsonAsync", "POST"),
493 ("PutAsync", "PUT"),
494 ("PutAsJsonAsync", "PUT"),
495 ("DeleteAsync", "DELETE"),
496 ("PatchAsync", "PATCH"),
497 ];
498 for (m, method) in methods {
499 if let Some(args) = cs_call_args(line, m) {
500 if let Some(url) = first_cs_string(args) {
501 if looks_like_url(&url) {
502 out.push((method.to_string(), url));
503 }
504 }
505 }
506 }
507 out
508}
509
510fn cs_call_args<'a>(line: &'a str, method: &str) -> Option<&'a str> {
512 let pos = line.find(&format!(".{method}"))?;
513 let mut rest = &line[pos + method.len() + 1..];
514 if rest.starts_with('<') {
515 let close = rest.find('>')?;
516 rest = &rest[close + 1..];
517 }
518 rest.strip_prefix('(')
519}
520
521fn join_path(prefix: &str, sub: &str) -> String {
522 let p = prefix.trim_end_matches('/');
523 let s = sub.trim_start_matches('/');
524 match (p.is_empty(), s.is_empty()) {
525 (true, true) => "/".to_string(),
526 (false, true) => p.to_string(),
527 (true, false) => format!("/{s}"),
528 (false, false) => format!("{p}/{s}"),
529 }
530}
531
532fn looks_like_url(s: &str) -> bool {
533 s.starts_with('/') || s.contains("://")
534}
535
536fn first_cs_string(s: &str) -> Option<String> {
538 let s = s.trim_start();
539 if s.starts_with('$') {
540 return None;
541 }
542 let offset = if s.starts_with("@\"") { 1 } else { 0 };
543 if s.as_bytes().get(offset).copied()? != b'"' {
544 return None;
545 }
546 let inner = &s[offset + 1..];
547 let end = inner.find('"')?;
548 Some(inner[..end].to_string())
549}
550
551fn infer_effects_from_body(body: &str) -> Vec<Effect> {
552 let mut effects: Vec<Effect> = Vec::new();
553
554 let fs_read_needles = [
556 "File.ReadAll",
557 "File.ReadLines(",
558 "File.OpenRead(",
559 "File.Open(",
560 "new FileStream(",
561 "new StreamReader(",
562 "Directory.GetFiles(",
563 "Directory.EnumerateFiles(",
564 ];
565 if let Some(note) = first_match_note(body, &fs_read_needles) {
566 effects.push(Effect {
567 effect: EffectCategory::IoFsRead,
568 qualifiers: serde_json::Value::Null,
569 note: Some(note),
570 ..Default::default()
571 });
572 }
573
574 let fs_write_needles = [
576 "File.WriteAll",
577 "File.AppendAll",
578 "File.Create(",
579 "File.OpenWrite(",
580 "new StreamWriter(",
581 "Directory.CreateDirectory(",
582 "Directory.Delete(",
583 ];
584 if let Some(note) = first_match_note(body, &fs_write_needles) {
585 effects.push(Effect {
586 effect: EffectCategory::IoFsWrite,
587 qualifiers: serde_json::Value::Null,
588 note: Some(note),
589 ..Default::default()
590 });
591 }
592
593 let net_needles = [
595 "new HttpClient(",
596 "HttpClient(",
597 "_httpClient.",
598 "httpClient.",
599 "client.GetAsync(",
600 "client.PostAsync(",
601 "client.SendAsync(",
602 "RestClient(",
603 "new RestClient(",
604 "WebClient(",
605 "new WebClient(",
606 "WebRequest.Create(",
607 "SocketsHttpHandler",
608 ];
609 let mut net_hosts: Vec<String> = Vec::new();
610 let mut net_note: Option<String> = None;
611 for needle in net_needles {
612 if body.contains(needle) && net_note.is_none() {
613 net_note = first_matching_line(body, &[needle]);
614 for off in find_occurrences(body, needle) {
615 let end = (off + 200).min(body.len());
616 let snippet = &body[off..end];
617 if let Some(host) = extract_url_host(snippet) {
618 if !net_hosts.contains(&host) {
619 net_hosts.push(host);
620 }
621 }
622 }
623 }
624 }
625 if net_note.is_some() {
626 let qualifiers = if net_hosts.is_empty() {
627 serde_json::Value::Null
628 } else {
629 json!({ "hosts": net_hosts })
630 };
631 effects.push(Effect {
632 effect: EffectCategory::IoNetOut,
633 qualifiers,
634 note: net_note,
635 ..Default::default()
636 });
637 }
638
639 let db_needles = [
641 "new SqlConnection(",
642 "SqlConnection(",
643 ".ExecuteReader(",
644 ".ExecuteScalar(",
645 ".ExecuteNonQuery(",
646 "dbContext.",
647 "context.SaveChanges",
648 "context.Add(",
649 "context.Remove(",
650 "context.Find(",
651 "SqlCommand(",
652 "new SqlCommand(",
653 ".Query<",
654 ".Execute(",
655 "connection.QueryAsync",
656 ];
657 if let Some(note) = first_match_note(body, &db_needles) {
658 let has_write = body.contains(".ExecuteNonQuery(")
659 || body.contains("context.SaveChanges")
660 || body.contains("context.Add(")
661 || body.contains("context.Remove(")
662 || body.contains(".Execute(");
663 let has_read = body.contains(".ExecuteReader(")
664 || body.contains(".ExecuteScalar(")
665 || body.contains("context.Find(")
666 || body.contains(".Query<")
667 || body.contains("connection.QueryAsync");
668 if has_read || (!has_read && !has_write) {
669 effects.push(Effect {
670 effect: EffectCategory::IoDbRead,
671 qualifiers: serde_json::Value::Null,
672 note: Some(note.clone()),
673 ..Default::default()
674 });
675 }
676 if has_write || (!has_read && !has_write) {
677 effects.push(Effect {
678 effect: EffectCategory::IoDbWrite,
679 qualifiers: serde_json::Value::Null,
680 note: Some(note),
681 ..Default::default()
682 });
683 }
684 }
685
686 let proc_needles = ["Process.Start(", "new Process(", "ProcessStartInfo("];
688 if let Some(note) = first_match_note(body, &proc_needles) {
689 effects.push(Effect {
690 effect: EffectCategory::ProcSpawn,
691 qualifiers: serde_json::Value::Null,
692 note: Some(note),
693 ..Default::default()
694 });
695 }
696
697 let env_needles = [
699 "Environment.GetEnvironmentVariable(",
700 "Environment.GetEnvironmentVariables(",
701 ];
702 if let Some(note) = first_match_note(body, &env_needles) {
703 let mut vars: Vec<String> = Vec::new();
704 for off in find_occurrences(body, "Environment.GetEnvironmentVariable(") {
705 let args = &body[off + "Environment.GetEnvironmentVariable(".len()..];
706 if let Some(v) = extract_first_string_literal(args) {
707 if !vars.contains(&v) {
708 vars.push(v);
709 }
710 }
711 }
712 let qualifiers = if vars.is_empty() {
713 serde_json::Value::Null
714 } else {
715 json!({ "vars": vars })
716 };
717 effects.push(Effect {
718 effect: EffectCategory::EnvRead,
719 qualifiers,
720 note: Some(note),
721 ..Default::default()
722 });
723 }
724
725 let log_needles = [
727 "Console.Write",
728 "_logger.",
729 "logger.",
730 "Log.Information(",
731 "Log.Warning(",
732 "Log.Error(",
733 "Log.Debug(",
734 "_log.",
735 "ILogger",
736 "NLog.",
737 "log4net.",
738 ];
739 if let Some(note) = first_match_note(body, &log_needles) {
740 effects.push(Effect {
741 effect: EffectCategory::Log,
742 qualifiers: serde_json::Value::Null,
743 note: Some(note),
744 ..Default::default()
745 });
746 }
747
748 let sleep_needles = ["Thread.Sleep(", "Task.Delay(", "await Task.Delay("];
750 if let Some(note) = first_match_note(body, &sleep_needles) {
751 effects.push(Effect {
752 effect: EffectCategory::TimeSleep,
753 qualifiers: serde_json::Value::Null,
754 note: Some(note),
755 ..Default::default()
756 });
757 }
758
759 let time_needles = [
761 "DateTime.Now",
762 "DateTime.UtcNow",
763 "DateTime.Today",
764 "DateTimeOffset.Now",
765 "DateTimeOffset.UtcNow",
766 "Stopwatch.GetTimestamp()",
767 "Stopwatch.StartNew()",
768 "Environment.TickCount",
769 "TimeProvider.System",
770 ];
771 if let Some(note) = first_match_note(body, &time_needles) {
772 effects.push(Effect {
773 effect: EffectCategory::TimeRead,
774 qualifiers: serde_json::Value::Null,
775 note: Some(note),
776 ..Default::default()
777 });
778 }
779
780 let rand_needles = [
782 "new Random(",
783 "Random.Shared",
784 "RandomNumberGenerator",
785 "Guid.NewGuid()",
786 "new RNGCryptoServiceProvider()",
787 ];
788 if let Some(note) = first_match_note(body, &rand_needles) {
789 effects.push(Effect {
790 effect: EffectCategory::Random,
791 qualifiers: serde_json::Value::Null,
792 note: Some(note),
793 ..Default::default()
794 });
795 }
796
797 let throw_needles = ["throw new ", "throw;", "throw ex;", "throw e;"];
799 if let Some(note) = first_match_note(body, &throw_needles) {
800 effects.push(Effect {
801 effect: EffectCategory::Throw,
802 qualifiers: serde_json::Value::Null,
803 note: Some(note),
804 ..Default::default()
805 });
806 }
807
808 effects
809}
810
811#[derive(Debug, Clone)]
817struct UsingBinding {
818 fqn: String,
820 alias: Option<String>,
822}
823
824fn parse_usings(source: &str) -> Vec<UsingBinding> {
825 let mut out = Vec::new();
826 for line in source.lines() {
827 let t = line.trim();
828 if let Some(rest) = t.strip_prefix("using ") {
829 let rest = rest.trim_end_matches(';').trim();
830 if rest.starts_with("static ") {
832 continue;
833 }
834 if let Some((alias, fqn)) = rest.split_once('=') {
835 out.push(UsingBinding {
836 fqn: fqn.trim().to_string(),
837 alias: Some(alias.trim().to_string()),
838 });
839 } else if !rest.is_empty() {
840 out.push(UsingBinding {
841 fqn: rest.to_string(),
842 alias: None,
843 });
844 }
845 }
846 }
847 out
848}
849
850fn enclosing_type_qname(qname: &str, known: &HashSet<&str>) -> Option<String> {
851 let idx = qname.rfind('.')?;
852 let parent = &qname[..idx];
853 if known.contains(parent) {
854 Some(parent.to_string())
855 } else {
856 None
857 }
858}
859
860fn collect_invocations(
861 node: Node<'_>,
862 src: &[u8],
863 sym: &ParsedSymbol,
864 by_simple: &HashMap<String, String>,
865 known: &HashSet<&str>,
866 usings: &[UsingBinding],
867 workspace: &WorkspaceSymbols,
868 enclosing_type: Option<&str>,
869 edges: &mut HashSet<CallEdge>,
870) {
871 if node.kind() == "invocation_expression" {
872 if let Some(func_node) = node.child(0) {
874 let (object_text, method_text) = if func_node.kind() == "member_access_expression" {
875 let obj = func_node.child(0).map(|n| node_text(n, src)).unwrap_or("");
877 let method = child_by_field(func_node, "name")
878 .map(|n| node_text(n, src))
879 .unwrap_or("");
880 (obj, method)
881 } else {
882 ("", node_text(func_node, src))
884 };
885
886 if !method_text.is_empty() {
887 let callee = if object_text.is_empty() {
888 if let Some(et) = enclosing_type {
890 let q = format!("{}.{}", et, method_text);
891 if known.contains(q.as_str()) {
892 Some(q)
893 } else {
894 by_simple.get(method_text).cloned()
895 }
896 } else {
897 by_simple.get(method_text).cloned()
898 }
899 } else {
900 let alias_match = usings
902 .iter()
903 .find(|u| u.alias.as_deref() == Some(object_text));
904 let ns_match = usings
905 .iter()
906 .find(|u| u.fqn.ends_with(&format!(".{}", object_text)));
907
908 if let Some(binding) = alias_match.or(ns_match) {
909 let q = format!("{}.{}", binding.fqn, method_text);
911 if known.contains(q.as_str()) || workspace.contains(&q) {
912 Some(q)
913 } else {
914 None
915 }
916 } else {
917 let mut found: Option<String> = None;
919 for u in usings {
920 let q = format!("{}.{}.{}", u.fqn, object_text, method_text);
921 if known.contains(q.as_str()) || workspace.contains(&q) {
922 found = Some(q);
923 break;
924 }
925 }
926 found
927 }
928 };
929
930 if let Some(callee_qname) = callee {
931 if callee_qname != sym.qname {
932 edges.insert(CallEdge {
933 caller_qname: sym.qname.clone(),
934 callee_qname,
935 });
936 }
937 }
938 }
939 }
940 }
941
942 for i in 0..node.child_count() {
943 collect_invocations(
944 node.child(i).unwrap(),
945 src,
946 sym,
947 by_simple,
948 known,
949 usings,
950 workspace,
951 enclosing_type,
952 edges,
953 );
954 }
955}
956
957fn extract_call_edges_impl(
958 file: &str,
959 source: &str,
960 symbols: &[ParsedSymbol],
961 workspace: &WorkspaceSymbols,
962) -> Vec<CallEdge> {
963 let _ = file;
964 let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
965
966 let mut by_simple: HashMap<String, String> = HashMap::new();
967 for s in symbols {
968 let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
969 by_simple.entry(simple).or_insert_with(|| s.qname.clone());
970 }
971
972 let mut parser = Parser::new();
973 if parser
974 .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
975 .is_err()
976 {
977 return Vec::new();
978 }
979
980 let usings = parse_usings(source);
981 let mut edges: HashSet<CallEdge> = HashSet::new();
982
983 for sym in symbols {
984 if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
985 continue;
986 }
987 let src_bytes = sym.body.as_bytes();
988 let tree = match parser.parse(src_bytes, None) {
989 Some(t) => t,
990 None => continue,
991 };
992 let enclosing_type = enclosing_type_qname(&sym.qname, &known);
993 collect_invocations(
994 tree.root_node(),
995 src_bytes,
996 sym,
997 &by_simple,
998 &known,
999 &usings,
1000 workspace,
1001 enclosing_type.as_deref(),
1002 &mut edges,
1003 );
1004 }
1005
1006 let mut out: Vec<CallEdge> = edges.into_iter().collect();
1007 out.sort_by(|a, b| {
1008 a.caller_qname
1009 .cmp(&b.caller_qname)
1010 .then_with(|| a.callee_qname.cmp(&b.callee_qname))
1011 });
1012 out
1013}
1014
1015#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
1023
1024 fn adapter() -> CSharpAdapter {
1025 CSharpAdapter::new()
1026 }
1027
1028 #[test]
1029 fn parses_namespace_class_method_enum() {
1030 let src = r#"
1031namespace MyApp.Payments
1032{
1033 public class PaymentService
1034 {
1035 public PaymentService() {}
1036
1037 public async Task<Receipt> ChargeAsync(string customerId, decimal amount)
1038 {
1039 return new Receipt();
1040 }
1041
1042 private void LogCharge(string msg) { }
1043 }
1044
1045 public interface IGateway
1046 {
1047 void Process(Payment p);
1048 }
1049
1050 public enum Currency { USD, EUR, GBP }
1051}
1052"#;
1053 let syms = adapter().parse_symbols("PaymentService.cs", src).unwrap();
1054 let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
1055 assert!(
1056 qnames.contains(&"MyApp.Payments.PaymentService"),
1057 "{qnames:?}"
1058 );
1059 assert!(
1060 qnames.contains(&"MyApp.Payments.PaymentService.ChargeAsync"),
1061 "{qnames:?}"
1062 );
1063 assert!(
1064 qnames.contains(&"MyApp.Payments.PaymentService.LogCharge"),
1065 "{qnames:?}"
1066 );
1067 assert!(qnames.contains(&"MyApp.Payments.IGateway"), "{qnames:?}");
1068 assert!(qnames.contains(&"MyApp.Payments.Currency"), "{qnames:?}");
1069 }
1070
1071 #[test]
1072 fn infers_fs_read_and_net_out() {
1073 let src = r#"
1074using System.Net.Http;
1075namespace App {
1076 public class Fetcher {
1077 public async Task<string> Fetch() {
1078 string data = File.ReadAllText("/tmp/config.json");
1079 var client = new HttpClient();
1080 var resp = await client.GetAsync("https://api.example.com/v1/data");
1081 return data;
1082 }
1083 }
1084}
1085"#;
1086 let syms = adapter().parse_symbols("Fetcher.cs", src).unwrap();
1087 let fetch = syms.iter().find(|s| s.qname.ends_with(".Fetch")).unwrap();
1088 let effs = adapter().infer_effects("", fetch);
1089 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1090 assert!(cats.contains(&&EffectCategory::IoFsRead), "{cats:?}");
1091 assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1092 }
1093
1094 #[test]
1095 fn infers_db_read_and_write() {
1096 let src = r#"
1097namespace App {
1098 public class UserRepo {
1099 public User Find(int id) {
1100 return dbContext.Find<User>(id);
1101 }
1102 public void Save(User u) {
1103 dbContext.Add(u);
1104 dbContext.SaveChanges();
1105 }
1106 }
1107}
1108"#;
1109 let syms = adapter().parse_symbols("UserRepo.cs", src).unwrap();
1110 let find = syms.iter().find(|s| s.qname.ends_with(".Find")).unwrap();
1111 let save = syms.iter().find(|s| s.qname.ends_with(".Save")).unwrap();
1112 let find_effs = adapter().infer_effects("", find);
1113 let save_effs = adapter().infer_effects("", save);
1114 assert!(
1115 find_effs
1116 .iter()
1117 .any(|e| e.effect == EffectCategory::IoDbRead)
1118 );
1119 assert!(
1120 save_effs
1121 .iter()
1122 .any(|e| e.effect == EffectCategory::IoDbWrite)
1123 );
1124 }
1125
1126 #[test]
1127 fn infers_log_and_env() {
1128 let src = r#"
1129namespace App {
1130 public class Config {
1131 private readonly ILogger<Config> _logger;
1132 public string Load() {
1133 string env = Environment.GetEnvironmentVariable("APP_ENV");
1134 _logger.LogInformation("Loading for {Env}", env);
1135 return env;
1136 }
1137 }
1138}
1139"#;
1140 let syms = adapter().parse_symbols("Config.cs", src).unwrap();
1141 let load = syms.iter().find(|s| s.qname.ends_with(".Load")).unwrap();
1142 let effs = adapter().infer_effects("", load);
1143 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1144 assert!(cats.contains(&&EffectCategory::EnvRead), "{cats:?}");
1145 assert!(cats.contains(&&EffectCategory::Log), "{cats:?}");
1146 let env_eff = effs
1148 .iter()
1149 .find(|e| e.effect == EffectCategory::EnvRead)
1150 .unwrap();
1151 if let Some(vars) = env_eff.qualifiers.get("vars") {
1152 let vars: Vec<&str> = vars
1153 .as_array()
1154 .unwrap()
1155 .iter()
1156 .map(|v| v.as_str().unwrap())
1157 .collect();
1158 assert!(vars.contains(&"APP_ENV"), "{vars:?}");
1159 }
1160 }
1161
1162 #[test]
1163 fn empty_when_no_patterns() {
1164 let src = r#"
1165namespace App {
1166 public class Math {
1167 public int Add(int a, int b) { return a + b; }
1168 }
1169}
1170"#;
1171 let syms = adapter().parse_symbols("Math.cs", src).unwrap();
1172 let add = syms.iter().find(|s| s.qname.ends_with(".Add")).unwrap();
1173 let effs = adapter().infer_effects("", add);
1174 assert!(effs.is_empty(), "{effs:?}");
1175 }
1176
1177 #[test]
1178 fn extracts_cross_module_call_edges() {
1179 let src = r#"
1180using MyApp.Payments;
1181
1182namespace MyApp.Orders {
1183 public class OrderService {
1184 public void PlaceOrder(Order order) {
1185 ChargeService.Charge(order.CustomerId, order.Total);
1186 }
1187 }
1188
1189 public class ChargeService {
1190 public static void Charge(string customerId, decimal amount) { }
1191 }
1192}
1193"#;
1194 let ws = WorkspaceSymbols {
1195 qnames: ["MyApp.Payments.ChargeService.Charge".to_string()].into(),
1196 kinds: HashMap::new(),
1197 ..Default::default()
1198 };
1199 let syms = adapter().parse_symbols("OrderService.cs", src).unwrap();
1200 let edges = adapter().extract_call_edges("OrderService.cs", src, &syms, &ws);
1201 let found = edges.iter().any(|e| {
1203 e.caller_qname.ends_with(".PlaceOrder") && e.callee_qname.ends_with(".Charge")
1204 });
1205 assert!(found, "expected call edge to Charge; got: {edges:?}");
1206 }
1207}
1208
1209#[cfg(test)]
1210mod service_endpoint_tests {
1211 use super::*;
1212 use agentstatedeveloper_core::adapter::LanguageAdapter;
1213
1214 fn detect(src: &str) -> Vec<DetectedEndpoint> {
1215 let a = CSharpAdapter;
1216 let symbols = a.parse_symbols("C.cs", src).unwrap();
1217 a.infer_service_endpoints("C.cs", src, &symbols)
1218 }
1219 fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1220 eps.iter()
1221 .filter(|e| e.direction == Direction::Inbound)
1222 .collect()
1223 }
1224 fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1225 eps.iter()
1226 .filter(|e| e.direction == Direction::Outbound)
1227 .collect()
1228 }
1229
1230 #[test]
1231 fn attribute_controller_with_controller_token_prefix() {
1232 let src = "\
1233[ApiController]
1234[Route(\"api/[controller]\")]
1235public class UsersController : ControllerBase {
1236 [HttpGet(\"{id}\")]
1237 public IActionResult Get(int id) { return Ok(); }
1238
1239 [HttpPost]
1240 public IActionResult Create() { return Ok(); }
1241}
1242";
1243 let eps = detect(src);
1244 let mut got: Vec<String> = inbound(&eps).iter().map(|e| e.contract.clone()).collect();
1245 got.sort();
1246 assert_eq!(
1248 got,
1249 vec![
1250 "http:GET /api/users/{}".to_string(),
1251 "http:POST /api/users".to_string()
1252 ],
1253 "{eps:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn minimal_api_routes() {
1259 let src = "public class P {\n void Cfg() {\n app.MapGet(\"/health\", () => \"ok\");\n }\n}\n";
1260 let inb_eps = detect(src);
1261 assert_eq!(inbound(&inb_eps)[0].contract, "http:GET /health");
1262 }
1263
1264 #[test]
1265 fn httpclient_clients_incl_generic() {
1266 let src = "public class S {\n async Task Call() {\n await client.GetFromJsonAsync<User>(\"https://users.svc/users/{id}\");\n await client.PostAsync(\"/charge\", body);\n }\n}\n";
1267 let eps = detect(src);
1268 let mut got: Vec<String> = outbound(&eps).iter().map(|e| e.contract.clone()).collect();
1269 got.sort();
1270 assert_eq!(
1271 got,
1272 vec![
1273 "http:GET /users/{}".to_string(),
1274 "http:POST /charge".to_string()
1275 ]
1276 );
1277 }
1278
1279 #[test]
1280 fn cs_server_matches_other_language_client() {
1281 let server = detect(
1282 "[Route(\"api/[controller]\")]\npublic class UsersController {\n [HttpGet(\"{id}\")]\n public IActionResult Get() { return Ok(); }\n}\n",
1283 );
1284 assert_eq!(inbound(&server)[0].contract, "http:GET /api/users/{}");
1285 assert_eq!(
1286 inbound(&server)[0].contract,
1287 http_contract("get", "https://svc/api/users/:id")
1288 );
1289 }
1290}