Skip to main content

amql_engine/query/
mod.rs

1//! Unified query pipeline: code + annotations -> matched results.
2//!
3//! Queries dynamically parse code within a scope, join code elements with
4//! annotation sidecars via binding keys, and match unified nodes against
5//! CSS-like selectors.
6
7mod node;
8mod rewrite;
9
10use crate::code_cache::CodeCache;
11use crate::error::AqlError;
12use crate::matcher;
13use crate::matcher::Matchable;
14use crate::resolver::ResolverRegistry;
15use crate::selector::parse_selector;
16use crate::store::AnnotationStore;
17use crate::types::{AttrName, Binding, CodeElementName, RelativePath, Scope, TagName};
18use node::{build_unified_nodes, flatten_annotations_recursive};
19use rustc_hash::FxHashMap;
20use serde::Serialize;
21use serde_json::Value as JsonValue;
22
23use crate::resolver::SourceLocation;
24
25/// Synthetic attribute name injected into unified nodes to carry the annotation tag.
26const ANN_TAG_ATTR: &str = "ann_tag";
27
28/// Enriched query result.
29#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
30#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
31#[cfg_attr(feature = "ts", ts(export))]
32#[cfg_attr(feature = "flow", flow(export))]
33#[derive(Debug, Clone, Serialize)]
34pub struct QueryResult {
35    /// The matched code element.
36    pub code_element: CodeElementSummary,
37    /// Annotations bound to this code element.
38    pub annotations: Vec<AnnotationSummary>,
39}
40
41/// Summary of a matched code element.
42#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
43#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
44#[cfg_attr(feature = "ts", ts(export))]
45#[cfg_attr(feature = "flow", flow(export))]
46#[derive(Debug, Clone, Serialize)]
47pub struct CodeElementSummary {
48    /// Element kind (e.g. "function", "struct").
49    pub tag: TagName,
50    /// Name of the code element.
51    pub name: CodeElementName,
52    /// Relative path to the source file.
53    pub file: RelativePath,
54    /// Full source location (file, line, column).
55    pub source: SourceLocation,
56    /// Attributes extracted from the source code.
57    #[cfg_attr(
58        feature = "ts",
59        ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
60    )]
61    #[cfg_attr(
62        feature = "flow",
63        flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
64    )]
65    pub attrs: FxHashMap<AttrName, JsonValue>,
66}
67
68/// Summary of an annotation attached to a code element.
69#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
70#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
71#[cfg_attr(feature = "ts", ts(export))]
72#[cfg_attr(feature = "flow", flow(export))]
73#[derive(Debug, Clone, Serialize)]
74pub struct AnnotationSummary {
75    /// Annotation tag (e.g. "controller", "react-hook").
76    pub tag: TagName,
77    /// Annotation attributes from the sidecar file.
78    #[cfg_attr(
79        feature = "ts",
80        ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
81    )]
82    #[cfg_attr(
83        feature = "flow",
84        flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
85    )]
86    pub attrs: FxHashMap<AttrName, JsonValue>,
87    /// Binding key that linked this annotation to its code element.
88    pub binding: Binding,
89}
90
91/// Main query entry point.
92///
93/// Pipeline:
94/// 1. Parse selector. If tag is not in CODE_TAGS, rewrite to `[ann_tag="{tag}"]` + original attrs.
95/// 2. `code_cache.ensure_scope(scope, resolvers)` — lazy parse code files.
96/// 3. Refresh annotation sidecars in scope.
97/// 4. For each file in scope: flatten CodeElement tree, look up annotations, build UnifiedNode list.
98/// 5. Match unified nodes against selector via `filter_by_selector`.
99/// 6. Build QueryResult from matched nodes (deduplicate by source location).
100#[must_use = "running a query is useless without inspecting the results"]
101#[cfg_attr(dylint_lib = "amql_lints", allow(no_mut_params))] // mtime-cache pattern: caches refresh from disk
102pub fn unified_query(
103    selector_str: &str,
104    scope: &Scope,
105    code_cache: &mut CodeCache,
106    store: &mut AnnotationStore,
107    resolvers: &ResolverRegistry,
108    opts: Option<&crate::query_options::QueryOptions>,
109) -> Result<Vec<QueryResult>, AqlError> {
110    // Step 1: Parse and potentially rewrite selector
111    let code_tags = resolvers.all_code_tags();
112    let rewritten = rewrite::rewrite_selector(selector_str, &code_tags);
113    let selector = parse_selector(&rewritten)?;
114
115    // Step 2: Ensure code files in scope are parsed and cached
116    code_cache.ensure_scope(scope, resolvers);
117
118    // Step 3: Refresh annotation sidecars in scope
119    let _ = store.refresh_scope(scope);
120
121    // Step 4: Build unified nodes
122    let elements = code_cache.elements_in_scope(scope);
123    let ann_by_file = store.annotations_in_scope(scope);
124    let ann_map: FxHashMap<&RelativePath, Vec<&crate::store::Annotation>> =
125        ann_by_file.into_iter().collect();
126
127    let mut unified_nodes = Vec::new();
128
129    for (rel_path, root) in &elements {
130        let file_annotations = ann_map.get(*rel_path).cloned().unwrap_or_default();
131        // Flatten all annotations (including children) for binding lookup
132        let flat_anns = flatten_annotations_recursive(&file_annotations);
133        build_unified_nodes(root, &flat_anns, &mut unified_nodes);
134    }
135
136    // Step 5: Match (using indexed parent lookup for combinator support)
137    let matchable_refs: Vec<&dyn Matchable> =
138        unified_nodes.iter().map(|n| n as &dyn Matchable).collect();
139    let parent_indices: Vec<Option<usize>> = unified_nodes.iter().map(|n| n.parent_idx).collect();
140    let matched_indices =
141        matcher::filter_by_selector_indexed(&matchable_refs, &parent_indices, &selector);
142
143    // Step 6: Build results, deduplicating by source location
144    let mut seen: FxHashMap<String, usize> = FxHashMap::default();
145    let mut results: Vec<QueryResult> = Vec::new();
146
147    for idx in matched_indices {
148        let node = &unified_nodes[idx];
149
150        let dedup_key = format!(
151            "{}:{}:{}",
152            node.source.file, node.source.line, node.source.column
153        );
154
155        if let Some(&idx) = seen.get(&dedup_key) {
156            // Same code element, different annotation — merge
157            if let Some(ann) = node.annotation {
158                results[idx].annotations.push(AnnotationSummary {
159                    tag: ann.tag.clone(),
160                    attrs: ann.attrs.clone(),
161                    binding: ann.binding.clone(),
162                });
163            }
164        } else {
165            let name = node
166                .merged_attrs
167                .get("name")
168                .and_then(|v| v.as_str())
169                .unwrap_or("");
170
171            // Collect code-only attrs (exclude ann_tag which is synthetic)
172            let mut code_attrs = node.merged_attrs.clone();
173            code_attrs.remove(ANN_TAG_ATTR);
174
175            let annotations = match node.annotation {
176                Some(ann) => vec![AnnotationSummary {
177                    tag: ann.tag.clone(),
178                    attrs: ann.attrs.clone(),
179                    binding: ann.binding.clone(),
180                }],
181                None => vec![],
182            };
183
184            let idx = results.len();
185            seen.insert(dedup_key, idx);
186            results.push(QueryResult {
187                code_element: CodeElementSummary {
188                    tag: node.code_tag.clone(),
189                    name: CodeElementName::from(name),
190                    file: node.source.file.clone(),
191                    source: node.source.clone(),
192                    attrs: code_attrs,
193                },
194                annotations,
195            });
196        }
197    }
198
199    let results = match opts {
200        Some(o) => crate::query_options::apply_to_query_results(results, o),
201        None => results,
202    };
203
204    Ok(results)
205}
206
207/// Run multiple selectors in parallel against the same scope.
208///
209/// Loads and parses files once (shared), then matches each selector via rayon.
210/// Returns results grouped by selector (one `Vec<QueryResult>` per input selector).
211#[must_use = "batch query results should be inspected"]
212pub fn batch_query(
213    selectors: &[&str],
214    scope: &Scope,
215    code_cache: &mut CodeCache,
216    store: &mut AnnotationStore,
217    resolvers: &ResolverRegistry,
218    opts: Option<&crate::query_options::QueryOptions>,
219) -> Result<Vec<Vec<QueryResult>>, AqlError> {
220    if selectors.is_empty() {
221        return Ok(vec![]);
222    }
223
224    // Shared setup: parse and cache scope once
225    let code_tags = resolvers.all_code_tags();
226    code_cache.ensure_scope(scope, resolvers);
227    let _ = store.refresh_scope(scope);
228
229    // Build unified nodes once
230    let elements = code_cache.elements_in_scope(scope);
231    let ann_by_file = store.annotations_in_scope(scope);
232    let ann_map: FxHashMap<&RelativePath, Vec<&crate::store::Annotation>> =
233        ann_by_file.into_iter().collect();
234
235    let mut unified_nodes = Vec::new();
236
237    for (rel_path, root) in &elements {
238        let file_annotations = ann_map.get(*rel_path).cloned().unwrap_or_default();
239        let flat_anns = flatten_annotations_recursive(&file_annotations);
240        build_unified_nodes(root, &flat_anns, &mut unified_nodes);
241    }
242
243    let matchable_refs: Vec<&dyn Matchable> =
244        unified_nodes.iter().map(|n| n as &dyn Matchable).collect();
245    let parent_indices: Vec<Option<usize>> = unified_nodes.iter().map(|n| n.parent_idx).collect();
246
247    // Parse and rewrite all selectors upfront
248    let parsed: Vec<_> = selectors
249        .iter()
250        .map(|s| {
251            let rewritten = rewrite::rewrite_selector(s, &code_tags);
252            parse_selector(&rewritten)
253        })
254        .collect::<Result<Vec<_>, _>>()?;
255
256    // Match each selector against the shared unified nodes
257    let all_matched: Vec<Vec<usize>> = parsed
258        .iter()
259        .map(|sel| matcher::filter_by_selector_indexed(&matchable_refs, &parent_indices, sel))
260        .collect();
261
262    // Build results for each selector, then apply opts to each set
263    let results: Vec<Vec<QueryResult>> = all_matched
264        .into_iter()
265        .map(|matched_indices| build_results(&unified_nodes, matched_indices))
266        .collect();
267
268    let results = match opts {
269        Some(o) => results
270            .into_iter()
271            .map(|set| crate::query_options::apply_to_query_results(set, o))
272            .collect(),
273        None => results,
274    };
275
276    Ok(results)
277}
278
279/// Build deduplicated QueryResult list from matched node indices.
280fn build_results(
281    unified_nodes: &[node::UnifiedNode<'_>],
282    matched_indices: Vec<usize>,
283) -> Vec<QueryResult> {
284    let mut seen: FxHashMap<String, usize> = FxHashMap::default();
285    let mut results: Vec<QueryResult> = Vec::new();
286
287    for idx in matched_indices {
288        let node = &unified_nodes[idx];
289        let dedup_key = format!(
290            "{}:{}:{}",
291            node.source.file, node.source.line, node.source.column
292        );
293
294        if let Some(&result_idx) = seen.get(&dedup_key) {
295            if let Some(ann) = node.annotation {
296                results[result_idx].annotations.push(AnnotationSummary {
297                    tag: ann.tag.clone(),
298                    attrs: ann.attrs.clone(),
299                    binding: ann.binding.clone(),
300                });
301            }
302        } else {
303            let name = node
304                .merged_attrs
305                .get("name")
306                .and_then(|v| v.as_str())
307                .unwrap_or("");
308
309            let mut code_attrs = node.merged_attrs.clone();
310            code_attrs.remove(ANN_TAG_ATTR);
311
312            let annotations = match node.annotation {
313                Some(ann) => vec![AnnotationSummary {
314                    tag: ann.tag.clone(),
315                    attrs: ann.attrs.clone(),
316                    binding: ann.binding.clone(),
317                }],
318                None => vec![],
319            };
320
321            let result_idx = results.len();
322            seen.insert(dedup_key, result_idx);
323            results.push(QueryResult {
324                code_element: CodeElementSummary {
325                    tag: node.code_tag.clone(),
326                    name: CodeElementName::from(name),
327                    file: node.source.file.clone(),
328                    source: node.source.clone(),
329                    attrs: code_attrs,
330                },
331                annotations,
332            });
333        }
334    }
335
336    results
337}