Skip to main content

source_map_tauri/
rust.rs

1use std::path::Path;
2
3use anyhow::Result;
4use lsp_types::SymbolKind;
5use regex::Regex;
6use serde_json::{Map, Value};
7
8use crate::{
9    config::{normalize_path, ResolvedConfig},
10    discovery::RepoDiscovery,
11    ids::document_id,
12    lsp::{line_contains, range_end_line, range_start_line, LspClient, SymbolLocation},
13    model::ArtifactDoc,
14    security::apply_artifact_security,
15};
16
17fn has_segment(path: &str, segment: &str) -> bool {
18    path.starts_with(&format!("{segment}/")) || path.contains(&format!("/{segment}/"))
19}
20
21fn line_number(text: &str, offset: usize) -> u32 {
22    text[..offset].bytes().filter(|byte| *byte == b'\n').count() as u32 + 1
23}
24
25fn new_doc(
26    config: &ResolvedConfig,
27    path: &Path,
28    kind: &str,
29    name: &str,
30    line: u32,
31    side: &str,
32) -> ArtifactDoc {
33    let source_path = normalize_path(&config.root, path);
34    ArtifactDoc {
35        id: document_id(
36            &config.repo,
37            kind,
38            Some(&source_path),
39            Some(line),
40            Some(name),
41        ),
42        repo: config.repo.clone(),
43        kind: kind.to_owned(),
44        side: Some(side.to_owned()),
45        language: Some("rust".to_owned()),
46        name: Some(name.to_owned()),
47        display_name: Some(name.to_owned()),
48        source_path: Some(source_path),
49        line_start: Some(line),
50        line_end: Some(line),
51        column_start: None,
52        column_end: None,
53        package_name: None,
54        comments: Vec::new(),
55        tags: Vec::new(),
56        related_symbols: Vec::new(),
57        related_tests: Vec::new(),
58        risk_level: "low".to_owned(),
59        risk_reasons: Vec::new(),
60        contains_phi: false,
61        has_related_tests: false,
62        updated_at: chrono::Utc::now().to_rfc3339(),
63        data: Map::new(),
64    }
65}
66
67pub fn extract(config: &ResolvedConfig, discovery: &RepoDiscovery) -> Result<Vec<ArtifactDoc>> {
68    let mut artifacts = Vec::new();
69    let command_re = Regex::new(r#"(?s)#\[(?:tauri::)?command\]\s*(?:pub\s+)?(async\s+)?fn\s+([A-Za-z0-9_]+)(?:<[^>]+>)?\s*(\([^)]*\))"#)
70        .expect("valid regex");
71    let command_attr_re = Regex::new(r#"(?m)^\s*#\[(?:tauri::)?command\]"#).expect("valid regex");
72    let builder_re = Regex::new(r#"Builder::new\("([^"]+)"\)"#).expect("valid regex");
73    let hook_re = Regex::new(r#"\.(setup|on_navigation|on_webview_ready|on_event|on_drop)\("#)
74        .expect("valid regex");
75    let permission_re = Regex::new(r#"identifier\s*=\s*"([^"]+)""#).expect("valid regex");
76    let commands_allow_re =
77        Regex::new(r#"commands\.allow\s*=\s*\[([^\]]+)\]"#).expect("valid regex");
78    let mut rust_lsp = LspClient::new("rust-analyzer", &config.root).ok();
79
80    for path in discovery
81        .rust_files
82        .iter()
83        .chain(discovery.plugin_rust_files.iter())
84    {
85        let text = std::fs::read_to_string(path)?;
86        let normalized = normalize_path(&config.root, path);
87        let symbol_locations = rust_lsp
88            .as_mut()
89            .and_then(|client| client.document_symbols(path, &text, "rust").ok())
90            .unwrap_or_default();
91        let plugin_name = if has_segment(&normalized, "plugins") {
92            builder_re
93                .captures(&text)
94                .and_then(|capture| capture.get(1))
95                .map(|item| item.as_str().to_owned())
96                .or_else(|| {
97                    normalized
98                        .strip_prefix("plugins/")
99                        .or_else(|| normalized.split("/plugins/").nth(1))
100                        .and_then(|tail| tail.split('/').next())
101                        .map(|item| item.trim_start_matches("tauri-plugin-").to_owned())
102                })
103        } else {
104            None
105        };
106
107        if let Some(plugin_name) = &plugin_name {
108            let line = builder_re
109                .captures(&text)
110                .and_then(|capture| capture.get(0))
111                .map(|item| line_number(&text, item.start()))
112                .unwrap_or(1);
113            let mut plugin_doc = new_doc(config, path, "tauri_plugin", plugin_name, line, "rust");
114            plugin_doc
115                .data
116                .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
117            apply_artifact_security(&mut plugin_doc);
118            artifacts.push(plugin_doc);
119
120            for capture in hook_re.captures_iter(&text) {
121                let hook_name = capture.get(1).expect("hook").as_str();
122                let line = line_number(&text, capture.get(0).expect("match").start());
123                let mut hook_doc = new_doc(
124                    config,
125                    path,
126                    "tauri_plugin_lifecycle_hook",
127                    hook_name,
128                    line,
129                    "rust",
130                );
131                hook_doc
132                    .data
133                    .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
134                hook_doc
135                    .data
136                    .insert("hook_name".to_owned(), Value::String(hook_name.to_owned()));
137                apply_artifact_security(&mut hook_doc);
138                artifacts.push(hook_doc);
139            }
140        }
141
142        let mut lsp_command_docs = build_lsp_command_docs(
143            config,
144            path,
145            &normalized,
146            &text,
147            &symbol_locations,
148            &command_attr_re,
149            plugin_name.as_deref(),
150        );
151
152        if lsp_command_docs.is_empty() {
153            lsp_command_docs = command_re
154                .captures_iter(&text)
155                .map(|capture| {
156                    let full = capture.get(0).expect("match");
157                    let name = capture.get(2).expect("name").as_str();
158                    let signature = capture
159                        .get(3)
160                        .map(|item| item.as_str().to_owned())
161                        .unwrap_or_default();
162                    let line = line_number(&text, full.start());
163                    let kind = if plugin_name.is_some() {
164                        "tauri_plugin_command"
165                    } else {
166                        "tauri_command"
167                    };
168                    let mut doc = new_doc(config, path, kind, name, line, "rust");
169                    doc.display_name = Some(name.to_owned());
170                    doc.tags = vec!["rust command".to_owned()];
171                    doc.data
172                        .insert("signature".to_owned(), Value::String(signature.clone()));
173                    doc.data.insert(
174                        "rust_fqn".to_owned(),
175                        Value::String(format!(
176                            "{}::{name}",
177                            normalized.replace('/', "::").trim_end_matches(".rs")
178                        )),
179                    );
180                    if let Some(plugin_name) = &plugin_name {
181                        doc.data
182                            .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
183                        doc.data.insert(
184                            "invoke_key".to_owned(),
185                            Value::String(format!("plugin:{plugin_name}|{name}")),
186                        );
187                    } else {
188                        doc.data
189                            .insert("invoke_key".to_owned(), Value::String(name.to_owned()));
190                    }
191                    let registered = text.contains("generate_handler!") && text.contains(name);
192                    doc.data
193                        .insert("registered".to_owned(), Value::Bool(registered));
194                    apply_artifact_security(&mut doc);
195                    doc
196                })
197                .collect();
198        }
199
200        artifacts.extend(lsp_command_docs);
201    }
202
203    for path in &discovery.permission_files {
204        let text = std::fs::read_to_string(path)?;
205        let normalized = normalize_path(&config.root, path);
206        if let Some(capture) = permission_re.captures(&text) {
207            let name = capture.get(1).expect("identifier").as_str();
208            let line = line_number(&text, capture.get(0).expect("match").start());
209            let mut doc = new_doc(config, path, "tauri_permission", name, line, "config");
210            let plugin_name = normalized
211                .strip_prefix("plugins/")
212                .or_else(|| normalized.split("/plugins/").nth(1))
213                .and_then(|tail| tail.split('/').next())
214                .map(|item| item.trim_start_matches("tauri-plugin-").to_owned());
215            if let Some(plugin_name) = plugin_name {
216                doc.data
217                    .insert("plugin_name".to_owned(), Value::String(plugin_name.clone()));
218                doc.name = Some(format!("{plugin_name}:{name}"));
219                doc.display_name = doc.name.clone();
220            }
221            if let Some(allow_capture) = commands_allow_re.captures(&text) {
222                let commands = allow_capture[1]
223                    .split(',')
224                    .map(|item| item.trim().trim_matches('"').to_owned())
225                    .filter(|item| !item.is_empty())
226                    .collect::<Vec<_>>();
227                doc.data.insert(
228                    "commands_allow".to_owned(),
229                    Value::Array(commands.into_iter().map(Value::String).collect()),
230                );
231            }
232            apply_artifact_security(&mut doc);
233            let permission_name = doc.name.clone().unwrap_or_else(|| name.to_owned());
234            artifacts.push(doc);
235
236            let mut scope_doc =
237                new_doc(config, path, "tauri_permission_scope", name, line, "config");
238            scope_doc
239                .data
240                .insert("permission_id".to_owned(), Value::String(permission_name));
241            apply_artifact_security(&mut scope_doc);
242            artifacts.push(scope_doc);
243        }
244    }
245
246    let rust_test_targets_re =
247        Regex::new(r#"async\s+fn\s+([A-Za-z0-9_]+)|fn\s+([A-Za-z0-9_]+)"#).expect("valid regex");
248
249    for path in &discovery.rust_test_files {
250        let text = std::fs::read_to_string(path)?;
251        let normalized = normalize_path(&config.root, path);
252        let name = Path::new(&normalized)
253            .file_name()
254            .and_then(|item| item.to_str())
255            .unwrap_or("rust_test");
256        let mut doc = new_doc(config, path, "rust_test", name, 1, "test");
257        let targets = rust_test_targets_re
258            .captures_iter(&text)
259            .filter_map(|capture| capture.get(1).or_else(|| capture.get(2)))
260            .map(|item| item.as_str().to_owned())
261            .collect::<Vec<_>>();
262        doc.data.insert(
263            "targets".to_owned(),
264            Value::Array(targets.into_iter().map(Value::String).collect()),
265        );
266        doc.data.insert(
267            "command".to_owned(),
268            Value::String(format!("cargo test {}", normalize_path(&config.root, path))),
269        );
270        apply_artifact_security(&mut doc);
271        artifacts.push(doc);
272    }
273
274    Ok(artifacts)
275}
276
277fn build_lsp_command_docs(
278    config: &ResolvedConfig,
279    path: &Path,
280    normalized: &str,
281    text: &str,
282    symbols: &[SymbolLocation],
283    command_attr_re: &Regex,
284    plugin_name: Option<&str>,
285) -> Vec<ArtifactDoc> {
286    let function_symbols = symbols
287        .iter()
288        .filter(|symbol| matches!(symbol.kind, SymbolKind::FUNCTION | SymbolKind::METHOD))
289        .collect::<Vec<_>>();
290
291    let mut docs = Vec::new();
292    for capture in command_attr_re.find_iter(text) {
293        let attr_line = line_number(text, capture.start());
294        let Some(symbol) = match_command_symbol(&function_symbols, attr_line) else {
295            continue;
296        };
297
298        let kind = if plugin_name.is_some() {
299            "tauri_plugin_command"
300        } else {
301            "tauri_command"
302        };
303        let mut doc = new_doc(
304            config,
305            path,
306            kind,
307            &symbol.name,
308            range_start_line(&symbol.range),
309            "rust",
310        );
311        doc.display_name = Some(symbol.name.clone());
312        doc.tags = vec!["rust command".to_owned()];
313        doc.line_end = Some(range_end_line(&symbol.range));
314        doc.data.insert(
315            "signature".to_owned(),
316            Value::String(extract_signature(text, &symbol.name)),
317        );
318        doc.data.insert(
319            "rust_fqn".to_owned(),
320            Value::String(format!(
321                "{}::{}",
322                normalized.replace('/', "::").trim_end_matches(".rs"),
323                symbol.name
324            )),
325        );
326        if let Some(plugin_name) = plugin_name {
327            doc.data.insert(
328                "plugin_name".to_owned(),
329                Value::String(plugin_name.to_owned()),
330            );
331            doc.data.insert(
332                "invoke_key".to_owned(),
333                Value::String(format!("plugin:{plugin_name}|{}", symbol.name)),
334            );
335        } else {
336            doc.data
337                .insert("invoke_key".to_owned(), Value::String(symbol.name.clone()));
338        }
339        let registered = text.contains("generate_handler!") && text.contains(&symbol.name);
340        doc.data
341            .insert("registered".to_owned(), Value::Bool(registered));
342        doc.data.insert(
343            "source_map_backend".to_owned(),
344            Value::String("rust-analyzer-lsp".to_owned()),
345        );
346        apply_artifact_security(&mut doc);
347        docs.push(doc);
348    }
349    docs
350}
351
352fn match_command_symbol<'a>(
353    symbols: &'a [&'a SymbolLocation],
354    attr_line: u32,
355) -> Option<&'a SymbolLocation> {
356    symbols
357        .iter()
358        .copied()
359        .find(|symbol| line_contains(&symbol.range, attr_line))
360        .or_else(|| {
361            symbols
362                .iter()
363                .copied()
364                .filter(|symbol| range_start_line(&symbol.range) >= attr_line)
365                .min_by_key(|symbol| range_start_line(&symbol.range) - attr_line)
366        })
367}
368
369fn extract_signature(text: &str, function_name: &str) -> String {
370    let pattern = format!(
371        r#"(?m)(?:pub\s+)?(?:async\s+)?fn\s+{}\b(?:<[^>]+>)?\s*(\([^)]*\))"#,
372        regex::escape(function_name)
373    );
374    Regex::new(&pattern)
375        .ok()
376        .and_then(|regex| regex.captures(text))
377        .and_then(|capture| capture.get(1))
378        .map(|capture| capture.as_str().to_owned())
379        .unwrap_or_default()
380}