Skip to main content

cha_core/
wasm.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use streaming_iterator::StreamingIterator;
5use wasmtime::component::{Component, HasSelf, Linker};
6use wasmtime::{Engine, Store};
7use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
8
9use crate::model::ArmValue;
10use crate::plugin::{Finding, Location, Severity, SmellCategory};
11use crate::{AnalysisContext, Plugin};
12
13mod bindings {
14    wasmtime::component::bindgen!({
15        path: "wit/plugin.wit",
16        world: "analyzer",
17    });
18}
19
20use bindings::Analyzer;
21use bindings::cha::plugin::project_query;
22use bindings::cha::plugin::tree_query;
23pub use bindings::cha::plugin::types as wit;
24
25use crate::ProjectQuery;
26
27// cha:ignore large_class
28struct HostState {
29    wasi: WasiCtx,
30    table: ResourceTable,
31    tree: Option<tree_sitter::Tree>,
32    source: Vec<u8>,
33    ts_language: Option<tree_sitter::Language>,
34    query_cache: HashMap<String, tree_sitter::Query>,
35    project: Option<std::sync::Arc<dyn ProjectQuery>>,
36}
37
38impl WasiView for HostState {
39    fn ctx(&mut self) -> WasiCtxView<'_> {
40        WasiCtxView {
41            ctx: &mut self.wasi,
42            table: &mut self.table,
43        }
44    }
45}
46
47fn new_host_state(
48    tree: Option<tree_sitter::Tree>,
49    source: Vec<u8>,
50    ts_language: Option<tree_sitter::Language>,
51    project: Option<std::sync::Arc<dyn ProjectQuery>>,
52) -> HostState {
53    let wasi = WasiCtxBuilder::new().build();
54    HostState {
55        wasi,
56        table: ResourceTable::new(),
57        tree,
58        source,
59        ts_language,
60        query_cache: HashMap::new(),
61        project,
62    }
63}
64
65impl bindings::cha::plugin::types::Host for HostState {}
66
67impl project_query::Host for HostState {
68    fn is_called_externally(&mut self, name: String, exclude_path: String) -> bool {
69        self.project
70            .as_ref()
71            .is_some_and(|p| p.is_called_externally(&name, std::path::Path::new(&exclude_path)))
72    }
73
74    fn callers_of(&mut self, name: String) -> Vec<String> {
75        self.project
76            .as_ref()
77            .map(|p| {
78                p.callers_of(&name)
79                    .into_iter()
80                    .map(|p| p.to_string_lossy().into_owned())
81                    .collect()
82            })
83            .unwrap_or_default()
84    }
85
86    fn cross_file_call_counts(&mut self) -> Vec<(String, String, u32)> {
87        self.project
88            .as_ref()
89            .map(|p| {
90                p.cross_file_call_counts()
91                    .into_iter()
92                    .map(|((a, b), c)| {
93                        (
94                            a.to_string_lossy().into_owned(),
95                            b.to_string_lossy().into_owned(),
96                            c,
97                        )
98                    })
99                    .collect()
100            })
101            .unwrap_or_default()
102    }
103
104    fn function_home(&mut self, name: String) -> Option<String> {
105        self.project
106            .as_ref()
107            .and_then(|p| p.function_home(&name))
108            .map(|p| p.to_string_lossy().into_owned())
109    }
110
111    fn function_by_name(&mut self, name: String) -> Option<(String, wit::FunctionInfo)> {
112        let p = self.project.as_ref()?;
113        let (path, info) = p.function_by_name(&name)?;
114        Some((
115            path.to_string_lossy().into_owned(),
116            convert_function_info(&info),
117        ))
118    }
119
120    fn class_home(&mut self, name: String) -> Option<String> {
121        self.project
122            .as_ref()
123            .and_then(|p| p.class_home(&name))
124            .map(|p| p.to_string_lossy().into_owned())
125    }
126
127    fn is_project_type(&mut self, name: String) -> bool {
128        self.project
129            .as_ref()
130            .is_some_and(|p| p.is_project_type(&name))
131    }
132
133    fn is_third_party(&mut self, type_ref: wit::TypeRef) -> bool {
134        let core_ref = wit_to_core_type_ref(&type_ref);
135        self.project
136            .as_ref()
137            .is_some_and(|p| p.is_third_party(&core_ref))
138    }
139
140    fn workspace_crate_names(&mut self) -> Vec<String> {
141        self.project
142            .as_ref()
143            .map(|p| p.workspace_crate_names())
144            .unwrap_or_default()
145    }
146
147    fn is_test_path(&mut self, path: String) -> bool {
148        self.project
149            .as_ref()
150            .is_some_and(|p| p.is_test_path(std::path::Path::new(&path)))
151    }
152
153    fn file_count(&mut self) -> u32 {
154        self.project
155            .as_ref()
156            .map(|p| p.file_count() as u32)
157            .unwrap_or(0)
158    }
159
160    fn function_at(&mut self, path: String, line: u32, col: u32) -> Option<wit::FunctionInfo> {
161        let p = self.project.as_ref()?;
162        let info = p.function_at(std::path::Path::new(&path), line, col)?;
163        Some(convert_function_info(&info))
164    }
165}
166
167fn wit_to_core_type_ref(t: &wit::TypeRef) -> crate::model::TypeRef {
168    crate::model::TypeRef {
169        name: t.name.clone(),
170        raw: t.raw.clone(),
171        origin: match &t.origin {
172            wit::TypeOrigin::ProjectLocal => crate::model::TypeOrigin::Local,
173            wit::TypeOrigin::External(s) => crate::model::TypeOrigin::External(s.clone()),
174            wit::TypeOrigin::Primitive => crate::model::TypeOrigin::Primitive,
175            wit::TypeOrigin::Unknown => crate::model::TypeOrigin::Unknown,
176        },
177    }
178}
179
180/// Convert a core FunctionInfo to its WIT counterpart for project_query callbacks.
181fn convert_function_info(f: &crate::model::FunctionInfo) -> wit::FunctionInfo {
182    wit::FunctionInfo {
183        name: f.name.clone(),
184        start_line: f.start_line as u32,
185        end_line: f.end_line as u32,
186        name_col: f.name_col as u32,
187        name_end_col: f.name_end_col as u32,
188        line_count: f.line_count as u32,
189        complexity: f.complexity as u32,
190        parameter_count: f.parameter_count as u32,
191        parameter_types: f.parameter_types.iter().map(to_wit_type_ref).collect(),
192        parameter_names: f.parameter_names.clone(),
193        chain_depth: f.chain_depth as u32,
194        switch_arms: f.switch_arms as u32,
195        switch_arm_values: f.switch_arm_values.iter().map(to_wit_arm_value).collect(),
196        external_refs: f.external_refs.clone(),
197        is_delegating: f.is_delegating,
198        is_exported: f.is_exported,
199        comment_lines: f.comment_lines as u32,
200        referenced_fields: f.referenced_fields.clone(),
201        null_check_fields: f.null_check_fields.clone(),
202        switch_dispatch_target: f.switch_dispatch_target.clone(),
203        optional_param_count: f.optional_param_count as u32,
204        called_functions: f.called_functions.clone(),
205        cognitive_complexity: f.cognitive_complexity as u32,
206        body_hash: f.body_hash.map(|h| format!("{h:016x}")),
207        return_type: f.return_type.as_ref().map(to_wit_type_ref),
208    }
209}
210
211impl tree_query::Host for HostState {
212    fn run_query(&mut self, pattern: String) -> Vec<Vec<tree_query::QueryMatch>> {
213        self.execute_query(&pattern)
214    }
215
216    fn run_queries(&mut self, patterns: Vec<String>) -> Vec<Vec<Vec<tree_query::QueryMatch>>> {
217        patterns.iter().map(|p| self.execute_query(p)).collect()
218    }
219
220    fn node_at(&mut self, line: u32, col: u32) -> Option<tree_query::QueryMatch> {
221        // Inputs are 1-based to match FunctionInfo / ClassInfo lines;
222        // tree-sitter Point is 0-based.
223        let tree = self.tree.as_ref()?;
224        let row = (line.saturating_sub(1)) as usize;
225        let point = tree_sitter::Point::new(row, col as usize);
226        let node = tree.root_node().descendant_for_point_range(point, point)?;
227        Some(node_to_query_match(&node, &self.source, ""))
228    }
229
230    fn nodes_in_range(&mut self, start_line: u32, end_line: u32) -> Vec<tree_query::QueryMatch> {
231        // Inputs are 1-based; compare against tree-sitter rows (0-based) by
232        // subtracting 1.
233        let tree = match &self.tree {
234            Some(t) => t,
235            None => return vec![],
236        };
237        let start_row = start_line.saturating_sub(1);
238        let end_row = end_line.saturating_sub(1);
239        let mut results = vec![];
240        let mut cursor = tree.root_node().walk();
241        for child in tree.root_node().children(&mut cursor) {
242            let node_start = child.start_position().row as u32;
243            let node_end = child.end_position().row as u32;
244            if node_end < start_row {
245                continue;
246            }
247            if node_start > end_row {
248                break;
249            }
250            if child.is_named() {
251                results.push(node_to_query_match(&child, &self.source, ""));
252            }
253        }
254        results
255    }
256}
257
258impl HostState {
259    fn execute_query(&mut self, pattern: &str) -> Vec<Vec<tree_query::QueryMatch>> {
260        let (tree, ts_lang) = match (&self.tree, &self.ts_language) {
261            (Some(t), Some(l)) => (t, l),
262            _ => return vec![],
263        };
264
265        if !self.query_cache.contains_key(pattern) {
266            let q = match tree_sitter::Query::new(ts_lang, pattern) {
267                Ok(q) => q,
268                Err(_) => return vec![],
269            };
270            self.query_cache.insert(pattern.to_string(), q);
271        }
272        let query = self.query_cache.get(pattern).unwrap();
273        let capture_names: Vec<&str> = query.capture_names().to_vec();
274
275        let mut cursor = tree_sitter::QueryCursor::new();
276        let mut results = vec![];
277        let mut matches = cursor.matches(query, tree.root_node(), self.source.as_slice());
278        while let Some(m) = StreamingIterator::next(&mut matches) {
279            let captures: Vec<_> = m
280                .captures
281                .iter()
282                .map(|c| {
283                    let name: &str = capture_names.get(c.index as usize).copied().unwrap_or("");
284                    node_to_query_match(&c.node, &self.source, name)
285                })
286                .collect();
287            results.push(captures);
288        }
289        results
290    }
291}
292
293fn node_to_query_match(
294    node: &tree_sitter::Node,
295    source: &[u8],
296    capture_name: &str,
297) -> tree_query::QueryMatch {
298    // Lines are 1-based to match FunctionInfo / ClassInfo / CommentInfo;
299    // tree-sitter `row` is 0-based so +1.
300    let text = node.utf8_text(source).unwrap_or("").to_string();
301    tree_query::QueryMatch {
302        capture_name: capture_name.to_string(),
303        node_kind: node.kind().to_string(),
304        text,
305        start_line: (node.start_position().row as u32) + 1,
306        start_col: node.start_position().column as u32,
307        end_line: (node.end_position().row as u32) + 1,
308        end_col: node.end_position().column as u32,
309    }
310}
311
312/// Adapter that loads a WASM component and wraps it as a Plugin.
313pub struct WasmPlugin {
314    engine: Engine,
315    component: Component,
316    plugin_name: String,
317    plugin_version: String,
318    plugin_description: String,
319    plugin_authors: Vec<String>,
320    plugin_smells: Vec<String>,
321    options: Vec<(String, wit::OptionValue)>,
322}
323
324impl WasmPlugin {
325    pub fn load(path: &Path) -> wasmtime::Result<Self> {
326        let engine = Engine::default();
327        let bytes = std::fs::read(path)?;
328        let component = Component::from_binary(&engine, &bytes)?;
329
330        let mut linker = Linker::<HostState>::new(&engine);
331        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
332        Analyzer::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
333
334        let mut store = Store::new(&engine, new_host_state(None, vec![], None, None));
335        let instance = Analyzer::instantiate(&mut store, &component, &linker)?;
336        let name = instance.call_name(&mut store)?;
337        let version = instance.call_version(&mut store)?;
338        let description = instance.call_description(&mut store)?;
339        let authors = instance.call_authors(&mut store)?;
340        let smells = instance.call_smells(&mut store)?;
341
342        Ok(Self {
343            engine,
344            component,
345            plugin_name: name,
346            plugin_version: version,
347            plugin_description: description,
348            plugin_authors: authors,
349            plugin_smells: smells,
350            options: vec![],
351        })
352    }
353
354    /// Set plugin options from config.
355    pub fn set_options(&mut self, options: Vec<(String, wit::OptionValue)>) {
356        self.options = options;
357    }
358}
359
360impl Plugin for WasmPlugin {
361    fn name(&self) -> &str {
362        &self.plugin_name
363    }
364
365    fn version(&self) -> &str {
366        &self.plugin_version
367    }
368
369    fn description(&self) -> &str {
370        &self.plugin_description
371    }
372
373    fn authors(&self) -> Vec<String> {
374        self.plugin_authors.clone()
375    }
376
377    fn smells(&self) -> Vec<String> {
378        self.plugin_smells.clone()
379    }
380
381    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
382        let result = (|| -> wasmtime::Result<Vec<Finding>> {
383            let mut linker = Linker::<HostState>::new(&self.engine);
384            wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
385            Analyzer::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
386
387            let (tree, ts_lang) = match (ctx.tree, ctx.ts_language) {
388                (Some(t), Some(l)) => (Some(t.clone()), Some(l.clone())),
389                _ => (None, None),
390            };
391            let source = ctx.file.content.as_bytes().to_vec();
392            let project = ctx.project.cloned();
393            let mut store =
394                Store::new(&self.engine, new_host_state(tree, source, ts_lang, project));
395            let instance = Analyzer::instantiate(&mut store, &self.component, &linker)?;
396            let input = to_wit_input(ctx, &self.options);
397            let results = instance.call_analyze(&mut store, &input)?;
398            Ok(results.into_iter().map(from_wit_finding).collect())
399        })();
400
401        result.unwrap_or_else(|e| {
402            eprintln!("wasm plugin error: {}", e);
403            vec![]
404        })
405    }
406}
407
408fn to_wit_input(
409    ctx: &AnalysisContext,
410    options: &[(String, wit::OptionValue)],
411) -> wit::AnalysisInput {
412    wit::AnalysisInput {
413        path: ctx.file.path.to_string_lossy().into(),
414        content: ctx.file.content.clone(),
415        language: ctx.model.language.clone(),
416        total_lines: ctx.model.total_lines as u32,
417        role: infer_file_role(&ctx.file.path),
418        functions: convert_functions(&ctx.model.functions),
419        classes: convert_classes(&ctx.model.classes),
420        imports: convert_imports(&ctx.model.imports),
421        comments: convert_comments(&ctx.model.comments),
422        type_aliases: ctx
423            .model
424            .type_aliases
425            .iter()
426            .map(|(k, v)| (k.clone(), v.clone()))
427            .collect(),
428        options: options.to_vec(),
429    }
430}
431
432fn infer_file_role(path: &Path) -> wit::FileRole {
433    let s = path.to_string_lossy();
434    if s.contains("/test") || s.contains("_test.") || s.contains("/tests/") || s.contains("/spec/")
435    {
436        return wit::FileRole::Test;
437    }
438    if s.contains("/generated/") || s.contains(".generated.") || s.contains(".gen.") {
439        return wit::FileRole::Generated;
440    }
441    match path.extension().and_then(|e| e.to_str()) {
442        Some("md" | "txt" | "rst" | "adoc") => return wit::FileRole::Doc,
443        Some("toml" | "json" | "yaml" | "yml" | "ini" | "cfg") => return wit::FileRole::Config,
444        _ => {}
445    }
446    wit::FileRole::Source
447}
448
449/// Generic slice converter to avoid duplicate map-collect patterns.
450fn convert_slice<T, U>(items: &[T], f: impl Fn(&T) -> U) -> Vec<U> {
451    items.iter().map(f).collect()
452}
453
454fn convert_functions(funcs: &[crate::model::FunctionInfo]) -> Vec<wit::FunctionInfo> {
455    convert_slice(funcs, |f| wit::FunctionInfo {
456        name: f.name.clone(),
457        start_line: f.start_line as u32,
458        end_line: f.end_line as u32,
459        name_col: f.name_col as u32,
460        name_end_col: f.name_end_col as u32,
461        line_count: f.line_count as u32,
462        complexity: f.complexity as u32,
463        parameter_count: f.parameter_count as u32,
464        parameter_types: f.parameter_types.iter().map(to_wit_type_ref).collect(),
465        parameter_names: f.parameter_names.clone(),
466        chain_depth: f.chain_depth as u32,
467        switch_arms: f.switch_arms as u32,
468        switch_arm_values: f.switch_arm_values.iter().map(to_wit_arm_value).collect(),
469        external_refs: f.external_refs.clone(),
470        is_delegating: f.is_delegating,
471        is_exported: f.is_exported,
472        comment_lines: f.comment_lines as u32,
473        referenced_fields: f.referenced_fields.clone(),
474        null_check_fields: f.null_check_fields.clone(),
475        switch_dispatch_target: f.switch_dispatch_target.clone(),
476        optional_param_count: f.optional_param_count as u32,
477        called_functions: f.called_functions.clone(),
478        cognitive_complexity: f.cognitive_complexity as u32,
479        body_hash: f.body_hash.map(|h| format!("{h:016x}")),
480        return_type: f.return_type.as_ref().map(to_wit_type_ref),
481    })
482}
483
484fn to_wit_arm_value(v: &ArmValue) -> wit::ArmValue {
485    match v {
486        ArmValue::Str(s) => wit::ArmValue::StrLit(s.clone()),
487        ArmValue::Int(i) => wit::ArmValue::IntLit(*i),
488        ArmValue::Char(c) => wit::ArmValue::CharLit(*c as u32),
489        ArmValue::Other => wit::ArmValue::Other,
490    }
491}
492
493fn to_wit_type_ref(t: &crate::model::TypeRef) -> wit::TypeRef {
494    wit::TypeRef {
495        name: t.name.clone(),
496        raw: t.raw.clone(),
497        origin: match &t.origin {
498            crate::model::TypeOrigin::Local => wit::TypeOrigin::ProjectLocal,
499            crate::model::TypeOrigin::External(m) => wit::TypeOrigin::External(m.clone()),
500            crate::model::TypeOrigin::Primitive => wit::TypeOrigin::Primitive,
501            crate::model::TypeOrigin::Unknown => wit::TypeOrigin::Unknown,
502        },
503    }
504}
505
506fn convert_classes(classes: &[crate::model::ClassInfo]) -> Vec<wit::ClassInfo> {
507    convert_slice(classes, |c| wit::ClassInfo {
508        name: c.name.clone(),
509        start_line: c.start_line as u32,
510        end_line: c.end_line as u32,
511        name_col: c.name_col as u32,
512        name_end_col: c.name_end_col as u32,
513        method_count: c.method_count as u32,
514        line_count: c.line_count as u32,
515        delegating_method_count: c.delegating_method_count as u32,
516        field_count: c.field_count as u32,
517        field_names: c.field_names.clone(),
518        field_types: c.field_types.clone(),
519        is_exported: c.is_exported,
520        has_behavior: c.has_behavior,
521        is_interface: c.is_interface,
522        parent_name: c.parent_name.clone(),
523        override_count: c.override_count as u32,
524        self_call_count: c.self_call_count as u32,
525        has_listener_field: c.has_listener_field,
526        has_notify_method: c.has_notify_method,
527    })
528}
529
530fn convert_imports(imports: &[crate::model::ImportInfo]) -> Vec<wit::ImportInfo> {
531    convert_slice(imports, |i| wit::ImportInfo {
532        source: i.source.clone(),
533        line: i.line as u32,
534        col: i.col as u32,
535        is_module_decl: i.is_module_decl,
536    })
537}
538
539fn convert_comments(comments: &[crate::model::CommentInfo]) -> Vec<wit::CommentInfo> {
540    convert_slice(comments, |c| wit::CommentInfo {
541        text: c.text.clone(),
542        line: c.line as u32,
543    })
544}
545
546fn from_wit_finding(f: wit::Finding) -> Finding {
547    Finding {
548        smell_name: f.smell_name,
549        category: convert_category(f.category),
550        severity: convert_severity(f.severity),
551        location: Location {
552            path: PathBuf::from(&f.location.path),
553            start_line: f.location.start_line as usize,
554            start_col: f.location.start_col as usize,
555            end_line: f.location.end_line as usize,
556            end_col: f.location.end_col as usize,
557            name: f.location.name,
558        },
559        message: f.message,
560        suggested_refactorings: f.suggested_refactorings,
561        actual_value: f.actual_value,
562        threshold: f.threshold,
563        risk_score: None,
564    }
565}
566
567fn convert_severity(s: wit::Severity) -> Severity {
568    match s {
569        wit::Severity::Hint => Severity::Hint,
570        wit::Severity::Warning => Severity::Warning,
571        wit::Severity::Error => Severity::Error,
572    }
573}
574
575fn convert_category(c: wit::SmellCategory) -> SmellCategory {
576    match c {
577        wit::SmellCategory::Bloaters => SmellCategory::Bloaters,
578        wit::SmellCategory::OoAbusers => SmellCategory::OoAbusers,
579        wit::SmellCategory::ChangePreventers => SmellCategory::ChangePreventers,
580        wit::SmellCategory::Dispensables => SmellCategory::Dispensables,
581        wit::SmellCategory::Couplers => SmellCategory::Couplers,
582        wit::SmellCategory::Security => SmellCategory::Security,
583    }
584}
585
586/// Scan plugin directories and load all .wasm components.
587pub fn load_wasm_plugins(project_dir: &Path) -> Vec<WasmPlugin> {
588    let mut plugins: Vec<WasmPlugin> = Vec::new();
589    let mut seen = HashMap::new();
590
591    let project_plugins = project_dir.join(".cha").join("plugins");
592    let global_plugins = home_dir().join(".cha").join("plugins");
593
594    for dir in [&project_plugins, &global_plugins] {
595        load_plugins_from_dir(dir, &mut seen, &mut plugins);
596    }
597
598    plugins
599}
600
601/// Load .wasm plugins from a single directory, skipping duplicates by filename.
602fn load_plugins_from_dir(
603    dir: &Path,
604    seen: &mut HashMap<String, bool>,
605    plugins: &mut Vec<WasmPlugin>,
606) {
607    let entries = match std::fs::read_dir(dir) {
608        Ok(e) => e,
609        Err(_) => return,
610    };
611    for entry in entries.flatten() {
612        let path = entry.path();
613        if path.extension().is_none_or(|e| e != "wasm") {
614            continue;
615        }
616        let filename = path.file_name().unwrap().to_string_lossy().to_string();
617        if seen.contains_key(&filename) {
618            continue;
619        }
620        match WasmPlugin::load(&path) {
621            Ok(p) => {
622                seen.insert(filename, true);
623                plugins.push(p);
624            }
625            Err(e) => {
626                eprintln!("failed to load wasm plugin {}: {}", path.display(), e);
627            }
628        }
629    }
630}
631
632fn home_dir() -> PathBuf {
633    std::env::var("HOME")
634        .map(PathBuf::from)
635        .unwrap_or_else(|_| PathBuf::from("."))
636}
637
638/// Convert a TOML value to a WIT option-value.
639pub fn toml_to_option_value(v: &toml::Value) -> Option<wit::OptionValue> {
640    match v {
641        toml::Value::String(s) => Some(wit::OptionValue::Str(s.clone())),
642        toml::Value::Integer(i) => Some(wit::OptionValue::Int(*i)),
643        toml::Value::Float(f) => Some(wit::OptionValue::Float(*f)),
644        toml::Value::Boolean(b) => Some(wit::OptionValue::Boolean(*b)),
645        toml::Value::Array(arr) => {
646            let strs: Vec<String> = arr
647                .iter()
648                .filter_map(|v| v.as_str().map(String::from))
649                .collect();
650            Some(wit::OptionValue::ListStr(strs))
651        }
652        _ => None,
653    }
654}