Skip to main content

agentstatedeveloper_ruby/
lib.rs

1//! Ruby language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-ruby`. Parses modules, classes, instance methods,
5//! singleton methods (def self.x), then runs substring-based effect inference.
6
7use std::collections::{HashMap, HashSet};
8
9use agentstatedeveloper_core::adapter::{
10    CallEdge, LanguageAdapter, ParsedSymbol, WorkspaceSymbols,
11};
12use agentstatedeveloper_core::cross_service::{
13    DetectedEndpoint, Direction, Transport, http_contract,
14};
15use agentstatedeveloper_core::error::{AsdError, Result};
16use agentstatedeveloper_core::schema::{Effect, EffectCategory, SymbolKind};
17use serde_json::json;
18use tree_sitter::{Node, Parser};
19
20/// Ruby language adapter.
21#[derive(Debug, Default, Clone, Copy)]
22pub struct RubyAdapter;
23
24impl RubyAdapter {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30impl LanguageAdapter for RubyAdapter {
31    fn language(&self) -> &str {
32        "ruby"
33    }
34
35    fn file_extensions(&self) -> &'static [&'static str] {
36        &["rb"]
37    }
38
39    fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
40        let mut parser = Parser::new();
41        parser
42            .set_language(&tree_sitter_ruby::LANGUAGE.into())
43            .map_err(|e| AsdError::Parse(format!("failed to set ruby language: {e}")))?;
44
45        let src_bytes = source.as_bytes();
46        let tree = parser
47            .parse(src_bytes, None)
48            .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
49
50        let file_prefix = file_qname_prefix(file);
51        let root = tree.root_node();
52        let mut out = Vec::new();
53        walk(root, src_bytes, &file_prefix, &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_ruby(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
85/// Walk path components and return the tail after the first `lib` segment.
86/// Falls back to the full path for non-gem layouts (e.g. Rails `app/`).
87///
88/// Examples:
89/// - `lib/myapp/parser.rb`      → `myapp/parser.rb`
90/// - `gems/foo/lib/foo/bar.rb`  → `foo/bar.rb`
91/// - `app/models/user.rb`       → `app/models/user.rb`  (no `lib`, unchanged)
92fn strip_lib_prefix(path: &str) -> &str {
93    let mut offset = 0usize;
94    for part in path.split('/') {
95        if part == "lib" {
96            let after = offset + part.len() + 1;
97            if after < path.len() {
98                return &path[after..];
99            }
100        }
101        offset += part.len() + 1;
102    }
103    path
104}
105
106/// `lib/myapp/parser.rb`   → `myapp.parser`  (gem convention, anchored at lib/)
107/// `app/models/payment.rb` → `app.models.payment`  (Rails — no lib/, fallback)
108fn file_qname_prefix(file: &str) -> String {
109    let s = file.strip_prefix("./").unwrap_or(file);
110    let s = s.strip_suffix(".rb").unwrap_or(s);
111    let s = strip_lib_prefix(s);
112    s.replace('/', ".")
113}
114
115fn node_text<'a>(node: Node<'_>, src: &'a [u8]) -> &'a str {
116    node.utf8_text(src).unwrap_or("")
117}
118
119fn child_by_field<'a>(node: Node<'a>, field: &str) -> Option<Node<'a>> {
120    node.child_by_field_name(field)
121}
122
123fn join_qname(prefix: &str, name: &str) -> String {
124    if prefix.is_empty() {
125        name.to_string()
126    } else {
127        format!("{}.{}", prefix, name)
128    }
129}
130
131fn make_symbol(node: Node<'_>, src: &[u8], qname: String, kind: SymbolKind) -> ParsedSymbol {
132    make_symbol_sig(node, src, qname, kind, None)
133}
134
135fn make_symbol_sig(
136    node: Node<'_>,
137    src: &[u8],
138    qname: String,
139    kind: SymbolKind,
140    signature: Option<String>,
141) -> ParsedSymbol {
142    ParsedSymbol {
143        qname,
144        kind,
145        start_line: node.start_position().row as u32 + 1,
146        start_col: node.start_position().column as u32,
147        end_line: node.end_position().row as u32 + 1,
148        end_col: node.end_position().column as u32,
149        body: node_text(node, src).to_string(),
150        signature,
151        doc: None,
152    }
153}
154
155/// Extract the Ruby method signature: the first line of the `def` declaration.
156/// Ruby uses `end` as the body terminator (not `{`), so we take everything
157/// up to the first newline.
158fn extract_ruby_sig(node: Node<'_>, src: &[u8]) -> Option<String> {
159    let text = std::str::from_utf8(&src[node.start_byte()..node.end_byte()]).ok()?;
160    let first_line = text.lines().next()?.trim().to_string();
161    if first_line.is_empty() {
162        None
163    } else {
164        Some(first_line)
165    }
166}
167
168// -----------------------------------------------------------------------------
169// Symbol walking
170// -----------------------------------------------------------------------------
171
172fn walk(node: Node<'_>, src: &[u8], scope: &str, out: &mut Vec<ParsedSymbol>) {
173    match node.kind() {
174        "program" => {
175            for i in 0..node.child_count() {
176                walk(node.child(i).unwrap(), src, scope, out);
177            }
178        }
179        "module" => {
180            let name = child_by_field(node, "name")
181                .map(|n| node_text(n, src))
182                .unwrap_or("");
183            if name.is_empty() {
184                return;
185            }
186            let qname = join_qname(scope, name);
187            out.push(make_symbol(node, src, qname.clone(), SymbolKind::Module));
188            // Walk body
189            if let Some(body) = child_by_field(node, "body") {
190                for i in 0..body.child_count() {
191                    walk(body.child(i).unwrap(), src, &qname, out);
192                }
193            }
194        }
195        "class" => {
196            let name = child_by_field(node, "name")
197                .map(|n| node_text(n, src))
198                .unwrap_or("");
199            if name.is_empty() {
200                return;
201            }
202            let qname = join_qname(scope, name);
203            out.push(make_symbol(node, src, qname.clone(), SymbolKind::Class));
204            if let Some(body) = child_by_field(node, "body") {
205                for i in 0..body.child_count() {
206                    walk(body.child(i).unwrap(), src, &qname, out);
207                }
208            }
209        }
210        "method" => {
211            let name = child_by_field(node, "name")
212                .map(|n| node_text(n, src))
213                .unwrap_or("");
214            if name.is_empty() {
215                return;
216            }
217            let qname = join_qname(scope, name);
218            let sig = extract_ruby_sig(node, src);
219            out.push(make_symbol_sig(node, src, qname, SymbolKind::Method, sig));
220        }
221        "singleton_method" => {
222            // def self.method_name — treat as a class-level Function
223            let name = child_by_field(node, "name")
224                .map(|n| node_text(n, src))
225                .unwrap_or("");
226            if name.is_empty() {
227                return;
228            }
229            let qname = join_qname(scope, name);
230            let sig = extract_ruby_sig(node, src);
231            out.push(make_symbol_sig(node, src, qname, SymbolKind::Function, sig));
232        }
233        _ => {
234            for i in 0..node.child_count() {
235                walk(node.child(i).unwrap(), src, scope, out);
236            }
237        }
238    }
239}
240
241// -----------------------------------------------------------------------------
242// Effect inference
243// -----------------------------------------------------------------------------
244
245fn find_occurrences(body: &str, needle: &str) -> Vec<usize> {
246    let mut out = Vec::new();
247    let mut from = 0;
248    while let Some(idx) = body[from..].find(needle) {
249        out.push(from + idx);
250        from += idx + needle.len();
251    }
252    out
253}
254
255fn extract_url_host(s: &str) -> Option<String> {
256    for scheme in ["https://", "http://"] {
257        if let Some(idx) = s.find(scheme) {
258            let tail = &s[idx + scheme.len()..];
259            let end = tail
260                .find(|c: char| c == '/' || c == '"' || c == '\'' || c == ')' || c.is_whitespace())
261                .unwrap_or(tail.len());
262            let host = &tail[..end];
263            if !host.is_empty() {
264                return Some(host.to_string());
265            }
266        }
267    }
268    None
269}
270
271fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
272    for line in body.lines() {
273        for n in needles {
274            if line.contains(n) {
275                return Some(line.trim().to_string());
276            }
277        }
278    }
279    None
280}
281
282fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
283    for n in needles {
284        if body.contains(n) {
285            return first_matching_line(body, needles);
286        }
287    }
288    None
289}
290
291fn extract_first_string_arg(s: &str) -> Option<String> {
292    let trimmed = s.trim_start();
293    let bytes = trimmed.as_bytes();
294    if bytes.is_empty() {
295        return None;
296    }
297    let q = bytes[0];
298    if q != b'"' && q != b'\'' {
299        return None;
300    }
301    let mut j = 1;
302    let mut out = String::new();
303    while j < bytes.len() {
304        let c = bytes[j];
305        if c == b'\\' && j + 1 < bytes.len() {
306            out.push(bytes[j + 1] as char);
307            j += 2;
308            continue;
309        }
310        if c == q {
311            return Some(out);
312        }
313        out.push(c as char);
314        j += 1;
315    }
316    None
317}
318
319// -----------------------------------------------------------------------------
320// Cross-service endpoint detection (t-014) — Sinatra / Rails routes + clients
321//
322// Inbound: a route DSL line `verb '/path' …` (Sinatra `get '/x' do`, Rails
323// `get '/x', to: ...`). Requiring the line to START with the verb keyword keeps
324// method calls like `obj.get('/x')` out. Outbound: RestClient/HTTParty
325// `.<verb>('url')`. Interpolated ("#{…}") strings are skipped.
326//
327// Limitation: Rails routes.rb routes live in a top-level `routes.draw` block
328// with no enclosing class/def, so there's no symbol to attribute them to and
329// they're dropped. Sinatra routes (inside the app class) attribute fine.
330// -----------------------------------------------------------------------------
331
332const VERBS: &[&str] = &["get", "post", "put", "patch", "delete"];
333
334fn infer_service_endpoints_in_ruby(
335    file: &str,
336    source: &str,
337    symbols: &[ParsedSymbol],
338) -> Vec<DetectedEndpoint> {
339    let mut out = Vec::new();
340    for (idx, raw) in source.lines().enumerate() {
341        let line_no = (idx + 1) as u32;
342        let line = raw.trim();
343
344        if let Some((method, path)) = ruby_route(line) {
345            if let Some(owner) = owner_for_body_line(symbols, line_no) {
346                out.push(DetectedEndpoint {
347                    transport: Transport::Http,
348                    direction: Direction::Inbound,
349                    contract: http_contract(&method, &path),
350                    owner_qname: owner.qname.clone(),
351                    file: file.to_string(),
352                    line: line_no,
353                    confidence: 0.85,
354                    note: None,
355                });
356            }
357        }
358        for (method, url) in ruby_clients(line) {
359            if let Some(owner) = owner_for_body_line(symbols, line_no) {
360                out.push(DetectedEndpoint {
361                    transport: Transport::Http,
362                    direction: Direction::Outbound,
363                    contract: http_contract(&method, &url),
364                    owner_qname: owner.qname.clone(),
365                    file: file.to_string(),
366                    line: line_no,
367                    confidence: 0.85,
368                    note: None,
369                });
370            }
371        }
372    }
373    out
374}
375
376fn owner_for_body_line(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
377    symbols
378        .iter()
379        .filter(|s| s.start_line <= line && line <= s.end_line)
380        .max_by_key(|s| s.start_line)
381}
382
383/// Sinatra/Rails route: line starts with `verb '/path'`.
384fn ruby_route(line: &str) -> Option<(String, String)> {
385    for verb in VERBS {
386        if let Some(rest) = line.strip_prefix(&format!("{verb} ")) {
387            if let Some(path) = first_ruby_string(rest.trim_start()) {
388                if path.starts_with('/') {
389                    return Some((verb.to_uppercase(), path));
390                }
391            }
392        }
393    }
394    None
395}
396
397/// RestClient/HTTParty client calls: `RestClient.get('url')` etc.
398fn ruby_clients(line: &str) -> Vec<(String, String)> {
399    let mut out = Vec::new();
400    for lib in ["RestClient", "HTTParty"] {
401        for verb in VERBS {
402            let needle = format!("{lib}.{verb}(");
403            if let Some(pos) = line.find(&needle) {
404                if let Some(url) = first_ruby_string(line[pos + needle.len()..].trim_start()) {
405                    out.push((verb.to_uppercase(), url));
406                }
407            }
408        }
409    }
410    out
411}
412
413/// First `'…'` or `"…"` string literal; interpolated (`"#{…}"`) strings are
414/// rejected as dynamic.
415fn first_ruby_string(s: &str) -> Option<String> {
416    let s = s.trim_start();
417    let q = s.as_bytes().first().copied()?;
418    if q != b'\'' && q != b'"' {
419        return None;
420    }
421    let inner = &s[1..];
422    let end = inner.find(q as char)?;
423    let val = &inner[..end];
424    if q == b'"' && val.contains("#{") {
425        return None;
426    }
427    Some(val.to_string())
428}
429
430fn infer_effects_from_body(body: &str) -> Vec<Effect> {
431    let mut effects: Vec<Effect> = Vec::new();
432
433    // FS Read
434    let fs_read_needles = [
435        "File.read(",
436        "File.readlines(",
437        "File.open(",
438        "IO.read(",
439        "IO.readlines(",
440        "CSV.read(",
441        "CSV.foreach(",
442        "File.foreach(",
443    ];
444    if let Some(note) = first_match_note(body, &fs_read_needles) {
445        effects.push(Effect {
446            effect: EffectCategory::IoFsRead,
447            qualifiers: serde_json::Value::Null,
448            note: Some(note),
449            ..Default::default()
450        });
451    }
452
453    // FS Write
454    let fs_write_needles = [
455        "File.write(",
456        "File.open(", // also write mode — covered by read; separate write markers:
457        "IO.write(",
458        "FileUtils.cp(",
459        "FileUtils.mv(",
460        "FileUtils.mkdir",
461        "FileUtils.rm",
462        "File.delete(",
463        "File.rename(",
464    ];
465    // Only fire write if we see a write-mode marker not already covered by read
466    let write_markers = [
467        "IO.write(",
468        "FileUtils.cp(",
469        "FileUtils.mv(",
470        "FileUtils.mkdir",
471        "FileUtils.rm",
472        "File.delete(",
473        "File.rename(",
474        "\"w\"",
475        "\"w+\"",
476        "\"a\"",
477        "\"a+\"",
478        ":write",
479        ", 'w'",
480        ", 'a'",
481    ];
482    let has_write_marker = write_markers.iter().any(|m| body.contains(m));
483    let _ = fs_write_needles; // suppress unused warning; logic uses write_markers directly
484    if has_write_marker {
485        if let Some(note) = first_match_note(body, &write_markers) {
486            effects.push(Effect {
487                effect: EffectCategory::IoFsWrite,
488                qualifiers: serde_json::Value::Null,
489                note: Some(note),
490                ..Default::default()
491            });
492        }
493    }
494
495    // Network
496    let net_needles = [
497        "Net::HTTP",
498        "Net::HTTP.get(",
499        "Net::HTTP.post(",
500        "Faraday.new(",
501        "Faraday.get(",
502        "Faraday.post(",
503        "HTTParty.get(",
504        "HTTParty.post(",
505        "RestClient.get(",
506        "RestClient.post(",
507        "Typhoeus.get(",
508        "open-uri",
509        "open(\"http",
510        "open('http",
511        "URI.open(",
512    ];
513    let mut net_hosts: Vec<String> = Vec::new();
514    let mut net_note: Option<String> = None;
515    for needle in net_needles {
516        if body.contains(needle) && net_note.is_none() {
517            net_note = first_matching_line(body, &[needle]);
518            for off in find_occurrences(body, needle) {
519                let end = (off + 200).min(body.len());
520                let snippet = &body[off..end];
521                if let Some(host) = extract_url_host(snippet) {
522                    if !net_hosts.contains(&host) {
523                        net_hosts.push(host);
524                    }
525                }
526            }
527        }
528    }
529    if net_note.is_some() {
530        let qualifiers = if net_hosts.is_empty() {
531            serde_json::Value::Null
532        } else {
533            json!({ "hosts": net_hosts })
534        };
535        effects.push(Effect {
536            effect: EffectCategory::IoNetOut,
537            qualifiers,
538            note: net_note,
539            ..Default::default()
540        });
541    }
542
543    // Database
544    let db_needles = [
545        ".where(",
546        ".find(",
547        ".find_by(",
548        ".first",
549        ".all",
550        ".create(",
551        ".save(",
552        ".update(",
553        ".destroy(",
554        ".delete(",
555        "ActiveRecord::",
556        "Sequel::",
557        ".execute(",
558        ".query(",
559        "DB[",
560        "connection.exec(",
561        "PG.connect(",
562        "Mysql2::Client.new(",
563        "SQLite3::Database.new(",
564    ];
565    if let Some(note) = first_match_note(body, &db_needles) {
566        let write_markers = [
567            ".create(",
568            ".save(",
569            ".update(",
570            ".destroy(",
571            ".delete(",
572            ".execute(",
573            ".insert(",
574        ];
575        let read_markers = [
576            ".where(",
577            ".find(",
578            ".find_by(",
579            ".first",
580            ".all",
581            ".query(",
582            ".exec(",
583            "SELECT",
584        ];
585        let has_write = write_markers.iter().any(|m| body.contains(m));
586        let has_read = read_markers.iter().any(|m| body.contains(m));
587        if has_read || (!has_read && !has_write) {
588            effects.push(Effect {
589                effect: EffectCategory::IoDbRead,
590                qualifiers: serde_json::Value::Null,
591                note: Some(note.clone()),
592                ..Default::default()
593            });
594        }
595        if has_write || (!has_read && !has_write) {
596            effects.push(Effect {
597                effect: EffectCategory::IoDbWrite,
598                qualifiers: serde_json::Value::Null,
599                note: Some(note),
600                ..Default::default()
601            });
602        }
603    }
604
605    // Process spawn
606    let proc_needles = [
607        "system(",
608        "exec(",
609        "spawn(",
610        "IO.popen(",
611        "Open3.",
612        "%x{",
613        "Kernel.system(",
614        "Kernel.exec(",
615    ];
616    // Avoid false-positive on common method calls like "exec" in ActiveRecord
617    let has_proc = proc_needles.iter().any(|n| body.contains(n));
618    if has_proc {
619        if let Some(note) = first_match_note(body, &proc_needles) {
620            effects.push(Effect {
621                effect: EffectCategory::ProcSpawn,
622                qualifiers: serde_json::Value::Null,
623                note: Some(note),
624                ..Default::default()
625            });
626        }
627    }
628
629    // Env read
630    let env_needles = ["ENV[", "ENV.fetch(", "ENV.key?(", "ENV.values"];
631    if let Some(note) = first_match_note(body, &env_needles) {
632        let mut vars: Vec<String> = Vec::new();
633        for off in find_occurrences(body, "ENV[") {
634            let args = &body[off + "ENV[".len()..];
635            if let Some(v) = extract_first_string_arg(args) {
636                if !vars.contains(&v) {
637                    vars.push(v);
638                }
639            }
640        }
641        for off in find_occurrences(body, "ENV.fetch(") {
642            let args = &body[off + "ENV.fetch(".len()..];
643            if let Some(v) = extract_first_string_arg(args) {
644                if !vars.contains(&v) {
645                    vars.push(v);
646                }
647            }
648        }
649        let qualifiers = if vars.is_empty() {
650            serde_json::Value::Null
651        } else {
652            json!({ "vars": vars })
653        };
654        effects.push(Effect {
655            effect: EffectCategory::EnvRead,
656            qualifiers,
657            note: Some(note),
658            ..Default::default()
659        });
660    }
661
662    // Logging
663    let log_needles = [
664        "puts ",
665        "print ",
666        "p ",
667        "pp ",
668        "Rails.logger.",
669        "logger.",
670        "Logger.new(",
671        "Sentry.",
672        "Bugsnag.",
673    ];
674    if let Some(note) = first_match_note(body, &log_needles) {
675        effects.push(Effect {
676            effect: EffectCategory::Log,
677            qualifiers: serde_json::Value::Null,
678            note: Some(note),
679            ..Default::default()
680        });
681    }
682
683    // Time sleep
684    let sleep_needles = ["sleep(", "sleep ", "Kernel.sleep("];
685    if let Some(note) = first_match_note(body, &sleep_needles) {
686        effects.push(Effect {
687            effect: EffectCategory::TimeSleep,
688            qualifiers: serde_json::Value::Null,
689            note: Some(note),
690            ..Default::default()
691        });
692    }
693
694    // Time read
695    let time_needles = [
696        "Time.now",
697        "Time.current",
698        "Time.at(",
699        "DateTime.now",
700        "DateTime.current",
701        "Date.today",
702        "Time.zone.now",
703        "Process.clock_gettime(",
704    ];
705    if let Some(note) = first_match_note(body, &time_needles) {
706        effects.push(Effect {
707            effect: EffectCategory::TimeRead,
708            qualifiers: serde_json::Value::Null,
709            note: Some(note),
710            ..Default::default()
711        });
712    }
713
714    // Random
715    let rand_needles = [
716        "rand(",
717        "rand ",
718        "Random.rand(",
719        "Random.new(",
720        "SecureRandom.",
721        "SecureRandom.uuid",
722        "SecureRandom.hex",
723        ".sample",
724        ".shuffle",
725    ];
726    if let Some(note) = first_match_note(body, &rand_needles) {
727        effects.push(Effect {
728            effect: EffectCategory::Random,
729            qualifiers: serde_json::Value::Null,
730            note: Some(note),
731            ..Default::default()
732        });
733    }
734
735    // Throw
736    let throw_needles = ["raise ", "raise(", "fail ", "fail("];
737    if let Some(note) = first_match_note(body, &throw_needles) {
738        effects.push(Effect {
739            effect: EffectCategory::Throw,
740            qualifiers: serde_json::Value::Null,
741            note: Some(note),
742            ..Default::default()
743        });
744    }
745
746    effects
747}
748
749// -----------------------------------------------------------------------------
750// Call-edge extraction
751// -----------------------------------------------------------------------------
752
753/// Require binding: local constant name → dotted qname prefix.
754#[derive(Debug, Clone)]
755struct RequireBinding {
756    /// Constant name as used in code, e.g. `PaymentsClient`.
757    constant: String,
758    /// Dotted qname prefix for the required file, e.g. `payments.client`.
759    qname_prefix: String,
760}
761
762fn parse_requires(source: &str) -> Vec<RequireBinding> {
763    let mut out = Vec::new();
764    for line in source.lines() {
765        let t = line.trim();
766        // require 'payments/client' or require_relative '../payments/client'
767        for kw in ["require ", "require_relative "] {
768            if let Some(rest) = t.strip_prefix(kw) {
769                let path = rest
770                    .trim()
771                    .trim_matches(|c| c == '"' || c == '\'')
772                    .trim_start_matches("../");
773                // Convert path to dotted prefix and derive a constant name
774                let prefix = path.replace('/', ".");
775                // Guess the constant: last segment, CamelCase
776                let constant = path
777                    .rsplit('/')
778                    .next()
779                    .unwrap_or(path)
780                    .split('_')
781                    .map(|w| {
782                        let mut chars = w.chars();
783                        match chars.next() {
784                            None => String::new(),
785                            Some(c) => c.to_uppercase().to_string() + chars.as_str(),
786                        }
787                    })
788                    .collect::<String>();
789                if !constant.is_empty() {
790                    out.push(RequireBinding {
791                        constant,
792                        qname_prefix: prefix,
793                    });
794                }
795            }
796        }
797    }
798    out
799}
800
801fn enclosing_type_qname(qname: &str, known: &HashSet<&str>) -> Option<String> {
802    let idx = qname.rfind('.')?;
803    let parent = &qname[..idx];
804    if known.contains(parent) {
805        Some(parent.to_string())
806    } else {
807        None
808    }
809}
810
811fn collect_calls(
812    node: Node<'_>,
813    src: &[u8],
814    sym: &ParsedSymbol,
815    by_simple: &HashMap<String, String>,
816    known: &HashSet<&str>,
817    requires: &[RequireBinding],
818    workspace: &WorkspaceSymbols,
819    enclosing_type: Option<&str>,
820    edges: &mut HashSet<CallEdge>,
821) {
822    if node.kind() == "call" {
823        // receiver.method or bare method
824        let receiver_node = child_by_field(node, "receiver");
825        let method_node = child_by_field(node, "method");
826
827        let method_text = method_node.map(|n| node_text(n, src)).unwrap_or("");
828        let receiver_text = receiver_node.map(|n| node_text(n, src)).unwrap_or("");
829
830        if !method_text.is_empty() {
831            let callee = if receiver_text.is_empty() {
832                // Bare call
833                if let Some(et) = enclosing_type {
834                    let q = format!("{}.{}", et, method_text);
835                    if known.contains(q.as_str()) {
836                        Some(q)
837                    } else {
838                        by_simple.get(method_text).cloned()
839                    }
840                } else {
841                    by_simple.get(method_text).cloned()
842                }
843            } else {
844                // Qualified: check requires for the receiver constant
845                let req = requires.iter().find(|r| r.constant == receiver_text);
846                if let Some(binding) = req {
847                    let q = format!("{}.{}", binding.qname_prefix, method_text);
848                    if known.contains(q.as_str()) || workspace.contains(&q) {
849                        Some(q)
850                    } else {
851                        None
852                    }
853                } else {
854                    // Try known workspace symbols
855                    let q = format!("{}.{}", receiver_text, method_text);
856                    if known.contains(q.as_str()) || workspace.contains(&q) {
857                        Some(q)
858                    } else {
859                        None
860                    }
861                }
862            };
863
864            if let Some(callee_qname) = callee {
865                if callee_qname != sym.qname {
866                    edges.insert(CallEdge {
867                        caller_qname: sym.qname.clone(),
868                        callee_qname,
869                    });
870                }
871            }
872        }
873    }
874
875    for i in 0..node.child_count() {
876        collect_calls(
877            node.child(i).unwrap(),
878            src,
879            sym,
880            by_simple,
881            known,
882            requires,
883            workspace,
884            enclosing_type,
885            edges,
886        );
887    }
888}
889
890fn extract_call_edges_impl(
891    file: &str,
892    source: &str,
893    symbols: &[ParsedSymbol],
894    workspace: &WorkspaceSymbols,
895) -> Vec<CallEdge> {
896    let _ = file;
897    let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
898
899    let mut by_simple: HashMap<String, String> = HashMap::new();
900    for s in symbols {
901        let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
902        by_simple.entry(simple).or_insert_with(|| s.qname.clone());
903    }
904
905    let mut parser = Parser::new();
906    if parser
907        .set_language(&tree_sitter_ruby::LANGUAGE.into())
908        .is_err()
909    {
910        return Vec::new();
911    }
912
913    let requires = parse_requires(source);
914    let mut edges: HashSet<CallEdge> = HashSet::new();
915
916    for sym in symbols {
917        if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
918            continue;
919        }
920        let src_bytes = sym.body.as_bytes();
921        let tree = match parser.parse(src_bytes, None) {
922            Some(t) => t,
923            None => continue,
924        };
925        let enclosing_type = enclosing_type_qname(&sym.qname, &known);
926        collect_calls(
927            tree.root_node(),
928            src_bytes,
929            sym,
930            &by_simple,
931            &known,
932            &requires,
933            workspace,
934            enclosing_type.as_deref(),
935            &mut edges,
936        );
937    }
938
939    let mut out: Vec<CallEdge> = edges.into_iter().collect();
940    out.sort_by(|a, b| {
941        a.caller_qname
942            .cmp(&b.caller_qname)
943            .then_with(|| a.callee_qname.cmp(&b.callee_qname))
944    });
945    out
946}
947
948// -----------------------------------------------------------------------------
949// Tests
950// -----------------------------------------------------------------------------
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955    use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
956
957    fn adapter() -> RubyAdapter {
958        RubyAdapter::new()
959    }
960
961    #[test]
962    fn parses_module_class_method_singleton() {
963        let src = r#"
964module Payments
965  class PaymentService
966    def charge(customer_id, amount)
967      puts "charging #{amount}"
968    end
969
970    def self.create_client
971      new
972    end
973  end
974end
975"#;
976        let syms = adapter()
977            .parse_symbols("payments/payment_service.rb", src)
978            .unwrap();
979        let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
980        assert!(
981            qnames.contains(&"payments.payment_service.Payments"),
982            "{qnames:?}"
983        );
984        assert!(
985            qnames.contains(&"payments.payment_service.Payments.PaymentService"),
986            "{qnames:?}"
987        );
988        assert!(
989            qnames.contains(&"payments.payment_service.Payments.PaymentService.charge"),
990            "{qnames:?}"
991        );
992        assert!(
993            qnames.contains(&"payments.payment_service.Payments.PaymentService.create_client"),
994            "{qnames:?}"
995        );
996    }
997
998    #[test]
999    fn file_prefix_strips_rb_extension() {
1000        // lib/ anchor — stable for gem convention
1001        assert_eq!(file_qname_prefix("lib/myapp/parser.rb"), "myapp.parser");
1002        assert_eq!(file_qname_prefix("gems/foo/lib/foo/bar.rb"), "foo.bar");
1003        assert_eq!(file_qname_prefix("./lib/helpers.rb"), "helpers");
1004        // no lib segment — full relative path (Rails app/ or bare files)
1005        assert_eq!(file_qname_prefix("app/models/user.rb"), "app.models.user");
1006        assert_eq!(file_qname_prefix("charge.rb"), "charge");
1007    }
1008
1009    #[test]
1010    fn strip_lib_prefix_variants() {
1011        assert_eq!(strip_lib_prefix("lib/foo/bar.rb"), "foo/bar.rb");
1012        assert_eq!(strip_lib_prefix("gems/foo/lib/foo.rb"), "foo.rb");
1013        assert_eq!(strip_lib_prefix("app/models/user.rb"), "app/models/user.rb");
1014    }
1015
1016    #[test]
1017    fn infers_fs_read_and_net_out() {
1018        let src = r#"
1019class Fetcher
1020  def fetch
1021    data = File.read("/tmp/config.yml")
1022    response = HTTParty.get("https://api.example.com/v1/data")
1023    data
1024  end
1025end
1026"#;
1027        let syms = adapter().parse_symbols("fetcher.rb", src).unwrap();
1028        let fetch = syms.iter().find(|s| s.qname.ends_with(".fetch")).unwrap();
1029        let effs = adapter().infer_effects("", fetch);
1030        let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1031        assert!(cats.contains(&&EffectCategory::IoFsRead), "{cats:?}");
1032        assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1033    }
1034
1035    #[test]
1036    fn infers_db_and_env() {
1037        let src = r#"
1038class UserService
1039  def find_user(id)
1040    db_url = ENV["DATABASE_URL"]
1041    User.find(id)
1042  end
1043
1044  def create_user(attrs)
1045    User.create(attrs)
1046  end
1047end
1048"#;
1049        let syms = adapter().parse_symbols("user_service.rb", src).unwrap();
1050        let find = syms
1051            .iter()
1052            .find(|s| s.qname.ends_with(".find_user"))
1053            .unwrap();
1054        let create = syms
1055            .iter()
1056            .find(|s| s.qname.ends_with(".create_user"))
1057            .unwrap();
1058        let find_effs = adapter().infer_effects("", find);
1059        let create_effs = adapter().infer_effects("", create);
1060        assert!(
1061            find_effs
1062                .iter()
1063                .any(|e| e.effect == EffectCategory::IoDbRead)
1064        );
1065        assert!(
1066            find_effs
1067                .iter()
1068                .any(|e| e.effect == EffectCategory::EnvRead)
1069        );
1070        assert!(
1071            create_effs
1072                .iter()
1073                .any(|e| e.effect == EffectCategory::IoDbWrite)
1074        );
1075
1076        // Check env var extraction
1077        let env_eff = find_effs
1078            .iter()
1079            .find(|e| e.effect == EffectCategory::EnvRead)
1080            .unwrap();
1081        if let Some(vars) = env_eff.qualifiers.get("vars") {
1082            let vars: Vec<&str> = vars
1083                .as_array()
1084                .unwrap()
1085                .iter()
1086                .map(|v| v.as_str().unwrap())
1087                .collect();
1088            assert!(vars.contains(&"DATABASE_URL"), "{vars:?}");
1089        }
1090    }
1091
1092    #[test]
1093    fn empty_when_no_patterns() {
1094        let src = r#"
1095class MathHelper
1096  def add(a, b)
1097    a + b
1098  end
1099end
1100"#;
1101        let syms = adapter().parse_symbols("math.rb", src).unwrap();
1102        let add = syms.iter().find(|s| s.qname.ends_with(".add")).unwrap();
1103        let effs = adapter().infer_effects("", add);
1104        assert!(effs.is_empty(), "{effs:?}");
1105    }
1106
1107    #[test]
1108    fn extracts_intra_class_call_edges() {
1109        let src = r#"
1110class OrderService
1111  def place_order(order)
1112    charge(order.customer_id, order.total)
1113  end
1114
1115  def charge(customer_id, amount)
1116    # process
1117  end
1118end
1119"#;
1120        let ws = WorkspaceSymbols {
1121            qnames: HashSet::new(),
1122            kinds: HashMap::new(),
1123            ..Default::default()
1124        };
1125        let syms = adapter().parse_symbols("order_service.rb", src).unwrap();
1126        let edges = adapter().extract_call_edges("order_service.rb", src, &syms, &ws);
1127        let found = edges.iter().any(|e| {
1128            e.caller_qname.ends_with(".place_order") && e.callee_qname.ends_with(".charge")
1129        });
1130        assert!(found, "expected intra-class edge; got: {edges:?}");
1131    }
1132}
1133
1134#[cfg(test)]
1135mod service_endpoint_tests {
1136    use super::*;
1137    use agentstatedeveloper_core::adapter::LanguageAdapter;
1138
1139    fn detect(src: &str) -> Vec<DetectedEndpoint> {
1140        let a = RubyAdapter;
1141        let symbols = a.parse_symbols("app.rb", src).unwrap();
1142        a.infer_service_endpoints("app.rb", src, &symbols)
1143    }
1144    fn inbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1145        eps.iter()
1146            .filter(|e| e.direction == Direction::Inbound)
1147            .collect()
1148    }
1149    fn outbound(eps: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1150        eps.iter()
1151            .filter(|e| e.direction == Direction::Outbound)
1152            .collect()
1153    }
1154
1155    #[test]
1156    fn sinatra_route_inbound() {
1157        // Sinatra routes live inside the app class, so they attribute to it.
1158        let sinatra = "class App < Sinatra::Base\n  get '/users/:id' do\n    body\n  end\nend\n";
1159        let eps = detect(sinatra);
1160        assert_eq!(inbound(&eps)[0].contract, "http:GET /users/{}", "{eps:?}");
1161    }
1162
1163    #[test]
1164    fn top_level_route_without_enclosing_symbol_is_dropped() {
1165        // Rails routes.rb routes sit at top level (no class/def), so there's no
1166        // symbol to attribute them to — a known limitation.
1167        let rails = "Rails.application.routes.draw do\n  post '/charge', to: 'x#y'\nend\n";
1168        assert!(inbound(&detect(rails)).is_empty(), "{:?}", detect(rails));
1169    }
1170
1171    #[test]
1172    fn method_call_get_is_not_a_route() {
1173        // `obj.get('/x')` is a method call, not a route DSL line.
1174        let src = "def f\n  cache.get('/x')\n  h.delete('key')\nend\n";
1175        assert!(inbound(&detect(src)).is_empty(), "{:?}", detect(src));
1176    }
1177
1178    #[test]
1179    fn restclient_and_httparty_clients() {
1180        let src = "def call\n  RestClient.get('https://users.svc/users/:id')\n  HTTParty.post('/charge', body)\nend\n";
1181        let eps = detect(src);
1182        let mut got: Vec<String> = outbound(&eps).iter().map(|e| e.contract.clone()).collect();
1183        got.sort();
1184        assert_eq!(
1185            got,
1186            vec![
1187                "http:GET /users/{}".to_string(),
1188                "http:POST /charge".to_string()
1189            ]
1190        );
1191    }
1192
1193    #[test]
1194    fn interpolated_url_is_skipped() {
1195        let src = "def call\n  RestClient.get(\"#{base}/users\")\nend\n";
1196        assert!(outbound(&detect(src)).is_empty(), "{:?}", detect(src));
1197    }
1198}