Skip to main content

cha_core/
wasm.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use wasmtime::component::{Component, Linker};
5use wasmtime::{Engine, Store};
6use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
7
8use crate::plugin::{Finding, Location, Severity, SmellCategory};
9use crate::{AnalysisContext, Plugin};
10
11mod bindings {
12    wasmtime::component::bindgen!({
13        path: "wit/plugin.wit",
14        world: "analyzer",
15    });
16}
17
18use bindings::Analyzer;
19pub use bindings::cha::plugin::types as wit;
20
21struct HostState {
22    wasi: WasiCtx,
23    table: ResourceTable,
24}
25
26impl WasiView for HostState {
27    fn ctx(&mut self) -> WasiCtxView<'_> {
28        WasiCtxView {
29            ctx: &mut self.wasi,
30            table: &mut self.table,
31        }
32    }
33}
34
35fn new_host_state() -> HostState {
36    let wasi = WasiCtxBuilder::new().build();
37    HostState {
38        wasi,
39        table: ResourceTable::new(),
40    }
41}
42
43/// Adapter that loads a WASM component and wraps it as a Plugin.
44pub struct WasmPlugin {
45    engine: Engine,
46    component: Component,
47    plugin_name: String,
48    plugin_version: String,
49    plugin_description: String,
50    plugin_authors: Vec<String>,
51    plugin_smells: Vec<String>,
52    options: Vec<(String, wit::OptionValue)>,
53}
54
55impl WasmPlugin {
56    pub fn load(path: &Path) -> wasmtime::Result<Self> {
57        let engine = Engine::default();
58        let bytes = std::fs::read(path)?;
59        let component = Component::from_binary(&engine, &bytes)?;
60
61        let mut linker = Linker::<HostState>::new(&engine);
62        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
63
64        let mut store = Store::new(&engine, new_host_state());
65        let instance = Analyzer::instantiate(&mut store, &component, &linker)?;
66        let name = instance.call_name(&mut store)?;
67        let version = instance.call_version(&mut store)?;
68        let description = instance.call_description(&mut store)?;
69        let authors = instance.call_authors(&mut store)?;
70        let smells = instance.call_smells(&mut store)?;
71
72        Ok(Self {
73            engine,
74            component,
75            plugin_name: name,
76            plugin_version: version,
77            plugin_description: description,
78            plugin_authors: authors,
79            plugin_smells: smells,
80            options: vec![],
81        })
82    }
83
84    /// Set plugin options from config.
85    pub fn set_options(&mut self, options: Vec<(String, wit::OptionValue)>) {
86        self.options = options;
87    }
88}
89
90impl Plugin for WasmPlugin {
91    fn name(&self) -> &str {
92        &self.plugin_name
93    }
94
95    fn version(&self) -> &str {
96        &self.plugin_version
97    }
98
99    fn description(&self) -> &str {
100        &self.plugin_description
101    }
102
103    fn authors(&self) -> Vec<String> {
104        self.plugin_authors.clone()
105    }
106
107    fn smells(&self) -> Vec<String> {
108        self.plugin_smells.clone()
109    }
110
111    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
112        let result = (|| -> wasmtime::Result<Vec<Finding>> {
113            let mut linker = Linker::<HostState>::new(&self.engine);
114            wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
115
116            let mut store = Store::new(&self.engine, new_host_state());
117            let instance = Analyzer::instantiate(&mut store, &self.component, &linker)?;
118            let input = to_wit_input(ctx, &self.options);
119            let results = instance.call_analyze(&mut store, &input)?;
120            Ok(results.into_iter().map(from_wit_finding).collect())
121        })();
122
123        result.unwrap_or_else(|e| {
124            eprintln!("wasm plugin error: {}", e);
125            vec![]
126        })
127    }
128}
129
130fn to_wit_input(
131    ctx: &AnalysisContext,
132    options: &[(String, wit::OptionValue)],
133) -> wit::AnalysisInput {
134    wit::AnalysisInput {
135        path: ctx.file.path.to_string_lossy().into(),
136        content: ctx.file.content.clone(),
137        language: ctx.model.language.clone(),
138        total_lines: ctx.model.total_lines as u32,
139        functions: convert_functions(&ctx.model.functions),
140        classes: convert_classes(&ctx.model.classes),
141        imports: convert_imports(&ctx.model.imports),
142        options: options.to_vec(),
143    }
144}
145
146/// Generic slice converter to avoid duplicate map-collect patterns.
147fn convert_slice<T, U>(items: &[T], f: impl Fn(&T) -> U) -> Vec<U> {
148    items.iter().map(f).collect()
149}
150
151fn convert_functions(funcs: &[crate::model::FunctionInfo]) -> Vec<wit::FunctionInfo> {
152    convert_slice(funcs, |f| wit::FunctionInfo {
153        name: f.name.clone(),
154        start_line: f.start_line as u32,
155        end_line: f.end_line as u32,
156        name_col: f.name_col as u32,
157        name_end_col: f.name_end_col as u32,
158        line_count: f.line_count as u32,
159        complexity: f.complexity as u32,
160        parameter_count: f.parameter_count as u32,
161        parameter_types: f.parameter_types.iter().map(to_wit_type_ref).collect(),
162        chain_depth: f.chain_depth as u32,
163        switch_arms: f.switch_arms as u32,
164        external_refs: f.external_refs.clone(),
165        is_delegating: f.is_delegating,
166        is_exported: f.is_exported,
167        comment_lines: f.comment_lines as u32,
168        referenced_fields: f.referenced_fields.clone(),
169        null_check_fields: f.null_check_fields.clone(),
170        switch_dispatch_target: f.switch_dispatch_target.clone(),
171        optional_param_count: f.optional_param_count as u32,
172        called_functions: f.called_functions.clone(),
173        cognitive_complexity: f.cognitive_complexity as u32,
174        body_hash: f.body_hash.map(|h| format!("{h:016x}")),
175        return_type: f.return_type.as_ref().map(to_wit_type_ref),
176    })
177}
178
179fn to_wit_type_ref(t: &crate::model::TypeRef) -> wit::TypeRef {
180    wit::TypeRef {
181        name: t.name.clone(),
182        raw: t.raw.clone(),
183        origin: match &t.origin {
184            crate::model::TypeOrigin::Local => wit::TypeOrigin::ProjectLocal,
185            crate::model::TypeOrigin::External(m) => wit::TypeOrigin::External(m.clone()),
186            crate::model::TypeOrigin::Primitive => wit::TypeOrigin::Primitive,
187            crate::model::TypeOrigin::Unknown => wit::TypeOrigin::Unknown,
188        },
189    }
190}
191
192fn convert_classes(classes: &[crate::model::ClassInfo]) -> Vec<wit::ClassInfo> {
193    convert_slice(classes, |c| wit::ClassInfo {
194        name: c.name.clone(),
195        start_line: c.start_line as u32,
196        end_line: c.end_line as u32,
197        name_col: c.name_col as u32,
198        name_end_col: c.name_end_col as u32,
199        method_count: c.method_count as u32,
200        line_count: c.line_count as u32,
201        delegating_method_count: c.delegating_method_count as u32,
202        field_count: c.field_count as u32,
203        field_names: c.field_names.clone(),
204        field_types: c.field_types.clone(),
205        is_exported: c.is_exported,
206        has_behavior: c.has_behavior,
207        is_interface: c.is_interface,
208        parent_name: c.parent_name.clone(),
209        override_count: c.override_count as u32,
210        self_call_count: c.self_call_count as u32,
211        has_listener_field: c.has_listener_field,
212        has_notify_method: c.has_notify_method,
213    })
214}
215
216fn convert_imports(imports: &[crate::model::ImportInfo]) -> Vec<wit::ImportInfo> {
217    convert_slice(imports, |i| wit::ImportInfo {
218        source: i.source.clone(),
219        line: i.line as u32,
220        col: i.col as u32,
221    })
222}
223
224fn from_wit_finding(f: wit::Finding) -> Finding {
225    Finding {
226        smell_name: f.smell_name,
227        category: convert_category(f.category),
228        severity: convert_severity(f.severity),
229        location: Location {
230            path: PathBuf::from(&f.location.path),
231            start_line: f.location.start_line as usize,
232            start_col: f.location.start_col as usize,
233            end_line: f.location.end_line as usize,
234            end_col: f.location.end_col as usize,
235            name: f.location.name,
236        },
237        message: f.message,
238        suggested_refactorings: f.suggested_refactorings,
239        actual_value: f.actual_value,
240        threshold: f.threshold,
241        risk_score: None,
242    }
243}
244
245fn convert_severity(s: wit::Severity) -> Severity {
246    match s {
247        wit::Severity::Hint => Severity::Hint,
248        wit::Severity::Warning => Severity::Warning,
249        wit::Severity::Error => Severity::Error,
250    }
251}
252
253fn convert_category(c: wit::SmellCategory) -> SmellCategory {
254    match c {
255        wit::SmellCategory::Bloaters => SmellCategory::Bloaters,
256        wit::SmellCategory::OoAbusers => SmellCategory::OoAbusers,
257        wit::SmellCategory::ChangePreventers => SmellCategory::ChangePreventers,
258        wit::SmellCategory::Dispensables => SmellCategory::Dispensables,
259        wit::SmellCategory::Couplers => SmellCategory::Couplers,
260        wit::SmellCategory::Security => SmellCategory::Security,
261    }
262}
263
264/// Scan plugin directories and load all .wasm components.
265pub fn load_wasm_plugins(project_dir: &Path) -> Vec<WasmPlugin> {
266    let mut plugins: Vec<WasmPlugin> = Vec::new();
267    let mut seen = HashMap::new();
268
269    let project_plugins = project_dir.join(".cha").join("plugins");
270    let global_plugins = home_dir().join(".cha").join("plugins");
271
272    for dir in [&project_plugins, &global_plugins] {
273        load_plugins_from_dir(dir, &mut seen, &mut plugins);
274    }
275
276    plugins
277}
278
279/// Load .wasm plugins from a single directory, skipping duplicates by filename.
280fn load_plugins_from_dir(
281    dir: &Path,
282    seen: &mut HashMap<String, bool>,
283    plugins: &mut Vec<WasmPlugin>,
284) {
285    let entries = match std::fs::read_dir(dir) {
286        Ok(e) => e,
287        Err(_) => return,
288    };
289    for entry in entries.flatten() {
290        let path = entry.path();
291        if path.extension().is_none_or(|e| e != "wasm") {
292            continue;
293        }
294        let filename = path.file_name().unwrap().to_string_lossy().to_string();
295        if seen.contains_key(&filename) {
296            continue;
297        }
298        match WasmPlugin::load(&path) {
299            Ok(p) => {
300                seen.insert(filename, true);
301                plugins.push(p);
302            }
303            Err(e) => {
304                eprintln!("failed to load wasm plugin {}: {}", path.display(), e);
305            }
306        }
307    }
308}
309
310fn home_dir() -> PathBuf {
311    std::env::var("HOME")
312        .map(PathBuf::from)
313        .unwrap_or_else(|_| PathBuf::from("."))
314}
315
316/// Convert a TOML value to a WIT option-value.
317pub fn toml_to_option_value(v: &toml::Value) -> Option<wit::OptionValue> {
318    match v {
319        toml::Value::String(s) => Some(wit::OptionValue::Str(s.clone())),
320        toml::Value::Integer(i) => Some(wit::OptionValue::Int(*i)),
321        toml::Value::Float(f) => Some(wit::OptionValue::Float(*f)),
322        toml::Value::Boolean(b) => Some(wit::OptionValue::Boolean(*b)),
323        toml::Value::Array(arr) => {
324            let strs: Vec<String> = arr
325                .iter()
326                .filter_map(|v| v.as_str().map(String::from))
327                .collect();
328            Some(wit::OptionValue::ListStr(strs))
329        }
330        _ => None,
331    }
332}