source_map_tauri/frontend/
http.rs1use std::path::Path;
2
3use regex::Regex;
4use serde_json::{json, Map, Value};
5
6use crate::{
7 config::{normalize_path, ResolvedConfig},
8 ids::document_id,
9 model::ArtifactDoc,
10 security::apply_artifact_security,
11};
12
13#[derive(Debug)]
14struct ExportedFunction<'a> {
15 name: &'a str,
16 line: u32,
17 body: &'a str,
18}
19
20fn line_number(text: &str, offset: usize) -> u32 {
21 text[..offset].bytes().filter(|byte| *byte == b'\n').count() as u32 + 1
22}
23
24fn base_artifact(
25 config: &ResolvedConfig,
26 path: &Path,
27 kind: &str,
28 name: &str,
29 line: u32,
30) -> ArtifactDoc {
31 let source_path = normalize_path(&config.root, path);
32 let mut doc = ArtifactDoc {
33 id: document_id(
34 &config.repo,
35 kind,
36 Some(&source_path),
37 Some(line),
38 Some(name),
39 ),
40 repo: config.repo.clone(),
41 kind: kind.to_owned(),
42 side: Some("frontend".to_owned()),
43 language: crate::frontend::language_for_path(path),
44 name: Some(name.to_owned()),
45 display_name: Some(name.to_owned()),
46 source_path: Some(source_path),
47 line_start: Some(line),
48 line_end: Some(line),
49 column_start: None,
50 column_end: None,
51 package_name: None,
52 comments: Vec::new(),
53 tags: Vec::new(),
54 related_symbols: Vec::new(),
55 related_tests: Vec::new(),
56 risk_level: "low".to_owned(),
57 risk_reasons: Vec::new(),
58 contains_phi: false,
59 has_related_tests: false,
60 updated_at: chrono::Utc::now().to_rfc3339(),
61 data: Map::new(),
62 };
63 apply_artifact_security(&mut doc);
64 doc
65}
66
67pub fn extract_http_artifacts(
68 config: &ResolvedConfig,
69 path: &Path,
70 text: &str,
71) -> Vec<ArtifactDoc> {
72 let mut artifacts = Vec::new();
73
74 for exported in exported_functions(text) {
75 if let Some((transport_name, method, normalized_path)) =
76 wrapper_transport_call(exported.body)
77 {
78 let mut doc = base_artifact(
79 config,
80 path,
81 "frontend_api_wrapper",
82 exported.name,
83 exported.line,
84 );
85 doc.display_name = Some(format!("{} wrapper", exported.name));
86 doc.tags = vec!["api wrapper".to_owned(), "http".to_owned()];
87 doc.data.insert(
88 "transport_name".to_owned(),
89 Value::String(transport_name.to_owned()),
90 );
91 doc.data
92 .insert("http_method".to_owned(), Value::String(method.to_owned()));
93 doc.data.insert(
94 "normalized_path".to_owned(),
95 Value::String(normalized_path.clone()),
96 );
97 doc.data.insert(
98 "endpoint_key".to_owned(),
99 Value::String(format!("{method} {normalized_path}")),
100 );
101 apply_artifact_security(&mut doc);
102 artifacts.push(doc);
103 } else if let Some((method, normalized_path)) = direct_http_call(exported.body) {
104 let mut doc = base_artifact(
105 config,
106 path,
107 "frontend_api_wrapper",
108 exported.name,
109 exported.line,
110 );
111 doc.display_name = Some(format!("{} wrapper", exported.name));
112 doc.tags = vec!["api wrapper".to_owned(), "http".to_owned()];
113 doc.data.insert(
114 "transport_name".to_owned(),
115 Value::String("tauriFetch".to_owned()),
116 );
117 doc.data
118 .insert("http_method".to_owned(), Value::String(method.to_owned()));
119 doc.data.insert(
120 "normalized_path".to_owned(),
121 Value::String(normalized_path.clone()),
122 );
123 doc.data.insert(
124 "endpoint_key".to_owned(),
125 Value::String(format!("{method} {normalized_path}")),
126 );
127 apply_artifact_security(&mut doc);
128 artifacts.push(doc);
129 }
130
131 if let Some((method, client_name, path_param, url_pattern)) =
132 transport_definition(exported.body)
133 {
134 let mut doc = base_artifact(
135 config,
136 path,
137 "frontend_transport",
138 exported.name,
139 exported.line,
140 );
141 doc.display_name = Some(format!("{} transport", exported.name));
142 doc.tags = vec!["transport".to_owned(), "http".to_owned()];
143 doc.data
144 .insert("http_method".to_owned(), Value::String(method.to_owned()));
145 doc.data.insert(
146 "http_client".to_owned(),
147 Value::String(client_name.to_owned()),
148 );
149 doc.data.insert(
150 "path_param".to_owned(),
151 Value::String(path_param.to_owned()),
152 );
153 doc.data.insert(
154 "url_pattern".to_owned(),
155 Value::String(url_pattern.to_owned()),
156 );
157 doc.data.insert(
158 "transport_signature".to_owned(),
159 json!({
160 "client": client_name,
161 "method": method,
162 "path_param": path_param,
163 }),
164 );
165 apply_artifact_security(&mut doc);
166 artifacts.push(doc);
167 }
168 }
169
170 artifacts
171}
172
173fn exported_functions(text: &str) -> Vec<ExportedFunction<'_>> {
174 let function_re = Regex::new(r"(?m)^\s*export\s+function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
175 .expect("valid regex");
176 let const_re =
177 Regex::new(r"(?m)^\s*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=").expect("valid regex");
178
179 let mut items = Vec::new();
180 for regex in [&function_re, &const_re] {
181 for capture in regex.captures_iter(text) {
182 let whole = capture.get(0).expect("match");
183 let Some(name) = capture.get(1).map(|value| value.as_str()) else {
184 continue;
185 };
186 let Some(body_start) = text[whole.start()..]
187 .find('{')
188 .map(|offset| whole.start() + offset)
189 else {
190 continue;
191 };
192 let Some(body_end) = find_matching_brace(text, body_start) else {
193 continue;
194 };
195 items.push(ExportedFunction {
196 name,
197 line: line_number(text, whole.start()),
198 body: &text[body_start + 1..body_end],
199 });
200 }
201 }
202
203 items.sort_by_key(|item| item.line);
204 items
205}
206
207fn find_matching_brace(text: &str, open_index: usize) -> Option<usize> {
208 let bytes = text.as_bytes();
209 let mut depth = 0_u32;
210 let mut index = open_index;
211 let mut in_single = false;
212 let mut in_double = false;
213 let mut in_template = false;
214 let mut line_comment = false;
215 let mut block_comment = false;
216 let mut escaped = false;
217
218 while index < bytes.len() {
219 let byte = bytes[index];
220 let next = bytes.get(index + 1).copied();
221
222 if line_comment {
223 if byte == b'\n' {
224 line_comment = false;
225 }
226 index += 1;
227 continue;
228 }
229
230 if block_comment {
231 if byte == b'*' && next == Some(b'/') {
232 block_comment = false;
233 index += 2;
234 } else {
235 index += 1;
236 }
237 continue;
238 }
239
240 if escaped {
241 escaped = false;
242 index += 1;
243 continue;
244 }
245
246 match byte {
247 b'\\' if in_single || in_double || in_template => {
248 escaped = true;
249 index += 1;
250 }
251 b'\'' if !in_double && !in_template => {
252 in_single = !in_single;
253 index += 1;
254 }
255 b'"' if !in_single && !in_template => {
256 in_double = !in_double;
257 index += 1;
258 }
259 b'`' if !in_single && !in_double => {
260 in_template = !in_template;
261 index += 1;
262 }
263 b'/' if !in_single && !in_double && !in_template && next == Some(b'/') => {
264 line_comment = true;
265 index += 2;
266 }
267 b'/' if !in_single && !in_double && !in_template && next == Some(b'*') => {
268 block_comment = true;
269 index += 2;
270 }
271 b'{' if !in_single && !in_double => {
272 depth += 1;
273 index += 1;
274 }
275 b'}' if !in_single && !in_double => {
276 depth = depth.saturating_sub(1);
277 if depth == 0 {
278 return Some(index);
279 }
280 index += 1;
281 }
282 _ => {
283 index += 1;
284 }
285 }
286 }
287
288 None
289}
290
291fn wrapper_transport_call(body: &str) -> Option<(&'static str, &'static str, String)> {
292 let call_re = Regex::new(
293 r#"\b(usePostApi|usePostMutation|usePostUploadMutation|postApi)\s*\(\s*["']([^"']+)["']"#,
294 )
295 .expect("valid regex");
296 let capture = call_re.captures(body)?;
297 let transport_name = capture.get(1)?.as_str();
298 let raw_path = capture.get(2)?.as_str();
299 let method = match transport_name {
300 "usePostApi" | "usePostMutation" | "usePostUploadMutation" | "postApi" => "POST",
301 _ => return None,
302 };
303 Some((
304 transport_name_static(transport_name)?,
305 method,
306 normalize_http_path(raw_path),
307 ))
308}
309
310fn transport_definition(
311 body: &str,
312) -> Option<(&'static str, &'static str, &'static str, &'static str)> {
313 let pattern_re = Regex::new(
314 r#"(?s)\btauriFetch\s*\(\s*`[^`]*\$\{API_URL\}/\$\{([A-Za-z_][A-Za-z0-9_]*)\}[^`]*`\s*,\s*\{.*?method\s*:\s*["']([A-Z]+)["']"#,
315 )
316 .expect("valid regex");
317 let capture = pattern_re.captures(body)?;
318 let path_param = capture.get(1)?.as_str();
319 let method = capture.get(2)?.as_str();
320 if path_param != "path" || method != "POST" {
321 return None;
322 }
323 Some(("POST", "tauriFetch", "path", "${API_URL}/${path}"))
324}
325
326fn direct_http_call(body: &str) -> Option<(&'static str, String)> {
327 let direct_re = Regex::new(
328 r#"(?s)\btauriFetch\s*\(\s*`[^`]*\$\{API_URL\}/([^`$]+)`\s*,\s*\{.*?method\s*:\s*["']([A-Z]+)["']"#,
329 )
330 .expect("valid regex");
331 let capture = direct_re.captures(body)?;
332 let raw_path = capture.get(1)?.as_str().trim();
333 let method = capture.get(2)?.as_str();
334 if method != "POST" {
335 return None;
336 }
337 Some((method_static(method)?, normalize_http_path(raw_path)))
338}
339
340fn normalize_http_path(path: &str) -> String {
341 let trimmed = path.trim();
342 if trimmed.starts_with('/') {
343 trimmed.to_owned()
344 } else {
345 format!("/{trimmed}")
346 }
347}
348
349fn transport_name_static(name: &str) -> Option<&'static str> {
350 match name {
351 "usePostApi" => Some("usePostApi"),
352 "usePostMutation" => Some("usePostMutation"),
353 "usePostUploadMutation" => Some("usePostUploadMutation"),
354 "postApi" => Some("postApi"),
355 _ => None,
356 }
357}
358
359fn method_static(method: &str) -> Option<&'static str> {
360 match method {
361 "POST" => Some("POST"),
362 "GET" => Some("GET"),
363 "PUT" => Some("PUT"),
364 "PATCH" => Some("PATCH"),
365 "DELETE" => Some("DELETE"),
366 _ => None,
367 }
368}