Skip to main content

agentstatedeveloper_swift/
lib.rs

1//! Swift language adapter for AgentStateDeveloper.
2//!
3//! Implements [`LanguageAdapter`](agentstatedeveloper_core::LanguageAdapter)
4//! on top of `tree-sitter-swift`. Parses classes, structs, enums, protocols,
5//! extensions, and functions/methods, 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};
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/// Swift language adapter.
27#[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
94// -----------------------------------------------------------------------------
95// qname helpers
96// -----------------------------------------------------------------------------
97
98/// Walk path components and return the tail after the first `Sources`,
99/// `Source`, or `src` segment.  Falls back to `path` unchanged when no such
100/// segment is present (non-SPM layouts, flat directories, etc.).
101///
102/// This makes qnames stable across different `asd index` invocation roots: for
103/// SPM projects the stable anchor is the SPM target name (the directory
104/// immediately inside `Sources/`), not the filesystem path from the index root.
105///
106/// Examples:
107/// - `Packages/SequencerCore/Sources/Engine/DriftCompiler`
108///   → `Engine/DriftCompiler`
109/// - `App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel`
110///   → `AcmeFlow/AcmeFlowViewModel`
111/// - `Sources/Engine/DriftCompiler`  (already at Sources)
112///   → `Engine/DriftCompiler`
113/// - `Engine/DriftCompiler`  (no Sources segment; no-op)
114///   → `Engine/DriftCompiler`
115fn 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; // skip segment + trailing slash
120            if after < path.len() {
121                return &path[after..];
122            }
123        }
124        offset += part.len() + 1;
125    }
126    path
127}
128
129/// Derive the stable qname prefix from a Swift file path.
130///
131/// Strips everything up to and including the first `Sources/` segment so that
132/// the prefix is anchored to the SPM target name, not the indexing root.
133///
134/// `App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel.swift`
135///   → `AcmeFlow.AcmeFlowViewModel`
136fn 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
144/// Collapse consecutive identical path segments after the `/` → `.`
145/// join. Xcode projects routinely nest the project name (no SPM
146/// `Sources/` marker to strip), producing paths like
147/// `App/AcmeFlow/AcmeFlow/AcmeFlow/Views/DriftPad/...`
148/// which yield qnames like
149/// `App.AcmeFlow.AcmeFlow.AcmeFlow.Views.DriftPad.DriftPadView`.
150/// The triple `AcmeFlow` is noise: it inflates token count for
151/// every Swift result, distorts BM25 against multi-word queries
152/// that match the duplicated stem, and lengthens display output.
153///
154/// Dedup is conservative — only CONSECUTIVE identical segments
155/// collapse. A legitimate qname like `Foo.bar.Foo.baz` (non-
156/// adjacent repeat) is unaffected. Cross-language safe: Python /
157/// Rust / TS adapters use their own qname builders.
158fn 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
214/// Extract the function signature: everything from the declaration start up to
215/// (but not including) the opening `{` of the function body.
216///
217/// Tracks `()` and `[]` depth to avoid splitting on closure literals used as
218/// default parameter values, e.g. `func foo(cb: () -> Void = { })`.
219/// For protocol requirements with no body, the full declaration text is returned.
220fn 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(); // default: whole text (no body brace found)
229
230    while i < bytes.len() {
231        match bytes[i] {
232            // Skip string literals to avoid confusing `{` inside them.
233            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
265// -----------------------------------------------------------------------------
266// Symbol walking
267// -----------------------------------------------------------------------------
268
269fn 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        // In swift 0.5.0 grammar, class/struct/enum/extension all use
277        // `class_declaration` with a `declaration_kind` field distinguishing them.
278        "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            // Skip extension re-declarations to avoid duplicating the type symbol.
290            if decl_kind != "extension" {
291                out.push(make_symbol(
292                    node,
293                    src,
294                    qname.clone(),
295                    SymbolKind::Class,
296                    None,
297                ));
298            }
299            // Walk body
300            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
361// -----------------------------------------------------------------------------
362// Effect inference
363// -----------------------------------------------------------------------------
364
365fn 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
438// -----------------------------------------------------------------------------
439// Cross-service endpoint detection (t-015) — Vapor routes.
440//
441// Vapor routes are `app.<verb>(component, component, …)` where components are
442// string path segments (`"users"`, `":id"`), joined with '/'. A route is
443// distinguished from an ordinary `.get("key")` by a handler indicator: a
444// trailing `{` closure or a `use:` argument. Clients (URLSession) are indirect
445// and deferred.
446// -----------------------------------------------------------------------------
447
448const 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
484/// `app.get("users", ":id") { … }` → (GET, /users/:id). Requires a handler
485/// indicator (`use:` or a trailing `{`) so plain `.get("key")` isn't a route.
486fn 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        // A route has a handler: a `use:` argument or a `{ … }` closure (which
494        // may be `{ req in` mid-line, not just a trailing brace).
495        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
508/// Leading string-literal path components, stopping at the first non-string
509/// argument (`use:`, a closure, etc.).
510fn 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
526/// First `"…"` literal; rejects Swift interpolation `\(…)`.
527fn 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    // FS Read
545    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    // FS Write
564    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    // Network
585    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    // Database
629    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    // Process spawn
670    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    // Env read
681    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    // Logging
710    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    // Time sleep
732    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    // Time read
749    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    // Random
768    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    // Throw
790    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
808// -----------------------------------------------------------------------------
809// Property-to-type map (for instance property call resolution)
810// -----------------------------------------------------------------------------
811
812/// Strip leading Swift property modifiers/attributes so `parse_property_line`
813/// sees `let`/`var` at the start.
814fn strip_property_prefixes(s: &str) -> &str {
815    let mut rest = s;
816    loop {
817        let prev = rest;
818        // @Attribute / @Attribute(args)
819        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
864/// Try to parse a single line as a stored Swift property declaration.
865/// Returns `(property_name, base_type_name)` on success.
866///
867/// Handled patterns:
868/// ```swift
869/// let pool: DriftSynthPool
870/// var compiler: DriftCompiler?
871/// @Published var items: [Item]
872/// private weak var delegate: SomeDelegate?
873/// let pool = DriftSynthPool()          // inferred from initializer
874/// let scheduler = BatchScheduler.make() // inferred from factory call
875/// ```
876fn 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    // Must start with `let` or `var`
897    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    // Property name: identifier chars
905    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    // --- Path A: explicit type annotation `let name: TypeName` ---
916    if let Some(after_colon) = rest_after_name.strip_prefix(':') {
917        let type_str = after_colon.trim_start();
918
919        // Stop at `=` or `{`
920        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        // Strip optional/implicit-unwrap markers
926        let type_name = raw_type.trim_end_matches('?').trim_end_matches('!').trim();
927
928        // Strip Swift 5.7+ `any`/`some` existential/opaque type markers so
929        // `var scheduler: any SchedulerProtocol` → `SchedulerProtocol`.
930        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        // Take just the base name before any generic brackets
937        let base_type = type_name
938            .find('<')
939            .map(|i| &type_name[..i])
940            .unwrap_or(type_name)
941            .trim();
942
943        // Swift type names start with uppercase
944        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    // --- Path B: inferred type from initializer `let name = TypeName(...)` ---
952    // e.g. `let driftSynthPool = DriftSynthPool()`
953    if let Some(after_eq) = rest_after_name.strip_prefix('=') {
954        let after_eq = after_eq.trim_start();
955
956        // Extract the first token (potential type name or expression)
957        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        // Must start with uppercase to be a type constructor (not a variable/literal)
963        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
973/// Build a flat map `"EnclosingTypeSimpleName.propertyName" → "TypeSimpleName"`
974/// by scanning each class/struct symbol body for stored property declarations.
975fn 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
991// -----------------------------------------------------------------------------
992// Call-edge extraction
993// -----------------------------------------------------------------------------
994
995/// Derive the enclosing type qname from a method qname.
996///
997/// For a method declared inside the same file (non-extension), the parent
998/// qname is present in the file-local `known` set.  For extension methods the
999/// class is defined in a *different* file, so `known` won't contain it.
1000///
1001/// Two fallback tiers:
1002///
1003/// 1. **Workspace exact**: check `workspace.qnames` for the direct parent.
1004///    Handles cases where the class file used the same structure.
1005///
1006/// 2. **Suffix search**: extension files embed the source file name in the
1007///    scope, producing qnames like:
1008///      `"AcmeFlow.AcmeFlowViewModel+DriftPad.AcmeFlowViewModel.method"`
1009///    The canonical class qname in the workspace is:
1010///      `"AcmeFlow.AcmeFlowViewModel.AcmeFlowViewModel"`
1011///    We recover it by searching for the class simple name (last component of
1012///    the parent) via the suffix index.
1013///
1014/// Returns `None` only when no match is found (free functions, truly unknown
1015/// contexts).
1016fn 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    // Tier 1: file-local set or direct workspace lookup.
1025    if known.contains(parent) || workspace.contains(parent) {
1026        return Some(parent.to_string());
1027    }
1028
1029    // Tier 2: extension files embed the file name in the scope, making the
1030    // parent not directly present.  Recover the canonical class qname from
1031    // the workspace suffix index using just the class simple name.
1032    let class_name = parent.rsplit('.').next()?;
1033    // Guard against single-component parents (module-level free functions)
1034    // where `class_name` would equal the entire parent — avoid false matches.
1035    if !parent.contains('.') {
1036        return None;
1037    }
1038    workspace.find_by_suffix(class_name).map(|s| s.to_string())
1039}
1040
1041/// Try to find a callee qname for `type_name.method` in the workspace.
1042///
1043/// Two-pass strategy to handle suffix ambiguity in SPM packages where the
1044/// module directory and class share the same name (e.g. `Scheduler/Scheduler.swift`
1045/// → qname `Engine.Scheduler.Scheduler.laneLoopPositions`):
1046///
1047/// 1. `"Scheduler.laneLoopPositions"` — simple 2-component suffix.
1048///    May be ambiguous if a test mock or protocol also defines the method.
1049///
1050/// 2. `"Scheduler.Scheduler.laneLoopPositions"` — the same-name-as-module
1051///    3-component suffix.  Much less likely to collide with test fixtures
1052///    and uniquely identifies the concrete class in the package.
1053fn 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                // In tree-sitter-swift the member name lives inside a
1086                // `navigation_suffix` child as a `simple_identifier`, not in
1087                // a field named "name".  Try that first, then fall back to a
1088                // "name" field for any grammar that does use it.
1089                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                    // Receiver may be a chained expression like `self.pool` — extract
1117                    // just the last component as the simple receiver name.
1118                    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                        // Suffix fallback: handles file-path qname prefixes.
1124                        Some(s.to_string())
1125                    } else if simple_recv == "Self" || simple_recv == "self" {
1126                        // `Self.method()` — treat exactly like a bare call from the
1127                        // enclosing type.  No qname in the workspace ends in
1128                        // `Self.<something>`, so suffix lookup above always fails.
1129                        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                        // Type-qualified static call: `DriftCompiler.filterByMuteSolo()`
1143                        // The workspace.find_by_suffix above already ran and missed
1144                        // (ambiguous or not found).  Secondary attempt: if the receiver
1145                        // matches the enclosing type's simple name, treat it as a
1146                        // same-type call and resolve via the full enclosing-type qname.
1147                        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                            // Cross-type static call: already tried find_by_suffix(q)
1158                            // above; nothing more to try without a type registry.
1159                            None
1160                        }
1161                    } else if simple_recv.starts_with(|c: char| c.is_lowercase()) {
1162                        // Instance property call: `pool.resolve()` where `pool` is a
1163                        // stored property.  Look up its declared type in the property
1164                        // map and retry suffix lookup on `ActualType.method`.
1165                        //
1166                        // Derive the simple enclosing type from `sym.qname` directly
1167                        // (second-to-last component) so this works for extension methods
1168                        // where the class isn't in the file-local `known` set.
1169                        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                            // Primary path: explicit type from property map.
1174                            // `resolve_instance_method` also tries the doubled-suffix
1175                            // ("Scheduler.Scheduler.method") to handle SPM packages
1176                            // where the module dir and class share a name and a single
1177                            // suffix lookup would be ambiguous.
1178                            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                                // Naming-convention fallback: Swift convention is that
1186                                // a property `scheduler` typically has type `Scheduler`.
1187                                // Activates when the prop_map has no entry (e.g. the
1188                                // declaration used `any`/`some` with a protocol that
1189                                // wasn't extracted, or the property was injected via
1190                                // init without a stored-property declaration).
1191                                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    // Use the workspace-wide property map for resolving instance property calls
1251    // like `pool.resolve()` where `pool: DriftSynthPool` is declared in another
1252    // file.  `workspace.properties` is populated by the index pipeline from ALL
1253    // files via `extract_property_types` before call-edge extraction begins.
1254    // Fall back to a file-local map if the workspace map is empty (e.g., unit tests).
1255    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// -----------------------------------------------------------------------------
1303// Tests
1304// -----------------------------------------------------------------------------
1305
1306#[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        // SPM: strip Sources/ segment → stable target-name anchor
1358        assert_eq!(
1359            file_qname_prefix("Sources/Payments/ChargeService.swift"),
1360            "Payments.ChargeService"
1361        );
1362        // Deep SPM path: same result regardless of index root depth
1363        assert_eq!(
1364            file_qname_prefix("Packages/SequencerCore/Sources/Engine/DriftCompiler.swift"),
1365            "Engine.DriftCompiler"
1366        );
1367        // Xcode app target path
1368        assert_eq!(
1369            file_qname_prefix("App/AcmeFlow/Sources/AcmeFlow/AcmeFlowViewModel.swift"),
1370            "AcmeFlow.AcmeFlowViewModel"
1371        );
1372        // Flat file with no Sources segment — unchanged
1373        assert_eq!(
1374            file_qname_prefix("Engine/DriftCompiler.swift"),
1375            "Engine.DriftCompiler"
1376        );
1377        // Legacy ./prefix stripped, no Sources segment
1378        assert_eq!(
1379            file_qname_prefix("./App/Models/User.swift"),
1380            "App.Models.User"
1381        );
1382        // Top-level file
1383        assert_eq!(file_qname_prefix("main.swift"), "main");
1384    }
1385
1386    #[test]
1387    fn file_prefix_dedupes_consecutive_repeated_segments() {
1388        // Refinement (1.0.74): Xcode-style nested project naming
1389        // produces paths like
1390        //   App/AcmeFlow/AcmeFlow/AcmeFlow/Views/DriftPad/DriftPadView.swift
1391        // (no SPM `Sources/` marker — strip_sources_prefix is a
1392        // no-op). Pre-fix this yielded
1393        //   App.AcmeFlow.AcmeFlow.AcmeFlow.Views.DriftPad.DriftPadView
1394        // — the triple AcmeFlow inflates tokens, distorts
1395        // BM25, and pollutes search output. Field-test surfaced
1396        // in AcmeProj 1.0.72.
1397        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        // Double, not triple
1403        assert_eq!(file_qname_prefix("Foo/Bar/Bar/baz.swift"), "Foo.Bar.baz");
1404        // Non-consecutive duplicates left alone (could be a real
1405        // semantic structure: `App.Auth.User.Auth.helper`)
1406        assert_eq!(
1407            file_qname_prefix("Foo/Bar/Baz/Bar/qux.swift"),
1408            "Foo.Bar.Baz.Bar.qux"
1409        );
1410        // Doesn't collide with the SPM Sources-strip path
1411        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"); // non-consecutive
1425        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        // Standard SPM Sources/
1434        assert_eq!(
1435            strip_sources_prefix("Packages/Core/Sources/Engine/Foo"),
1436            "Engine/Foo"
1437        );
1438        // Source (singular)
1439        assert_eq!(
1440            strip_sources_prefix("MyApp/Source/Models/Bar"),
1441            "Models/Bar"
1442        );
1443        // src (lowercase)
1444        assert_eq!(
1445            strip_sources_prefix("web/src/utils/helpers"),
1446            "utils/helpers"
1447        );
1448        // Already at Sources (no prefix to strip)
1449        assert_eq!(strip_sources_prefix("Sources/Engine/Foo"), "Engine/Foo");
1450        // No Sources segment → unchanged
1451        assert_eq!(
1452            strip_sources_prefix("Engine/DriftCompiler"),
1453            "Engine/DriftCompiler"
1454        );
1455        // Sources at the end with no tail → unchanged (edge case)
1456        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        // Symbols parsed with a Sources/-anchored path
1475        let syms = adapter
1476            .parse_symbols("Sources/Engine/DriftCompiler.swift", src)
1477            .unwrap();
1478        // All four methods should be parsed
1479        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        // Build workspace seeded from these symbols (simulates what index_pipeline does)
1504        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        // Bare call: filterByMuteSolo(...)
1521        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        // Self.expandClip(...)
1528        assert!(
1529            caller
1530                .iter()
1531                .any(|q| q.ends_with("DriftCompiler.expandClip")),
1532            "missing Self.method() call; edges from compile: {caller:?}"
1533        );
1534        // DriftCompiler.postProcess(...)
1535        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        // `pool` is a stored property of type `DriftSynthPool`.
1657        // `schedule()` calls `pool.resolve()` — the call graph must resolve
1658        // the receiver to `DriftSynthPool` and emit the cross-file edge.
1659        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        // Array/dictionary types start with `[`, not uppercase → not tracked
1701        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        // Initializer-inferred type
1710        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        // Computed property — has `{`, type ends before it → type_name should
1719        // still be extracted since the `{` cuts the type string.
1720        // But if the type string becomes empty we return None — this is fine.
1721        assert_eq!(parse_property_line("    func doSomething() {}"), None);
1722        assert_eq!(parse_property_line("    // let x: SomeType"), None);
1723        // `any`/`some` existential/opaque markers (Swift 5.7+)
1724        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    /// When `find_by_suffix("Scheduler.laneLoopPositions")` is ambiguous (e.g. a
1735    /// test mock also defines the method), the resolver must fall back to the
1736    /// longer `"Scheduler.Scheduler.laneLoopPositions"` suffix that uniquely
1737    /// identifies the concrete SPM class.
1738    #[test]
1739    fn resolve_instance_method_uses_doubled_suffix_on_ambiguity() {
1740        let mut ws = WorkspaceSymbols::default();
1741        // Real class (SPM package: Engine/Scheduler/Scheduler.swift)
1742        ws.qnames
1743            .insert("Engine.Scheduler.Scheduler.laneLoopPositions".to_string());
1744        // Test mock (creates ambiguity on the simple 2-component suffix)
1745        ws.qnames
1746            .insert("Tests.MockScheduler.laneLoopPositions".to_string());
1747        ws.build_suffix_index();
1748
1749        // Simple suffix is ambiguous → resolve_instance_method should pick the
1750        // doubled-suffix variant instead.
1751        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    /// Naming-convention fallback: when no explicit type annotation exists in
1760    /// the prop_map for the receiver, try capitalising the receiver name.
1761    /// `scheduler.restartLane(...)` → look for `Scheduler.restartLane`.
1762    #[test]
1763    fn naming_convention_fallback_resolves_property_call() {
1764        // Class with NO explicit type on the scheduler property (injected via init;
1765        // parse_property_line can't extract the type from the assignment).
1766        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        // Callee is in a different package with the same-name-as-module pattern.
1777        ws.qnames
1778            .insert("Engine.Scheduler.Scheduler.restartLane".to_string());
1779        // Inject ambiguity on the simple suffix to force the doubled-suffix path.
1780        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    /// Regression: extension methods calling stored properties via labeled arguments
1800    /// must produce call edges even when the class declaration is in a different file.
1801    ///
1802    /// Real-world pattern: `AcmeFlowViewModel+DriftPad.swift` calls
1803    /// `scheduler.restartLane(laneID, at: tick)` and
1804    /// `scheduler.laneLoopPositions(currentTick: tick)`, but `scheduler: Scheduler`
1805    /// is declared in `AcmeFlowViewModel.swift`.
1806    #[test]
1807    fn extension_file_property_call_cross_file() {
1808        // ---- "class file" symbols (AcmeFlowViewModel.swift) ----
1809        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        // ---- "extension file" symbols (AcmeFlowViewModel+DriftPad.swift) ----
1824        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        // ---- workspace: both files contribute qnames ----
1841        let mut ws = WorkspaceSymbols::default();
1842        // Callee lives in the Engine package
1843        ws.qnames
1844            .insert("Engine.Scheduler.Scheduler.laneLoopPositions".to_string());
1845        ws.qnames
1846            .insert("Engine.Scheduler.Scheduler.restartLane".to_string());
1847        // All parsed symbols from both files
1848        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        // ---- populate workspace.properties from the class file ----
1855        ws.properties
1856            .extend(adapter().extract_property_types(&class_syms));
1857
1858        // ---- extract edges from the extension file ----
1859        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    /// `enclosing_type` must be resolved from the workspace for extension methods
1888    /// (the class symbol is not in the file-local `known` set for extension files).
1889    #[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        // dict.get("key") has no `use:`/trailing-`{`, so it isn't a route.
1977        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}