Skip to main content

cha_core/
wasm.rs

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