Skip to main content

source_map_tauri/frontend/
hooks.rs

1use std::{collections::BTreeSet, path::Path};
2
3use anyhow::Result;
4use regex::Regex;
5use serde_json::{Map, Value};
6
7use crate::{
8    config::{normalize_path, ResolvedConfig},
9    discovery::RepoDiscovery,
10    ids::document_id,
11    model::ArtifactDoc,
12    security::apply_artifact_security,
13};
14
15fn line_number(text: &str, offset: usize) -> u32 {
16    text[..offset].bytes().filter(|byte| *byte == b'\n').count() as u32 + 1
17}
18
19fn base_artifact(
20    config: &ResolvedConfig,
21    path: &Path,
22    kind: &str,
23    name: &str,
24    line: u32,
25) -> ArtifactDoc {
26    let source_path = normalize_path(&config.root, path);
27    let mut doc = ArtifactDoc {
28        id: document_id(
29            &config.repo,
30            kind,
31            Some(&source_path),
32            Some(line),
33            Some(name),
34        ),
35        repo: config.repo.clone(),
36        kind: kind.to_owned(),
37        side: Some("frontend".to_owned()),
38        language: crate::frontend::language_for_path(path),
39        name: Some(name.to_owned()),
40        display_name: Some(name.to_owned()),
41        source_path: Some(source_path),
42        line_start: Some(line),
43        line_end: Some(line),
44        column_start: None,
45        column_end: None,
46        package_name: None,
47        comments: Vec::new(),
48        tags: Vec::new(),
49        related_symbols: Vec::new(),
50        related_tests: Vec::new(),
51        risk_level: "low".to_owned(),
52        risk_reasons: Vec::new(),
53        contains_phi: false,
54        has_related_tests: false,
55        updated_at: chrono::Utc::now().to_rfc3339(),
56        data: Map::new(),
57    };
58    apply_artifact_security(&mut doc);
59    doc
60}
61
62pub fn extract_components_and_hooks(
63    config: &ResolvedConfig,
64    path: &Path,
65    text: &str,
66    known_hooks: &BTreeSet<String>,
67) -> Vec<ArtifactDoc> {
68    let export_fn =
69        Regex::new(r"(?m)^\s*export\s+function\s+([A-Za-z0-9_]+)").expect("valid regex");
70    let hook_call = Regex::new(r"\b(use[A-Z][A-Za-z0-9_]*)\(").expect("valid regex");
71
72    let mut docs = Vec::new();
73    let mut component_names = Vec::new();
74
75    for capture in export_fn.captures_iter(text) {
76        let whole = capture.get(0).expect("match");
77        let name = capture.get(1).expect("name").as_str();
78        let line = line_number(text, whole.start());
79        if name.starts_with("use") {
80            let mut doc = base_artifact(config, path, "frontend_hook_def", name, line);
81            let hook_kind = if text.contains("new Channel") || text.contains("Channel<") {
82                "channel_stream"
83            } else if text.contains("listen(") || text.contains("once(") {
84                "event_subscription"
85            } else if text.contains("invoke(") {
86                "invoke_once"
87            } else {
88                "unknown"
89            };
90            doc.display_name = Some(format!("{name} hook"));
91            doc.tags = vec!["custom hook".to_owned()];
92            doc.data
93                .insert("hook_kind".to_owned(), Value::String(hook_kind.to_owned()));
94            doc.data.insert(
95                "requires_cleanup".to_owned(),
96                Value::Bool(text.contains("listen(") || text.contains("once(")),
97            );
98            doc.data.insert(
99                "cleanup_present".to_owned(),
100                Value::Bool(text.contains("return () =>") || text.contains("unlisten")),
101            );
102            apply_artifact_security(&mut doc);
103            docs.push(doc);
104        } else if name
105            .chars()
106            .next()
107            .map(|item| item.is_uppercase())
108            .unwrap_or(false)
109        {
110            let mut doc = base_artifact(config, path, "frontend_component", name, line);
111            doc.display_name = Some(format!("{name} component"));
112            doc.tags = vec!["component".to_owned()];
113            doc.data
114                .insert("component".to_owned(), Value::String(name.to_owned()));
115            apply_artifact_security(&mut doc);
116            component_names.push(name.to_owned());
117            docs.push(doc);
118        }
119    }
120
121    for capture in hook_call.captures_iter(text) {
122        let whole = capture.get(0).expect("match");
123        let name = capture.get(1).expect("name").as_str();
124        if !known_hooks.contains(name) {
125            continue;
126        }
127        if text[whole.start()..whole.end()].starts_with("function ") {
128            continue;
129        }
130        let line = line_number(text, whole.start());
131        let mut doc = base_artifact(config, path, "frontend_hook_use", name, line);
132        if let Some(component) = component_names.first() {
133            doc.data
134                .insert("component".to_owned(), Value::String(component.clone()));
135            doc.display_name = Some(format!("{component} uses {name}"));
136        }
137        doc.data
138            .insert("hook_kind".to_owned(), Value::String("unknown".to_owned()));
139        doc.data
140            .insert("hook_def_name".to_owned(), Value::String(name.to_owned()));
141        apply_artifact_security(&mut doc);
142        docs.push(doc);
143    }
144
145    docs
146}
147
148pub fn discover_hook_names(discovery: &RepoDiscovery) -> Result<BTreeSet<String>> {
149    let export_fn =
150        Regex::new(r"(?m)^\s*export\s+function\s+(use[A-Z][A-Za-z0-9_]*)").expect("valid regex");
151    let mut names = BTreeSet::new();
152
153    for path in &discovery.frontend_files {
154        let text = std::fs::read_to_string(path)?;
155        for capture in export_fn.captures_iter(&text) {
156            if let Some(name) = capture.get(1) {
157                names.insert(name.as_str().to_owned());
158            }
159        }
160    }
161
162    Ok(names)
163}