Skip to main content

agentstatedeveloper_csharp/
lib.rs

1//! C# language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-c-sharp`. Parses namespaces, classes, interfaces,
5//! structs, enums, records, methods, and constructors, then runs
6//! substring-based effect inference.
7
8use 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/// C# language adapter.
22#[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
81// -----------------------------------------------------------------------------
82// qname helpers
83// -----------------------------------------------------------------------------
84
85fn 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
125/// Extract method/constructor signature: text up to (not including) body `{`.
126fn 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
165// -----------------------------------------------------------------------------
166// Symbol walking
167// -----------------------------------------------------------------------------
168
169fn 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            // Walk the body
185            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                // file-scoped namespace: remaining siblings in compilation_unit
191                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            // Walk body for members
211            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
247// -----------------------------------------------------------------------------
248// Effect inference
249// -----------------------------------------------------------------------------
250
251fn 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
324// -----------------------------------------------------------------------------
325// Cross-service endpoint detection (t-014) — ASP.NET Core
326//
327// Inbound: attribute controllers ([HttpGet("x")] … with the class-level
328// [Route("api/[controller]")] prefix; [controller] resolves to the controller
329// class name) and minimal APIs (app.MapGet("/x", …)). Outbound: HttpClient
330// GetAsync/PostAsync/…/GetFromJsonAsync<T>. Verbatim @"…" strings are literals;
331// interpolated $"…" are skipped.
332// -----------------------------------------------------------------------------
333
334const 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
416/// Class-level `[Route("…")]` prefix; `[controller]` resolves to the controller
417/// class name (minus the `Controller` suffix).
418fn 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
447/// `(METHOD, sub_path)` for a method-level HTTP attribute line.
448fn 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
462/// Minimal-API routes `app.MapGet("/x", …)`.
463fn 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
483/// HttpClient calls. URLs must look like a URL so unrelated `*Async` methods
484/// with a non-URL string argument don't match.
485fn 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
510/// Returns the argument substring after `.method(` or `.method<…>(`.
511fn 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
536/// First string literal: `"…"` or verbatim `@"…"`. Interpolated `$"…"` → None.
537fn 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    // FS Read
555    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    // FS Write
575    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    // Network
594    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    // Database
640    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    // Process spawn
687    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    // Env read
698    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    // Logging
726    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    // Time sleep
749    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    // Time read
760    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    // Random
781    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    // Throw
798    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// -----------------------------------------------------------------------------
812// Call-edge extraction
813// -----------------------------------------------------------------------------
814
815/// Import binding: `using` alias or fully-qualified type.
816#[derive(Debug, Clone)]
817struct UsingBinding {
818    /// Fully-qualified namespace or type, e.g. `MyApp.Payments`.
819    fqn: String,
820    /// Simple alias when the `using` is `using Alias = Fully.Qualified.Type`.
821    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            // Skip `using static` for simplicity
831            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        // The function is the first child (a member_access_expression or identifier)
873        if let Some(func_node) = node.child(0) {
874            let (object_text, method_text) = if func_node.kind() == "member_access_expression" {
875                // obj.Method
876                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                // bare method call
883                ("", node_text(func_node, src))
884            };
885
886            if !method_text.is_empty() {
887                let callee = if object_text.is_empty() {
888                    // Bare call — try enclosing type then module-level
889                    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                    // Qualified: look up object type in usings
901                    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                        // alias points to a type, so method is on that type
910                        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                        // Try workspace lookup with known usings as namespace prefixes
918                        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// -----------------------------------------------------------------------------
1016// Tests
1017// -----------------------------------------------------------------------------
1018
1019#[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        // Check env var extraction
1147        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        // Local ChargeService.Charge should be found
1202        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        // [controller] → "Users" → normalized lowercase /api/users.
1247        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}