diff --git a/Cargo.lock b/Cargo.lock index 049f28038..e6ac1380e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "candid-checker" +version = "0.1.0" +dependencies = [ + "anyhow", + "candid_parser 0.2.0-beta.1", + "clap", + "codespan-reporting", + "proc-macro2", + "syn 2.0.60", +] + [[package]] name = "candid-extractor" version = "0.1.3" @@ -284,12 +296,34 @@ dependencies = [ "hex", "lalrpop", "lalrpop-util", - "logos", + "logos 0.13.0", "num-bigint", "pretty", "thiserror", ] +[[package]] +name = "candid_parser" +version = "0.2.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d55a6757ccf6afca45b37eacd5ed554287e42842a724b5d86a96db1809d802" +dependencies = [ + "anyhow", + "candid", + "codespan-reporting", + "convert_case", + "handlebars", + "hex", + "lalrpop", + "lalrpop-util", + "logos 0.14.0", + "num-bigint", + "pretty", + "serde", + "thiserror", + "toml", +] + [[package]] name = "cargo-platform" version = "0.1.8" @@ -889,6 +923,20 @@ dependencies = [ "crunchy", ] +[[package]] +name = "handlebars" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -945,7 +993,7 @@ name = "ic-cdk-bindgen" version = "0.1.3" dependencies = [ "candid", - "candid_parser", + "candid_parser 0.1.4", "convert_case", "pretty", ] @@ -1209,7 +1257,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c000ca4d908ff18ac99b93a062cb8958d331c3220719c52e77cb19cc6ac5d2c1" dependencies = [ - "logos-derive", + "logos-derive 0.13.0", +] + +[[package]] +name = "logos" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161971eb88a0da7ae0c333e1063467c5b5727e7fb6b710b8db4814eade3a42e8" +dependencies = [ + "logos-derive 0.14.0", ] [[package]] @@ -1226,13 +1283,37 @@ dependencies = [ "syn 2.0.60", ] +[[package]] +name = "logos-codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e31badd9de5131fdf4921f6473d457e3dd85b11b7f091ceb50e4df7c3eeb12a" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax 0.8.3", + "syn 2.0.60", +] + [[package]] name = "logos-derive" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbfc0d229f1f42d790440136d941afd806bc9e949e2bcb8faa813b0f00d1267e" dependencies = [ - "logos-codegen", + "logos-codegen 0.13.0", +] + +[[package]] +name = "logos-derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2a69b3eb68d5bd595107c9ee58d7e07fe2bb5e360cc85b0f084dedac80de0a" +dependencies = [ + "logos-codegen 0.14.0", ] [[package]] @@ -1351,6 +1432,51 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "pest" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.60", +] + +[[package]] +name = "pest_meta" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.6.4" @@ -1909,6 +2035,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/src/candid-checker/Cargo.toml b/src/candid-checker/Cargo.toml new file mode 100644 index 000000000..96b8b8883 --- /dev/null +++ b/src/candid-checker/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "candid-checker" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +license.workspace = true +description = "CLI tool to check if Rust code implements a given did file" + +[[bin]] +name = "candid-checker" +path = "src/main.rs" +required-features = ["exe"] + +[dependencies] +syn = { version = "2.0", features = ["full", "visit", "extra-traits"] } +proc-macro2 = { version = "1.0", features = ["span-locations"] } +candid_parser = "0.2.0-beta.1" +codespan-reporting = "0.11" + +clap = { version = "4", features = ["derive"], optional = true } +anyhow = { version = "1.0", optional = true } + +[features] +exe = ["dep:clap", "dep:anyhow"] + diff --git a/src/candid-checker/src/lib.rs b/src/candid-checker/src/lib.rs new file mode 100644 index 000000000..338d29cb7 --- /dev/null +++ b/src/candid-checker/src/lib.rs @@ -0,0 +1,411 @@ +use candid_parser::bindings::rust::{emit_bindgen, Config, Method, Output}; +use candid_parser::configs::Configs; +use candid_parser::{utils::CandidSource, Result}; +use codespan_reporting::{ + diagnostic::{Diagnostic, Label}, + files::SimpleFile, + term::{self, termcolor::StandardStream}, +}; +use std::collections::BTreeMap; +use std::fs; +use std::ops::Range; +use std::path::{Path, PathBuf}; +use syn::spanned::Spanned; +use syn::{Attribute, Expr, ExprLit, FnArg, Lit, Meta, ReturnType, Signature}; + +pub fn check_rust(rust: &Path, candid: &Path, config: &Option) -> Result<()> { + let candid = CandidSource::File(candid); + let (env, actor) = candid.load()?; + let config = if let Some(config) = config { + fs::read_to_string(config)? + } else { + "".to_string() + }; + let config: Configs = config.parse()?; + let config = Config::new(config); + let (output, unused) = emit_bindgen(&config, &env, &actor); + report_unused(unused); + let name = rust.file_name().unwrap().to_str().unwrap(); + let source = fs::read_to_string(rust)?; + let rust = get_endpoint_from_rust_source(&source); + let diags = diff_did_and_rust(&output, &rust); + report_errors(name, &source, &diags); + Ok(()) +} +fn report_unused(unused: Vec) { + if !unused.is_empty() { + let unused = unused + .iter() + .map(|x| format!("{x} is not used")) + .collect::>(); + let diag = Diagnostic::warning() + .with_message("Unused paths from the config file") + .with_notes(unused); + report_errors("config", "", &[diag]); + } +} +fn report_errors(name: &str, source: &str, diags: &[Diagnostic<()>]) { + let writer = StandardStream::stderr(term::termcolor::ColorChoice::Auto); + let config = term::Config::default(); + let file = SimpleFile::new(name, source); + for diag in diags { + term::emit(&mut writer.lock(), &config, &file, diag).unwrap(); + } +} +fn get_endpoint_from_rust_source(source: &str) -> Vec { + use syn::visit::{self, Visit}; + use syn::{ImplItemFn, ItemFn}; + struct FnVisitor(Vec); + impl<'ast> Visit<'ast> for FnVisitor { + fn visit_item_fn(&mut self, node: &'ast ItemFn) { + if let Some(m) = get_cdk_function(&node.attrs, &node.sig) { + self.0.push(m); + } + // handle nested functions + visit::visit_item_fn(self, node); + } + fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) { + if let Some(m) = get_cdk_function(&node.attrs, &node.sig) { + self.0.push(m); + } + // handle nested functions + visit::visit_impl_item_fn(self, node); + } + } + let ast = syn::parse_file(source).unwrap(); + let mut visitor = FnVisitor(Vec::new()); + visitor.visit_file(&ast); + visitor.0 +} +fn diff_did_and_rust(candid: &Output, rust_list: &[CDKMethod]) -> Vec> { + let mut res = Vec::new(); + let mut ids: BTreeMap<_, _> = rust_list + .iter() + .map(|m| (m.func_name.to_string(), m)) + .collect(); + let rust: BTreeMap<_, _> = rust_list + .iter() + .map(|m| { + let name = m + .export_name + .as_ref() + .map(|x| x.0.clone()) + .unwrap_or(m.func_name.to_string()); + (name, m) + }) + .collect(); + for m in &candid.methods { + let diag = + Diagnostic::error().with_message(format!("Checking Candid method {}", m.original_name)); + let mut labels = Vec::new(); + let mut notes = Vec::new(); + if let Some(func) = rust.get(&m.original_name) { + ids.remove(&func.func_name.to_string()); + // check function name + if func.func_name != m.name { + labels.push( + Label::primary((), func.func_name.span().byte_range()) + .with_message(format!("Expect function name: {}", m.name)), + ); + } + // check mode + let mode = if m.mode == "update" { + "update" + } else { + "query" + }; + if func.mode != mode { + labels.push( + Label::primary((), func.mode.span().byte_range()) + .with_message(format!("Expect mode: {}", mode)), + ); + } + if m.mode == "composite_query" && func.composite.is_none() { + labels.push( + Label::primary((), func.mode.span().byte_range()) + .with_message("Expect attribute: composite = true"), + ); + } + // check rename attribute + if m.original_name != m.name && func.export_name.is_none() { + // no need to check func.export_name != m.original_name, since we already found the function + labels.push( + Label::primary((), func.mode.span().byte_range()).with_message(format!( + "Expect attribute: name = \"{}\"", + m.original_name.escape_debug() + )), + ); + } + // check args + let args = m.args.iter().map(|x| x.1.clone()).collect::>(); + let (mut labs, need_pp) = check_args(&func.args, &args, &func.args_span); + if need_pp { + let pp = pp_args(&m.args); + labs.push( + Label::secondary((), func.args_span.clone()) + .with_message(format!("Suggestion: {}", pp)), + ); + } + labels.extend(labs); + let (mut labs, need_pp) = check_args(&func.rets, &m.rets, &func.rets_span); + if need_pp { + let mut pp = pp_rets(&m.rets); + if pp.is_empty() { + pp = "remove the return type".to_string(); + } + labs.push( + Label::secondary((), func.rets_span.clone()) + .with_message(format!("Suggestion: {}", pp)), + ); + } + labels.extend(labs); + if !labels.is_empty() { + labels.push(Label::secondary((), func.fn_span.clone())); + } + } else { + if let Some(func) = ids.remove(&m.original_name) { + let (_, meta) = func.export_name.as_ref().unwrap(); + labels.push( + Label::primary((), meta.span().byte_range()) + .with_message("You may want to remove the name attribute"), + ); + labels.push( + Label::secondary((), func.func_name.span().byte_range()) + .with_message("This function name matches the Candid method name"), + ); + } else { + notes.push(format!( + "Method \"{}\" is missing from Rust code. Use this signature to get started:\n{}", + m.original_name, pp_func(m), + )); + } + } + if labels.is_empty() && notes.is_empty() { + continue; + } + res.push(diag.with_labels(labels).with_notes(notes)); + } + if let Some(init_args) = &candid.init_args { + let diags = check_init_args(init_args, &mut ids); + res.extend(diags); + } + for (_, func) in ids { + let span = func.mode.span().byte_range().start..func.fn_span.end; + let label = Label::secondary((), span); + let diag = Diagnostic::warning() + .with_message(format!( + "Function {} doesn't appear in Candid file", + func.func_name + )) + .with_labels(vec![label]); + res.push(diag); + } + res +} +fn check_init_args( + candid: &[(String, String)], + rust: &mut BTreeMap, +) -> Vec> { + let diag = Diagnostic::error().with_message("Checking init args"); + let mut notes = Vec::new(); + let mut labels = Vec::new(); + if let Some((name, func)) = rust.iter().find(|(_, m)| m.mode == "init") { + let args = candid.iter().map(|x| x.1.clone()).collect::>(); + let (mut labs, need_pp) = check_args(&func.args, &args, &func.args_span); + if need_pp { + let pp = pp_args(candid); + labs.push( + Label::secondary((), func.args_span.clone()) + .with_message(format!("Suggestion: {}", pp)), + ); + } + labels.extend(labs); + rust.remove(&name.clone()); + } else { + notes.push(format!( + "Init args is missing from Rust code. Use this signature to get started:\n{}", + pp_init_args(candid) + )); + } + if notes.is_empty() && labels.is_empty() { + Vec::new() + } else { + vec![diag.with_notes(notes).with_labels(labels)] + } +} +fn check_args( + rust: &[syn::Type], + candid: &[String], + span: &Range, +) -> (Vec>, bool) { + let mut labels = Vec::new(); + if rust.len() != candid.len() { + labels.push(Label::primary((), span.clone()).with_message("Argument count mismatch")); + return (labels, true); + } + let args = rust.iter().zip(candid.iter()); + for (rust_arg, candid_arg) in args { + let parsed_candid_arg: syn::Type = syn::parse_str(candid_arg).unwrap(); + if parsed_candid_arg != *rust_arg { + labels.push( + Label::primary((), rust_arg.span().byte_range()) + .with_message(format!("Expect type: {}", candid_arg)), + ); + } + } + (labels, false) +} +fn pp_args(args: &[(String, String)]) -> String { + let body = args + .iter() + .map(|(id, ty)| format!("{id}: {ty}")) + .collect::>() + .join(", "); + format!("({body})") +} +fn pp_rets(rets: &[String]) -> String { + match rets.len() { + 0 => "".to_string(), + 1 => format!("-> {}", rets[0]), + _ => format!("-> ({})", rets.join(", ")), + } +} +fn pp_init_args(args: &[(String, String)]) -> String { + format!("#[init]\nfn init{}", pp_args(args)) +} +fn pp_attr(m: &Method) -> String { + let mode = if m.mode == "update" { + "update" + } else { + "query" + }; + let mut attr = Vec::new(); + if m.mode == "composite_query" { + attr.push("composite = true".to_string()); + } + if m.original_name != m.name { + attr.push(format!("name = \"{}\"", m.original_name.escape_debug())); + } + let attr = if attr.is_empty() { + String::new() + } else { + format!("({})", attr.join(", ")) + }; + format!("#[{mode}{attr}]") +} +fn pp_func(m: &Method) -> String { + format!( + "{}\nfn {}{} {}", + pp_attr(m), + m.name, + pp_args(&m.args), + pp_rets(&m.rets) + ) +} +struct CDKMethod { + func_name: syn::Ident, + export_name: Option<(String, syn::Meta)>, + composite: Option, + mode: syn::Ident, + args: Vec, + rets: Vec, + fn_span: Range, + args_span: Range, + rets_span: Range, +} +fn get_cdk_function(attrs: &[Attribute], sig: &Signature) -> Option { + use syn::parse::Parser; + let func_name = sig.ident.clone(); + let mut mode = None; + let mut export_name = None; + let mut composite = None; + let mut fn_span = None; + for attr in attrs { + let attr_name = &attr.meta.path().segments.last().unwrap().ident; + if attr_name != "update" && attr_name != "query" && attr_name != "init" { + continue; + } + mode = Some(attr_name.clone()); + fn_span = Some(sig.span().byte_range()); + if let Meta::List(list) = &attr.meta { + let nested = syn::punctuated::Punctuated::::parse_terminated + .parse2(list.tokens.clone()) + .unwrap(); + for meta in nested { + if let Meta::NameValue(ref m) = meta { + if m.path.is_ident("name") { + if let Expr::Lit(ExprLit { + lit: Lit::Str(name), + .. + }) = &m.value + { + export_name = Some((name.value(), meta)); + } + } else if m.path.is_ident("composite") { + if let Expr::Lit(ExprLit { + lit: Lit::Bool(b), .. + }) = &m.value + { + if b.value { + composite = Some(meta); + } + } + } + } + } + } + } + let args = sig + .inputs + .iter() + .filter_map(|arg| match arg { + FnArg::Receiver(_) => None, + FnArg::Typed(pat) => Some(*pat.ty.clone()), + }) + .collect(); + let rets = match &sig.output { + ReturnType::Default => Vec::new(), + ReturnType::Type(_, ty) => match ty.as_ref() { + syn::Type::Tuple(ty) => ty.elems.iter().map(|t| (*t).clone()).collect(), + _ => vec![*ty.clone()], + }, + }; + let args_span = sig.paren_token.span; + let args_span = args_span.open().byte_range().start..args_span.close().byte_range().end; + let mut rets_span = sig.output.span().byte_range(); + if rets_span.end == 0 { + rets_span = args_span.end..args_span.end; + } + mode.map(|mode| CDKMethod { + func_name, + export_name, + composite, + args, + rets, + mode, + fn_span: fn_span.unwrap(), + args_span, + rets_span, + }) +} +impl CDKMethod { + fn debug_print(&self, source: &str) { + println!("{} {}", self.func_name, self.mode); + if let Some((_, meta)) = &self.export_name { + let range = meta.span().byte_range(); + println!(" export {}", &source[range]); + } + if let Some(composite) = &self.composite { + let range = composite.span().byte_range(); + println!(" composite {}", &source[range]); + } + for arg in &self.args { + let range = arg.span().byte_range(); + println!(" arg {}", &source[range]); + } + for ret in &self.rets { + let range = ret.span().byte_range(); + println!(" ret {}", &source[range]); + } + } +} diff --git a/src/candid-checker/src/main.rs b/src/candid-checker/src/main.rs new file mode 100644 index 000000000..0b365302a --- /dev/null +++ b/src/candid-checker/src/main.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +use candid_checker::check_rust; + +#[derive(Parser)] +#[command(version, about)] +struct Opts { + /// Rust source file + rust: PathBuf, + /// Candid file + candid: PathBuf, + /// TOML config file + config: Option, +} + +fn main() -> Result<()> { + let opts = Opts::parse(); + check_rust(&opts.rust, &opts.candid, &opts.config)?; + Ok(()) +} diff --git a/src/candid-checker/test.did b/src/candid-checker/test.did new file mode 100644 index 000000000..160b6569d --- /dev/null +++ b/src/candid-checker/test.did @@ -0,0 +1,9 @@ +type my_type = principal; +type Profile = record { name: text; age: nat8; country: text }; +type List = opt record { head: blob; tail: List }; +service : (Profile) -> { + f:(my_type, vec Profile)->(List) composite_query; + g:() -> (); + inner:()->(); + missing:(int) -> (nat) query; +} diff --git a/src/candid-checker/test.rs b/src/candid-checker/test.rs new file mode 100644 index 000000000..ce7e54ca2 --- /dev/null +++ b/src/candid-checker/test.rs @@ -0,0 +1,35 @@ +// This is an experimental feature to generate Rust binding from Candid. +// You may want to manually adjust some of the types. +#![allow(dead_code, unused_imports)] +use candid::{self, CandidType, Deserialize, Principal}; + +pub type MyType = Principal; +#[derive(CandidType, Deserialize)] +pub struct Profile { pub age: u8, pub country: String, pub name: String } +#[derive(CandidType, Deserialize)] +pub struct ListInner { pub head: serde_bytes::ByteBuf, pub tail: Box } +#[derive(CandidType, Deserialize)] +pub struct List(Option); + +#[ic_cdk::update] +#[candid_method(update)] +pub async fn f(test: MyType, argument: Vec/* whatever */) -> (List, u8) { +} + +fn not_candid() {} + +#[init] +fn take_init() {} + +mod A { + #[query(composite = true)] + async fn inner(a: List) -> Result {} +} + +#[::ic_cdk::query(name="test")] +fn g() -> () { +} +impl T { + #[query] + fn h(&mut self) -> (u8,u16) {} +} diff --git a/src/candid-checker/test.toml b/src/candid-checker/test.toml new file mode 100644 index 000000000..4995d7274 --- /dev/null +++ b/src/candid-checker/test.toml @@ -0,0 +1,3 @@ +f.name = "FFF" +my_type.name = "CanisterId2" +