Skip to main content

amql_engine/
sidecar.rs

1//! Sidecar file location strategies.
2//!
3//! A `SidecarLocator` maps between source files and their `.aqm` annotation
4//! sidecars. The default strategy is colocation (sidecar next to source).
5//! Alternative strategies allow centralized annotation directories.
6
7use crate::types::{Binding, ProjectRoot, RelativePath, TagName};
8use std::path::{Path, PathBuf};
9
10// ---------------------------------------------------------------------------
11// Pure path/string helpers (no filesystem, no feature gates)
12// ---------------------------------------------------------------------------
13
14/// Source extensions the engine's resolver registry handles.
15pub const SOURCE_EXTENSIONS: &[&str] =
16    &[".rs", ".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".go"];
17
18/// Derive the colocated sidecar path from a source path.
19/// `src/api.ts` → `src/api.aqm`
20#[must_use = "returns the derived sidecar path"]
21pub fn sidecar_for_colocated(source: &RelativePath) -> RelativePath {
22    let p = Path::new(AsRef::<str>::as_ref(source));
23    let stem = p.file_stem().unwrap_or_default().to_string_lossy();
24    match p.parent().filter(|p| !p.as_os_str().is_empty()) {
25        Some(parent) => RelativePath::from(format!("{}/{stem}.aqm", parent.to_string_lossy())),
26        None => RelativePath::from(format!("{stem}.aqm")),
27    }
28}
29
30/// Candidate source paths for a sidecar. Inverse of `sidecar_for_colocated`.
31pub fn source_candidates(sidecar: &RelativePath) -> Vec<RelativePath> {
32    let p = Path::new(AsRef::<str>::as_ref(sidecar));
33    let stem = p.file_stem().unwrap_or_default().to_string_lossy();
34    let parent = p.parent().filter(|p| !p.as_os_str().is_empty());
35    SOURCE_EXTENSIONS
36        .iter()
37        .map(|ext| {
38            RelativePath::from(match parent {
39                Some(dir) => format!("{}/{stem}{ext}", dir.to_string_lossy()),
40                None => format!("{stem}{ext}"),
41            })
42        })
43        .collect()
44}
45
46/// Extract the `bind="..."` value from a single line of AQL XML.
47pub fn extract_bind_from_line(line: &str) -> Option<Binding> {
48    let rest = line.split_once("bind=")?.1;
49    let quote = rest.as_bytes().first()?;
50    if *quote != b'"' && *quote != b'\'' {
51        return None;
52    }
53    let inner = &rest[1..];
54    let end = inner.find(*quote as char)?;
55    Some(Binding::from(&inner[..end]))
56}
57
58/// Resolve qualified bindings: `Foo.bar` → `bar`.
59///
60/// Semantically returns a `CodeElementName`, but since it borrows a substring
61/// of the input binding, the return type remains `&str`.
62pub fn resolve_bind_name(bind: &Binding) -> &str {
63    let s: &str = bind.as_ref();
64    s.rsplit('.').next().unwrap_or(s)
65}
66
67/// A symbol extracted from an `.aqm` file for editor outline/navigation.
68#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
69#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
70#[cfg_attr(feature = "ts", ts(export))]
71#[cfg_attr(feature = "flow", flow(export))]
72#[derive(Debug, Clone, serde::Serialize)]
73pub struct AqlSymbol {
74    /// The `bind="..."` value.
75    pub binding: Binding,
76    /// The XML element tag name (e.g. "controller", "parser").
77    pub tag: TagName,
78    /// 0-based line number in the document.
79    pub line: usize,
80}
81
82/// Extract symbols from `.aqm` document text for editor features (outline, go-to).
83pub fn extract_aql_symbols(text: &str) -> Vec<AqlSymbol> {
84    text.lines()
85        .enumerate()
86        .filter_map(|(line, content)| {
87            let tag = extract_tag_name(content)?;
88            let binding = extract_bind_from_line(content)?;
89            Some(AqlSymbol { binding, tag, line })
90        })
91        .collect()
92}
93
94fn extract_tag_name(line: &str) -> Option<TagName> {
95    let rest = line.trim_start().strip_prefix('<')?;
96    let end = rest.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?;
97    let name = &rest[..end];
98    if name.is_empty() || name.starts_with('!') || name.starts_with('?') {
99        return None;
100    }
101    Some(TagName::from(name))
102}
103
104// ---------------------------------------------------------------------------
105// Locator trait
106// ---------------------------------------------------------------------------
107
108/// Strategy for locating `.aqm` sidecar files relative to source files.
109///
110/// Not sealed — plugins and users can implement custom locators.
111pub trait SidecarLocator: Send + Sync {
112    /// Discover all sidecar files and their corresponding source-relative paths.
113    fn discover(&self, project_root: &ProjectRoot) -> Vec<(PathBuf, RelativePath)>;
114
115    /// Given a source-relative path, return the sidecar file path (relative to project root).
116    fn sidecar_for(&self, source: &RelativePath) -> RelativePath;
117}
118
119/// Default strategy: sidecar lives next to its source file with `.aqm` extension.
120/// `src/api.ts` → `src/api.aqm`
121#[cfg(feature = "fs")]
122pub struct ColocatedLocator;
123
124#[cfg(feature = "fs")]
125impl SidecarLocator for ColocatedLocator {
126    fn discover(&self, project_root: &ProjectRoot) -> Vec<(PathBuf, RelativePath)> {
127        let aql_paths = crate::store::glob_aql_files(project_root);
128        let dir_listings = precompute_dir_listings(&aql_paths);
129
130        aql_paths
131            .into_iter()
132            .filter_map(|sidecar_path| {
133                let stem = sidecar_path.file_stem()?.to_string_lossy().to_string();
134                let parent = sidecar_path.parent()?;
135
136                let source_path = dir_listings
137                    .get(parent)
138                    .and_then(|files| find_source_in_listing(files, &stem))
139                    .unwrap_or_else(|| parent.join(&stem));
140
141                let rel_source = crate::paths::relative(project_root, &source_path);
142                Some((sidecar_path, rel_source))
143            })
144            .collect()
145    }
146
147    fn sidecar_for(&self, source: &RelativePath) -> RelativePath {
148        let p = Path::new(AsRef::<str>::as_ref(source));
149        let stem = p.file_stem().unwrap_or_default().to_string_lossy();
150        RelativePath::from(match p.parent().filter(|p| !p.as_os_str().is_empty()) {
151            Some(parent) => format!("{}/{stem}.aqm", parent.to_string_lossy()),
152            None => format!("{stem}.aqm"),
153        })
154    }
155}
156
157/// Centralized strategy: all sidecars live under a single directory,
158/// mirroring the source tree structure.
159/// With `base = ".annotations"`: `src/api.ts` → `.annotations/src/api.aqm`
160#[cfg(feature = "fs")]
161pub struct DirectoryLocator {
162    /// Directory (relative to project root) containing all sidecar files.
163    pub base: RelativePath,
164}
165
166#[cfg(feature = "fs")]
167impl SidecarLocator for DirectoryLocator {
168    fn discover(&self, project_root: &ProjectRoot) -> Vec<(PathBuf, RelativePath)> {
169        let base_dir = project_root.join(AsRef::<Path>::as_ref(&self.base));
170        if !base_dir.is_dir() {
171            return vec![];
172        }
173
174        let aql_paths = crate::store::glob_aql_files(&base_dir);
175        let dir_listings = precompute_dir_listings_for_project(project_root, &base_dir, &aql_paths);
176
177        aql_paths
178            .into_iter()
179            .filter_map(|sidecar_path| {
180                // Strip base directory to get the mirrored source path
181                let rel_in_base = sidecar_path.strip_prefix(&base_dir).ok()?;
182                let stem = rel_in_base.file_stem()?.to_string_lossy().to_string();
183                let parent = rel_in_base.parent().filter(|p| !p.as_os_str().is_empty());
184
185                // Resolve the actual source file (with extension) from the project root
186                let source_dir = match parent {
187                    Some(p) => project_root.join(p),
188                    None => project_root.to_path_buf(),
189                };
190                let source_path = dir_listings
191                    .get(&source_dir)
192                    .and_then(|files| find_source_in_listing(files, &stem))
193                    .unwrap_or_else(|| {
194                        // Fallback: use stem without extension
195                        match parent {
196                            Some(p) => project_root.join(p).join(&stem),
197                            None => project_root.join(&stem),
198                        }
199                    });
200
201                let rel_source = crate::paths::relative(project_root, &source_path);
202                Some((sidecar_path, rel_source))
203            })
204            .collect()
205    }
206
207    fn sidecar_for(&self, source: &RelativePath) -> RelativePath {
208        let p = Path::new(AsRef::<str>::as_ref(source));
209        let stem = p.file_stem().unwrap_or_default().to_string_lossy();
210        let sidecar_name = match p.parent().filter(|p| !p.as_os_str().is_empty()) {
211            Some(parent) => format!("{}/{stem}.aqm", parent.to_string_lossy()),
212            None => format!("{stem}.aqm"),
213        };
214        RelativePath::from(format!("{}/{sidecar_name}", self.base))
215    }
216}
217
218// ---------------------------------------------------------------------------
219// In-memory locator for builds without filesystem access
220// ---------------------------------------------------------------------------
221
222/// Stub locator that derives sidecar paths from source paths without filesystem access.
223/// Used when the `fs` feature is disabled (e.g. WASM builds).
224#[cfg(not(feature = "fs"))]
225pub(crate) struct InMemoryLocator;
226
227#[cfg(not(feature = "fs"))]
228impl SidecarLocator for InMemoryLocator {
229    fn discover(&self, _project_root: &ProjectRoot) -> Vec<(PathBuf, RelativePath)> {
230        vec![]
231    }
232
233    fn sidecar_for(&self, source: &RelativePath) -> RelativePath {
234        let p = Path::new(AsRef::<str>::as_ref(source));
235        let stem = p.file_stem().unwrap_or_default().to_string_lossy();
236        RelativePath::from(match p.parent().filter(|p| !p.as_os_str().is_empty()) {
237            Some(parent) => format!("{}/{stem}.aqm", parent.to_string_lossy()),
238            None => format!("{stem}.aqm"),
239        })
240    }
241}
242
243// ---------------------------------------------------------------------------
244// Filesystem helpers (only with fs feature)
245// ---------------------------------------------------------------------------
246
247#[cfg(feature = "fs")]
248use rustc_hash::FxHashMap;
249
250/// Precompute directory listings for all unique parent directories of sidecar paths.
251#[cfg(feature = "fs")]
252fn precompute_dir_listings(paths: &[PathBuf]) -> FxHashMap<PathBuf, Vec<PathBuf>> {
253    let mut map: FxHashMap<PathBuf, Vec<PathBuf>> = FxHashMap::default();
254    for path in paths {
255        if let Some(parent) = path.parent() {
256            map.entry(parent.to_path_buf())
257                .or_insert_with(|| read_dir_listing(parent));
258        }
259    }
260    map
261}
262
263/// Precompute directory listings for project source dirs corresponding to sidecar paths.
264/// Maps sidecar parent dirs (under `base_dir`) to project source dirs.
265#[cfg(feature = "fs")]
266fn precompute_dir_listings_for_project(
267    project_root: &ProjectRoot,
268    base_dir: &Path,
269    sidecar_paths: &[PathBuf],
270) -> FxHashMap<PathBuf, Vec<PathBuf>> {
271    let mut map: FxHashMap<PathBuf, Vec<PathBuf>> = FxHashMap::default();
272    for path in sidecar_paths {
273        if let Some(parent) = path.parent() {
274            // Map .annotations/src/ → <project_root>/src/
275            let source_dir = match parent.strip_prefix(base_dir) {
276                Ok(rel) if !rel.as_os_str().is_empty() => project_root.join(rel),
277                _ => project_root.to_path_buf(),
278            };
279            map.entry(source_dir.clone())
280                .or_insert_with(|| read_dir_listing(&source_dir));
281        }
282    }
283    map
284}
285
286/// List files in a directory. Returns empty vec on I/O error.
287#[cfg(feature = "fs")]
288fn read_dir_listing(dir: &Path) -> Vec<PathBuf> {
289    std::fs::read_dir(dir)
290        .ok()
291        .map(|entries| {
292            entries
293                .flatten()
294                .map(|e| e.path())
295                .filter(|p| p.is_file())
296                .collect()
297        })
298        .unwrap_or_default()
299}
300
301/// Find a source file whose stem matches `stem` from a pre-computed file listing.
302#[cfg(feature = "fs")]
303fn find_source_in_listing(files: &[PathBuf], stem: &str) -> Option<PathBuf> {
304    files
305        .iter()
306        .find(|path| {
307            path.file_stem()
308                .is_some_and(|s| s.to_string_lossy() == stem)
309                && path.extension().is_some_and(|ext| ext != "aqm")
310        })
311        .cloned()
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    #[cfg(feature = "fs")]
320    fn colocated_sidecar_for() {
321        // Arrange
322        let locator = ColocatedLocator;
323
324        // Act and Assert
325        assert_eq!(
326            locator.sidecar_for(&RelativePath::from("src/api.ts")),
327            "src/api.aqm",
328            "should derive colocated sidecar path"
329        );
330        assert_eq!(
331            locator.sidecar_for(&RelativePath::from("lib.rs")),
332            "lib.aqm",
333            "should handle root-level files"
334        );
335    }
336
337    #[test]
338    #[cfg(feature = "fs")]
339    fn directory_sidecar_for() {
340        // Arrange
341        let locator = DirectoryLocator {
342            base: RelativePath::from(".annotations"),
343        };
344
345        // Act and Assert
346        assert_eq!(
347            locator.sidecar_for(&RelativePath::from("src/api.ts")),
348            ".annotations/src/api.aqm",
349            "should place sidecar under base directory"
350        );
351        assert_eq!(
352            locator.sidecar_for(&RelativePath::from("lib.rs")),
353            ".annotations/lib.aqm",
354            "should handle root-level files under base"
355        );
356    }
357
358    #[test]
359    fn free_fn_sidecar_for_colocated() {
360        // Act and Assert
361        assert_eq!(
362            sidecar_for_colocated(&RelativePath::from("src/api.ts")),
363            "src/api.aqm",
364            "should derive colocated sidecar path"
365        );
366        assert_eq!(
367            sidecar_for_colocated(&RelativePath::from("lib.rs")),
368            "lib.aqm",
369            "should handle root-level files"
370        );
371    }
372
373    #[test]
374    fn free_fn_source_candidates() {
375        // Act
376        let candidates = source_candidates(&RelativePath::from("src/api.aqm"));
377
378        // Assert
379        assert_eq!(
380            candidates.len(),
381            SOURCE_EXTENSIONS.len(),
382            "should produce one candidate per extension"
383        );
384        assert_eq!(candidates[0], "src/api.rs", "first candidate should be .rs");
385        assert_eq!(
386            candidates[1], "src/api.ts",
387            "second candidate should be .ts"
388        );
389    }
390
391    #[test]
392    fn extract_bind_double_quotes() {
393        // Act and Assert
394        assert_eq!(
395            extract_bind_from_line(r#"<parser bind="parse_selector" owner="@engine">"#),
396            Some(Binding::from("parse_selector")),
397            "should extract bind value from double-quoted attribute"
398        );
399    }
400
401    #[test]
402    fn extract_bind_single_quotes() {
403        // Act and Assert
404        assert_eq!(
405            extract_bind_from_line("<parser bind='parse_selector'>"),
406            Some(Binding::from("parse_selector")),
407            "should extract bind value from single-quoted attribute"
408        );
409    }
410
411    #[test]
412    fn extract_bind_no_match() {
413        // Act and Assert
414        assert_eq!(
415            extract_bind_from_line("<parser owner=\"@engine\">"),
416            None,
417            "should return None when no bind attribute"
418        );
419    }
420
421    #[test]
422    fn resolve_bind_name_simple() {
423        // Act and Assert
424        assert_eq!(
425            resolve_bind_name(&Binding::from("parse_selector")),
426            "parse_selector",
427            "should return name unchanged when not qualified"
428        );
429    }
430
431    #[test]
432    fn resolve_bind_name_qualified() {
433        // Act and Assert
434        assert_eq!(
435            resolve_bind_name(&Binding::from("Foo.bar")),
436            "bar",
437            "should return last segment of qualified binding"
438        );
439    }
440}