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};
19use tree_sitter_language::LanguageFn;
20
21unsafe extern "C" {
22 fn tree_sitter_swift() -> *const ();
23}
24const SWIFT: LanguageFn = unsafe { LanguageFn::from_raw(tree_sitter_swift) };
25
26#[derive(Debug, Default, Clone, Copy)]
28pub struct SwiftAdapter;
29
30impl SwiftAdapter {
31 pub fn new() -> Self {
32 Self
33 }
34}
35
36impl LanguageAdapter for SwiftAdapter {
37 fn language(&self) -> &str {
38 "swift"
39 }
40
41 fn file_extensions(&self) -> &'static [&'static str] {
42 &["swift"]
43 }
44
45 fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>> {
46 let mut parser = Parser::new();
47 parser
48 .set_language(&SWIFT.into())
49 .map_err(|e| AsdError::Parse(format!("failed to set swift language: {e}")))?;
50
51 let src_bytes = source.as_bytes();
52 let tree = parser
53 .parse(src_bytes, None)
54 .ok_or_else(|| AsdError::Parse(format!("failed to parse {file}")))?;
55
56 let file_prefix = file_qname_prefix(file);
57 let root = tree.root_node();
58 let mut out = Vec::new();
59 walk(root, src_bytes, &file_prefix, &mut out);
60 Ok(out)
61 }
62
63 fn infer_effects(&self, _source: &str, symbol: &ParsedSymbol) -> Vec<Effect> {
64 infer_effects_from_body(&symbol.body)
65 }
66
67 fn infer_service_endpoints(
68 &self,
69 file: &str,
70 source: &str,
71 symbols: &[ParsedSymbol],
72 ) -> Vec<DetectedEndpoint> {
73 infer_service_endpoints_in_swift(file, source, symbols)
74 }
75
76 fn extract_call_edges(
77 &self,
78 file: &str,
79 source: &str,
80 symbols: &[ParsedSymbol],
81 workspace: &WorkspaceSymbols,
82 ) -> Vec<CallEdge> {
83 extract_call_edges_impl(file, source, symbols, workspace)
84 }
85
86 fn extract_property_types(
87 &self,
88 symbols: &[ParsedSymbol],
89 ) -> std::collections::HashMap<String, String> {
90 build_property_type_map(symbols)
91 }
92}
93
94fn strip_sources_prefix(path: &str) -> &str {
116 let mut offset = 0usize;
117 for part in path.split('/') {
118 if matches!(part, "Sources" | "Source" | "src") {
119 let after = offset + part.len() + 1; if after < path.len() {
121 return &path[after..];
122 }
123 }
124 offset += part.len() + 1;
125 }
126 path
127}
128
129fn file_qname_prefix(file: &str) -> String {
137 let s = file.strip_prefix("./").unwrap_or(file);
138 let s = s.strip_suffix(".swift").unwrap_or(s);
139 let s = strip_sources_prefix(s);
140 let joined = s.replace('/', ".");
141 dedupe_consecutive_segments(&joined)
142}
143
144fn dedupe_consecutive_segments(qname: &str) -> String {
159 let mut out: Vec<&str> = Vec::with_capacity(qname.split('.').count());
160 for seg in qname.split('.') {
161 if out.last() != Some(&seg) {
162 out.push(seg);
163 }
164 }
165 out.join(".")
166}
167
168fn node_text<'a>(node: Node<'_>, src: &'a [u8]) -> &'a str {
169 node.utf8_text(src).unwrap_or("")
170}
171
172fn child_by_field<'a>(node: Node<'a>, field: &str) -> Option<Node<'a>> {
173 node.child_by_field_name(field)
174}
175
176fn find_child_by_kind<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
177 for i in 0..node.child_count() {
178 let c = node.child(i).unwrap();
179 if c.kind() == kind {
180 return Some(c);
181 }
182 }
183 None
184}
185
186fn join_qname(prefix: &str, name: &str) -> String {
187 if prefix.is_empty() {
188 name.to_string()
189 } else {
190 format!("{}.{}", prefix, name)
191 }
192}
193
194fn make_symbol(
195 node: Node<'_>,
196 src: &[u8],
197 qname: String,
198 kind: SymbolKind,
199 signature: Option<String>,
200) -> ParsedSymbol {
201 ParsedSymbol {
202 qname,
203 kind,
204 start_line: node.start_position().row as u32 + 1,
205 start_col: node.start_position().column as u32,
206 end_line: node.end_position().row as u32 + 1,
207 end_col: node.end_position().column as u32,
208 body: node_text(node, src).to_string(),
209 signature,
210 doc: None,
211 }
212}
213
214fn extract_function_signature(node: Node<'_>, src: &[u8]) -> Option<String> {
221 let start = node.start_byte();
222 let end = node.end_byte();
223 let text = std::str::from_utf8(&src[start..end]).ok()?;
224 let bytes = text.as_bytes();
225
226 let mut depth: i32 = 0;
227 let mut i = 0;
228 let mut sig_end = text.len(); while i < bytes.len() {
231 match bytes[i] {
232 b'"' => {
234 i += 1;
235 while i < bytes.len() {
236 if bytes[i] == b'\\' {
237 i += 2;
238 continue;
239 }
240 if bytes[i] == b'"' {
241 break;
242 }
243 i += 1;
244 }
245 }
246 b'(' | b'[' => depth += 1,
247 b')' | b']' => {
248 if depth > 0 {
249 depth -= 1;
250 }
251 }
252 b'{' if depth == 0 => {
253 sig_end = i;
254 break;
255 }
256 _ => {}
257 }
258 i += 1;
259 }
260
261 let sig = text[..sig_end].trim().to_string();
262 if sig.is_empty() { None } else { Some(sig) }
263}
264
265fn walk(node: Node<'_>, src: &[u8], scope: &str, out: &mut Vec<ParsedSymbol>) {
270 match node.kind() {
271 "source_file" => {
272 for i in 0..node.child_count() {
273 walk(node.child(i).unwrap(), src, scope, out);
274 }
275 }
276 "class_declaration" => {
279 let name = child_by_field(node, "name")
280 .map(|n| node_text(n, src))
281 .unwrap_or("");
282 if name.is_empty() {
283 return;
284 }
285 let decl_kind = child_by_field(node, "declaration_kind")
286 .map(|n| node_text(n, src))
287 .unwrap_or("class");
288 let qname = join_qname(scope, name);
289 if decl_kind != "extension" {
291 out.push(make_symbol(
292 node,
293 src,
294 qname.clone(),
295 SymbolKind::Class,
296 None,
297 ));
298 }
299 if let Some(body) = child_by_field(node, "body")
301 .or_else(|| find_child_by_kind(node, "class_body"))
302 .or_else(|| find_child_by_kind(node, "enum_class_body"))
303 {
304 for i in 0..body.child_count() {
305 walk(body.child(i).unwrap(), src, &qname, out);
306 }
307 }
308 }
309 "protocol_declaration" => {
310 let name = child_by_field(node, "name")
311 .map(|n| node_text(n, src))
312 .unwrap_or("");
313 if name.is_empty() {
314 return;
315 }
316 let qname = join_qname(scope, name);
317 out.push(make_symbol(
318 node,
319 src,
320 qname.clone(),
321 SymbolKind::Class,
322 None,
323 ));
324 if let Some(body) =
325 child_by_field(node, "body").or_else(|| find_child_by_kind(node, "protocol_body"))
326 {
327 for i in 0..body.child_count() {
328 walk(body.child(i).unwrap(), src, &qname, out);
329 }
330 }
331 }
332 "function_declaration" | "protocol_function_declaration" => {
333 let name = child_by_field(node, "name")
334 .map(|n| node_text(n, src))
335 .unwrap_or("");
336 if name.is_empty() {
337 return;
338 }
339 let qname = join_qname(scope, name);
340 let kind = if scope.contains('.') {
341 SymbolKind::Method
342 } else {
343 SymbolKind::Function
344 };
345 let sig = extract_function_signature(node, src);
346 out.push(make_symbol(node, src, qname, kind, sig));
347 }
348 "init_declaration" => {
349 let qname = join_qname(scope, "init");
350 let sig = extract_function_signature(node, src);
351 out.push(make_symbol(node, src, qname, SymbolKind::Function, sig));
352 }
353 _ => {
354 for i in 0..node.child_count() {
355 walk(node.child(i).unwrap(), src, scope, out);
356 }
357 }
358 }
359}
360
361fn find_occurrences(body: &str, needle: &str) -> Vec<usize> {
366 let mut out = Vec::new();
367 let mut from = 0;
368 while let Some(idx) = body[from..].find(needle) {
369 out.push(from + idx);
370 from += idx + needle.len();
371 }
372 out
373}
374
375fn extract_url_host(s: &str) -> Option<String> {
376 for scheme in ["https://", "http://"] {
377 if let Some(idx) = s.find(scheme) {
378 let tail = &s[idx + scheme.len()..];
379 let end = tail
380 .find(|c: char| c == '/' || c == '"' || c == ')' || c.is_whitespace())
381 .unwrap_or(tail.len());
382 let host = &tail[..end];
383 if !host.is_empty() {
384 return Some(host.to_string());
385 }
386 }
387 }
388 None
389}
390
391fn first_matching_line(body: &str, needles: &[&str]) -> Option<String> {
392 for line in body.lines() {
393 for n in needles {
394 if line.contains(n) {
395 return Some(line.trim().to_string());
396 }
397 }
398 }
399 None
400}
401
402fn first_match_note(body: &str, needles: &[&str]) -> Option<String> {
403 for n in needles {
404 if body.contains(n) {
405 return first_matching_line(body, needles);
406 }
407 }
408 None
409}
410
411fn extract_first_string_literal(s: &str) -> Option<String> {
412 let trimmed = s.trim_start();
413 let bytes = trimmed.as_bytes();
414 if bytes.is_empty() {
415 return None;
416 }
417 if bytes[0] != b'"' {
418 return None;
419 }
420 let mut j = 1;
421 let mut out = String::new();
422 while j < bytes.len() {
423 let c = bytes[j];
424 if c == b'\\' && j + 1 < bytes.len() {
425 out.push(bytes[j + 1] as char);
426 j += 2;
427 continue;
428 }
429 if c == b'"' {
430 return Some(out);
431 }
432 out.push(c as char);
433 j += 1;
434 }
435 None
436}
437
438const SW_VERBS: &[&str] = &["get", "post", "put", "patch", "delete"];
449
450fn infer_service_endpoints_in_swift(
451 file: &str,
452 source: &str,
453 symbols: &[ParsedSymbol],
454) -> Vec<DetectedEndpoint> {
455 let mut out = Vec::new();
456 for (idx, raw) in source.lines().enumerate() {
457 let line_no = (idx + 1) as u32;
458 let line = raw.trim();
459 if let Some((method, path)) = vapor_route(line) {
460 if let Some(owner) = sw_owner_for_body(symbols, line_no) {
461 out.push(DetectedEndpoint {
462 transport: Transport::Http,
463 direction: Direction::Inbound,
464 contract: http_contract(&method, &path),
465 owner_qname: owner.qname.clone(),
466 file: file.to_string(),
467 line: line_no,
468 confidence: 0.85,
469 note: None,
470 });
471 }
472 }
473 }
474 out
475}
476
477fn sw_owner_for_body(symbols: &[ParsedSymbol], line: u32) -> Option<&ParsedSymbol> {
478 symbols
479 .iter()
480 .filter(|s| s.start_line <= line && line <= s.end_line)
481 .max_by_key(|s| s.start_line)
482}
483
484fn vapor_route(line: &str) -> Option<(String, String)> {
487 for verb in SW_VERBS {
488 let needle = format!(".{verb}(");
489 let Some(pos) = line.find(&needle) else {
490 continue;
491 };
492 let args = &line[pos + needle.len()..];
493 let is_route = args.contains("use:") || args.contains('{');
496 if !is_route {
497 continue;
498 }
499 let comps = swift_path_components(args);
500 if comps.is_empty() {
501 continue;
502 }
503 return Some((verb.to_uppercase(), format!("/{}", comps.join("/"))));
504 }
505 None
506}
507
508fn swift_path_components(args: &str) -> Vec<String> {
511 let mut comps = Vec::new();
512 for part in args.split(',') {
513 let p = part.trim();
514 if p.starts_with('"') {
515 match first_swift_string(p) {
516 Some(lit) => comps.push(lit),
517 None => break,
518 }
519 } else {
520 break;
521 }
522 }
523 comps
524}
525
526fn first_swift_string(s: &str) -> Option<String> {
528 let s = s.trim_start();
529 if !s.starts_with('"') {
530 return None;
531 }
532 let inner = &s[1..];
533 let end = inner.find('"')?;
534 let val = &inner[..end];
535 if val.contains("\\(") {
536 return None;
537 }
538 Some(val.to_string())
539}
540
541fn infer_effects_from_body(body: &str) -> Vec<Effect> {
542 let mut effects: Vec<Effect> = Vec::new();
543
544 let fs_read_needles = [
546 "FileManager.default",
547 "contentsOfFile:",
548 "contentsOfDirectory(",
549 "String(contentsOf:",
550 "Data(contentsOf:",
551 "FileHandle(forReadingAtPath:",
552 "FileHandle.init(forReadingFrom:",
553 ];
554 if let Some(note) = first_match_note(body, &fs_read_needles) {
555 effects.push(Effect {
556 effect: EffectCategory::IoFsRead,
557 qualifiers: serde_json::Value::Null,
558 note: Some(note),
559 ..Default::default()
560 });
561 }
562
563 let fs_write_needles = [
565 "write(to:",
566 "write(toFile:",
567 "createFile(atPath:",
568 "createDirectory(",
569 "moveItem(",
570 "copyItem(",
571 "removeItem(",
572 "FileHandle(forWritingAtPath:",
573 "FileHandle.init(forWritingTo:",
574 ];
575 if let Some(note) = first_match_note(body, &fs_write_needles) {
576 effects.push(Effect {
577 effect: EffectCategory::IoFsWrite,
578 qualifiers: serde_json::Value::Null,
579 note: Some(note),
580 ..Default::default()
581 });
582 }
583
584 let net_needles = [
586 "URLSession.",
587 "URLSession.shared",
588 "dataTask(with:",
589 "AF.request(",
590 "Alamofire.",
591 "URLRequest(",
592 "session.dataTask(",
593 "session.downloadTask(",
594 "session.uploadTask(",
595 "Moya",
596 "Apollo.",
597 ];
598 let mut net_hosts: Vec<String> = Vec::new();
599 let mut net_note: Option<String> = None;
600 for needle in net_needles {
601 if body.contains(needle) && net_note.is_none() {
602 net_note = first_matching_line(body, &[needle]);
603 for off in find_occurrences(body, needle) {
604 let end = (off + 200).min(body.len());
605 let snippet = &body[off..end];
606 if let Some(host) = extract_url_host(snippet) {
607 if !net_hosts.contains(&host) {
608 net_hosts.push(host);
609 }
610 }
611 }
612 }
613 }
614 if net_note.is_some() {
615 let qualifiers = if net_hosts.is_empty() {
616 serde_json::Value::Null
617 } else {
618 json!({ "hosts": net_hosts })
619 };
620 effects.push(Effect {
621 effect: EffectCategory::IoNetOut,
622 qualifiers,
623 note: net_note,
624 ..Default::default()
625 });
626 }
627
628 let db_needles = [
630 "CoreData",
631 "NSManagedObject",
632 "NSFetchRequest(",
633 "viewContext.",
634 "context.save(",
635 "context.fetch(",
636 "GRDB",
637 "SQLite.",
638 "Realm(",
639 "realm.write {",
640 "realm.objects(",
641 ];
642 if let Some(note) = first_match_note(body, &db_needles) {
643 let has_write = body.contains("context.save(")
644 || body.contains("realm.write {")
645 || body.contains(".insert(")
646 || body.contains(".delete(");
647 let has_read = body.contains("context.fetch(")
648 || body.contains("NSFetchRequest(")
649 || body.contains("realm.objects(")
650 || body.contains(".filter(");
651 if has_read || (!has_read && !has_write) {
652 effects.push(Effect {
653 effect: EffectCategory::IoDbRead,
654 qualifiers: serde_json::Value::Null,
655 note: Some(note.clone()),
656 ..Default::default()
657 });
658 }
659 if has_write || (!has_read && !has_write) {
660 effects.push(Effect {
661 effect: EffectCategory::IoDbWrite,
662 qualifiers: serde_json::Value::Null,
663 note: Some(note),
664 ..Default::default()
665 });
666 }
667 }
668
669 let proc_needles = ["Process(", "Process.launchedProcess(", "NSTask("];
671 if let Some(note) = first_match_note(body, &proc_needles) {
672 effects.push(Effect {
673 effect: EffectCategory::ProcSpawn,
674 qualifiers: serde_json::Value::Null,
675 note: Some(note),
676 ..Default::default()
677 });
678 }
679
680 let env_needles = [
682 "ProcessInfo.processInfo.environment",
683 "ProcessInfo.processInfo.environment[",
684 "getenv(",
685 ];
686 if let Some(note) = first_match_note(body, &env_needles) {
687 let mut vars: Vec<String> = Vec::new();
688 for off in find_occurrences(body, "ProcessInfo.processInfo.environment[") {
689 let args = &body[off + "ProcessInfo.processInfo.environment[".len()..];
690 if let Some(v) = extract_first_string_literal(args) {
691 if !vars.contains(&v) {
692 vars.push(v);
693 }
694 }
695 }
696 let qualifiers = if vars.is_empty() {
697 serde_json::Value::Null
698 } else {
699 json!({ "vars": vars })
700 };
701 effects.push(Effect {
702 effect: EffectCategory::EnvRead,
703 qualifiers,
704 note: Some(note),
705 ..Default::default()
706 });
707 }
708
709 let log_needles = [
711 "print(",
712 "debugPrint(",
713 "NSLog(",
714 "os.log",
715 "Logger(",
716 "os_log(",
717 "logger.debug(",
718 "logger.info(",
719 "logger.error(",
720 "Logger.shared",
721 ];
722 if let Some(note) = first_match_note(body, &log_needles) {
723 effects.push(Effect {
724 effect: EffectCategory::Log,
725 qualifiers: serde_json::Value::Null,
726 note: Some(note),
727 ..Default::default()
728 });
729 }
730
731 let sleep_needles = [
733 "Thread.sleep(",
734 "usleep(",
735 "sleep(",
736 "Task.sleep(",
737 "try await Task.sleep(",
738 ];
739 if let Some(note) = first_match_note(body, &sleep_needles) {
740 effects.push(Effect {
741 effect: EffectCategory::TimeSleep,
742 qualifiers: serde_json::Value::Null,
743 note: Some(note),
744 ..Default::default()
745 });
746 }
747
748 let time_needles = [
750 "Date()",
751 "Date.now",
752 "Date.timeIntervalSinceReferenceDate",
753 "CFAbsoluteTimeGetCurrent()",
754 "DispatchTime.now()",
755 "Clock.now",
756 "ProcessInfo.processInfo.systemUptime",
757 ];
758 if let Some(note) = first_match_note(body, &time_needles) {
759 effects.push(Effect {
760 effect: EffectCategory::TimeRead,
761 qualifiers: serde_json::Value::Null,
762 note: Some(note),
763 ..Default::default()
764 });
765 }
766
767 let rand_needles = [
769 ".random(",
770 ".shuffled(",
771 "Int.random(",
772 "Double.random(",
773 "Float.random(",
774 "Bool.random()",
775 "arc4random(",
776 "arc4random_uniform(",
777 "UUID()",
778 "SystemRandomNumberGenerator()",
779 ];
780 if let Some(note) = first_match_note(body, &rand_needles) {
781 effects.push(Effect {
782 effect: EffectCategory::Random,
783 qualifiers: serde_json::Value::Null,
784 note: Some(note),
785 ..Default::default()
786 });
787 }
788
789 let throw_needles = [
791 "throw ",
792 "fatalError(",
793 "preconditionFailure(",
794 "assertionFailure(",
795 ];
796 if let Some(note) = first_match_note(body, &throw_needles) {
797 effects.push(Effect {
798 effect: EffectCategory::Throw,
799 qualifiers: serde_json::Value::Null,
800 note: Some(note),
801 ..Default::default()
802 });
803 }
804
805 effects
806}
807
808fn strip_property_prefixes(s: &str) -> &str {
815 let mut rest = s;
816 loop {
817 let prev = rest;
818 if rest.starts_with('@') {
820 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
821 let after_attr = rest[end..].trim_start();
822 if after_attr.starts_with('(') {
823 let close = after_attr
824 .find(')')
825 .unwrap_or(after_attr.len().saturating_sub(1));
826 rest = after_attr[close + 1..].trim_start();
827 } else {
828 rest = after_attr;
829 }
830 continue;
831 }
832 for prefix in &[
833 "private(set) ",
834 "public(set) ",
835 "internal(set) ",
836 "private ",
837 "public ",
838 "internal ",
839 "fileprivate ",
840 "open ",
841 "weak ",
842 "unowned(safe) ",
843 "unowned(unsafe) ",
844 "unowned ",
845 "lazy ",
846 "static ",
847 "override ",
848 "final ",
849 "nonisolated ",
850 "isolated ",
851 ] {
852 if let Some(after) = rest.strip_prefix(prefix) {
853 rest = after.trim_start();
854 break;
855 }
856 }
857 if rest == prev {
858 break;
859 }
860 }
861 rest
862}
863
864fn parse_property_line(line: &str) -> Option<(String, String)> {
877 let trimmed = line.trim();
878 if trimmed.is_empty()
879 || trimmed.starts_with("//")
880 || trimmed.starts_with("/*")
881 || trimmed.starts_with("func ")
882 || trimmed.starts_with("init(")
883 || trimmed.starts_with("deinit")
884 || trimmed.starts_with("class ")
885 || trimmed.starts_with("struct ")
886 || trimmed.starts_with("enum ")
887 || trimmed.starts_with("case ")
888 || trimmed.starts_with("typealias ")
889 || trimmed.starts_with("subscript")
890 {
891 return None;
892 }
893
894 let rest = strip_property_prefixes(trimmed);
895
896 let after_binding = rest
898 .strip_prefix("let ")
899 .or_else(|| rest.strip_prefix("var "))
900 .or_else(|| rest.strip_prefix("let\t"))
901 .or_else(|| rest.strip_prefix("var\t"))?
902 .trim_start();
903
904 let name_end = after_binding
906 .find(|c: char| !c.is_alphanumeric() && c != '_')
907 .unwrap_or(after_binding.len());
908 let name = &after_binding[..name_end];
909 if name.is_empty() {
910 return None;
911 }
912
913 let rest_after_name = after_binding[name_end..].trim_start();
914
915 if let Some(after_colon) = rest_after_name.strip_prefix(':') {
917 let type_str = after_colon.trim_start();
918
919 let type_end = type_str
921 .find(|c: char| c == '=' || c == '{')
922 .unwrap_or(type_str.len());
923 let raw_type = type_str[..type_end].trim();
924
925 let type_name = raw_type.trim_end_matches('?').trim_end_matches('!').trim();
927
928 let type_name = type_name
931 .strip_prefix("any ")
932 .or_else(|| type_name.strip_prefix("some "))
933 .unwrap_or(type_name)
934 .trim();
935
936 let base_type = type_name
938 .find('<')
939 .map(|i| &type_name[..i])
940 .unwrap_or(type_name)
941 .trim();
942
943 if base_type.is_empty() || !base_type.starts_with(|c: char| c.is_uppercase()) {
945 return None;
946 }
947
948 return Some((name.to_string(), base_type.to_string()));
949 }
950
951 if let Some(after_eq) = rest_after_name.strip_prefix('=') {
954 let after_eq = after_eq.trim_start();
955
956 let token_end = after_eq
958 .find(|c: char| c == '(' || c == '.' || c == ' ' || c == '\t' || c == '{')
959 .unwrap_or(after_eq.len());
960 let token = &after_eq[..token_end];
961
962 if token.is_empty() || !token.starts_with(|c: char| c.is_uppercase()) {
964 return None;
965 }
966
967 return Some((name.to_string(), token.to_string()));
968 }
969
970 None
971}
972
973fn build_property_type_map(symbols: &[ParsedSymbol]) -> HashMap<String, String> {
976 let mut map = HashMap::new();
977 for sym in symbols {
978 if !matches!(sym.kind, SymbolKind::Class) {
979 continue;
980 }
981 let type_simple = sym.qname.rsplit('.').next().unwrap_or(&sym.qname);
982 for line in sym.body.lines() {
983 if let Some((prop_name, type_name)) = parse_property_line(line) {
984 map.insert(format!("{}.{}", type_simple, prop_name), type_name);
985 }
986 }
987 }
988 map
989}
990
991fn enclosing_type_qname(
1017 qname: &str,
1018 known: &HashSet<&str>,
1019 workspace: &WorkspaceSymbols,
1020) -> Option<String> {
1021 let idx = qname.rfind('.')?;
1022 let parent = &qname[..idx];
1023
1024 if known.contains(parent) || workspace.contains(parent) {
1026 return Some(parent.to_string());
1027 }
1028
1029 let class_name = parent.rsplit('.').next()?;
1033 if !parent.contains('.') {
1036 return None;
1037 }
1038 workspace.find_by_suffix(class_name).map(|s| s.to_string())
1039}
1040
1041fn resolve_instance_method(
1054 type_name: &str,
1055 method: &str,
1056 workspace: &WorkspaceSymbols,
1057) -> Option<String> {
1058 workspace
1059 .find_by_suffix(&format!("{type_name}.{method}"))
1060 .map(|s| s.to_string())
1061 .or_else(|| {
1062 workspace
1063 .find_by_suffix(&format!("{type_name}.{type_name}.{method}"))
1064 .map(|s| s.to_string())
1065 })
1066}
1067
1068fn collect_calls(
1069 node: Node<'_>,
1070 src: &[u8],
1071 sym: &ParsedSymbol,
1072 by_simple: &HashMap<String, String>,
1073 known: &HashSet<&str>,
1074 workspace: &WorkspaceSymbols,
1075 enclosing_type: Option<&str>,
1076 prop_map: &HashMap<String, String>,
1077 edges: &mut HashSet<CallEdge>,
1078) {
1079 if node.kind() == "call_expression" {
1080 if let Some(func_node) = node.child(0) {
1081 let (receiver, method) = if func_node.kind() == "navigation_expression"
1082 || func_node.kind() == "member_expression"
1083 {
1084 let recv = func_node.child(0).map(|n| node_text(n, src)).unwrap_or("");
1085 let method = find_child_by_kind(func_node, "navigation_suffix")
1090 .and_then(|suffix| find_child_by_kind(suffix, "simple_identifier"))
1091 .map(|n| node_text(n, src))
1092 .or_else(|| child_by_field(func_node, "name").map(|n| node_text(n, src)))
1093 .unwrap_or("");
1094 (recv, method)
1095 } else {
1096 ("", node_text(func_node, src))
1097 };
1098
1099 if !method.is_empty() {
1100 let callee = if receiver.is_empty() {
1101 if let Some(et) = enclosing_type {
1102 let q = format!("{}.{}", et, method);
1103 if known.contains(q.as_str()) {
1104 Some(q)
1105 } else {
1106 by_simple.get(method).cloned().or_else(|| {
1107 workspace
1108 .find_by_suffix(&format!("{}.{}", et, method))
1109 .map(|s| s.to_string())
1110 })
1111 }
1112 } else {
1113 by_simple.get(method).cloned()
1114 }
1115 } else {
1116 let simple_recv = receiver.rsplit('.').next().unwrap_or(receiver);
1119 let q = format!("{}.{}", simple_recv, method);
1120 if known.contains(q.as_str()) || workspace.contains(&q) {
1121 Some(q)
1122 } else if let Some(s) = workspace.find_by_suffix(&q) {
1123 Some(s.to_string())
1125 } else if simple_recv == "Self" || simple_recv == "self" {
1126 if let Some(et) = enclosing_type {
1130 let q2 = format!("{}.{}", et, method);
1131 if known.contains(q2.as_str()) {
1132 Some(q2)
1133 } else {
1134 workspace
1135 .find_by_suffix(&format!("{}.{}", et, method))
1136 .map(|s| s.to_string())
1137 }
1138 } else {
1139 None
1140 }
1141 } else if simple_recv.starts_with(|c: char| c.is_uppercase()) {
1142 let et_simple = sym.qname.split('.').rev().nth(1).unwrap_or("");
1148 if !et_simple.is_empty() && simple_recv == et_simple {
1149 if let Some(et) = enclosing_type {
1150 workspace
1151 .find_by_suffix(&format!("{}.{}", et, method))
1152 .map(|s| s.to_string())
1153 } else {
1154 None
1155 }
1156 } else {
1157 None
1160 }
1161 } else if simple_recv.starts_with(|c: char| c.is_lowercase()) {
1162 let et_simple = sym.qname.split('.').rev().nth(1).unwrap_or("");
1170 if !et_simple.is_empty() {
1171 let prop_key = format!("{}.{}", et_simple, simple_recv);
1172
1173 let via_prop_map = prop_map.get(&prop_key).and_then(|actual_type| {
1179 resolve_instance_method(actual_type, method, workspace)
1180 });
1181
1182 if via_prop_map.is_some() {
1183 via_prop_map
1184 } else {
1185 let capitalized = {
1192 let mut s = simple_recv.to_string();
1193 if let Some(first) = s.get_mut(0..1) {
1194 first.make_ascii_uppercase();
1195 }
1196 s
1197 };
1198 resolve_instance_method(&capitalized, method, workspace)
1199 }
1200 } else {
1201 None
1202 }
1203 } else {
1204 None
1205 }
1206 };
1207
1208 if let Some(callee_qname) = callee {
1209 if callee_qname != sym.qname {
1210 edges.insert(CallEdge {
1211 caller_qname: sym.qname.clone(),
1212 callee_qname,
1213 });
1214 }
1215 }
1216 }
1217 }
1218 }
1219
1220 for i in 0..node.child_count() {
1221 collect_calls(
1222 node.child(i).unwrap(),
1223 src,
1224 sym,
1225 by_simple,
1226 known,
1227 workspace,
1228 enclosing_type,
1229 prop_map,
1230 edges,
1231 );
1232 }
1233}
1234
1235fn extract_call_edges_impl(
1236 file: &str,
1237 _source: &str,
1238 symbols: &[ParsedSymbol],
1239 workspace: &WorkspaceSymbols,
1240) -> Vec<CallEdge> {
1241 let _ = file;
1242 let known: HashSet<&str> = symbols.iter().map(|s| s.qname.as_str()).collect();
1243
1244 let mut by_simple: HashMap<String, String> = HashMap::new();
1245 for s in symbols {
1246 let simple = s.qname.rsplit('.').next().unwrap_or(&s.qname).to_string();
1247 by_simple.entry(simple).or_insert_with(|| s.qname.clone());
1248 }
1249
1250 let local_prop_map;
1256 let prop_map: &HashMap<String, String> = if !workspace.properties.is_empty() {
1257 &workspace.properties
1258 } else {
1259 local_prop_map = build_property_type_map(symbols);
1260 &local_prop_map
1261 };
1262
1263 let mut parser = Parser::new();
1264 if parser.set_language(&SWIFT.into()).is_err() {
1265 return Vec::new();
1266 }
1267
1268 let mut edges: HashSet<CallEdge> = HashSet::new();
1269
1270 for sym in symbols {
1271 if !matches!(sym.kind, SymbolKind::Function | SymbolKind::Method) {
1272 continue;
1273 }
1274 let src_bytes = sym.body.as_bytes();
1275 let tree = match parser.parse(src_bytes, None) {
1276 Some(t) => t,
1277 None => continue,
1278 };
1279 let enclosing_type = enclosing_type_qname(&sym.qname, &known, workspace);
1280 collect_calls(
1281 tree.root_node(),
1282 src_bytes,
1283 sym,
1284 &by_simple,
1285 &known,
1286 workspace,
1287 enclosing_type.as_deref(),
1288 &prop_map,
1289 &mut edges,
1290 );
1291 }
1292
1293 let mut out: Vec<CallEdge> = edges.into_iter().collect();
1294 out.sort_by(|a, b| {
1295 a.caller_qname
1296 .cmp(&b.caller_qname)
1297 .then_with(|| a.callee_qname.cmp(&b.callee_qname))
1298 });
1299 out
1300}
1301
1302#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use agentstatedeveloper_core::adapter::{LanguageAdapter, WorkspaceSymbols};
1310
1311 fn adapter() -> SwiftAdapter {
1312 SwiftAdapter::new()
1313 }
1314
1315 #[test]
1316 fn parses_class_struct_enum_protocol_method() {
1317 let src = r#"
1318class PaymentService {
1319 init() {}
1320
1321 func charge(customerId: String, amount: Decimal) -> Receipt {
1322 return Receipt()
1323 }
1324}
1325
1326struct Receipt {
1327 let id: String
1328}
1329
1330protocol Gateway {
1331 func process(_ payment: Payment) throws
1332}
1333
1334enum Currency {
1335 case usd, eur, gbp
1336}
1337"#;
1338 let syms = adapter()
1339 .parse_symbols("Sources/Payments/PaymentService.swift", src)
1340 .unwrap();
1341 let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
1342 assert!(
1343 qnames.iter().any(|q| q.ends_with("PaymentService")),
1344 "{qnames:?}"
1345 );
1346 assert!(
1347 qnames.iter().any(|q| q.ends_with("PaymentService.charge")),
1348 "{qnames:?}"
1349 );
1350 assert!(qnames.iter().any(|q| q.ends_with("Receipt")), "{qnames:?}");
1351 assert!(qnames.iter().any(|q| q.ends_with("Gateway")), "{qnames:?}");
1352 assert!(qnames.iter().any(|q| q.ends_with("Currency")), "{qnames:?}");
1353 }
1354
1355 #[test]
1356 fn file_prefix_strips_swift_extension() {
1357 assert_eq!(
1359 file_qname_prefix("Sources/Payments/ChargeService.swift"),
1360 "Payments.ChargeService"
1361 );
1362 assert_eq!(
1364 file_qname_prefix("Packages/SequencerCore/Sources/Engine/DriftCompiler.swift"),
1365 "Engine.DriftCompiler"
1366 );
1367 assert_eq!(
1369 file_qname_prefix("App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel.swift"),
1370 "AcmeFlow.AcmeFlowViewModel"
1371 );
1372 assert_eq!(
1374 file_qname_prefix("Engine/DriftCompiler.swift"),
1375 "Engine.DriftCompiler"
1376 );
1377 assert_eq!(
1379 file_qname_prefix("./App/Models/User.swift"),
1380 "App.Models.User"
1381 );
1382 assert_eq!(file_qname_prefix("main.swift"), "main");
1384 }
1385
1386 #[test]
1387 fn file_prefix_dedupes_consecutive_repeated_segments() {
1388 assert_eq!(
1398 file_qname_prefix("App/AcmeFlow/AcmeFlow/AcmeFlow/Views/DriftPad/DriftPadView.swift"),
1399 "App.AcmeFlow.Views.DriftPad.DriftPadView",
1400 "consecutive `AcmeFlow` segments should collapse to one"
1401 );
1402 assert_eq!(file_qname_prefix("Foo/Bar/Bar/baz.swift"), "Foo.Bar.baz");
1404 assert_eq!(
1407 file_qname_prefix("Foo/Bar/Baz/Bar/qux.swift"),
1408 "Foo.Bar.Baz.Bar.qux"
1409 );
1410 assert_eq!(
1412 file_qname_prefix("App/AcmeFlow/Sources/AcmeFlow/AcmeFlow/foo.swift"),
1413 "AcmeFlow.foo",
1414 "after Sources-strip, the AcmeFlow/AcmeFlow/ also dedupes"
1415 );
1416 }
1417
1418 #[test]
1419 fn dedupe_consecutive_segments_unit() {
1420 assert_eq!(dedupe_consecutive_segments("a.a.a.b.c"), "a.b.c");
1421 assert_eq!(dedupe_consecutive_segments("a.b.c"), "a.b.c");
1422 assert_eq!(dedupe_consecutive_segments(""), "");
1423 assert_eq!(dedupe_consecutive_segments("a"), "a");
1424 assert_eq!(dedupe_consecutive_segments("a.b.a"), "a.b.a"); assert_eq!(
1426 dedupe_consecutive_segments("App.AcmeFlow.AcmeFlow.AcmeFlow.Views"),
1427 "App.AcmeFlow.Views"
1428 );
1429 }
1430
1431 #[test]
1432 fn strip_sources_prefix_variants() {
1433 assert_eq!(
1435 strip_sources_prefix("Packages/Core/Sources/Engine/Foo"),
1436 "Engine/Foo"
1437 );
1438 assert_eq!(
1440 strip_sources_prefix("MyApp/Source/Models/Bar"),
1441 "Models/Bar"
1442 );
1443 assert_eq!(
1445 strip_sources_prefix("web/src/utils/helpers"),
1446 "utils/helpers"
1447 );
1448 assert_eq!(strip_sources_prefix("Sources/Engine/Foo"), "Engine/Foo");
1450 assert_eq!(
1452 strip_sources_prefix("Engine/DriftCompiler"),
1453 "Engine/DriftCompiler"
1454 );
1455 assert_eq!(strip_sources_prefix("Pkg/Sources"), "Pkg/Sources");
1457 }
1458
1459 #[test]
1460 fn static_method_calls_within_same_type() {
1461 let src = r#"
1462struct DriftCompiler {
1463 static func compile(clips: [String]) -> [String] {
1464 let filtered = filterByMuteSolo(clips: clips)
1465 let expanded = Self.expandClip(filtered[0])
1466 return DriftCompiler.postProcess(expanded)
1467 }
1468 static func filterByMuteSolo(clips: [String]) -> [String] { clips }
1469 static func expandClip(_ c: String) -> String { c }
1470 static func postProcess(_ c: String) -> [String] { [c] }
1471}
1472"#;
1473 let adapter = adapter();
1474 let syms = adapter
1476 .parse_symbols("Sources/Engine/DriftCompiler.swift", src)
1477 .unwrap();
1478 let qnames: Vec<&str> = syms.iter().map(|s| s.qname.as_str()).collect();
1480 assert!(
1481 qnames.iter().any(|q| q.ends_with("DriftCompiler.compile")),
1482 "{qnames:?}"
1483 );
1484 assert!(
1485 qnames
1486 .iter()
1487 .any(|q| q.ends_with("DriftCompiler.filterByMuteSolo")),
1488 "{qnames:?}"
1489 );
1490 assert!(
1491 qnames
1492 .iter()
1493 .any(|q| q.ends_with("DriftCompiler.expandClip")),
1494 "{qnames:?}"
1495 );
1496 assert!(
1497 qnames
1498 .iter()
1499 .any(|q| q.ends_with("DriftCompiler.postProcess")),
1500 "{qnames:?}"
1501 );
1502
1503 let mut ws = WorkspaceSymbols::default();
1505 for s in &syms {
1506 ws.qnames.insert(s.qname.clone());
1507 ws.kinds.insert(s.qname.clone(), s.kind);
1508 }
1509 ws.build_suffix_index();
1510
1511 let edges =
1512 adapter.extract_call_edges("Sources/Engine/DriftCompiler.swift", src, &syms, &ws);
1513
1514 let caller: Vec<&str> = edges
1515 .iter()
1516 .filter(|e| e.caller_qname.ends_with("DriftCompiler.compile"))
1517 .map(|e| e.callee_qname.as_str())
1518 .collect();
1519
1520 assert!(
1522 caller
1523 .iter()
1524 .any(|q| q.ends_with("DriftCompiler.filterByMuteSolo")),
1525 "missing bare intra-type call; edges from compile: {caller:?}"
1526 );
1527 assert!(
1529 caller
1530 .iter()
1531 .any(|q| q.ends_with("DriftCompiler.expandClip")),
1532 "missing Self.method() call; edges from compile: {caller:?}"
1533 );
1534 assert!(
1536 caller
1537 .iter()
1538 .any(|q| q.ends_with("DriftCompiler.postProcess")),
1539 "missing TypeName.method() call; edges from compile: {caller:?}"
1540 );
1541 }
1542
1543 #[test]
1544 fn self_dot_call_resolves_to_enclosing_type() {
1545 let src = r#"
1546class Scheduler {
1547 func start() {
1548 Self.validate()
1549 self.reset()
1550 }
1551 func validate() {}
1552 func reset() {}
1553}
1554"#;
1555 let adapter = adapter();
1556 let syms = adapter
1557 .parse_symbols("Sources/Core/Scheduler.swift", src)
1558 .unwrap();
1559 let mut ws = WorkspaceSymbols::default();
1560 for s in &syms {
1561 ws.qnames.insert(s.qname.clone());
1562 ws.kinds.insert(s.qname.clone(), s.kind);
1563 }
1564 ws.build_suffix_index();
1565 let edges = adapter.extract_call_edges("Sources/Core/Scheduler.swift", src, &syms, &ws);
1566 let from_start: Vec<&str> = edges
1567 .iter()
1568 .filter(|e| e.caller_qname.ends_with("Scheduler.start"))
1569 .map(|e| e.callee_qname.as_str())
1570 .collect();
1571 assert!(
1572 from_start.iter().any(|q| q.ends_with("Scheduler.validate")),
1573 "Self.validate() not resolved; edges: {from_start:?}"
1574 );
1575 assert!(
1576 from_start.iter().any(|q| q.ends_with("Scheduler.reset")),
1577 "self.reset() not resolved; edges: {from_start:?}"
1578 );
1579 }
1580
1581 #[test]
1582 fn infers_fs_read_and_net_out() {
1583 let src = r#"
1584class Fetcher {
1585 func fetch() async throws -> String {
1586 let data = try Data(contentsOf: URL(fileURLWithPath: "/tmp/config"))
1587 let (respData, _) = try await URLSession.shared.data(from: URL(string: "https://api.example.com/v1")!)
1588 return String(data: data, encoding: .utf8)!
1589 }
1590}
1591"#;
1592 let syms = adapter().parse_symbols("Fetcher.swift", src).unwrap();
1593 let fetch = syms.iter().find(|s| s.qname.ends_with(".fetch")).unwrap();
1594 let effs = adapter().infer_effects("", fetch);
1595 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1596 assert!(cats.contains(&&EffectCategory::IoFsRead), "{cats:?}");
1597 assert!(cats.contains(&&EffectCategory::IoNetOut), "{cats:?}");
1598 }
1599
1600 #[test]
1601 fn infers_log_and_env() {
1602 let src = r#"
1603class Config {
1604 func load() -> String {
1605 let env = ProcessInfo.processInfo.environment["APP_ENV"] ?? "dev"
1606 print("Loading config for \(env)")
1607 return env
1608 }
1609}
1610"#;
1611 let syms = adapter().parse_symbols("Config.swift", src).unwrap();
1612 let load = syms.iter().find(|s| s.qname.ends_with(".load")).unwrap();
1613 let effs = adapter().infer_effects("", load);
1614 let cats: Vec<_> = effs.iter().map(|e| &e.effect).collect();
1615 assert!(cats.contains(&&EffectCategory::EnvRead), "{cats:?}");
1616 assert!(cats.contains(&&EffectCategory::Log), "{cats:?}");
1617 }
1618
1619 #[test]
1620 fn empty_when_no_patterns() {
1621 let src = r#"
1622struct Math {
1623 func add(_ a: Int, _ b: Int) -> Int { a + b }
1624}
1625"#;
1626 let syms = adapter().parse_symbols("Math.swift", src).unwrap();
1627 let add = syms.iter().find(|s| s.qname.ends_with(".add")).unwrap();
1628 let effs = adapter().infer_effects("", add);
1629 assert!(effs.is_empty(), "{effs:?}");
1630 }
1631
1632 #[test]
1633 fn extracts_intra_class_call_edges() {
1634 let src = r#"
1635class OrderService {
1636 func placeOrder(_ order: Order) {
1637 charge(customerId: order.customerId, amount: order.total)
1638 }
1639
1640 func charge(customerId: String, amount: Decimal) {
1641 // process
1642 }
1643}
1644"#;
1645 let ws = WorkspaceSymbols::default();
1646 let syms = adapter().parse_symbols("OrderService.swift", src).unwrap();
1647 let edges = adapter().extract_call_edges("OrderService.swift", src, &syms, &ws);
1648 let found = edges.iter().any(|e| {
1649 e.caller_qname.ends_with(".placeOrder") && e.callee_qname.ends_with(".charge")
1650 });
1651 assert!(found, "expected intra-class edge; got: {edges:?}");
1652 }
1653
1654 #[test]
1655 fn resolves_instance_property_call_via_prop_map() {
1656 let src = r#"
1660class SynthScheduler {
1661 let pool: DriftSynthPool
1662
1663 func schedule() {
1664 pool.resolve()
1665 }
1666}
1667
1668class DriftSynthPool {
1669 func resolve() {}
1670}
1671"#;
1672 let mut ws = WorkspaceSymbols::default();
1673 ws.qnames
1674 .insert("Sources.Models.DriftSynthPool.resolve".to_string());
1675 ws.build_suffix_index();
1676
1677 let syms = adapter()
1678 .parse_symbols("Sources/Models/SynthScheduler.swift", src)
1679 .unwrap();
1680 let edges =
1681 adapter().extract_call_edges("Sources/Models/SynthScheduler.swift", src, &syms, &ws);
1682
1683 let found = edges.iter().any(|e| {
1684 e.caller_qname.ends_with(".schedule")
1685 && e.callee_qname.ends_with("DriftSynthPool.resolve")
1686 });
1687 assert!(found, "expected property-map edge; got: {edges:?}");
1688 }
1689
1690 #[test]
1691 fn parse_property_line_handles_variants() {
1692 assert_eq!(
1693 parse_property_line(" let pool: DriftSynthPool"),
1694 Some(("pool".into(), "DriftSynthPool".into()))
1695 );
1696 assert_eq!(
1697 parse_property_line(" var compiler: DriftCompiler?"),
1698 Some(("compiler".into(), "DriftCompiler".into()))
1699 );
1700 assert_eq!(
1702 parse_property_line(" @Published var items: [Item]"),
1703 None
1704 );
1705 assert_eq!(
1706 parse_property_line(" private weak var delegate: SomeDelegate?"),
1707 Some(("delegate".into(), "SomeDelegate".into()))
1708 );
1709 assert_eq!(
1711 parse_property_line(" let driftSynthPool = DriftSynthPool()"),
1712 Some(("driftSynthPool".into(), "DriftSynthPool".into()))
1713 );
1714 assert_eq!(
1715 parse_property_line(" let scheduler = BatchScheduler.make()"),
1716 Some(("scheduler".into(), "BatchScheduler".into()))
1717 );
1718 assert_eq!(parse_property_line(" func doSomething() {}"), None);
1722 assert_eq!(parse_property_line(" // let x: SomeType"), None);
1723 assert_eq!(
1725 parse_property_line(" var scheduler: any SchedulerProtocol"),
1726 Some(("scheduler".into(), "SchedulerProtocol".into()))
1727 );
1728 assert_eq!(
1729 parse_property_line(" var engine: some AudioEngineProtocol?"),
1730 Some(("engine".into(), "AudioEngineProtocol".into()))
1731 );
1732 }
1733
1734 #[test]
1739 fn resolve_instance_method_uses_doubled_suffix_on_ambiguity() {
1740 let mut ws = WorkspaceSymbols::default();
1741 ws.qnames
1743 .insert("Engine.Scheduler.Scheduler.laneLoopPositions".to_string());
1744 ws.qnames
1746 .insert("Tests.MockScheduler.laneLoopPositions".to_string());
1747 ws.build_suffix_index();
1748
1749 let result = resolve_instance_method("Scheduler", "laneLoopPositions", &ws);
1752 assert_eq!(
1753 result.as_deref(),
1754 Some("Engine.Scheduler.Scheduler.laneLoopPositions"),
1755 "should resolve via doubled suffix when simple suffix is ambiguous"
1756 );
1757 }
1758
1759 #[test]
1763 fn naming_convention_fallback_resolves_property_call() {
1764 let src = r#"
1767class SessionViewModel {
1768 let scheduler: Scheduler
1769
1770 func handlePad(laneID: Int, tick: Int) {
1771 scheduler.restartLane(laneID, at: tick)
1772 }
1773}
1774"#;
1775 let mut ws = WorkspaceSymbols::default();
1776 ws.qnames
1778 .insert("Engine.Scheduler.Scheduler.restartLane".to_string());
1779 ws.qnames
1781 .insert("Tests.MockScheduler.restartLane".to_string());
1782 ws.build_suffix_index();
1783
1784 let syms = adapter()
1785 .parse_symbols("Sources/Session/SessionViewModel.swift", src)
1786 .unwrap();
1787 let edges =
1788 adapter().extract_call_edges("Sources/Session/SessionViewModel.swift", src, &syms, &ws);
1789 let found = edges.iter().any(|e| {
1790 e.caller_qname.ends_with("handlePad")
1791 && e.callee_qname == "Engine.Scheduler.Scheduler.restartLane"
1792 });
1793 assert!(
1794 found,
1795 "expected doubled-suffix resolution of scheduler.restartLane; got: {edges:?}"
1796 );
1797 }
1798
1799 #[test]
1807 fn extension_file_property_call_cross_file() {
1808 let class_src = r#"
1810class AcmeFlowViewModel {
1811 let scheduler: Scheduler
1812
1813 func mainMethod() {}
1814}
1815"#;
1816 let class_syms = adapter()
1817 .parse_symbols(
1818 "App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel.swift",
1819 class_src,
1820 )
1821 .unwrap();
1822
1823 let ext_src = r#"
1825extension AcmeFlowViewModel {
1826 func driftPadMethod(laneID: Int, tick: Int) {
1827 let pos = scheduler.laneLoopPositions(currentTick: tick)
1828 scheduler.restartLane(laneID, at: tick)
1829 self.scheduler.laneLoopPositions(currentTick: tick)
1830 }
1831}
1832"#;
1833 let ext_syms = adapter()
1834 .parse_symbols(
1835 "App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel+DriftPad.swift",
1836 ext_src,
1837 )
1838 .unwrap();
1839
1840 let mut ws = WorkspaceSymbols::default();
1842 ws.qnames
1844 .insert("Engine.Scheduler.Scheduler.laneLoopPositions".to_string());
1845 ws.qnames
1846 .insert("Engine.Scheduler.Scheduler.restartLane".to_string());
1847 for s in class_syms.iter().chain(ext_syms.iter()) {
1849 ws.qnames.insert(s.qname.clone());
1850 ws.kinds.insert(s.qname.clone(), s.kind);
1851 }
1852 ws.build_suffix_index();
1853
1854 ws.properties
1856 .extend(adapter().extract_property_types(&class_syms));
1857
1858 let edges = adapter().extract_call_edges(
1860 "App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel+DriftPad.swift",
1861 ext_src,
1862 &ext_syms,
1863 &ws,
1864 );
1865 let callees: Vec<&str> = edges
1866 .iter()
1867 .filter(|e| e.caller_qname.ends_with("driftPadMethod"))
1868 .map(|e| e.callee_qname.as_str())
1869 .collect();
1870
1871 assert!(
1872 callees
1873 .iter()
1874 .any(|q| q.ends_with("Scheduler.laneLoopPositions")),
1875 "laneLoopPositions not resolved; callees from driftPadMethod: {callees:?}\n\
1876 All edges: {edges:?}\n\
1877 workspace.properties: {:?}",
1878 ws.properties
1879 );
1880 assert!(
1881 callees.iter().any(|q| q.ends_with("Scheduler.restartLane")),
1882 "restartLane not resolved; callees from driftPadMethod: {callees:?}\n\
1883 All edges: {edges:?}"
1884 );
1885 }
1886
1887 #[test]
1890 fn self_method_call_in_extension_resolves_via_workspace() {
1891 let class_src = r#"
1892class AudioEngine {
1893 func start() {}
1894 func stop() {}
1895}
1896"#;
1897 let ext_src = r#"
1898extension AudioEngine {
1899 func restart() {
1900 self.stop()
1901 self.start()
1902 }
1903}
1904"#;
1905 let class_syms = adapter()
1906 .parse_symbols("Sources/Audio/AudioEngine.swift", class_src)
1907 .unwrap();
1908 let ext_syms = adapter()
1909 .parse_symbols("Sources/Audio/AudioEngine+Restart.swift", ext_src)
1910 .unwrap();
1911
1912 let mut ws = WorkspaceSymbols::default();
1913 for s in class_syms.iter().chain(ext_syms.iter()) {
1914 ws.qnames.insert(s.qname.clone());
1915 ws.kinds.insert(s.qname.clone(), s.kind);
1916 }
1917 ws.build_suffix_index();
1918
1919 let edges = adapter().extract_call_edges(
1920 "Sources/Audio/AudioEngine+Restart.swift",
1921 ext_src,
1922 &ext_syms,
1923 &ws,
1924 );
1925 let from_restart: Vec<&str> = edges
1926 .iter()
1927 .filter(|e| e.caller_qname.ends_with("restart"))
1928 .map(|e| e.callee_qname.as_str())
1929 .collect();
1930
1931 assert!(
1932 from_restart.iter().any(|q| q.ends_with("AudioEngine.stop")),
1933 "self.stop() not resolved in extension; edges: {from_restart:?}"
1934 );
1935 assert!(
1936 from_restart
1937 .iter()
1938 .any(|q| q.ends_with("AudioEngine.start")),
1939 "self.start() not resolved in extension; edges: {from_restart:?}"
1940 );
1941 }
1942}
1943
1944#[cfg(test)]
1945mod service_endpoint_tests {
1946 use super::*;
1947 use agentstatedeveloper_core::adapter::LanguageAdapter;
1948
1949 fn detect(src: &str) -> Vec<DetectedEndpoint> {
1950 let a = SwiftAdapter;
1951 let s = a.parse_symbols("routes.swift", src).unwrap();
1952 a.infer_service_endpoints("routes.swift", src, &s)
1953 }
1954 fn inb(e: &[DetectedEndpoint]) -> Vec<&DetectedEndpoint> {
1955 e.iter()
1956 .filter(|e| e.direction == Direction::Inbound)
1957 .collect()
1958 }
1959
1960 #[test]
1961 fn vapor_route_with_components() {
1962 let src = "func routes(_ app: Application) throws {\n app.get(\"users\", \":id\") { req in\n return user\n }\n}\n";
1963 let eps = detect(src);
1964 assert_eq!(inb(&eps)[0].contract, "http:GET /users/{}", "{eps:?}");
1965 }
1966
1967 #[test]
1968 fn vapor_post_with_use_handler() {
1969 let src = "func routes(_ app: Application) throws {\n app.post(\"charge\", use: chargeHandler)\n}\n";
1970 let eps = detect(src);
1971 assert_eq!(inb(&eps)[0].contract, "http:POST /charge");
1972 }
1973
1974 #[test]
1975 fn plain_get_without_handler_is_not_a_route() {
1976 let src = "func f() {\n let v = cache.get(\"key\")\n _ = v\n}\n";
1978 assert!(inb(&detect(src)).is_empty(), "{:?}", detect(src));
1979 }
1980
1981 #[test]
1982 fn swift_server_matches_other_language_client() {
1983 let server = detect(
1984 "func r(_ app: Application) throws {\n app.get(\"users\", \":id\") { req in return u }\n}\n",
1985 );
1986 assert_eq!(
1987 inb(&server)[0].contract,
1988 http_contract("get", "https://svc/users/:id")
1989 );
1990 assert_eq!(inb(&server)[0].contract, "http:GET /users/{}");
1991 }
1992}