#![allow(clippy::style)]
#[cfg(test)]
mod tests;
mod unbox;
use convert_case::Casing;
use inflector::Inflector;
use once_cell::sync::Lazy;
use quote::{format_ident, quote, quote_spanned, ToTokens};
use regex::Regex;
use serde::Deserialize;
use serde_tokenstream::{from_tokenstream, Error};
use syn::{
parse::{Parse, ParseStream},
Attribute, Signature, Visibility,
};
use unbox::unbox;
#[derive(Deserialize, Debug)]
struct StdlibMetadata {
name: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
unpublished: bool,
#[serde(default)]
deprecated: bool,
}
#[proc_macro_attribute]
pub fn stdlib(attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream {
do_output(do_stdlib(attr.into(), item.into()))
}
fn do_stdlib(
attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
let metadata = from_tokenstream(&attr)?;
do_stdlib_inner(metadata, attr, item)
}
fn do_output(res: Result<(proc_macro2::TokenStream, Vec<Error>), Error>) -> proc_macro::TokenStream {
match res {
Err(err) => err.to_compile_error().into(),
Ok((stdlib_docs, errors)) => {
let compiler_errors = errors.iter().map(|err| err.to_compile_error());
let output = quote! {
#stdlib_docs
#( #compiler_errors )*
};
output.into()
}
}
}
fn do_stdlib_inner(
metadata: StdlibMetadata,
_attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> Result<(proc_macro2::TokenStream, Vec<Error>), Error> {
let ast: ItemFnForSignature = syn::parse2(item.clone())?;
let mut errors = Vec::new();
if ast.sig.constness.is_some() {
errors.push(Error::new_spanned(
&ast.sig.constness,
"stdlib functions may not be const functions",
));
}
if ast.sig.unsafety.is_some() {
errors.push(Error::new_spanned(
&ast.sig.unsafety,
"stdlib functions may not be unsafe",
));
}
if ast.sig.abi.is_some() {
errors.push(Error::new_spanned(
&ast.sig.abi,
"stdlib functions may not use an alternate ABI",
));
}
if !ast.sig.generics.params.is_empty() {
errors.push(Error::new_spanned(
&ast.sig.generics,
"generics are not permitted for stdlib functions",
));
}
if ast.sig.variadic.is_some() {
errors.push(Error::new_spanned(&ast.sig.variadic, "no language C here"));
}
let name = metadata.name;
let whitelist = [
"patternLinear3d",
"patternLinear2d",
"patternCircular3d",
"patternCircular2d",
];
if !name.is_camel_case() && !whitelist.contains(&name.as_str()) {
errors.push(Error::new_spanned(
&ast.sig.ident,
format!("stdlib function names must be in camel case: `{}`", name),
));
}
let name_ident = format_ident!("{}", name.to_case(convert_case::Case::UpperCamel));
let name_str = name.to_string();
let fn_name = &ast.sig.ident;
let fn_name_str = fn_name.to_string().replace("inner_", "");
let fn_name_ident = format_ident!("{}", fn_name_str);
let boxed_fn_name_ident = format_ident!("boxed_{}", fn_name_str);
let _visibility = &ast.vis;
let doc_info = extract_doc_from_attrs(&ast.attrs);
let comment_text = {
let mut buf = String::new();
buf.push_str("Std lib function: ");
buf.push_str(&name_str);
if let Some(s) = &doc_info.summary {
buf.push_str("\n");
buf.push_str(&s);
}
if let Some(s) = &doc_info.description {
buf.push_str("\n");
buf.push_str(&s);
}
buf
};
let description_doc_comment = quote! {
#[doc = #comment_text]
};
let summary = if let Some(summary) = doc_info.summary {
quote! { #summary }
} else {
quote! { "" }
};
let description = if let Some(description) = doc_info.description {
quote! { #description }
} else {
quote! { "" }
};
let cb = doc_info.code_blocks.clone();
let code_blocks = if !cb.is_empty() {
quote! {
let code_blocks = vec![#(#cb),*];
code_blocks.iter().map(|cb| {
let tokens = crate::token::lexer(cb);
let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap();
let mut options: crate::ast::types::FormatOptions = Default::default();
options.insert_final_newline = false;
program.recast(&options, 0)
}).collect::<Vec<String>>()
}
} else {
errors.push(Error::new_spanned(
&ast.sig,
"stdlib functions must have at least one code block",
));
quote! { vec![] }
};
for code_block in doc_info.code_blocks.iter() {
if !code_block.contains(&name) {
errors.push(Error::new_spanned(
&ast.sig,
format!(
"stdlib functions must have the function name `{}` in the code block",
name
),
));
}
}
let test_code_blocks = doc_info
.code_blocks
.iter()
.enumerate()
.map(|(index, code_block)| generate_code_block_test(&fn_name_str, code_block, index, &metadata.tags))
.collect::<Vec<_>>();
let tags = metadata
.tags
.iter()
.map(|tag| {
quote! { #tag.to_string() }
})
.collect::<Vec<_>>();
let deprecated = if metadata.deprecated {
quote! { true }
} else {
quote! { false }
};
let unpublished = if metadata.unpublished {
quote! { true }
} else {
quote! { false }
};
let docs_crate = get_crate(None);
let mut arg_types = Vec::new();
for arg in ast.sig.inputs.iter() {
let arg_name = match arg {
syn::FnArg::Receiver(pat) => {
let span = pat.self_token.span.unwrap();
span.source_text().unwrap().to_string()
}
syn::FnArg::Typed(pat) => match &*pat.pat {
syn::Pat::Ident(ident) => ident.ident.to_string(),
_ => {
errors.push(Error::new_spanned(
&pat.pat,
"stdlib functions may not use destructuring patterns",
));
continue;
}
},
}
.trim_start_matches('_')
.to_string();
let ty = match arg {
syn::FnArg::Receiver(pat) => pat.ty.as_ref().into_token_stream(),
syn::FnArg::Typed(pat) => pat.ty.as_ref().into_token_stream(),
};
let (ty_string, ty_ident) = clean_ty_string(ty.to_string().as_str());
let ty_string = rust_type_to_openapi_type(&ty_string);
let required = !ty_ident.to_string().starts_with("Option <");
if ty_string != "Args" {
let schema = if ty_ident.to_string().starts_with("Vec < ")
|| ty_ident.to_string().starts_with("Option <")
|| ty_ident.to_string().starts_with('[')
{
quote! {
<#ty_ident>::json_schema(&mut generator)
}
} else {
quote! {
#ty_ident::json_schema(&mut generator)
}
};
arg_types.push(quote! {
#docs_crate::StdLibFnArg {
name: #arg_name.to_string(),
type_: #ty_string.to_string(),
schema: #schema,
required: #required,
}
});
}
}
let return_type_inner = match &ast.sig.output {
syn::ReturnType::Default => quote! { () },
syn::ReturnType::Type(_, ty) => {
match &**ty {
syn::Type::Path(syn::TypePath { path, .. }) => {
let path = &path.segments;
if path.len() == 1 {
let seg = &path[0];
if seg.ident == "Result" {
if let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
args,
..
}) = &seg.arguments
{
if args.len() == 2 || args.len() == 1 {
let mut args = args.iter();
let ok = args.next().unwrap();
if let syn::GenericArgument::Type(ty) = ok {
let ty = unbox(ty.clone());
quote! { #ty }
} else {
quote! { () }
}
} else {
quote! { () }
}
} else {
quote! { () }
}
} else {
let ty = unbox(*ty.clone());
quote! { #ty }
}
} else {
quote! { () }
}
}
_ => {
quote! { () }
}
}
}
};
let ret_ty_string = return_type_inner.to_string().replace(' ', "");
let return_type = if !ret_ty_string.is_empty() || ret_ty_string != "()" {
let ret_ty_string = rust_type_to_openapi_type(&ret_ty_string);
quote! {
Some(#docs_crate::StdLibFnArg {
name: "".to_string(),
type_: #ret_ty_string.to_string(),
schema: <#return_type_inner>::json_schema(&mut generator),
required: true,
})
}
} else {
quote! {
None
}
};
let span = ast.sig.ident.span();
let const_struct = quote_spanned! {span=>
pub(crate) const #name_ident: #name_ident = #name_ident {};
};
let test_mod_name = format_ident!("test_examples_{}", fn_name_str);
let stream = quote! {
#[cfg(test)]
mod #test_mod_name {
#(#test_code_blocks)*
}
#[allow(non_camel_case_types, missing_docs)]
#description_doc_comment
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, schemars::JsonSchema, ts_rs::TS)]
#[ts(export)]
pub(crate) struct #name_ident {}
#[allow(non_upper_case_globals, missing_docs)]
#description_doc_comment
#const_struct
fn #boxed_fn_name_ident(
args: crate::std::Args,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = anyhow::Result<crate::executor::MemoryItem, crate::errors::KclError>>>,
> {
Box::pin(#fn_name_ident(args))
}
impl #docs_crate::StdLibFn for #name_ident
{
fn name(&self) -> String {
#name_str.to_string()
}
fn summary(&self) -> String {
#summary.to_string()
}
fn description(&self) -> String {
#description.to_string()
}
fn tags(&self) -> Vec<String> {
vec![#(#tags),*]
}
fn args(&self) -> Vec<#docs_crate::StdLibFnArg> {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
vec![#(#arg_types),*]
}
fn return_value(&self) -> Option<#docs_crate::StdLibFnArg> {
let mut settings = schemars::gen::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let mut generator = schemars::gen::SchemaGenerator::new(settings);
#return_type
}
fn unpublished(&self) -> bool {
#unpublished
}
fn deprecated(&self) -> bool {
#deprecated
}
fn examples(&self) -> Vec<String> {
#code_blocks
}
fn std_lib_fn(&self) -> crate::std::StdFn {
#boxed_fn_name_ident
}
fn clone_box(&self) -> Box<dyn #docs_crate::StdLibFn> {
Box::new(self.clone())
}
}
#item
};
if !errors.is_empty() {
errors.insert(0, Error::new_spanned(&ast.sig, ""));
}
Ok((stream, errors))
}
#[allow(dead_code)]
fn to_compile_errors(errors: Vec<syn::Error>) -> proc_macro2::TokenStream {
let compile_errors = errors.iter().map(syn::Error::to_compile_error);
quote!(#(#compile_errors)*)
}
fn get_crate(var: Option<String>) -> proc_macro2::TokenStream {
if let Some(s) = var {
if let Ok(ts) = syn::parse_str(s.as_str()) {
return ts;
}
}
quote!(crate::docs)
}
#[derive(Debug)]
struct DocInfo {
pub summary: Option<String>,
pub description: Option<String>,
pub code_blocks: Vec<String>,
}
fn extract_doc_from_attrs(attrs: &[syn::Attribute]) -> DocInfo {
let doc = syn::Ident::new("doc", proc_macro2::Span::call_site());
let mut code_blocks: Vec<String> = Vec::new();
let raw_lines = attrs.iter().flat_map(|attr| {
if let syn::Meta::NameValue(nv) = &attr.meta {
if nv.path.is_ident(&doc) {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s), ..
}) = &nv.value
{
return normalize_comment_string(s.value());
}
}
}
Vec::new()
});
let mut code_block: Option<String> = None;
let mut parsed_lines = Vec::new();
for line in raw_lines {
if line.starts_with("```") {
if let Some(ref inner_code_block) = code_block {
code_blocks.push(inner_code_block.trim().to_string());
code_block = None;
} else {
code_block = Some(String::new());
}
continue;
}
if let Some(ref mut code_block) = code_block {
code_block.push_str(&line);
code_block.push('\n');
} else {
parsed_lines.push(line);
}
}
let mut lines = Vec::new();
for line in parsed_lines {
if line.starts_with(" ") || line.starts_with('\t') {
if let Some(ref mut code_block) = code_block {
code_block.push_str(&line.trim_start_matches(" ").trim_start_matches('\t'));
code_block.push('\n');
} else {
code_block = Some(format!("{}\n", line));
}
} else {
if let Some(ref inner_code_block) = code_block {
code_blocks.push(inner_code_block.trim().to_string());
code_block = None;
}
lines.push(line);
}
}
let mut lines = lines.into_iter();
if let Some(code_block) = code_block {
code_blocks.push(code_block.trim().to_string());
}
let summary = loop {
match lines.next() {
Some(s) if s.is_empty() => (),
next => break next,
}
};
let first = loop {
match lines.next() {
Some(s) if s.is_empty() => (),
next => break next,
}
};
let (summary, description) = match (summary, first) {
(None, _) => (None, None),
(summary, None) => (summary, None),
(Some(summary), Some(first)) => (
Some(summary),
Some(
lines
.fold(first, |acc, comment| {
if acc.ends_with('-') || acc.ends_with('\n') || acc.is_empty() {
format!("{}{}", acc, comment)
} else if comment.is_empty() {
format!("{}\n", acc)
} else {
format!("{} {}", acc, comment)
}
})
.trim_end()
.to_string(),
),
),
};
DocInfo {
summary,
description,
code_blocks,
}
}
fn normalize_comment_string(s: String) -> Vec<String> {
s.split('\n')
.enumerate()
.map(|(idx, s)| {
let new = s
.chars()
.enumerate()
.flat_map(|(idx, c)| {
if idx == 0 {
if c == ' ' {
return None;
}
}
Some(c)
})
.collect::<String>()
.trim_end()
.to_string();
let s = new.as_str();
if idx == 0 {
s
} else {
s.strip_prefix("* ").unwrap_or_else(|| s.strip_prefix('*').unwrap_or(s))
}
.to_string()
})
.collect()
}
struct ItemFnForSignature {
pub attrs: Vec<Attribute>,
pub vis: Visibility,
pub sig: Signature,
pub _block: proc_macro2::TokenStream,
}
impl Parse for ItemFnForSignature {
fn parse(input: ParseStream) -> syn::parse::Result<Self> {
let attrs = input.call(Attribute::parse_outer)?;
let vis: Visibility = input.parse()?;
let sig: Signature = input.parse()?;
let block = input.parse()?;
Ok(ItemFnForSignature {
attrs,
vis,
sig,
_block: block,
})
}
}
fn clean_ty_string(t: &str) -> (String, proc_macro2::TokenStream) {
let mut ty_string = t.replace('&', "").replace("mut", "").replace(' ', "");
if ty_string.starts_with("Args") {
ty_string = "Args".to_string();
}
let ty_string = ty_string.trim().to_string();
let ty_ident = if ty_string.starts_with("Vec<") {
let ty_string = ty_string.trim_start_matches("Vec<").trim_end_matches('>');
let (_, ty_ident) = clean_ty_string(&ty_string);
quote! {
Vec<#ty_ident>
}
} else if ty_string.starts_with("kittycad::types::") {
let ty_string = ty_string.trim_start_matches("kittycad::types::").trim_end_matches('>');
let ty_ident = format_ident!("{}", ty_string);
quote! {
kittycad::types::#ty_ident
}
} else if ty_string.starts_with("Option<") {
let ty_string = ty_string.trim_start_matches("Option<").trim_end_matches('>');
let (_, ty_ident) = clean_ty_string(&ty_string);
quote! {
Option<#ty_ident>
}
} else if let Some((inner_array_type, num)) = parse_array_type(&ty_string) {
let ty_string = inner_array_type.to_owned();
let (_, ty_ident) = clean_ty_string(&ty_string);
quote! {
[#ty_ident; #num]
}
} else if ty_string.starts_with("Box<") {
let ty_string = ty_string.trim_start_matches("Box<").trim_end_matches('>');
let (_, ty_ident) = clean_ty_string(&ty_string);
quote! {
#ty_ident
}
} else {
let ty_ident = format_ident!("{}", ty_string);
quote! {
#ty_ident
}
};
(ty_string, ty_ident)
}
fn rust_type_to_openapi_type(t: &str) -> String {
let mut t = t.to_string();
if t.starts_with("Vec<") {
t = t.replace("Vec<", "[").replace('>', "]");
}
if t.starts_with("Box<") {
t = t.replace("Box<", "").replace('>', "");
}
if t.starts_with("Option<") {
t = t.replace("Option<", "").replace('>', "");
}
if let Some((inner_type, _length)) = parse_array_type(&t) {
t = format!("[{inner_type}]")
}
if t == "f64" {
return "number".to_string();
} else if t == "str" {
return "string".to_string();
} else {
return t.replace("f64", "number").to_string();
}
}
fn parse_array_type(type_name: &str) -> Option<(&str, usize)> {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\[([a-zA-Z0-9<>]+); ?(\d+)\]").unwrap());
let cap = RE.captures(type_name)?;
let inner_type = cap.get(1)?;
let length = cap.get(2)?.as_str().parse().ok()?;
Some((inner_type.as_str(), length))
}
fn generate_code_block_test(
fn_name: &str,
code_block: &str,
index: usize,
tags: &[String],
) -> proc_macro2::TokenStream {
let test_name = format_ident!("serial_test_example_{}{}", fn_name, index);
let test_name_str = format!("serial_test_example_{}{}", fn_name, index);
let ignored = if tags.contains(&"norun".to_string()) {
quote! { #[ignore] }
} else {
quote! {}
};
quote! {
#[tokio::test(flavor = "multi_thread", worker_threads = 5)]
#ignored
async fn #test_name() {
let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60));
let ws_client = reqwest::Client::builder()
.user_agent(user_agent)
.timeout(std::time::Duration::from_secs(600))
.connect_timeout(std::time::Duration::from_secs(60))
.connection_verbose(true)
.tcp_keepalive(std::time::Duration::from_secs(600))
.http1_only();
let token = std::env::var("KITTYCAD_API_TOKEN").expect("KITTYCAD_API_TOKEN not set");
let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
if let Ok(addr) = std::env::var("LOCAL_ENGINE_ADDR") {
client.set_base_url(addr);
}
let ws = client
.modeling()
.commands_ws(None, None, None, None, None, Some(false))
.await.unwrap();
let tokens = crate::token::lexer(#code_block);
let parser = crate::parser::Parser::new(tokens);
let program = parser.ast().unwrap();
let mut mem: crate::executor::ProgramMemory = Default::default();
let units = kittycad::types::UnitLength::Mm;
let ctx = crate::executor::ExecutorContext::new(ws, units.clone()).await.unwrap();
crate::executor::execute(program, &mut mem, crate::executor::BodyType::Root, &ctx).await.unwrap();
let (x, y) = crate::std::utils::get_camera_zoom_magnitude_per_unit_length(units);
ctx.engine
.send_modeling_cmd(
false,
uuid::Uuid::new_v4(),
crate::executor::SourceRange::default(),
kittycad::types::ModelingCmd::DefaultCameraLookAt {
center: kittycad::types::Point3D { x: 0.0, y: 0.0, z: 0.0 },
up: kittycad::types::Point3D { x: 0.0, y: 0.0, z: 1.0 },
vantage: kittycad::types::Point3D { x: 0.0, y: -x, z: y },
sequence: None,
},
)
.await.unwrap();
let resp = ctx
.engine
.send_modeling_cmd(
false,
uuid::Uuid::new_v4(),
crate::executor::SourceRange::default(),
kittycad::types::ModelingCmd::TakeSnapshot {
format: kittycad::types::ImageFormat::Png,
},
)
.await.unwrap();
let output_file = std::env::temp_dir().join(format!("kcl_output_{}.png", uuid::Uuid::new_v4()));
if let kittycad::types::OkWebSocketResponseData::Modeling {
modeling_response: kittycad::types::OkModelingCmdResponse::TakeSnapshot { data },
} = &resp
{
std::fs::write(&output_file, &data.contents.0).unwrap();
} else {
panic!("Unexpected response from engine: {:?}", resp);
}
let actual = image::io::Reader::open(output_file).unwrap().decode().unwrap();
twenty_twenty::assert_image(&format!("tests/outputs/{}.png", #test_name_str), &actual, 1.0);
}
}
}