1use 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#[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
81fn 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
106fn 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
155fn 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
168fn 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 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 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
241fn 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
319const 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
383fn 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
397fn 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
413fn 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 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 let fs_write_needles = [
455 "File.write(",
456 "File.open(", "IO.write(",
458 "FileUtils.cp(",
459 "FileUtils.mv(",
460 "FileUtils.mkdir",
461 "FileUtils.rm",
462 "File.delete(",
463 "File.rename(",
464 ];
465 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; 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 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 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 let proc_needles = [
607 "system(",
608 "exec(",
609 "spawn(",
610 "IO.popen(",
611 "Open3.",
612 "%x{",
613 "Kernel.system(",
614 "Kernel.exec(",
615 ];
616 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 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 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 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 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 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 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#[derive(Debug, Clone)]
755struct RequireBinding {
756 constant: String,
758 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 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 let prefix = path.replace('/', ".");
775 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 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 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 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 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#[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 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 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 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 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 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 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}