1use std::collections::{BTreeMap, BTreeSet};
5
6use anyhow::{Context, Result, bail};
7use ra_ap_syntax::{
8 AstNode, Edition, SourceFile, SyntaxKind, SyntaxNode,
9 ast::{self, HasArgList, HasModuleItem, HasName, LiteralKind},
10};
11use wowlab_fs::{
12 directory::{self, EntryKind},
13 file,
14 path::{Path, PathBuf},
15};
16use wowlab_manifest_schema::{ItemManifestEntry, Manifest};
17
18use super::types::{LedgerDisposition, LedgerFinding, LedgerRow, LedgerSource, OperationCategory};
19
20pub(super) const PLACEHOLDER_EVIDENCE: &str = "hook body requires operation-level source inventory";
22
23#[derive(Clone, Debug)]
24struct HookBinding {
25 manifest_key: String,
26 function: String,
27}
28
29#[derive(Clone, Debug)]
30struct SourceOperation {
31 category: OperationCategory,
32 disposition: LedgerDisposition,
33 evidence: String,
34 aura_reference: Option<AuraReference>,
35 controlled: bool,
36 coupled: bool,
37}
38
39#[derive(Clone, Debug)]
40struct SourceCall {
41 name: String,
42 controlled: bool,
43}
44
45#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
46enum AuraReference {
47 Id(u32),
48 Name(String),
49}
50
51#[derive(Clone, Debug)]
52struct FunctionInfo {
53 file: PathBuf,
54 name: String,
55 operations: Vec<SourceOperation>,
56 calls: Vec<SourceCall>,
57 imperative_procs: usize,
58}
59
60#[derive(Default)]
61struct OperationVisitor {
62 operations: Vec<SourceOperation>,
63 calls: Vec<SourceCall>,
64 imperative_procs: usize,
65}
66
67impl OperationVisitor {
68 fn collect(block: &ast::BlockExpr) -> Self {
69 let mut visitor = Self::default();
70
71 for node in block.syntax().descendants() {
72 let controlled = is_controlled(&node, block);
73
74 if let Some(call) = ast::MethodCallExpr::cast(node.clone()) {
75 let Some(name) = call.name_ref().map(|name| name.text().to_string()) else {
76 continue;
77 };
78
79 if let Some(mut operation) = classify_call(&name) {
80 operation.aura_reference = call
81 .arg_list()
82 .and_then(|arguments| arguments.args().next())
83 .as_ref()
84 .and_then(aura_reference);
85 operation.controlled = controlled;
86 visitor.operations.push(operation);
87 }
88
89 continue;
90 }
91
92 if let Some(call) = ast::CallExpr::cast(node.clone()) {
93 let Some(ast::Expr::PathExpr(path)) = call.expr() else {
94 continue;
95 };
96 let Some(name) = path.path().as_ref().and_then(last_segment) else {
97 continue;
98 };
99
100 if let Some(mut operation) = classify_call(&name) {
101 operation.aura_reference = call
102 .arg_list()
103 .and_then(|arguments| arguments.args().next())
104 .as_ref()
105 .and_then(aura_reference);
106 operation.controlled = controlled;
107 visitor.operations.push(operation);
108 }
109
110 visitor.calls.push(SourceCall { name, controlled });
111
112 continue;
113 }
114
115 if ast::RecordExpr::cast(node)
116 .and_then(|record| record.path())
117 .as_ref()
118 .and_then(last_segment)
119 .is_some_and(|name| is_imperative_proc_type(&name))
120 {
121 visitor.imperative_procs += 1;
122 }
123 }
124
125 visitor
126 }
127}
128
129fn is_controlled(node: &SyntaxNode, block: &ast::BlockExpr) -> bool {
130 node.ancestors()
131 .skip(1)
132 .take_while(|ancestor| ancestor != block.syntax())
133 .any(|ancestor| {
134 ast::ForExpr::can_cast(ancestor.kind())
135 || ast::IfExpr::can_cast(ancestor.kind())
136 || ast::LoopExpr::can_cast(ancestor.kind())
137 || ast::MatchExpr::can_cast(ancestor.kind())
138 || ast::WhileExpr::can_cast(ancestor.kind())
139 })
140}
141
142pub(super) fn replace_hook_placeholders(
143 rows: &mut Vec<LedgerRow>,
144 slug: &str,
145 hooks_dir: &Path,
146 manifest: &Manifest,
147) -> Result<()> {
148 let spec_dir = hooks_dir.join(slug);
149 let mut index = SourceIndex::load(&spec_dir)?;
150
151 index.merge(SourceIndex::load(&hooks_dir.join("shared"))?);
152 let aura_ids = manifest
153 .auras
154 .iter()
155 .map(|(name, aura)| (name.clone(), aura.id))
156 .collect();
157
158 replace_bindings(rows, slug, &index, &spec_bindings(manifest), &aura_ids)
159}
160
161pub(super) fn replace_item_hook_placeholders(
162 rows: &mut Vec<LedgerRow>,
163 item_name: &str,
164 hooks_dir: &Path,
165 item: &ItemManifestEntry,
166) -> Result<()> {
167 let owner = format!("item:{item_name}");
168 let module = item_name.to_ascii_lowercase();
169 let file = hooks_dir.join("items").join(format!("{module}.rs"));
170 let index = if directory::inspect(&file)?.is_some() {
171 SourceIndex::load_file(&file)?
172 } else {
173 SourceIndex::default()
174 };
175 let aura_ids = item
176 .auras
177 .iter()
178 .map(|(aura_name, aura)| (aura_name.clone(), aura.id))
179 .collect();
180
181 replace_bindings(
182 rows,
183 &owner,
184 &index,
185 &item_bindings(item_name, item),
186 &aura_ids,
187 )
188}
189
190pub(super) fn collect_imperative_proc_registrations(
191 rows: &mut Vec<LedgerRow>,
192 hooks_dir: &Path,
193) -> Result<()> {
194 let index = SourceIndex::load(hooks_dir)?;
195
196 for function in index.functions.values().flatten() {
197 for occurrence in 0..function.imperative_procs {
198 let owner = owner_from_hook_path(hooks_dir, &function.file);
199
200 rows.push(
201 LedgerRow::new(
202 LedgerSource::new(
203 owner,
204 "(imperative hook registration)",
205 format!("impact_proc[{occurrence}]"),
206 ),
207 LedgerFinding::new(
208 OperationCategory::Proc,
209 LedgerDisposition::GenericGap,
210 "imperative impact proc registration; cross-check against DBC AU42/proc data",
211 ),
212 )
213 .hook(
214 &relative_path(hooks_dir, &function.file),
215 function.name.clone(),
216 ),
217 );
218 }
219 }
220
221 Ok(())
222}
223
224#[cfg(test)]
225pub(super) fn imperative_proc_count(hooks_dir: &Path) -> Result<usize> {
226 let index = SourceIndex::load(hooks_dir)?;
227
228 Ok(index
229 .functions
230 .values()
231 .flatten()
232 .map(|function| function.imperative_procs)
233 .sum())
234}
235
236fn replace_bindings(
237 rows: &mut Vec<LedgerRow>,
238 owner: &str,
239 index: &SourceIndex,
240 bindings: &[HookBinding],
241 aura_ids: &BTreeMap<String, u32>,
242) -> Result<()> {
243 for binding in bindings {
244 let Some(position) = rows.iter().position(|row| {
245 row.spec_or_item == owner
246 && row.manifest_key == binding.manifest_key
247 && row.evidence == PLACEHOLDER_EVIDENCE
248 }) else {
249 bail!(
250 "{owner} hook binding {} has no ledger placeholder",
251 binding.manifest_key
252 );
253 };
254 let placeholder = rows.remove(position);
255 let Some(function) = index.unique(&binding.function)? else {
256 bail!(
257 "{owner} hook function {} for {} was not found below the owning hook module",
258 binding.function,
259 binding.manifest_key
260 );
261 };
262 let operations = index.expanded_operations(function);
263
264 if operations.is_empty() {
265 let mut resolved = placeholder;
266
267 resolved.hook_file = Some(function.file.display().to_string());
268 resolved.hook_function = Some(function.name.clone());
269 resolved.evidence = "hook contains no recognized state mutation; inspect returned formula/control policy".to_string();
270 rows.insert(position, resolved);
271 continue;
272 }
273
274 for (operation_index, operation) in operations.into_iter().enumerate().rev() {
275 let mut row = placeholder
276 .clone()
277 .hook(&function.file, function.name.clone());
278
279 row.hook_operation = Some(operation_index + 1);
280 row.category = operation.category;
281 row.disposition = operation.disposition;
282 row.evidence = operation.evidence;
283 let coupled = operation.coupled;
284
285 if let Some(aura_id) = operation
286 .aura_reference
287 .as_ref()
288 .and_then(|reference| resolve_aura_reference(reference, aura_ids))
289 {
290 row.child_spell_ids.push(aura_id);
291
292 if coupled {
293 row.evidence = format!("coupled {}", row.evidence);
294 }
295 }
296
297 rows.insert(position, row);
298 }
299 }
300
301 Ok(())
302}
303
304#[derive(Default)]
305struct SourceIndex {
306 functions: BTreeMap<String, Vec<FunctionInfo>>,
307}
308
309impl SourceIndex {
310 fn load(root: &Path) -> Result<Self> {
311 if directory::inspect(root)?.is_none() {
312 return Ok(Self::default());
313 }
314
315 let mut paths = Vec::new();
316
317 collect_rust_paths(root, &mut paths)?;
318 paths.sort();
319 let mut index = Self::default();
320
321 for path in paths {
322 index.parse_file(&path)?;
323 }
324
325 Ok(index)
326 }
327
328 fn load_file(path: &Path) -> Result<Self> {
329 let mut index = Self::default();
330
331 index.parse_file(path)?;
332
333 Ok(index)
334 }
335
336 fn parse_file(&mut self, path: &Path) -> Result<()> {
337 let source = file::read_text(path)
338 .with_context(|| format!("failed to read hook source {}", path.display()))?;
339 let syntax = parse_hook_source(path, &source)?;
340 let mut parsed_proc_count = 0;
341
342 for item in syntax.items() {
343 match item {
344 ast::Item::Fn(function) => {
345 let (Some(name), Some(block)) = (function.name(), function.body()) else {
346 continue;
347 };
348
349 parsed_proc_count +=
350 self.insert_function(path, name.text().to_string(), &block);
351 }
352 ast::Item::Impl(implementation) => {
353 if let Some(items) = implementation.assoc_item_list() {
354 for item in items.assoc_items() {
355 if let ast::AssocItem::Fn(function) = item {
356 let (Some(name), Some(block)) = (function.name(), function.body())
357 else {
358 continue;
359 };
360
361 parsed_proc_count +=
362 self.insert_function(path, name.text().to_string(), &block);
363 }
364 }
365 }
366 }
367 _ => {}
368 }
369 }
370
371 let token_proc_count = count_proc_structs(syntax.syntax());
372
373 if token_proc_count > parsed_proc_count {
374 let missing = token_proc_count - parsed_proc_count;
375
376 self.functions
377 .entry("<macro registration>".to_string())
378 .or_default()
379 .push(FunctionInfo {
380 file: path.to_path_buf(),
381 name: "<macro registration>".to_string(),
382 operations: Vec::new(),
383 calls: Vec::new(),
384 imperative_procs: missing,
385 });
386 }
387
388 Ok(())
389 }
390
391 fn insert_function(&mut self, path: &Path, name: String, block: &ast::BlockExpr) -> usize {
392 let mut visitor = OperationVisitor::collect(block);
393 let duration_mutations: BTreeSet<_> = visitor
394 .operations
395 .iter()
396 .filter(|operation| operation.evidence == "hook operation `extend_aura`")
397 .filter_map(|operation| operation.aura_reference.clone())
398 .collect();
399
400 for operation in &mut visitor.operations {
401 let aura_application = operation.evidence == "hook operation `apply_aura`";
402 let coupled_duration = aura_application
403 && operation
404 .aura_reference
405 .as_ref()
406 .is_some_and(|reference| duration_mutations.contains(reference));
407
408 if is_aura_identity_operation(operation) && (operation.controlled || coupled_duration) {
409 operation.coupled = true;
410 }
411 }
412
413 let function = FunctionInfo {
414 file: path.to_path_buf(),
415 name: name.clone(),
416 operations: visitor.operations,
417 calls: visitor.calls,
418 imperative_procs: visitor.imperative_procs,
419 };
420 let imperative_procs = function.imperative_procs;
421
422 self.functions.entry(name).or_default().push(function);
423
424 imperative_procs
425 }
426
427 fn merge(&mut self, other: Self) {
428 for (name, functions) in other.functions {
429 self.functions.entry(name).or_default().extend(functions);
430 }
431 }
432
433 fn unique(&self, name: &str) -> Result<Option<&FunctionInfo>> {
434 let Some(functions) = self.functions.get(name) else {
435 return Ok(None);
436 };
437
438 if functions.len() != 1 {
439 bail!("hook function {name} is ambiguous across the owning module");
440 }
441
442 Ok(functions.first())
443 }
444
445 fn expanded_operations(&self, root: &FunctionInfo) -> Vec<SourceOperation> {
446 let mut pending = vec![(root, false)];
447 let mut reachable = BTreeMap::new();
448
449 while let Some((function, inherited_control)) = pending.pop() {
450 let identity = (function.file.clone(), function.name.clone());
451
452 match reachable.get_mut(&identity) {
453 Some(controlled) if *controlled && !inherited_control => *controlled = false,
454 Some(_) => continue,
455 None => {
456 reachable.insert(identity, inherited_control);
457 }
458 }
459
460 for call in &function.calls {
461 let Some(candidates) = self.functions.get(&call.name) else {
462 continue;
463 };
464
465 if let Some(local) = candidates
466 .iter()
467 .find(|candidate| candidate.file == function.file)
468 .or_else(|| (candidates.len() == 1).then(|| &candidates[0]))
469 {
470 pending.push((local, inherited_control || call.controlled));
471 }
472 }
473 }
474
475 let mut operations = Vec::new();
476
477 for ((file, name), inherited_control) in reachable {
478 let Some(function) = self
479 .functions
480 .get(&name)
481 .and_then(|candidates| candidates.iter().find(|candidate| candidate.file == file))
482 else {
483 continue;
484 };
485
486 operations.extend(function.operations.iter().cloned().map(|mut operation| {
487 if inherited_control && is_aura_identity_operation(&operation) {
488 operation.coupled = true;
489 }
490
491 operation
492 }));
493 }
494
495 operations
496 }
497}
498
499fn parse_hook_source(path: &Path, source: &str) -> Result<SourceFile> {
500 let parse = SourceFile::parse(source, Edition::Edition2024);
501 let parse_errors = parse.errors();
502
503 if !parse_errors.is_empty() {
504 let details = parse_errors
505 .iter()
506 .map(ToString::to_string)
507 .collect::<Vec<_>>()
508 .join("; ");
509
510 bail!("failed to parse hook source {}: {details}", path.display());
511 }
512
513 Ok(parse.tree())
514}
515
516fn is_aura_identity_operation(operation: &SourceOperation) -> bool {
517 matches!(
518 operation.evidence.as_str(),
519 "hook operation `apply_aura`" | "hook operation `expire_aura`"
520 )
521}
522
523fn collect_rust_paths(root: &Path, paths: &mut Vec<PathBuf>) -> Result<()> {
524 let mut directories = vec![root.to_path_buf()];
525
526 while let Some(current_dir) = directories.pop() {
527 for entry in directory::entries(¤t_dir)
528 .with_context(|| format!("failed to walk hook directory {}", current_dir.display()))?
529 {
530 match entry.kind() {
531 EntryKind::Directory => directories.push(entry.path().to_path_buf()),
532 EntryKind::File
533 if entry
534 .path()
535 .extension()
536 .is_some_and(|extension| extension == "rs") =>
537 {
538 paths.push(entry.path().to_path_buf());
539 }
540 _ => {}
541 }
542 }
543 }
544
545 Ok(())
546}
547
548fn spec_bindings(manifest: &Manifest) -> Vec<HookBinding> {
550 let mut bindings = vec![HookBinding {
551 manifest_key: "mastery.hook".to_string(),
552 function: path_function(&manifest.mastery.hook),
553 }];
554
555 if let Some(handler) = &manifest.spec.custom_handler {
556 bindings.push(HookBinding {
557 manifest_key: "spec.custom_handler".to_string(),
558 function: path_function(handler),
559 });
560 }
561
562 if let Some(hook) = &manifest.mastery.crit_damage_hook {
563 bindings.push(HookBinding {
564 manifest_key: "mastery.crit_damage_hook".to_string(),
565 function: path_function(hook),
566 });
567 }
568
569 for (name, aura) in &manifest.auras {
570 if let Some(hook) = &aura.expire_hook {
571 bindings.push(HookBinding {
572 manifest_key: format!("auras.{name}.expire_hook"),
573 function: suffixed(hook, "_hook"),
574 });
575 }
576
577 if let Some(hook) = &aura.tick_hook {
578 bindings.push(HookBinding {
579 manifest_key: format!("auras.{name}.tick_hook"),
580 function: suffixed(hook, "_hook"),
581 });
582 }
583 }
584
585 for (name, spell) in &manifest.spells {
586 if let Some(hook) = &spell.hook {
587 bindings.push(HookBinding {
588 manifest_key: format!("spells.{name}.hook"),
589 function: suffixed(hook, "_hook"),
590 });
591 }
592
593 if let Some(hook) = spell
594 .channel
595 .as_ref()
596 .and_then(|channel| channel.tick_hook.as_ref())
597 {
598 bindings.push(HookBinding {
599 manifest_key: format!("spells.{name}.channel.tick_hook"),
600 function: suffixed(hook, "_tick_hook"),
601 });
602 }
603 }
604
605 for (name, attack) in &manifest.auto_attacks {
606 if let Some(hook) = &attack.hook {
607 bindings.push(HookBinding {
608 manifest_key: format!("auto_attacks.{name}.hook"),
609 function: path_function(hook),
610 });
611 }
612 }
613
614 bindings
615}
616
617fn item_bindings(name: &str, item: &ItemManifestEntry) -> Vec<HookBinding> {
618 let mut bindings =
619 Vec::with_capacity(item.spells.len() + usize::from(item.player_cast_hook.is_some()));
620
621 for (spell_name, spell) in &item.spells {
622 if let Some(hook) = &spell.hook {
623 bindings.push(HookBinding {
624 manifest_key: format!("spells.{spell_name}.hook"),
625 function: path_function(hook),
626 });
627 }
628 }
629
630 if let Some(hook) = &item.player_cast_hook {
631 bindings.push(HookBinding {
632 manifest_key: format!("items.{name}.player_cast_hook"),
633 function: path_function(hook),
634 });
635 }
636
637 bindings
638}
639
640fn suffixed(path: &str, suffix: &str) -> String {
641 let function = path_function(path);
642
643 if function.ends_with(suffix) {
644 function
645 } else {
646 format!("{function}{suffix}")
647 }
648}
649
650fn path_function(path: &str) -> String {
651 path.rsplit("::").next().unwrap_or(path).to_string()
652}
653
654fn last_segment(path: &ast::Path) -> Option<String> {
655 path.segment()?
656 .name_ref()
657 .map(|name| name.text().to_string())
658}
659
660fn is_imperative_proc_type(name: &str) -> bool {
661 matches!(
662 name,
663 "ImpactProc" | "AccumulatingImpactProc" | "ImpactEffectProc"
664 )
665}
666
667fn count_proc_structs(root: &SyntaxNode) -> usize {
668 let mut count = 0;
669 let mut pending_proc_ident = false;
670
671 for token in root
672 .descendants_with_tokens()
673 .filter_map(ra_ap_syntax::NodeOrToken::into_token)
674 .filter(|token| !token.kind().is_trivia())
675 {
676 if token.kind() == SyntaxKind::IDENT {
677 pending_proc_ident = is_imperative_proc_type(token.text());
678 } else if token.text() == "{" {
679 if pending_proc_ident {
680 count += 1;
681 }
682
683 pending_proc_ident = false;
684 } else if token.text() != ":" {
685 pending_proc_ident = false;
686 }
687 }
688
689 count
690}
691
692fn classify_call(name: &str) -> Option<SourceOperation> {
694 let (category, disposition) = if name.starts_with("deal_")
695 || name == "add_residual_damage"
696 || name == "schedule_ground_effect"
697 {
698 (OperationCategory::Damage, LedgerDisposition::GenericGap)
699 } else if name.starts_with("apply_aura") || name == "extend_aura" {
700 (OperationCategory::AuraApply, LedgerDisposition::GenericGap)
701 } else if name.starts_with("expire_aura")
702 || name.starts_with("consume_aura")
703 || name == "reduce_aura"
704 {
705 (OperationCategory::AuraRemove, LedgerDisposition::GenericGap)
706 } else if name.contains("resource") && (name.starts_with("gain") || name.starts_with("spend")) {
707 (OperationCategory::Resource, LedgerDisposition::GenericGap)
708 } else if name.contains("cooldown") || name.contains("recharge") {
709 (OperationCategory::Cooldown, LedgerDisposition::GenericGap)
710 } else if name.contains("resolved_spell_effect_targets") || name.starts_with("target_") {
711 (OperationCategory::Targeting, LedgerDisposition::GenericGap)
712 } else if name.contains("guardian") || name.contains("pet_") || name == "summon_pet" {
713 (OperationCategory::ActorOrPet, LedgerDisposition::Unresolved)
714 } else if name.contains("replacement") || name.contains("override_spell") {
715 (
716 OperationCategory::Replacement,
717 LedgerDisposition::GenericGap,
718 )
719 } else if name.starts_with("schedule_") || name.contains("spec_runtime_mut") {
720 (
721 OperationCategory::OrderedContent,
722 LedgerDisposition::ContentOrdering,
723 )
724 } else {
725 return None;
726 };
727
728 Some(SourceOperation {
729 category,
730 disposition,
731 evidence: format!("hook operation `{name}`"),
732 aura_reference: None,
733 controlled: false,
734 coupled: false,
735 })
736}
737
738fn aura_reference(expression: &ast::Expr) -> Option<AuraReference> {
739 let mut expression = expression.clone();
740
741 loop {
742 match expression {
743 ast::Expr::FieldExpr(field) => expression = field.expr()?,
744 ast::Expr::Literal(literal) => {
745 return match literal.kind() {
746 LiteralKind::IntNumber(value) => u32::try_from(value.value().ok()?)
747 .ok()
748 .map(AuraReference::Id),
749 _ => None,
750 };
751 }
752 ast::Expr::ParenExpr(paren) => expression = paren.expr()?,
753 ast::Expr::PathExpr(path) => {
754 return path
755 .path()
756 .as_ref()
757 .and_then(last_segment)
758 .and_then(|name| {
759 let normalized = name.strip_prefix("AURA_").unwrap_or(&name);
760
761 (normalized != "AURA").then(|| AuraReference::Name(normalized.to_string()))
762 });
763 }
764 ast::Expr::RefExpr(reference) => expression = reference.expr()?,
765 _ => return None,
766 }
767 }
768}
769
770fn resolve_aura_reference(
771 reference: &AuraReference,
772 aura_ids: &BTreeMap<String, u32>,
773) -> Option<u32> {
774 match reference {
775 AuraReference::Id(id) => Some(*id),
776 AuraReference::Name(name) => aura_ids.get(name).copied(),
777 }
778}
779
780fn owner_from_hook_path(root: &Path, file: &Path) -> String {
781 let relative = file.strip_prefix(root).unwrap_or(file);
782 let mut components = relative.components();
783 let first = components.next().map_or_else(
784 || "unknown".to_string(),
785 |component| component.as_os_str().to_string_lossy().into_owned(),
786 );
787
788 if first == "items" {
789 let item = components
790 .next()
791 .and_then(|component| {
792 Path::new(component.as_os_str())
793 .file_stem()
794 .map(|stem| stem.to_string_lossy().to_ascii_uppercase())
795 })
796 .unwrap_or_else(|| "UNKNOWN".to_string());
797
798 format!("item:{item}")
799 } else {
800 first
801 }
802}
803
804fn relative_path(root: &Path, file: &Path) -> PathBuf {
805 file.strip_prefix(root)
806 .map_or_else(|_| file.to_path_buf(), Path::to_path_buf)
807}
808
809#[cfg(test)]
810mod tests;