1use crate::types::{Binding, ProjectRoot, RelativePath, TagName};
8use std::path::{Path, PathBuf};
9
10pub const SOURCE_EXTENSIONS: &[&str] =
16 &[".rs", ".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".go"];
17
18#[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
30pub 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
46pub 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
58pub fn resolve_bind_name(bind: &Binding) -> &str {
63 let s: &str = bind.as_ref();
64 s.rsplit('.').next().unwrap_or(s)
65}
66
67#[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 pub binding: Binding,
76 pub tag: TagName,
78 pub line: usize,
80}
81
82pub 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
104pub trait SidecarLocator: Send + Sync {
112 fn discover(&self, project_root: &ProjectRoot) -> Vec<(PathBuf, RelativePath)>;
114
115 fn sidecar_for(&self, source: &RelativePath) -> RelativePath;
117}
118
119#[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#[cfg(feature = "fs")]
161pub struct DirectoryLocator {
162 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 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 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 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#[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#[cfg(feature = "fs")]
248use rustc_hash::FxHashMap;
249
250#[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#[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 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#[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#[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 let locator = ColocatedLocator;
323
324 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 let locator = DirectoryLocator {
342 base: RelativePath::from(".annotations"),
343 };
344
345 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 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 let candidates = source_candidates(&RelativePath::from("src/api.aqm"));
377
378 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 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 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 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 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 assert_eq!(
435 resolve_bind_name(&Binding::from("Foo.bar")),
436 "bar",
437 "should return last segment of qualified binding"
438 );
439 }
440}