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
26struct 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
177fn 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 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 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
289pub 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 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
425fn 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
562pub 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
577fn 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
614pub 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}