1#[cfg(feature = "fs")]
7mod glob;
8mod parse;
9
10#[cfg(feature = "fs")]
11pub use glob::{glob_annotation_files, glob_aql_files};
12
13use crate::error::AqlError;
14use crate::matcher::{self, Matchable};
15use crate::selector::parse_selector;
16use crate::sidecar::SidecarLocator;
17use crate::types::{AttrName, Binding, ProjectRoot, RelativePath, Scope, TagName};
18use rustc_hash::FxHashMap;
19use serde::Serialize;
20use serde_json::Value as JsonValue;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::SystemTime;
24
25#[cfg(feature = "fs")]
26use rayon::prelude::*;
27
28#[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 Annotation {
35 pub tag: TagName,
37 #[cfg_attr(
39 feature = "ts",
40 ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
41 )]
42 #[cfg_attr(
43 feature = "flow",
44 flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
45 )]
46 pub attrs: FxHashMap<AttrName, JsonValue>,
47 pub binding: Binding,
50 pub file: RelativePath,
52 pub children: Vec<Annotation>,
54}
55
56#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
59#[cfg_attr(feature = "ts", ts(export))]
60#[cfg_attr(feature = "flow", flow(export))]
61#[derive(Debug, Clone, serde::Deserialize, Serialize)]
62pub struct FileEntry {
63 pub file: RelativePath,
65 pub xml: String,
67}
68
69struct CachedFileEntry {
71 annotations: Vec<Annotation>,
72 #[cfg_attr(not(feature = "fs"), allow(dead_code))]
73 mtime: SystemTime,
74 #[cfg_attr(not(feature = "fs"), allow(dead_code))]
75 sidecar_path: PathBuf,
76}
77
78pub struct AnnotationStore {
80 index: FxHashMap<RelativePath, CachedFileEntry>,
81 #[cfg_attr(not(feature = "fs"), allow(dead_code))]
82 project_root: ProjectRoot,
83 locator: Arc<dyn SidecarLocator>,
84}
85
86impl AnnotationStore {
87 #[cfg(feature = "fs")]
90 pub fn new(project_root: &Path) -> Self {
91 Self {
92 index: FxHashMap::default(),
93 project_root: ProjectRoot::from(project_root),
94 locator: Arc::new(crate::sidecar::ColocatedLocator),
95 }
96 }
97
98 #[cfg(not(feature = "fs"))]
100 pub fn new(project_root: &Path) -> Self {
101 Self {
102 index: FxHashMap::default(),
103 project_root: ProjectRoot::from(project_root),
104 locator: Arc::new(crate::sidecar::InMemoryLocator),
105 }
106 }
107
108 pub fn with_locator(project_root: &Path, locator: Arc<dyn SidecarLocator>) -> Self {
110 Self {
111 index: FxHashMap::default(),
112 project_root: ProjectRoot::from(project_root),
113 locator,
114 }
115 }
116
117 pub fn locator(&self) -> &dyn SidecarLocator {
119 &*self.locator
120 }
121
122 #[cfg(feature = "fs")]
125 pub fn load_all_from_locator(&mut self) -> Vec<String> {
126 let pairs = self.locator.discover(&self.project_root);
127 let results: Vec<_> = pairs
128 .par_iter()
129 .filter_map(|(sidecar_path, rel_source)| {
130 match (
131 std::fs::metadata(sidecar_path),
132 std::fs::read_to_string(sidecar_path),
133 ) {
134 (Ok(meta), Ok(raw)) => {
135 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
136 Some((
137 sidecar_path.clone(),
138 rel_source.clone(),
139 mtime,
140 parse::parse_sidecar(&raw, rel_source),
141 ))
142 }
143 _ => None,
144 }
145 })
146 .collect();
147
148 let mut errors = Vec::new();
149 for (sidecar_path, rel_source, mtime, parse_result) in results {
150 match parse_result {
151 Ok(annotations) => {
152 self.index.insert(
153 rel_source,
154 CachedFileEntry {
155 annotations,
156 mtime,
157 sidecar_path,
158 },
159 );
160 }
161 Err(e) => errors.push(e),
162 }
163 }
164 errors
165 }
166
167 #[cfg(feature = "fs")]
170 pub fn load_all(&mut self, sidecar_paths: &[PathBuf]) -> Vec<String> {
171 let project_root = &self.project_root;
172 let results: Vec<_> = sidecar_paths
173 .par_iter()
174 .filter_map(|path| {
175 let stem = path.file_stem()?.to_string_lossy().to_string();
176 let parent = path.parent()?;
177 let source_path = parent.join(&stem);
178 let rel_source = crate::paths::relative(project_root, &source_path);
179
180 match (std::fs::metadata(path), std::fs::read_to_string(path)) {
181 (Ok(meta), Ok(raw)) => {
182 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
183 let parsed = parse::parse_sidecar(&raw, &rel_source);
184 Some((path.to_path_buf(), rel_source, mtime, parsed))
185 }
186 _ => None,
187 }
188 })
189 .collect();
190
191 let mut errors = Vec::new();
192 for (sidecar_path, rel_source, mtime, parse_result) in results {
193 match parse_result {
194 Ok(annotations) => {
195 self.index.insert(
196 rel_source,
197 CachedFileEntry {
198 annotations,
199 mtime,
200 sidecar_path,
201 },
202 );
203 }
204 Err(e) => errors.push(e),
205 }
206 }
207 errors
208 }
209
210 #[cfg(feature = "fs")]
214 pub fn load_file(&mut self, sidecar_path: &Path) -> Result<(), String> {
215 let stem = sidecar_path
216 .file_stem()
217 .map(|s| s.to_string_lossy().to_string())
218 .unwrap_or_default();
219 let parent = sidecar_path.parent().unwrap_or(Path::new(""));
220 let source_path = parent.join(&stem);
221
222 let rel_source = crate::paths::relative(&self.project_root, &source_path);
223
224 match (
225 std::fs::metadata(sidecar_path),
226 std::fs::read_to_string(sidecar_path),
227 ) {
228 (Ok(meta), Ok(raw)) => {
229 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
230 let annotations = match parse::parse_sidecar(&raw, &rel_source) {
231 Ok(a) => a,
232 Err(e) => {
233 self.index.remove(&*rel_source);
234 return Err(e);
235 }
236 };
237 self.index.insert(
238 rel_source,
239 CachedFileEntry {
240 annotations,
241 mtime,
242 sidecar_path: sidecar_path.to_path_buf(),
243 },
244 );
245 Ok(())
246 }
247 _ => {
248 self.index.remove(&*rel_source);
249 Ok(())
250 }
251 }
252 }
253
254 #[cfg(feature = "fs")]
257 pub fn refresh_if_stale(&mut self, rel_source: &RelativePath) -> bool {
258 let entry = match self.index.get(rel_source) {
259 Some(e) => e,
260 None => return false,
261 };
262
263 let sidecar_path = entry.sidecar_path.clone();
264
265 if sidecar_path.as_os_str().is_empty() {
267 return false;
268 }
269
270 match std::fs::metadata(&sidecar_path) {
271 Ok(meta) => {
272 let new_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
273 if new_mtime > entry.mtime {
274 let _ = self.load_file(&sidecar_path);
276 return true;
277 }
278 }
279 Err(_) => {
280 self.index.remove(rel_source);
281 return true;
282 }
283 }
284 false
285 }
286
287 #[cfg(feature = "fs")]
293 pub fn is_stale(&self, rel_source: &RelativePath) -> bool {
294 let entry = match self.index.get(rel_source) {
295 Some(e) => e,
296 None => return false,
297 };
298 if entry.sidecar_path.as_os_str().is_empty() {
299 return false;
300 }
301 match std::fs::metadata(&entry.sidecar_path) {
302 Ok(meta) => {
303 let disk_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
304 disk_mtime > entry.mtime
305 }
306 Err(_) => true,
307 }
308 }
309
310 pub fn get_file_annotations(&self, rel_source: &str) -> Vec<&Annotation> {
315 if let Some(entry) = self.index.get(rel_source) {
317 return entry.annotations.iter().collect();
318 }
319
320 let stem = rel_source
322 .strip_suffix(".aqm")
323 .or_else(|| rel_source.rfind('.').map(|dot| &rel_source[..dot]))
324 .unwrap_or(rel_source);
325
326 self.index
328 .iter()
329 .find(|(key, _)| {
330 let key_str: &str = key.as_ref();
331 let key_stem = match key_str.rfind('.') {
332 Some(dot) => &key_str[..dot],
333 None => key_str,
334 };
335 key_stem == stem
336 })
337 .map(|(_, entry)| entry.annotations.iter().collect())
338 .unwrap_or_default()
339 }
340
341 pub fn get_all_annotations(&self) -> Vec<&Annotation> {
343 self.index
344 .values()
345 .flat_map(|e| e.annotations.iter())
346 .collect()
347 }
348
349 pub fn get_scope_annotations(&self, scope: &Scope) -> Vec<&Annotation> {
351 self.index
352 .iter()
353 .filter(|(key, _)| {
354 let k: &str = key.as_ref();
355 k.starts_with(&**scope)
356 })
357 .flat_map(|(_, e)| e.annotations.iter())
358 .collect()
359 }
360
361 pub fn select(
366 &self,
367 selector_str: &str,
368 file: Option<&str>,
369 scope: Option<&str>,
370 opts: Option<&crate::query_options::QueryOptions>,
371 ) -> Result<Vec<Annotation>, AqlError> {
372 let selector = parse_selector(selector_str)?;
373
374 let annotations: Vec<&Annotation> = match file {
375 Some(f) => self.get_file_annotations(f),
376 None => match scope {
377 Some(s) if !s.is_empty() => self.get_scope_annotations(&Scope::from(s)),
378 _ => self.get_all_annotations(),
379 },
380 };
381
382 let flat = flatten_annotations(&annotations);
383 let matchable_refs: Vec<&dyn Matchable> =
384 flat.iter().map(|n| n as &dyn Matchable).collect();
385 let parent_indices: Vec<Option<usize>> = flat.iter().map(|n| n.parent_idx).collect();
386 let matched_indices =
387 matcher::filter_by_selector_indexed(&matchable_refs, &parent_indices, &selector);
388
389 let results: Vec<Annotation> = matched_indices
390 .into_iter()
391 .map(|idx| flat[idx].ann.clone())
392 .collect();
393
394 let results = match opts {
395 Some(o) => crate::query_options::apply_to_annotations(results, o),
396 None => results,
397 };
398
399 Ok(results)
400 }
401
402 #[cfg(feature = "fs")]
407 pub fn refresh_scope(&mut self, scope: &Scope) -> Vec<String> {
408 let mut errors = Vec::new();
409
410 let keys: Vec<RelativePath> = self
412 .index
413 .keys()
414 .filter(|k| scope.is_empty() || k.starts_with(&**scope))
415 .cloned()
416 .collect();
417 for key in keys {
418 self.refresh_if_stale(&key);
419 }
420
421 let pairs = self.locator.discover(&self.project_root);
423 for (sidecar_path, rel_source) in pairs {
424 if !(scope.is_empty() || rel_source.starts_with(&**scope)) {
425 continue;
426 }
427 if self.index.contains_key(&*rel_source) {
428 continue;
429 }
430 if let (Ok(meta), Ok(raw)) = (
432 std::fs::metadata(&sidecar_path),
433 std::fs::read_to_string(&sidecar_path),
434 ) {
435 let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
436 match parse::parse_sidecar(&raw, &rel_source) {
437 Ok(annotations) => {
438 self.index.insert(
439 rel_source,
440 CachedFileEntry {
441 annotations,
442 mtime,
443 sidecar_path,
444 },
445 );
446 }
447 Err(e) => errors.push(e),
448 }
449 }
450 }
451 errors
452 }
453
454 pub fn annotations_in_scope(&self, scope: &Scope) -> Vec<(&RelativePath, Vec<&Annotation>)> {
456 self.index
457 .iter()
458 .filter(|(rel, _)| scope.is_empty() || rel.starts_with(&**scope))
459 .map(|(rel, entry)| (rel, entry.annotations.iter().collect()))
460 .collect()
461 }
462
463 pub fn file_count(&self) -> usize {
465 self.index.len()
466 }
467
468 pub fn annotated_files(&self) -> Vec<RelativePath> {
470 self.index.keys().cloned().collect()
471 }
472
473 pub fn load_xml(&mut self, rel_source: &RelativePath, xml: &str) -> Result<(), AqlError> {
477 let annotations = parse::parse_sidecar(xml, rel_source).map_err(AqlError::from)?;
478 self.index.insert(
479 rel_source.clone(),
480 CachedFileEntry {
481 annotations,
482 mtime: SystemTime::UNIX_EPOCH,
483 sidecar_path: PathBuf::new(),
484 },
485 );
486 Ok(())
487 }
488
489 pub fn load_extractor_output(&mut self, annotations: Vec<Annotation>) {
494 for ann in annotations {
495 let rel = ann.file.clone();
496 let entry = self.index.entry(rel).or_insert_with(|| CachedFileEntry {
497 annotations: Vec::new(),
498 mtime: SystemTime::UNIX_EPOCH,
499 sidecar_path: PathBuf::new(),
500 });
501
502 let existing = entry.annotations.iter_mut().find(|a| {
504 !a.binding.is_empty()
505 && !ann.binding.is_empty()
506 && a.binding == ann.binding
507 && a.tag == ann.tag
508 });
509
510 match existing {
511 Some(existing_ann) => {
512 for (key, value) in &ann.attrs {
514 existing_ann
515 .attrs
516 .entry(key.clone())
517 .or_insert(value.clone());
518 }
519 }
520 None => {
521 entry.annotations.push(ann);
522 }
523 }
524 }
525 }
526
527 #[cfg(test)]
529 pub fn inject_test_data(&mut self, rel_source: &RelativePath, annotations: Vec<Annotation>) {
530 let sidecar_rel = self.locator.sidecar_for(rel_source);
531 let sidecar_path = self.project_root.join(AsRef::<Path>::as_ref(&sidecar_rel));
532
533 self.index.insert(
534 rel_source.clone(),
535 CachedFileEntry {
536 annotations,
537 mtime: SystemTime::now(),
538 sidecar_path,
539 },
540 );
541 }
542}
543
544struct AnnotationNode<'a> {
549 ann: &'a Annotation,
550 parent_idx: Option<usize>,
551}
552
553impl Matchable for AnnotationNode<'_> {
554 fn tag(&self) -> &TagName {
555 &self.ann.tag
556 }
557 fn attrs(&self) -> &FxHashMap<AttrName, JsonValue> {
558 &self.ann.attrs
559 }
560 fn parent(&self) -> Option<&dyn Matchable> {
561 None
562 }
563}
564
565fn flatten_annotations<'a>(annotations: &[&'a Annotation]) -> Vec<AnnotationNode<'a>> {
566 let mut result = Vec::new();
567
568 fn walk<'a>(
569 ann: &'a Annotation,
570 parent_idx: Option<usize>,
571 result: &mut Vec<AnnotationNode<'a>>,
572 ) {
573 let idx = result.len();
574 result.push(AnnotationNode { ann, parent_idx });
575 for child in &ann.children {
576 walk(child, Some(idx), result);
577 }
578 }
579
580 for ann in annotations {
581 walk(ann, None, &mut result);
582 }
583 result
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn loads_xml_from_string() {
592 let mut store = AnnotationStore::new(Path::new("/project"));
594 let xml = r#"<controller method="POST" bind="handle_create" />"#;
595
596 store
598 .load_xml(&RelativePath::from("src/api.ts"), xml)
599 .unwrap();
600 let results = store.select("controller", None, None, None).unwrap();
601
602 assert_eq!(store.file_count(), 1, "should have one loaded file");
604 assert_eq!(results.len(), 1, "should find one controller annotation");
605 assert_eq!(results[0].tag, "controller", "tag should be controller");
606 assert_eq!(
607 results[0].file, "src/api.ts",
608 "file should match rel_source"
609 );
610 assert_eq!(
611 results[0].binding, "handle_create",
612 "binding should come from bind attr"
613 );
614 }
615
616 #[test]
617 fn selects_annotations() {
618 let mut store = AnnotationStore::new(Path::new("/project"));
620 store.inject_test_data(
621 &RelativePath::from("src/api.ts"),
622 vec![
623 Annotation {
624 tag: TagName::from("controller"),
625 attrs: {
626 let mut m = FxHashMap::default();
627 m.insert(
628 AttrName::from("method"),
629 JsonValue::String("POST".to_string()),
630 );
631 m
632 },
633 binding: Binding::from(""),
634 file: RelativePath::from("src/api.ts"),
635 children: vec![],
636 },
637 Annotation {
638 tag: TagName::from("react-hook"),
639 attrs: FxHashMap::default(),
640 binding: Binding::from(""),
641 file: RelativePath::from("src/api.ts"),
642 children: vec![],
643 },
644 ],
645 );
646 store.inject_test_data(
647 &RelativePath::from("src/b.ts"),
648 vec![Annotation {
649 tag: TagName::from("controller"),
650 attrs: FxHashMap::default(),
651 binding: Binding::from(""),
652 file: RelativePath::from("src/b.ts"),
653 children: vec![],
654 }],
655 );
656
657 let by_tag = store.select("controller", None, None, None).unwrap();
659 let by_attr_match = store
660 .select(r#"controller[method="POST"]"#, None, None, None)
661 .unwrap();
662 let by_attr_miss = store
663 .select(r#"controller[method="GET"]"#, None, None, None)
664 .unwrap();
665 let scoped = store
666 .select("controller", Some("src/api.ts"), None, None)
667 .unwrap();
668
669 assert_eq!(by_tag.len(), 2);
671 assert!(by_tag.iter().all(|r| r.tag == "controller"));
672
673 assert_eq!(by_attr_match.len(), 1);
674 assert_eq!(by_attr_miss.len(), 0);
675
676 assert_eq!(scoped.len(), 1);
677 assert_eq!(scoped[0].file, "src/api.ts");
678 }
679}