插件开发指南

Cha 是代码坏味道(code smell)检测工具。除了 34 个内置检测器,还可以装第三方插件——一个 WASM 模块,host 把每个文件解析后丢给你的 analyze(),你返回若干 Finding(检测到的坏味道),host 汇合所有 finding 输出给用户。

这一页讲怎么从零写、编译、装、测一个这样的插件。

前置

  • Rust 工具链 + wasm32-wasip1 target:

    rustup target add wasm32-wasip1
    
  • cha CLI 装好且在 $PATH

Quick Start

mkdir my-plugin && cd my-plugin
cha plugin new my-plugin
cha plugin build           # 产出 my_plugin.wasm
cha plugin install my_plugin.wasm
cha analyze src/

包名是 my-plugin,编译产物是 my_plugin.wasm——Cargo 把 - 转成 _。后面 cha analyze 自动加载装好的所有插件。

脚手架

cha plugin new <名字> 生成:

my-plugin/
  Cargo.toml   # cdylib + cha-plugin-sdk + wit-bindgen 依赖
  src/
    lib.rs     # plugin! 宏 + 一个 PluginImpl 最简实现

当前目录空就在原地生成;不空就建一个 <名字>/ 子目录。

插件结构

#![allow(unused)]
fn main() {
cha_plugin_sdk::plugin!(MyPlugin);

struct MyPlugin;

impl PluginImpl for MyPlugin {
    fn name() -> String { "my-plugin".into() }
    fn smells() -> Vec<String> { vec!["my_smell".into()] }
    fn analyze(input: AnalysisInput) -> Vec<Finding> { vec![] }
}
}

plugin! 宏会替你接好 host 跟插件之间的通信、把下面要用到的所有类型 import 进作用域。你只用关心 PluginImpl trait 怎么实现。

类型清单

plugin!(MyPlugin) 之后,下面这些类型自动在作用域里。PluginImpl 是你要实现的 trait。

类型说明
AnalysisInputanalyze() 拿到的完整文件上下文
Finding一条 finding
FunctionInfo单个函数的信息(名字、行号、复杂度、参数等)
ClassInfo单个类的信息(方法数、字段、是否导出等)
ImportInfoimport 来源 + 行号 + 是否是模块声明
CommentInfo注释文本 + 行号
ArmValueswitch / match 分支的字面值(StrLit / IntLit / CharLit / Other
FileRoleSource / Test / Doc / Config / Generated
Location文件路径 + 行列范围
SeverityHint / Warning / Error
SmellCategoryBloaters / Couplers / Dispensables / ...
OptionValue配置值类型:Str / Int / Float / Boolean / ListStr
tree_queryAST query 模块(见下)
project_query跨文件查询模块(见下)

AnalysisInput 字段

#![allow(unused)]
fn main() {
pub struct AnalysisInput {
    pub path: String,             // 文件路径
    pub content: String,          // 源码原文
    pub language: String,         // "typescript" | "rust" | "python" | "go" | "c" | "cpp"
    pub total_lines: u32,
    pub role: FileRole,           // Source / Test / Doc / Config / Generated
    pub functions: Vec<FunctionInfo>,
    pub classes: Vec<ClassInfo>,
    pub imports: Vec<ImportInfo>,
    pub comments: Vec<CommentInfo>,
    pub type_aliases: Vec<(String, String)>,
    pub options: Vec<(String, OptionValue)>,  // 来自 .cha.toml
}
}

Warning:WASM 插件跑在沙箱里,没有文件系统权限。读源码用 input.content不要std::fs::read_to_string(&input.path)——会静默返回空字符串。

文件角色

role 字段告诉你正在分析的是什么类型的文件。利用它给不同文件套不同规则:

#![allow(unused)]
fn main() {
fn analyze(input: AnalysisInput) -> Vec<Finding> {
    if input.role == FileRole::Test {
        return vec![];  // 测试文件跳过
    }
    // ...
}
}

Declaring smells

每个 Finding 都带一个 smell_name。在 smells() 里把全部 smell 名声明出来,host 就能:

  • cha plugin list 里展示你这个插件能出哪些 smell
  • 让用户在 .cha.toml 里写 disabled_smells = ["你的_smell"] 来禁用某条
  • 把禁用名单回传给你的插件,你早点跳过这部分计算

input.options 里有个特殊 key __disabled_smells__ 装着用户禁用的 smell 名单。提前跳过:

#![allow(unused)]
fn main() {
use cha_plugin_sdk::is_smell_disabled;

fn analyze(input: AnalysisInput) -> Vec<Finding> {
    let mut out = Vec::new();
    if !is_smell_disabled!(&input.options, "my_smell") {
        // 没被禁的时候才算
    }
    out
}
}

is_smell_disabled! 是个宏(注意感叹号)。它返回 bool

host 也会事后再过滤一遍 finding,所以忘调 is_smell_disabled! 不会让被禁的 smell 漏到用户输出——只是白算一遍。

AST Query API(tree_query

插件可以通过 host 回调跑 tree-sitter query,查当前文件的 AST:

#![allow(unused)]
fn main() {
fn analyze(input: AnalysisInput) -> Vec<Finding> {
    // 找文件里所有 unsafe 块
    // 返回 Vec<Vec<QueryMatch>> —— 外层每个 match 一项,内层每个 capture 一项
    let matches: Vec<Vec<QueryMatch>> = tree_query::run_query("(unsafe_block) @blk");
    for m in &matches {
        for capture in m {
            // capture.node_kind / capture.text / capture.start_line ...
        }
    }

    // 一次跑多个 query(减少 host 边界穿越开销)
    // 返回 Vec<Vec<Vec<QueryMatch>>>,每个 pattern 一项,顺序跟入参一致
    let results: Vec<Vec<Vec<QueryMatch>>> = tree_query::run_queries(&[
        "(if_statement) @if".into(),
        "(for_statement) @for".into(),
    ]);

    // 拿指定位置的 AST 节点。返回 Option<QueryMatch>。
    // 行 1-based,列 0-based。
    if let Some(node) = tree_query::node_at(10, 4) {
        // node.node_kind, node.text, ...
    }

    // 拿一段行范围内的所有命名顶层节点。返回 Vec<QueryMatch>。
    let nodes: Vec<QueryMatch> = tree_query::nodes_in_range(1, 50);

    vec![]
}
}

Query pattern 用 tree-sitter 的 S 表达式 query 语言。重复跑同一个 pattern 没额外开销。

每个 QueryMatch 包含:

  • capture_name —— pattern 里的 @名字
  • node_kind —— tree-sitter 节点类型(比如 "function_definition"
  • text —— 匹配到的源码原文
  • start_line / end_line —— 1-based 行号(跟 FunctionInfo.start_line / ClassInfo.start_line 一致)
  • start_col / end_col —— 0-based 字节列

Note:SDK 里所有行号都是 1-based,列号都是 0-based 字节偏移。

Project Query API(project_query

跨文件分析(调用方、类型来源、其他文件的函数体)通过 project_query host 函数:

调用图

#![allow(unused)]
fn main() {
// 这个函数有没有被本文件之外的人调过?
let unused = !project_query::is_called_externally(&fn_name, &input.path);

// 哪些文件引用了 `name`
let callers: Vec<String> = project_query::callers_of(&fn_name);

// 全项目跨文件调用计数:(caller_path, callee_path, count) 元组
let counts: Vec<(String, String, u32)> = project_query::cross_file_call_counts();
}

符号定义所在

#![allow(unused)]
fn main() {
// 这个函数 / 类首次声明在哪个文件
let f_home: Option<String> = project_query::function_home(&fn_name);
let c_home: Option<String> = project_query::class_home(&class_name);

// 这个函数名对应的 (文件, FunctionInfo) 元组
let f: Option<(String, FunctionInfo)> = project_query::function_by_name(&fn_name);

// 哪个函数声明覆盖了这个 (line, col)?
// 行 1-based,列 0-based。返回最内层匹配(行范围最小的那个)。
if let Some(host_fn) = project_query::function_at(&input.path, line, col) {
    // host_fn.start_line / host_fn.end_line 都是 1-based
}
}

类型来源 / 项目形态

#![allow(unused)]
fn main() {
// 项目里有没有声明这个名字
let is_local = project_query::is_project_type(&type_ref.name);

// 是不是真正的第三方依赖
// (External origin,且不是 stdlib,也不是 workspace 同级 crate)
let is_3p = project_query::is_third_party(&type_ref);

// Rust workspace 同级 crate 名(非 Rust workspace 时是空)
let siblings: Vec<String> = project_query::workspace_crate_names();

// 路径是否符合测试文件特征:__tests__/ / __mocks__/ / .test.ts / .spec.ts 等
if project_query::is_test_path(&input.path) { /* ... */ }

// 全项目分析过的文件总数
let n: u32 = project_query::file_count();
}

function_at 用来回答"这个位置(行列)归属于哪个函数声明"——配合 tree-query 用得多,query 命中一个位置之后想拿到包含它的函数。

FunctionInfo 字段

字段类型含义
nameString函数名
start_line / end_lineu32函数起止行(1-based)
name_col / name_end_colu32函数名标识符的起止列(0-based 字节列)
line_countu32函数体行数
complexityu32圈复杂度(cyclomatic complexity):1 + 分支点数
cognitive_complexityu32认知复杂度(SonarSource 2017):把嵌套深度也算进去的可读性指标
is_exportedbool是否对外暴露(pub / export
parameter_countu32参数个数
parameter_typesVec<TypeRef>参数类型,按声明顺序排列;每项是已经解析好的 TypeRef
parameter_namesVec<String>参数名,跟 parameter_types 一一对应;匿名参数(C void foo(int);)填空串
optional_param_countu32可选参数个数(驱动 Builder pattern 检测)
return_typeOption<TypeRef>声明的返回类型;没标注 / 推断不出时为 None
external_refsVec<String>函数体里引用的"外部对象的字段或方法"名集合(驱动 Feature Envy)
referenced_fieldsVec<String>函数体里访问到的本类字段名(驱动 Temporary Field)
null_check_fieldsVec<String>函数体里做 null/None 判空的字段名(驱动 Null Object pattern)
called_functionsVec<String>函数体里调用到的函数 / 方法名(喂给项目级调用图)
chain_depthu32函数体内方法链的最大长度(驱动 Message Chains,比如 a.b.c.d = 4)
switch_armsu32函数体里 switch / match 分支的总条数
switch_arm_valuesVec<ArmValue>每个 switch / match 分支的字面量值,按源码顺序排;驱动 stringly_typed_dispatch(≥3 条全是字符串字面量)这类基于值的检测
switch_dispatch_targetOption<String>switch / match 是基于哪个字段 / 变量在分发的(驱动 Strategy / State pattern)
is_delegatingbool这个函数是不是单纯转调另一个对象的方法(驱动 Middle Man)
comment_linesu32函数体内注释行数
body_hashOption<String>函数体 AST 结构哈希;驱动重复代码检测,结构等价但变量名不同也能匹中

ClassInfo 字段

字段类型含义
nameString类 / 结构体名
start_line / end_lineu32类起止行(1-based)
name_col / name_end_colu32类名标识符的起止列(0-based 字节列)
line_countu32类体行数
is_exportedbool是否对外暴露
is_interfacebool是不是接口 / 抽象类
has_behaviorbool类里有没有非访问器方法(也就是真业务逻辑);用来区分 Data Class
method_countu32方法总数
field_countu32字段总数
field_namesVec<String>类里声明的字段名
parent_nameOption<String>父类 / 父 trait 名(驱动 Refused Bequest)
override_countu32覆盖了父类多少个方法(驱动 Refused Bequest)
self_call_countu32类里最长的那个方法对自身其它方法的调用次数(驱动 Template Method)
has_listener_fieldbool类里有没有监听器 / 回调集合字段(驱动 Observer pattern 识别)
has_notify_methodbool类里有没有 notify / emit 类型的方法(同上)

读配置项

.cha.toml 里写的选项:

[plugins.my-plugin]
threshold = 10
label = "custom"
tags = ["a", "b"]

用 SDK 提供的取值宏:

#![allow(unused)]
fn main() {
use cha_plugin_sdk::{option_int, option_str, option_list_str};

let threshold = option_int!(&input.options, "threshold").unwrap_or(5);
let label     = option_str!(&input.options, "label").unwrap_or("default");
let tags      = option_list_str!(&input.options, "tags").unwrap_or(&[]);
}

可用的宏:option_str! / option_int! / option_float! / option_bool! / option_list_str! / str_options!

编译

cha plugin build

它跑 cargo build --target wasm32-wasip1 --release,再用内嵌的 WASI adapter 把输出转成 WASM Component。结果是当前目录下的 <名字>.wasm

Warning:发布时不要直接用 cargo build。Cargo 出来的原始 .wasm 是 core module,不是 component——Cha host 加载不了。cha plugin build 包了一层 component 编码(用 wasm-tools component new + WASI adapter)。

开发期间为了调试可以用 cargo build,但重新装之前要再跑一遍 cha plugin build,否则 host 拿到的还是上一版。

WASM 兼容性速查

插件跑在 wasm32-wasip1 + WASI Reactor adapter 里。一些 Rust crate 在这个环境就算能编也不能用:

Crate / API状态备注
regex❌ runtime panicRegex::new() 在当前 host 配置下会失败。改手写字符扫描——常见模式大概 50 LOC,更安全
std::time::SystemTime::now()❌ 不可靠 / panicWASI clock 各 host 不一致。要"今天的日期"就在 .cha.toml 加一个 today 选项
serde_json✅ 能用体积大,但没坑
tree-sitter(Rust crate 本身)❌ 别用插件已经在 WASM 里跑了;要 query 调 host 的 tree_query
文件系统❌ 沙箱关std::fs::read_to_string(&input.path) 返回空。读源码用 input.content
git / 网络❌ 沙箱关没子进程、没 socket

不确定的时候:依赖尽量精简、小模式手写不引 crate、外部状态(时间 / 配置)通过插件选项传进来。

安装

cha plugin install my_plugin.wasm        # 项目级:.cha/plugins/
cp my_plugin.wasm ~/.cha/plugins/        # 全局

每次 cha analyze 都会从这两个位置加载所有 .wasm 插件。

列出 / 卸载

cha plugin list                  # 显示已装插件 + 各自的 smell 名单
cha plugin remove my_plugin      # 用 .wasm 文件名(不带 .wasm 也行)

配置

插件装好就默认启用。在 .cha.toml 里关闭或调参:

[plugins.my-plugin]
enabled = false       # 或者保留默认 true,下面这一项是给插件传配置
threshold = 20

section 名要跟 name() 返回的字符串一致。

测试

Cargo.toml 里加:

[dev-dependencies]
cha-plugin-sdk = { git = "https://github.com/W-Mai/Cha", features = ["test-utils"] }

test-utils feature 没默认开,所以 dev-dependencies 单独写一行带 features 的引用。SDK 还没在 crates.io 上发布,所以走 git

写测试——source(language, code) 给测试一个虚拟源文件:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use cha_plugin_sdk::test_utils::WasmPluginTest;

    #[test]
    fn detects_issue() {
        WasmPluginTest::new()
            .source("typescript", "function todo_fix() {}")
            .assert_finding("my_smell_name");
    }

    #[test]
    fn no_false_positive() {
        WasmPluginTest::new()
            .source("typescript", "function processData() {}")
            .assert_no_finding();
    }

    #[test]
    fn respects_options() {
        WasmPluginTest::new()
            .source("typescript", r#"fetch("https://example.com");"#)
            .option("DOMAIN", "example.com")
            .assert_finding("hardcoded_string");
    }

    #[test]
    fn list_options_work() {
        WasmPluginTest::new()
            .source("typescript", "// REVIEW: needs second look")
            .option_list("extra_tags", &["REVIEW"])
            .assert_finding("extended_todo_tag");
    }
}
}

可用的选项设置:

  • .option(key, value) —— 字符串
  • .option_list(key, &[values]) —— 字符串列表
  • .option_bool(key, true_or_false)
  • .option_int(key, integer)
  • .option_float(key, float)

跑:

cha plugin build
cargo test

cargo test 时如果 .wasm 不存在,WasmPluginTest 会自动跑一次 cha plugin build

断言 API

方法作用
.assert_any_finding()至少一条 finding
.assert_no_finding()没有任何 finding
.assert_finding("name")至少一条命中指定 smell name 的 finding
.assert_no_finding_named("name")没有命中指定 smell name 的 finding
.findings()返回 Vec<Finding>,给自定义断言用

示例插件

仓库 examples/ 下有 4 个端到端示例:

WIT 接口

想看 host 跟插件之间的契约长啥样,完整 WIT 在 wit/plugin.wit

world analyzer {
    use types.{analysis-input, finding};

    import tree-query;
    import project-query;

    export name: func() -> string;
    export version: func() -> string;       // 自动从 Cargo.toml 读
    export description: func() -> string;   // 自动从 Cargo.toml 读
    export authors: func() -> list<string>; // 自动从 Cargo.toml 读
    export smells: func() -> list<string>;  // 来自 PluginImpl::smells(默认空)
    export analyze: func(input: analysis-input) -> list<finding>;
}