Skip to main content

agentstatedeveloper_core/
adapter.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::cross_service::DetectedEndpoint;
4use crate::dataflow::DetectedDataFlow;
5use crate::error::Result;
6use crate::schema::{Effect, SymbolKind};
7
8/// Parsed symbol returned by a language adapter. Carries enough info to
9/// build a full [`Symbol`](crate::schema::Symbol) node.
10#[derive(Debug, Clone)]
11pub struct ParsedSymbol {
12    pub qname: String,
13    pub kind: SymbolKind,
14    pub start_line: u32,
15    pub start_col: u32,
16    pub end_line: u32,
17    pub end_col: u32,
18    pub body: String,
19    pub signature: Option<String>,
20    pub doc: Option<String>,
21}
22
23/// A directed call edge from one qname to another, produced by a language
24/// adapter's intra-module call-graph extractor.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct CallEdge {
27    pub caller_qname: String,
28    pub callee_qname: String,
29}
30
31/// Workspace-wide context passed to adapters when resolving call edges
32/// across module boundaries.
33///
34/// ## Suffix-based lookup
35///
36/// Qnames include file-path prefixes (e.g., `Sources.Models.DriftCompiler.compile`).
37/// A call site using `DriftCompiler.compile(...)` only sees the type+method tail.
38/// After inserting all qnames, call [`build_suffix_index`] once so that
39/// [`find_by_suffix`] can resolve `"DriftCompiler.compile"` → the full qname in O(1).
40///
41/// ## Cross-file property map
42///
43/// Stored property declarations (`let name: TypeName` / `var name: TypeName`)
44/// are collected across ALL files in the current index run and stored in
45/// `properties` as `"EnclosingTypeSimpleName.propName" → "TypeSimpleName"`.
46/// Call adapters populate this via [`LanguageAdapter::extract_property_types`].
47/// Swift uses it to resolve `pool.resolve()` → `DriftSynthPool.resolve` when
48/// `pool: DriftSynthPool` is declared in a different file of the same project.
49#[derive(Debug, Clone, Default)]
50pub struct WorkspaceSymbols {
51    /// All qnames known to the indexer, flat.
52    pub qnames: HashSet<String>,
53    /// Map from qname to its kind, so adapters can distinguish
54    /// class-level methods from module-level functions during resolution.
55    pub kinds: HashMap<String, SymbolKind>,
56    /// suffix → Vec<full_qname>. Built by [`build_suffix_index`].
57    /// Each key is a 2-or-more component tail of a qname, e.g.
58    /// `"DriftCompiler.compile"` → `["Sources.Models.DriftCompiler.compile"]`.
59    pub suffix_index: HashMap<String, Vec<String>>,
60    /// Cross-file property map: `"EnclosingTypeSimple.propName"` →
61    /// `"TypeSimpleName"`.  Populated by the index pipeline from ALL files
62    /// via [`LanguageAdapter::extract_property_types`] before call-graph
63    /// extraction runs.  Allows resolving instance property calls when the
64    /// property declaration is in a different file than the method body.
65    pub properties: HashMap<String, String>,
66}
67
68impl WorkspaceSymbols {
69    /// Exact qname membership test.
70    pub fn contains(&self, qname: &str) -> bool {
71        self.qnames.contains(qname)
72    }
73
74    /// Build the suffix index from the current `qnames` set. Must be called
75    /// once after all qnames have been inserted, before any `find_by_suffix`
76    /// calls. Safe to call multiple times (rebuilds from scratch each time).
77    ///
78    /// For every qname with N dot-separated components, indexes all tails
79    /// of length 2..=N so that `"TypeName.method"` matches
80    /// `"file.path.TypeName.method"`.
81    pub fn build_suffix_index(&mut self) {
82        self.suffix_index.clear();
83        for qname in &self.qnames {
84            let parts: Vec<&str> = qname.split('.').collect();
85            // Only index tails with 2+ components (1-component is too ambiguous).
86            for start in 1..parts.len() {
87                let suffix = parts[start..].join(".");
88                self.suffix_index
89                    .entry(suffix)
90                    .or_default()
91                    .push(qname.clone());
92            }
93        }
94    }
95
96    /// Look up a qname by suffix (e.g., `"DriftCompiler.compile"`).
97    ///
98    /// Returns the unique full qname when exactly one workspace qname ends
99    /// with `.<suffix>` or equals `suffix` exactly.  Returns `None` when no
100    /// match is found or when the suffix is ambiguous (multiple matches).
101    ///
102    /// Requires [`build_suffix_index`] to have been called after the last
103    /// batch of qname inserts.
104    pub fn find_by_suffix<'a>(&'a self, suffix: &str) -> Option<&'a str> {
105        // Exact match first (no prefix stripping needed).
106        if let Some(q) = self.qnames.get(suffix) {
107            return Some(q.as_str());
108        }
109        let matches = self.suffix_index.get(suffix)?;
110        if matches.len() == 1 {
111            Some(matches[0].as_str())
112        } else {
113            // Ambiguous — don't guess.
114            None
115        }
116    }
117}
118
119/// Plan L t-006: one call site the static walker tried to resolve but
120/// couldn't. May be stdlib (`print`, `len`), third-party (an
121/// unindexed dependency), or genuinely dynamic. Aggregated by the
122/// indexer to expose the static-resolution coverage gap so agents
123/// don't assume missing edges are bugs.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct UnresolvedCall {
126    pub file: String,
127    pub line: u32,
128    /// The function-position text as it appears in source (e.g.
129    /// `requests.get`, `Foo.bar`, `local_var`).
130    pub callee_text: String,
131}
132
133/// Plan L t-005: one dynamic-dispatch call site detected by an adapter.
134/// `pattern` is a short label (`getattr` / `callback-arg` / `metaclass`);
135/// `snippet` is the source line trimmed for display in CLI warnings.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct DynamicDispatchHint {
138    pub file: String,
139    pub line: u32,
140    pub pattern: String,
141    pub snippet: String,
142}
143
144/// Language-specific parsing + effect inference. Implementations live in
145/// sibling crates: `agentstatedeveloper-python`, `-typescript`, etc.
146pub trait LanguageAdapter: Send + Sync {
147    /// Stable language identifier — "python", "typescript", ...
148    fn language(&self) -> &str;
149
150    /// File extensions this adapter handles, without the leading dot.
151    /// Used by the shared index pipeline for file dispatch.
152    /// Default: empty (adapter is never auto-selected by extension).
153    fn file_extensions(&self) -> &'static [&'static str] {
154        &[]
155    }
156
157    /// Parse a file and return declared symbols.
158    fn parse_symbols(&self, file: &str, source: &str) -> Result<Vec<ParsedSymbol>>;
159
160    /// Heuristic effect inference for a symbol. Returns an empty Vec when
161    /// the adapter has no opinion (author must declare explicitly).
162    fn infer_effects(&self, source: &str, symbol: &ParsedSymbol) -> Vec<Effect>;
163
164    /// Plan L t-005: report dynamic-dispatch sites in the file's source.
165    /// These are call patterns the static call-graph walker can't resolve
166    /// (e.g. `getattr(obj, attr_name)(args)`, callback-by-argument,
167    /// metaclass tricks). Returning a non-empty list signals to the
168    /// indexer that some call paths in this file won't show up in the
169    /// graph; the indexer surfaces a one-line warning so agents/humans
170    /// know the missing edges aren't a bug, they're a known limit.
171    ///
172    /// Default: empty. Adapters with no dynamic-dispatch story return
173    /// nothing.
174    fn scan_dynamic_dispatch(&self, _file: &str, _source: &str) -> Vec<DynamicDispatchHint> {
175        Vec::new()
176    }
177
178    /// Plan L t-006: report call sites in this file whose function
179    /// position couldn't be resolved to a workspace qname. Default:
180    /// empty. Adapters that can compute this cheaply override.
181    fn report_unresolved_calls(
182        &self,
183        _file: &str,
184        _source: &str,
185        _symbols: &[ParsedSymbol],
186        _workspace: &WorkspaceSymbols,
187    ) -> Vec<UnresolvedCall> {
188        Vec::new()
189    }
190
191    /// Extract call edges from a file's parsed symbols. Returned pairs are
192    /// (caller_qname, callee_qname). The adapter is free to use heuristics;
193    /// resolution quality is best-effort.
194    ///
195    /// `workspace` carries workspace-wide qname/kind context so adapters
196    /// can resolve cross-module calls (via imports). Adapters that only
197    /// care about intra-module edges may safely ignore it.
198    fn extract_call_edges(
199        &self,
200        _file: &str,
201        _source: &str,
202        _symbols: &[ParsedSymbol],
203        _workspace: &WorkspaceSymbols,
204    ) -> Vec<CallEdge> {
205        Vec::new()
206    }
207
208    /// Detect cross-service endpoints (HTTP routes/clients, pub-sub
209    /// emit/listen) in a file. Returns [`DetectedEndpoint`]s attributed to the
210    /// owning symbol by qname; the index pipeline enriches them with repo +
211    /// symbol identity. Whole-file signature (not per-symbol) because the
212    /// signal often lives just outside a symbol's body (e.g. a route decorator
213    /// above a `def`). Default: empty — adapters opt in.
214    ///
215    /// See [`crate::cross_service`] for the contract-keyed matching model.
216    fn infer_service_endpoints(
217        &self,
218        _file: &str,
219        _source: &str,
220        _symbols: &[ParsedSymbol],
221    ) -> Vec<DetectedEndpoint> {
222        Vec::new()
223    }
224
225    /// Project-level pass (Plan Q t-004b): rewrite endpoint contracts using
226    /// cross-file context — e.g. resolving nested router-mount prefix chains
227    /// (`app.include_router(api, prefix="/api")` → `api.include_router(calc,
228    /// prefix="/calc")` → `@calc.get("/x")` = `/api/calc/x`) that span files.
229    /// `files` is `(path, source)` for THIS adapter's language; `endpoints` is
230    /// the full project set — the adapter must only touch its own. Runs once
231    /// after per-file detection. Default: no-op.
232    fn resolve_endpoint_prefixes(
233        &self,
234        _files: &[(String, String)],
235        _endpoints: &mut Vec<crate::cross_service::ServiceEndpoint>,
236    ) {
237    }
238
239    /// Detect intra-process data-flow sites (`arg → param` at call sites) in a
240    /// file. Returns [`DetectedDataFlow`] naming caller/callee by qname plus the
241    /// argument and its position; the index pipeline resolves the callee's
242    /// parameter name and symbol identity. Default: empty — adapters opt in.
243    ///
244    /// See [`crate::dataflow`] for the model.
245    fn extract_dataflow(
246        &self,
247        _file: &str,
248        _source: &str,
249        _symbols: &[ParsedSymbol],
250        _workspace: &WorkspaceSymbols,
251    ) -> Vec<DetectedDataFlow> {
252        Vec::new()
253    }
254
255    /// Extract stored property declarations from a file's parsed symbols,
256    /// returning `"EnclosingTypeSimpleName.propertyName" → "TypeSimpleName"`.
257    ///
258    /// Called by the index pipeline for EVERY file in a run so that
259    /// `workspace.properties` accumulates a project-wide property map.
260    /// That map is then available to `extract_call_edges` to resolve
261    /// instance property calls across file boundaries.
262    ///
263    /// Default: returns an empty map (most adapters don't need this).
264    fn extract_property_types(&self, _symbols: &[ParsedSymbol]) -> HashMap<String, String> {
265        HashMap::new()
266    }
267}