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
27struct 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
161fn wit_to_core_type_ref(t: &wit::TypeRef) -> crate::model::TypeRef {
162 crate::model::TypeRef {
163 name: t.name.clone(),
164 raw: t.raw.clone(),
165 origin: match &t.origin {
166 wit::TypeOrigin::ProjectLocal => crate::model::TypeOrigin::Local,
167 wit::TypeOrigin::External(s) => crate::model::TypeOrigin::External(s.clone()),
168 wit::TypeOrigin::Primitive => crate::model::TypeOrigin::Primitive,
169 wit::TypeOrigin::Unknown => crate::model::TypeOrigin::Unknown,
170 },
171 }
172}
173
174fn convert_function_info(f: &crate::model::FunctionInfo) -> wit::FunctionInfo {
176 wit::FunctionInfo {
177 name: f.name.clone(),
178 start_line: f.start_line as u32,
179 end_line: f.end_line as u32,
180 name_col: f.name_col as u32,
181 name_end_col: f.name_end_col as u32,
182 line_count: f.line_count as u32,
183 complexity: f.complexity as u32,
184 parameter_count: f.parameter_count as u32,
185 parameter_types: f.parameter_types.iter().map(to_wit_type_ref).collect(),
186 parameter_names: f.parameter_names.clone(),
187 chain_depth: f.chain_depth as u32,
188 switch_arms: f.switch_arms as u32,
189 switch_arm_values: f.switch_arm_values.iter().map(to_wit_arm_value).collect(),
190 external_refs: f.external_refs.clone(),
191 is_delegating: f.is_delegating,
192 is_exported: f.is_exported,
193 comment_lines: f.comment_lines as u32,
194 referenced_fields: f.referenced_fields.clone(),
195 null_check_fields: f.null_check_fields.clone(),
196 switch_dispatch_target: f.switch_dispatch_target.clone(),
197 optional_param_count: f.optional_param_count as u32,
198 called_functions: f.called_functions.clone(),
199 cognitive_complexity: f.cognitive_complexity as u32,
200 body_hash: f.body_hash.map(|h| format!("{h:016x}")),
201 return_type: f.return_type.as_ref().map(to_wit_type_ref),
202 }
203}
204
205impl tree_query::Host for HostState {
206 fn run_query(&mut self, pattern: String) -> Vec<Vec<tree_query::QueryMatch>> {
207 self.execute_query(&pattern)
208 }
209
210 fn run_queries(&mut self, patterns: Vec<String>) -> Vec<Vec<Vec<tree_query::QueryMatch>>> {
211 patterns.iter().map(|p| self.execute_query(p)).collect()
212 }
213
214 fn node_at(&mut self, line: u32, col: u32) -> Option<tree_query::QueryMatch> {
215 let tree = self.tree.as_ref()?;
216 let point = tree_sitter::Point::new(line as usize, col as usize);
217 let node = tree.root_node().descendant_for_point_range(point, point)?;
218 Some(node_to_query_match(&node, &self.source, ""))
219 }
220
221 fn nodes_in_range(&mut self, start_line: u32, end_line: u32) -> Vec<tree_query::QueryMatch> {
222 let tree = match &self.tree {
223 Some(t) => t,
224 None => return vec![],
225 };
226 let mut results = vec![];
227 let mut cursor = tree.root_node().walk();
228 for child in tree.root_node().children(&mut cursor) {
229 let node_start = child.start_position().row as u32;
230 let node_end = child.end_position().row as u32;
231 if node_end < start_line {
232 continue;
233 }
234 if node_start > end_line {
235 break;
236 }
237 if child.is_named() {
238 results.push(node_to_query_match(&child, &self.source, ""));
239 }
240 }
241 results
242 }
243}
244
245impl HostState {
246 fn execute_query(&mut self, pattern: &str) -> Vec<Vec<tree_query::QueryMatch>> {
247 let (tree, ts_lang) = match (&self.tree, &self.ts_language) {
248 (Some(t), Some(l)) => (t, l),
249 _ => return vec![],
250 };
251
252 if !self.query_cache.contains_key(pattern) {
253 let q = match tree_sitter::Query::new(ts_lang, pattern) {
254 Ok(q) => q,
255 Err(_) => return vec![],
256 };
257 self.query_cache.insert(pattern.to_string(), q);
258 }
259 let query = self.query_cache.get(pattern).unwrap();
260 let capture_names: Vec<&str> = query.capture_names().to_vec();
261
262 let mut cursor = tree_sitter::QueryCursor::new();
263 let mut results = vec![];
264 let mut matches = cursor.matches(query, tree.root_node(), self.source.as_slice());
265 while let Some(m) = StreamingIterator::next(&mut matches) {
266 let captures: Vec<_> = m
267 .captures
268 .iter()
269 .map(|c| {
270 let name: &str = capture_names.get(c.index as usize).copied().unwrap_or("");
271 node_to_query_match(&c.node, &self.source, name)
272 })
273 .collect();
274 results.push(captures);
275 }
276 results
277 }
278}
279
280fn node_to_query_match(
281 node: &tree_sitter::Node,
282 source: &[u8],
283 capture_name: &str,
284) -> tree_query::QueryMatch {
285 let text = node.utf8_text(source).unwrap_or("").to_string();
286 tree_query::QueryMatch {
287 capture_name: capture_name.to_string(),
288 node_kind: node.kind().to_string(),
289 text,
290 start_line: node.start_position().row as u32,
291 start_col: node.start_position().column as u32,
292 end_line: node.end_position().row as u32,
293 end_col: node.end_position().column as u32,
294 }
295}
296
297pub struct WasmPlugin {
299 engine: Engine,
300 component: Component,
301 plugin_name: String,
302 plugin_version: String,
303 plugin_description: String,
304 plugin_authors: Vec<String>,
305 plugin_smells: Vec<String>,
306 options: Vec<(String, wit::OptionValue)>,
307}
308
309impl WasmPlugin {
310 pub fn load(path: &Path) -> wasmtime::Result<Self> {
311 let engine = Engine::default();
312 let bytes = std::fs::read(path)?;
313 let component = Component::from_binary(&engine, &bytes)?;
314
315 let mut linker = Linker::<HostState>::new(&engine);
316 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
317 Analyzer::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
318
319 let mut store = Store::new(&engine, new_host_state(None, vec![], None, None));
320 let instance = Analyzer::instantiate(&mut store, &component, &linker)?;
321 let name = instance.call_name(&mut store)?;
322 let version = instance.call_version(&mut store)?;
323 let description = instance.call_description(&mut store)?;
324 let authors = instance.call_authors(&mut store)?;
325 let smells = instance.call_smells(&mut store)?;
326
327 Ok(Self {
328 engine,
329 component,
330 plugin_name: name,
331 plugin_version: version,
332 plugin_description: description,
333 plugin_authors: authors,
334 plugin_smells: smells,
335 options: vec![],
336 })
337 }
338
339 pub fn set_options(&mut self, options: Vec<(String, wit::OptionValue)>) {
341 self.options = options;
342 }
343}
344
345impl Plugin for WasmPlugin {
346 fn name(&self) -> &str {
347 &self.plugin_name
348 }
349
350 fn version(&self) -> &str {
351 &self.plugin_version
352 }
353
354 fn description(&self) -> &str {
355 &self.plugin_description
356 }
357
358 fn authors(&self) -> Vec<String> {
359 self.plugin_authors.clone()
360 }
361
362 fn smells(&self) -> Vec<String> {
363 self.plugin_smells.clone()
364 }
365
366 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
367 let result = (|| -> wasmtime::Result<Vec<Finding>> {
368 let mut linker = Linker::<HostState>::new(&self.engine);
369 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
370 Analyzer::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
371
372 let (tree, ts_lang) = match (ctx.tree, ctx.ts_language) {
373 (Some(t), Some(l)) => (Some(t.clone()), Some(l.clone())),
374 _ => (None, None),
375 };
376 let source = ctx.file.content.as_bytes().to_vec();
377 let project = ctx.project.cloned();
378 let mut store =
379 Store::new(&self.engine, new_host_state(tree, source, ts_lang, project));
380 let instance = Analyzer::instantiate(&mut store, &self.component, &linker)?;
381 let input = to_wit_input(ctx, &self.options);
382 let results = instance.call_analyze(&mut store, &input)?;
383 Ok(results.into_iter().map(from_wit_finding).collect())
384 })();
385
386 result.unwrap_or_else(|e| {
387 eprintln!("wasm plugin error: {}", e);
388 vec![]
389 })
390 }
391}
392
393fn to_wit_input(
394 ctx: &AnalysisContext,
395 options: &[(String, wit::OptionValue)],
396) -> wit::AnalysisInput {
397 wit::AnalysisInput {
398 path: ctx.file.path.to_string_lossy().into(),
399 content: ctx.file.content.clone(),
400 language: ctx.model.language.clone(),
401 total_lines: ctx.model.total_lines as u32,
402 role: infer_file_role(&ctx.file.path),
403 functions: convert_functions(&ctx.model.functions),
404 classes: convert_classes(&ctx.model.classes),
405 imports: convert_imports(&ctx.model.imports),
406 comments: convert_comments(&ctx.model.comments),
407 type_aliases: ctx
408 .model
409 .type_aliases
410 .iter()
411 .map(|(k, v)| (k.clone(), v.clone()))
412 .collect(),
413 options: options.to_vec(),
414 }
415}
416
417fn infer_file_role(path: &Path) -> wit::FileRole {
418 let s = path.to_string_lossy();
419 if s.contains("/test") || s.contains("_test.") || s.contains("/tests/") || s.contains("/spec/")
420 {
421 return wit::FileRole::Test;
422 }
423 if s.contains("/generated/") || s.contains(".generated.") || s.contains(".gen.") {
424 return wit::FileRole::Generated;
425 }
426 match path.extension().and_then(|e| e.to_str()) {
427 Some("md" | "txt" | "rst" | "adoc") => return wit::FileRole::Doc,
428 Some("toml" | "json" | "yaml" | "yml" | "ini" | "cfg") => return wit::FileRole::Config,
429 _ => {}
430 }
431 wit::FileRole::Source
432}
433
434fn convert_slice<T, U>(items: &[T], f: impl Fn(&T) -> U) -> Vec<U> {
436 items.iter().map(f).collect()
437}
438
439fn convert_functions(funcs: &[crate::model::FunctionInfo]) -> Vec<wit::FunctionInfo> {
440 convert_slice(funcs, |f| wit::FunctionInfo {
441 name: f.name.clone(),
442 start_line: f.start_line as u32,
443 end_line: f.end_line as u32,
444 name_col: f.name_col as u32,
445 name_end_col: f.name_end_col as u32,
446 line_count: f.line_count as u32,
447 complexity: f.complexity as u32,
448 parameter_count: f.parameter_count as u32,
449 parameter_types: f.parameter_types.iter().map(to_wit_type_ref).collect(),
450 parameter_names: f.parameter_names.clone(),
451 chain_depth: f.chain_depth as u32,
452 switch_arms: f.switch_arms as u32,
453 switch_arm_values: f.switch_arm_values.iter().map(to_wit_arm_value).collect(),
454 external_refs: f.external_refs.clone(),
455 is_delegating: f.is_delegating,
456 is_exported: f.is_exported,
457 comment_lines: f.comment_lines as u32,
458 referenced_fields: f.referenced_fields.clone(),
459 null_check_fields: f.null_check_fields.clone(),
460 switch_dispatch_target: f.switch_dispatch_target.clone(),
461 optional_param_count: f.optional_param_count as u32,
462 called_functions: f.called_functions.clone(),
463 cognitive_complexity: f.cognitive_complexity as u32,
464 body_hash: f.body_hash.map(|h| format!("{h:016x}")),
465 return_type: f.return_type.as_ref().map(to_wit_type_ref),
466 })
467}
468
469fn to_wit_arm_value(v: &ArmValue) -> wit::ArmValue {
470 match v {
471 ArmValue::Str(s) => wit::ArmValue::StrLit(s.clone()),
472 ArmValue::Int(i) => wit::ArmValue::IntLit(*i),
473 ArmValue::Char(c) => wit::ArmValue::CharLit(*c as u32),
474 ArmValue::Other => wit::ArmValue::Other,
475 }
476}
477
478fn to_wit_type_ref(t: &crate::model::TypeRef) -> wit::TypeRef {
479 wit::TypeRef {
480 name: t.name.clone(),
481 raw: t.raw.clone(),
482 origin: match &t.origin {
483 crate::model::TypeOrigin::Local => wit::TypeOrigin::ProjectLocal,
484 crate::model::TypeOrigin::External(m) => wit::TypeOrigin::External(m.clone()),
485 crate::model::TypeOrigin::Primitive => wit::TypeOrigin::Primitive,
486 crate::model::TypeOrigin::Unknown => wit::TypeOrigin::Unknown,
487 },
488 }
489}
490
491fn convert_classes(classes: &[crate::model::ClassInfo]) -> Vec<wit::ClassInfo> {
492 convert_slice(classes, |c| wit::ClassInfo {
493 name: c.name.clone(),
494 start_line: c.start_line as u32,
495 end_line: c.end_line as u32,
496 name_col: c.name_col as u32,
497 name_end_col: c.name_end_col as u32,
498 method_count: c.method_count as u32,
499 line_count: c.line_count as u32,
500 delegating_method_count: c.delegating_method_count as u32,
501 field_count: c.field_count as u32,
502 field_names: c.field_names.clone(),
503 field_types: c.field_types.clone(),
504 is_exported: c.is_exported,
505 has_behavior: c.has_behavior,
506 is_interface: c.is_interface,
507 parent_name: c.parent_name.clone(),
508 override_count: c.override_count as u32,
509 self_call_count: c.self_call_count as u32,
510 has_listener_field: c.has_listener_field,
511 has_notify_method: c.has_notify_method,
512 })
513}
514
515fn convert_imports(imports: &[crate::model::ImportInfo]) -> Vec<wit::ImportInfo> {
516 convert_slice(imports, |i| wit::ImportInfo {
517 source: i.source.clone(),
518 line: i.line as u32,
519 col: i.col as u32,
520 is_module_decl: i.is_module_decl,
521 })
522}
523
524fn convert_comments(comments: &[crate::model::CommentInfo]) -> Vec<wit::CommentInfo> {
525 convert_slice(comments, |c| wit::CommentInfo {
526 text: c.text.clone(),
527 line: c.line as u32,
528 })
529}
530
531fn from_wit_finding(f: wit::Finding) -> Finding {
532 Finding {
533 smell_name: f.smell_name,
534 category: convert_category(f.category),
535 severity: convert_severity(f.severity),
536 location: Location {
537 path: PathBuf::from(&f.location.path),
538 start_line: f.location.start_line as usize,
539 start_col: f.location.start_col as usize,
540 end_line: f.location.end_line as usize,
541 end_col: f.location.end_col as usize,
542 name: f.location.name,
543 },
544 message: f.message,
545 suggested_refactorings: f.suggested_refactorings,
546 actual_value: f.actual_value,
547 threshold: f.threshold,
548 risk_score: None,
549 }
550}
551
552fn convert_severity(s: wit::Severity) -> Severity {
553 match s {
554 wit::Severity::Hint => Severity::Hint,
555 wit::Severity::Warning => Severity::Warning,
556 wit::Severity::Error => Severity::Error,
557 }
558}
559
560fn convert_category(c: wit::SmellCategory) -> SmellCategory {
561 match c {
562 wit::SmellCategory::Bloaters => SmellCategory::Bloaters,
563 wit::SmellCategory::OoAbusers => SmellCategory::OoAbusers,
564 wit::SmellCategory::ChangePreventers => SmellCategory::ChangePreventers,
565 wit::SmellCategory::Dispensables => SmellCategory::Dispensables,
566 wit::SmellCategory::Couplers => SmellCategory::Couplers,
567 wit::SmellCategory::Security => SmellCategory::Security,
568 }
569}
570
571pub fn load_wasm_plugins(project_dir: &Path) -> Vec<WasmPlugin> {
573 let mut plugins: Vec<WasmPlugin> = Vec::new();
574 let mut seen = HashMap::new();
575
576 let project_plugins = project_dir.join(".cha").join("plugins");
577 let global_plugins = home_dir().join(".cha").join("plugins");
578
579 for dir in [&project_plugins, &global_plugins] {
580 load_plugins_from_dir(dir, &mut seen, &mut plugins);
581 }
582
583 plugins
584}
585
586fn load_plugins_from_dir(
588 dir: &Path,
589 seen: &mut HashMap<String, bool>,
590 plugins: &mut Vec<WasmPlugin>,
591) {
592 let entries = match std::fs::read_dir(dir) {
593 Ok(e) => e,
594 Err(_) => return,
595 };
596 for entry in entries.flatten() {
597 let path = entry.path();
598 if path.extension().is_none_or(|e| e != "wasm") {
599 continue;
600 }
601 let filename = path.file_name().unwrap().to_string_lossy().to_string();
602 if seen.contains_key(&filename) {
603 continue;
604 }
605 match WasmPlugin::load(&path) {
606 Ok(p) => {
607 seen.insert(filename, true);
608 plugins.push(p);
609 }
610 Err(e) => {
611 eprintln!("failed to load wasm plugin {}: {}", path.display(), e);
612 }
613 }
614 }
615}
616
617fn home_dir() -> PathBuf {
618 std::env::var("HOME")
619 .map(PathBuf::from)
620 .unwrap_or_else(|_| PathBuf::from("."))
621}
622
623pub fn toml_to_option_value(v: &toml::Value) -> Option<wit::OptionValue> {
625 match v {
626 toml::Value::String(s) => Some(wit::OptionValue::Str(s.clone())),
627 toml::Value::Integer(i) => Some(wit::OptionValue::Int(*i)),
628 toml::Value::Float(f) => Some(wit::OptionValue::Float(*f)),
629 toml::Value::Boolean(b) => Some(wit::OptionValue::Boolean(*b)),
630 toml::Value::Array(arr) => {
631 let strs: Vec<String> = arr
632 .iter()
633 .filter_map(|v| v.as_str().map(String::from))
634 .collect();
635 Some(wit::OptionValue::ListStr(strs))
636 }
637 _ => None,
638 }
639}